Compare commits
2 Commits
cbe708b53b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2014be28c4 | |||
| cf466a3959 |
@@ -21,8 +21,7 @@ on:
|
|||||||
env:
|
env:
|
||||||
REGISTRY: code.littlelan.cn
|
REGISTRY: code.littlelan.cn
|
||||||
IMAGE_NAME: carrot_bbs/frontend-web
|
IMAGE_NAME: carrot_bbs/frontend-web
|
||||||
OTA_PLATFORM_ANDROID: android
|
OTA_PLATFORM: android
|
||||||
OTA_PLATFORM_IOS: ios
|
|
||||||
OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish
|
OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish
|
||||||
OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest
|
OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest
|
||||||
|
|
||||||
@@ -40,13 +39,7 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '25.6.1'
|
node-version: '25.6.1'
|
||||||
registry-url: 'https://registry.npmmirror.com'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Remove deprecated always-auth npm config
|
|
||||||
run: |
|
|
||||||
if [ -f ~/.npmrc ]; then
|
|
||||||
sed -i '/always-auth/d' ~/.npmrc
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
@@ -83,7 +76,7 @@ jobs:
|
|||||||
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
||||||
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM_ANDROID}" \
|
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM}" \
|
||||||
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
||||||
-H "Content-Type: application/zip" \
|
-H "Content-Type: application/zip" \
|
||||||
--data-binary @"dist-android-update.zip"
|
--data-binary @"dist-android-update.zip"
|
||||||
@@ -91,7 +84,7 @@ jobs:
|
|||||||
- name: Verify OTA manifest
|
- name: Verify OTA manifest
|
||||||
run: |
|
run: |
|
||||||
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
|
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
|
||||||
-H "expo-platform: ${OTA_PLATFORM_ANDROID}" \
|
-H "expo-platform: ${OTA_PLATFORM}" \
|
||||||
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
|
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
|
||||||
-H "expo-protocol-version: 1" \
|
-H "expo-protocol-version: 1" \
|
||||||
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\\\"id\\\":.*\\\"extra\\\":\\{.*\\}\\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
|
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\\\"id\\\":.*\\\"extra\\\":\\{.*\\}\\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
|
||||||
@@ -100,79 +93,6 @@ jobs:
|
|||||||
echo "Local bundle: ${LOCAL}"
|
echo "Local bundle: ${LOCAL}"
|
||||||
test "${REMOTE}" = "${LOCAL}"
|
test "${REMOTE}" = "${LOCAL}"
|
||||||
|
|
||||||
ota-ios:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || github.event.inputs.publish_ota == 'true')
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Set up Node
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: '25.6.1'
|
|
||||||
registry-url: 'https://registry.npmmirror.com'
|
|
||||||
|
|
||||||
- name: Remove deprecated always-auth npm config
|
|
||||||
run: |
|
|
||||||
if [ -f ~/.npmrc ]; then
|
|
||||||
sed -i '/always-auth/d' ~/.npmrc
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Resolve runtime version
|
|
||||||
id: runtime
|
|
||||||
run: |
|
|
||||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
|
||||||
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
|
||||||
|
|
||||||
- name: Export iOS update bundle
|
|
||||||
run: |
|
|
||||||
rm -rf dist-ios dist-ios-update.zip
|
|
||||||
npx expo export --platform ios --output-dir dist-ios
|
|
||||||
npx expo config --type public --json > dist-ios/expoConfig.json
|
|
||||||
|
|
||||||
- name: Archive iOS update bundle
|
|
||||||
run: |
|
|
||||||
python - <<'PY'
|
|
||||||
import os
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
with zipfile.ZipFile('dist-ios-update.zip', 'w', zipfile.ZIP_DEFLATED) as zf:
|
|
||||||
for root, _, files in os.walk('dist-ios'):
|
|
||||||
for name in files:
|
|
||||||
src = os.path.join(root, name)
|
|
||||||
arc = os.path.relpath(src, 'dist-ios')
|
|
||||||
zf.write(src, arc)
|
|
||||||
PY
|
|
||||||
|
|
||||||
- name: Publish OTA
|
|
||||||
env:
|
|
||||||
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
|
||||||
run: |
|
|
||||||
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
|
||||||
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM_IOS}" \
|
|
||||||
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
|
||||||
-H "Content-Type: application/zip" \
|
|
||||||
--data-binary @"dist-ios-update.zip"
|
|
||||||
|
|
||||||
- name: Verify OTA manifest
|
|
||||||
run: |
|
|
||||||
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
|
|
||||||
-H "expo-platform: ${OTA_PLATFORM_IOS}" \
|
|
||||||
-H "expo-runtime-version: ${{ steps.runtime.outputs.runtime_version }}" \
|
|
||||||
-H "expo-protocol-version: 1" \
|
|
||||||
| python -c "import sys, json, re; s=sys.stdin.read(); m=re.search(r'\\{\"id\":.*\"extra\":\{.*\}\}', s); j=json.loads(m.group(0)); print(j['launchAsset']['url'].split('/')[-1].split('&')[0])")"
|
|
||||||
LOCAL="$(python -c "import json; m=json.load(open('dist-ios/metadata.json')); print(m['fileMetadata']['ios']['bundle'].split('/')[-1])")"
|
|
||||||
echo "Remote bundle: ${REMOTE}"
|
|
||||||
echo "Local bundle: ${LOCAL}"
|
|
||||||
test "${REMOTE}" = "${LOCAL}"
|
|
||||||
|
|
||||||
build-android-apk:
|
build-android-apk:
|
||||||
runs-on: android-builder
|
runs-on: android-builder
|
||||||
container:
|
container:
|
||||||
@@ -201,13 +121,7 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: '25.6.1'
|
node-version: '25.6.1'
|
||||||
registry-url: 'https://registry.npmmirror.com'
|
cache: 'npm'
|
||||||
|
|
||||||
- name: Remove deprecated always-auth npm config
|
|
||||||
run: |
|
|
||||||
if [ -f ~/.npmrc ]; then
|
|
||||||
sed -i '/always-auth/d' ~/.npmrc
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Cache Gradle
|
- name: Cache Gradle
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
@@ -236,37 +150,21 @@ jobs:
|
|||||||
- name: Generate Android native project
|
- name: Generate Android native project
|
||||||
run: npx expo prebuild --platform android
|
run: npx expo prebuild --platform android
|
||||||
|
|
||||||
- name: Switch Gradle distribution to Tencent mirror
|
- name: Configure Gradle with Aliyun Maven mirror
|
||||||
run: |
|
|
||||||
PROPS="android/gradle/wrapper/gradle-wrapper.properties"
|
|
||||||
if [ -f "$PROPS" ]; then
|
|
||||||
sed -i 's|distributionUrl=https\\://services.gradle.org/distributions/|distributionUrl=https\\://mirrors.cloud.tencent.com/gradle/|' "$PROPS"
|
|
||||||
echo "Updated gradle-wrapper.properties:"
|
|
||||||
cat "$PROPS"
|
|
||||||
else
|
|
||||||
echo "gradle-wrapper.properties not found"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Decode Android signing keystore
|
|
||||||
run: |
|
|
||||||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android/app/withyou-release-key.keystore
|
|
||||||
|
|
||||||
- name: Configure Gradle with China Maven mirrors
|
|
||||||
run: |
|
run: |
|
||||||
cd android
|
cd android
|
||||||
|
|
||||||
# Update settings.gradle with China Maven mirrors
|
# Update settings.gradle with Aliyun Maven mirror
|
||||||
cat > settings.gradle << 'SETTINGS_EOF'
|
cat > settings.gradle << 'SETTINGS_EOF'
|
||||||
pluginManagement {
|
pluginManagement {
|
||||||
repositories {
|
repositories {
|
||||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
gradlePluginPortal()
|
gradlePluginPortal()
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||||
}
|
}
|
||||||
def reactNativeGradlePlugin = new File(
|
def reactNativeGradlePlugin = new File(
|
||||||
providers.exec {
|
providers.exec {
|
||||||
@@ -289,7 +187,6 @@ jobs:
|
|||||||
plugins {
|
plugins {
|
||||||
id("com.facebook.react.settings")
|
id("com.facebook.react.settings")
|
||||||
id("expo-autolinking-settings")
|
id("expo-autolinking-settings")
|
||||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||||
@@ -301,7 +198,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
expoAutolinking.useExpoModules()
|
expoAutolinking.useExpoModules()
|
||||||
|
|
||||||
rootProject.name = '威友'
|
rootProject.name = '萝卜社区'
|
||||||
|
|
||||||
expoAutolinking.useExpoVersionCatalog()
|
expoAutolinking.useExpoVersionCatalog()
|
||||||
|
|
||||||
@@ -309,18 +206,18 @@ jobs:
|
|||||||
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||||
SETTINGS_EOF
|
SETTINGS_EOF
|
||||||
|
|
||||||
# Update build.gradle with China Maven mirrors
|
# Update build.gradle with Aliyun Maven mirror
|
||||||
cat > build.gradle << 'BUILD_EOF'
|
cat > build.gradle << 'BUILD_EOF'
|
||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
repositories {
|
repositories {
|
||||||
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath('com.android.tools.build:gradle')
|
classpath('com.android.tools.build:gradle')
|
||||||
@@ -331,12 +228,12 @@ jobs:
|
|||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
maven { url 'https://maven.aliyun.com/repository/google' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/public' }
|
|
||||||
maven { url 'https://maven.aliyun.com/repository/central' }
|
|
||||||
maven { url 'https://www.jitpack.io' }
|
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven { url 'https://www.jitpack.io' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/public' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/google' }
|
||||||
|
maven { url 'https://maven.aliyun.com/repository/central' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,16 +260,6 @@ jobs:
|
|||||||
expo.useLegacyPackaging=false
|
expo.useLegacyPackaging=false
|
||||||
PROPS_EOF
|
PROPS_EOF
|
||||||
|
|
||||||
- name: Configure Gradle signing properties
|
|
||||||
run: |
|
|
||||||
cd android
|
|
||||||
cat >> gradle.properties << SIGNING_PROPS
|
|
||||||
MYAPP_UPLOAD_STORE_FILE=withyou-release-key.keystore
|
|
||||||
MYAPP_UPLOAD_STORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
|
|
||||||
MYAPP_UPLOAD_KEY_ALIAS=withyou-key
|
|
||||||
MYAPP_UPLOAD_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}
|
|
||||||
SIGNING_PROPS
|
|
||||||
|
|
||||||
- name: Build Android release APK (arm64 only)
|
- name: Build Android release APK (arm64 only)
|
||||||
run: |
|
run: |
|
||||||
cd android
|
cd android
|
||||||
@@ -382,45 +269,9 @@ jobs:
|
|||||||
- name: Upload APK artifact
|
- name: Upload APK artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: withyou-android-release-apk
|
name: carrot-bbs-android-release-apk
|
||||||
path: android/app/build/outputs/apk/release/app-release.apk
|
path: android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
|
||||||
- name: Resolve runtime version
|
|
||||||
id: runtime
|
|
||||||
run: |
|
|
||||||
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
|
||||||
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
|
||||||
|
|
||||||
- name: Upload APK to Updates Server
|
|
||||||
env:
|
|
||||||
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
|
||||||
UPDATES_SERVER_URL: https://updates.littlelan.cn
|
|
||||||
run: |
|
|
||||||
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
|
||||||
|
|
||||||
APK_PATH="android/app/build/outputs/apk/release/app-release.apk"
|
|
||||||
RUNTIME_VERSION="${{ steps.runtime.outputs.runtime_version }}"
|
|
||||||
|
|
||||||
echo "Uploading APK for runtime version: ${RUNTIME_VERSION}"
|
|
||||||
|
|
||||||
HTTP_CODE=$(curl -s -o response.json -w "%{http_code}" \
|
|
||||||
-X POST \
|
|
||||||
"${UPDATES_SERVER_URL}/admin/apk/upload?runtimeVersion=${RUNTIME_VERSION}" \
|
|
||||||
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
|
||||||
-H "Content-Type: application/vnd.android.package-archive" \
|
|
||||||
--data-binary @"${APK_PATH}")
|
|
||||||
|
|
||||||
echo "HTTP Response Code: ${HTTP_CODE}"
|
|
||||||
cat response.json
|
|
||||||
|
|
||||||
if [ "${HTTP_CODE}" -ne 200 ]; then
|
|
||||||
echo "Failed to upload APK"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "APK uploaded successfully!"
|
|
||||||
|
|
||||||
build-and-push-web:
|
build-and-push-web:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
10
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# Screenshots
|
||||||
|
screenshots/
|
||||||
9
.idea/frontend.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
448
.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@expo/dom-webview/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/@expo/dom-webview/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@expo/log-box/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/@expo/log-box/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@react-native-async-storage/async-storage/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/@react-native-async-storage/async-storage/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@react-native/gradle-plugin" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/@react-native/gradle-plugin" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-application/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-application/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-background-fetch/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-background-fetch/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-constants/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-constants/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-constants/scripts" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-constants/scripts" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-client/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-dev-client/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-launcher/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-dev-launcher/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-launcher/expo-dev-launcher-gradle-plugin" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-dev-launcher/expo-dev-launcher-gradle-plugin" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-menu-interface/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-dev-menu-interface/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-menu/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-dev-menu/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-eas-client/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-eas-client/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-file-system/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-file-system/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-font/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-font/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-haptics/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-haptics/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image-loader/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-image-loader/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image-picker/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-image-picker/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-image/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-json-utils/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-json-utils/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-linear-gradient/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-linear-gradient/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-linking/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-linking/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-manifests/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-manifests/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-media-library/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-media-library/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-autolinking/android/expo-gradle-plugin" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-modules-autolinking/android/expo-gradle-plugin" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-core/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-modules-core/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-core/expo-module-gradle-plugin" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-modules-core/expo-module-gradle-plugin" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-notifications/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-notifications/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-router/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-router/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-sqlite/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-sqlite/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-structured-headers/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-structured-headers/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-system-ui/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-system-ui/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-task-manager/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-task-manager/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates-interface/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-updates-interface/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-updates/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates/expo-updates-gradle-plugin" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-updates/expo-updates-gradle-plugin" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-video/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo-video/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-asset/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-asset/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-keep-awake/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-keep-awake/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/react-native" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-gesture-handler/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/react-native-gesture-handler/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-pager-view/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/react-native-pager-view/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-reanimated/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/react-native-reanimated/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-safe-area-context/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/react-native-safe-area-context/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-screens/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/react-native-screens/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-worklets/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/react-native-worklets/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/unimodules-app-loader/android" />
|
||||||
|
<option name="gradleJvm" value="ms-21" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$/node_modules/unimodules-app-loader/android" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
7
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="ProjectRootManager">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/frontend.iml" filepath="$PROJECT_DIR$/.idea/frontend.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
376
.kilo/package-lock.json
generated
@@ -1,376 +0,0 @@
|
|||||||
{
|
|
||||||
"name": ".kilo",
|
|
||||||
"lockfileVersion": 3,
|
|
||||||
"requires": true,
|
|
||||||
"packages": {
|
|
||||||
"": {
|
|
||||||
"dependencies": {
|
|
||||||
"@kilocode/plugin": "7.2.31"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@kilocode/plugin": {
|
|
||||||
"version": "7.2.31",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@kilocode/plugin/-/plugin-7.2.31.tgz",
|
|
||||||
"integrity": "sha512-KmKTTIly7hRlJdXKhqZ/j/brvTPh0z0UTjWSjJWq5fqf4pATgYGn7G0g3ZjILnN7MUkkZXuljgqExTEeQJHGkQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@kilocode/sdk": "7.2.31",
|
|
||||||
"effect": "4.0.0-beta.48",
|
|
||||||
"zod": "4.1.8"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@opentui/core": ">=0.1.100",
|
|
||||||
"@opentui/solid": ">=0.1.100"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@opentui/core": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@opentui/solid": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@kilocode/sdk": {
|
|
||||||
"version": "7.2.31",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@kilocode/sdk/-/sdk-7.2.31.tgz",
|
|
||||||
"integrity": "sha512-Sx05yv+3TIlc6M4Ze+YGgKCLoIg8B0WRE15JXDriVncT+wz7M6+e+4mjNWkwfsuywdeOTYPfeFViqX8iO7ckKA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cross-spawn": "7.0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"node_modules/@standard-schema/spec": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/cross-spawn": {
|
|
||||||
"version": "7.0.6",
|
|
||||||
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
|
||||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"path-key": "^3.1.0",
|
|
||||||
"shebang-command": "^2.0.0",
|
|
||||||
"which": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/detect-libc": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/effect": {
|
|
||||||
"version": "4.0.0-beta.48",
|
|
||||||
"resolved": "https://registry.npmmirror.com/effect/-/effect-4.0.0-beta.48.tgz",
|
|
||||||
"integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@standard-schema/spec": "^1.1.0",
|
|
||||||
"fast-check": "^4.6.0",
|
|
||||||
"find-my-way-ts": "^0.1.6",
|
|
||||||
"ini": "^6.0.0",
|
|
||||||
"kubernetes-types": "^1.30.0",
|
|
||||||
"msgpackr": "^1.11.9",
|
|
||||||
"multipasta": "^0.2.7",
|
|
||||||
"toml": "^4.1.1",
|
|
||||||
"uuid": "^13.0.0",
|
|
||||||
"yaml": "^2.8.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fast-check": {
|
|
||||||
"version": "4.7.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/fast-check/-/fast-check-4.7.0.tgz",
|
|
||||||
"integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://github.com/sponsors/dubzzz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/fast-check"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"pure-rand": "^8.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.17.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/find-my-way-ts": {
|
|
||||||
"version": "0.1.6",
|
|
||||||
"resolved": "https://registry.npmmirror.com/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz",
|
|
||||||
"integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/ini": {
|
|
||||||
"version": "6.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/ini/-/ini-6.0.0.tgz",
|
|
||||||
"integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==",
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.17.0 || >=22.9.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/isexe": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/kubernetes-types": {
|
|
||||||
"version": "1.30.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/kubernetes-types/-/kubernetes-types-1.30.0.tgz",
|
|
||||||
"integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==",
|
|
||||||
"license": "Apache-2.0"
|
|
||||||
},
|
|
||||||
"node_modules/msgpackr": {
|
|
||||||
"version": "1.11.12",
|
|
||||||
"resolved": "https://registry.npmmirror.com/msgpackr/-/msgpackr-1.11.12.tgz",
|
|
||||||
"integrity": "sha512-RBdJ1Un7yGlXWajrkxcSa93nvQ0w4zBf60c0yYv7YtBelP8H2FA7XsfBbMHtXKXUMUxH7zV3Zuozh+kUQWhHvg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optionalDependencies": {
|
|
||||||
"msgpackr-extract": "^3.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/msgpackr-extract": {
|
|
||||||
"version": "3.0.3",
|
|
||||||
"resolved": "https://registry.npmmirror.com/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
|
|
||||||
"integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"node-gyp-build-optional-packages": "5.2.2"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
|
|
||||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
|
|
||||||
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
|
|
||||||
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
|
|
||||||
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
|
|
||||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/multipasta": {
|
|
||||||
"version": "0.2.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/multipasta/-/multipasta-0.2.7.tgz",
|
|
||||||
"integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/node-gyp-build-optional-packages": {
|
|
||||||
"version": "5.2.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
|
||||||
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"detect-libc": "^2.0.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"node-gyp-build-optional-packages": "bin.js",
|
|
||||||
"node-gyp-build-optional-packages-optional": "optional.js",
|
|
||||||
"node-gyp-build-optional-packages-test": "build-test.js"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/path-key": {
|
|
||||||
"version": "3.1.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
|
|
||||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/pure-rand": {
|
|
||||||
"version": "8.4.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/pure-rand/-/pure-rand-8.4.0.tgz",
|
|
||||||
"integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://github.com/sponsors/dubzzz"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/fast-check"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/shebang-command": {
|
|
||||||
"version": "2.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
|
|
||||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"shebang-regex": "^3.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/shebang-regex": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/toml": {
|
|
||||||
"version": "4.1.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/toml/-/toml-4.1.1.tgz",
|
|
||||||
"integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/uuid": {
|
|
||||||
"version": "13.0.1",
|
|
||||||
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-13.0.1.tgz",
|
|
||||||
"integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==",
|
|
||||||
"funding": [
|
|
||||||
"https://github.com/sponsors/broofa",
|
|
||||||
"https://github.com/sponsors/ctavan"
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"uuid": "dist-node/bin/uuid"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/which": {
|
|
||||||
"version": "2.0.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz",
|
|
||||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"isexe": "^2.0.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"node-which": "bin/node-which"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yaml": {
|
|
||||||
"version": "2.8.4",
|
|
||||||
"resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.4.tgz",
|
|
||||||
"integrity": "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==",
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/zod": {
|
|
||||||
"version": "4.1.8",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
231
ARCHITECTURE_REFACTOR_PLAN.md
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
# 架构修复方案文档
|
||||||
|
|
||||||
|
## 1. 重构目标
|
||||||
|
|
||||||
|
### 1.1 核心原则
|
||||||
|
- **单一职责**:每个模块只负责一个明确的功能
|
||||||
|
- **依赖倒置**:高层模块不依赖低层模块,都依赖抽象
|
||||||
|
- **开闭原则**:对扩展开放,对修改关闭
|
||||||
|
|
||||||
|
### 1.2 目标架构
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── core/ # 核心业务逻辑(框架无关)
|
||||||
|
│ ├── entities/ # 业务实体
|
||||||
|
│ ├── repositories/ # 仓库接口
|
||||||
|
│ └── usecases/ # 业务用例
|
||||||
|
├── data/ # 数据层
|
||||||
|
│ ├── datasources/ # 数据源实现(API、DB、Cache)
|
||||||
|
│ ├── repositories/ # 仓库实现
|
||||||
|
│ └── mappers/ # 数据映射
|
||||||
|
├── presentation/ # 表现层
|
||||||
|
│ ├── components/ # UI组件
|
||||||
|
│ ├── screens/ # 屏幕组件
|
||||||
|
│ ├── stores/ # 状态管理
|
||||||
|
│ └── hooks/ # 自定义hooks
|
||||||
|
└── infrastructure/ # 基础设施
|
||||||
|
├── navigation/ # 导航
|
||||||
|
├── theme/ # 主题
|
||||||
|
└── services/ # 外部服务
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 具体修复方案
|
||||||
|
|
||||||
|
### 2.1 messageManager.ts 重构方案
|
||||||
|
|
||||||
|
#### 现状问题
|
||||||
|
- 1500+行,上帝对象
|
||||||
|
- 混合了 WebSocket、数据库、状态管理、UI通知
|
||||||
|
- 与SQLite深度耦合
|
||||||
|
|
||||||
|
#### 目标结构
|
||||||
|
```
|
||||||
|
src/data/
|
||||||
|
├── datasources/
|
||||||
|
│ ├── WebSocketClient.ts # WebSocket连接管理
|
||||||
|
│ └── MessageDataSource.ts # 消息数据源
|
||||||
|
├── repositories/
|
||||||
|
│ └── MessageRepository.ts # 消息仓库实现
|
||||||
|
└── mappers/
|
||||||
|
└── MessageMapper.ts # 消息数据映射
|
||||||
|
|
||||||
|
src/core/
|
||||||
|
├── entities/
|
||||||
|
│ └── Message.ts # 消息实体
|
||||||
|
├── repositories/
|
||||||
|
│ └── IMessageRepository.ts # 仓库接口
|
||||||
|
└── usecases/
|
||||||
|
├── ProcessMessageUseCase.ts
|
||||||
|
├── SyncMessagesUseCase.ts
|
||||||
|
└── UpdateReadReceiptUseCase.ts
|
||||||
|
|
||||||
|
src/presentation/stores/
|
||||||
|
└── messageStore.ts # 简化的消息状态管理
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 重构步骤
|
||||||
|
1. 提取 WebSocketClient - 纯连接管理
|
||||||
|
2. 创建 MessageRepository - 数据访问抽象
|
||||||
|
3. 创建 UseCases - 业务逻辑
|
||||||
|
4. 重写 messageStore - 仅负责UI状态
|
||||||
|
5. 删除旧的 messageManager.ts
|
||||||
|
|
||||||
|
### 2.2 乐观更新工具函数方案
|
||||||
|
|
||||||
|
#### 现状问题
|
||||||
|
- userStore.ts 中重复6+次相同模式
|
||||||
|
- 代码冗余,难以维护
|
||||||
|
|
||||||
|
#### 目标API
|
||||||
|
```typescript
|
||||||
|
// utils/optimisticUpdate.ts
|
||||||
|
export interface OptimisticUpdateOptions<T, R> {
|
||||||
|
store: StoreApi<any>;
|
||||||
|
getCurrentState: () => T;
|
||||||
|
optimisticUpdate: (current: T) => T;
|
||||||
|
apiCall: () => Promise<R>;
|
||||||
|
onSuccess?: (result: R, current: T) => T;
|
||||||
|
onError?: (error: any, original: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function optimisticUpdate<T, R>(
|
||||||
|
options: OptimisticUpdateOptions<T, R>
|
||||||
|
): Promise<R>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 使用示例
|
||||||
|
```typescript
|
||||||
|
// 重构前(重复代码)
|
||||||
|
set(state => ({ posts: updatedPosts }));
|
||||||
|
try {
|
||||||
|
const result = await postService.like(postId);
|
||||||
|
// ...
|
||||||
|
} catch (error) {
|
||||||
|
set(state => ({ posts: originalPosts }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重构后(简洁)
|
||||||
|
await optimisticUpdate({
|
||||||
|
store,
|
||||||
|
getCurrentState: () => store.getState().posts,
|
||||||
|
optimisticUpdate: (posts) => updatePostLikes(posts, postId, true),
|
||||||
|
apiCall: () => postService.like(postId),
|
||||||
|
onSuccess: (result, posts) => updatePostWithServerData(posts, result),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 useResponsive.ts 拆分方案
|
||||||
|
|
||||||
|
#### 现状问题
|
||||||
|
- 485行,43个导出
|
||||||
|
- 功能混杂:断点检测、响应式值计算、平台检测
|
||||||
|
|
||||||
|
#### 目标结构
|
||||||
|
```
|
||||||
|
src/presentation/hooks/responsive/
|
||||||
|
├── useBreakpoint.ts # 断点检测
|
||||||
|
├── useResponsiveValue.ts # 响应式值映射
|
||||||
|
├── useOrientation.ts # 方向检测
|
||||||
|
├── usePlatform.ts # 平台检测
|
||||||
|
├── useScreenSize.ts # 屏幕尺寸
|
||||||
|
└── index.ts # 统一导出
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 各Hook职责
|
||||||
|
- `useBreakpoint`: 返回当前断点 (mobile/tablet/desktop/wide)
|
||||||
|
- `useResponsiveValue`: 根据断点返回不同值
|
||||||
|
- `useOrientation`: 返回 portrait/landscape
|
||||||
|
- `usePlatform`: 返回 ios/android/web
|
||||||
|
- `useScreenSize`: 返回 width/height
|
||||||
|
|
||||||
|
### 2.4 MainNavigator 解耦方案
|
||||||
|
|
||||||
|
#### 现状问题
|
||||||
|
- 1118行,直接依赖store
|
||||||
|
- 混合了导航配置和业务逻辑
|
||||||
|
|
||||||
|
#### 目标结构
|
||||||
|
```
|
||||||
|
src/infrastructure/navigation/
|
||||||
|
├── MainNavigator.tsx # 简化后的导航器
|
||||||
|
├── AuthNavigator.tsx # 认证导航
|
||||||
|
├── TabNavigator.tsx # 标签导航
|
||||||
|
├── navigationService.ts # 导航服务(解耦层)
|
||||||
|
├── navigationTypes.ts # 类型定义
|
||||||
|
└── hooks/
|
||||||
|
└── useNavigationState.ts # 导航状态hook
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 解耦策略
|
||||||
|
1. 创建 NavigationService - 提供导航操作的抽象
|
||||||
|
2. Store通过NavigationService触发导航,而非直接操作
|
||||||
|
3. MainNavigator只负责渲染,不处理业务逻辑
|
||||||
|
|
||||||
|
### 2.5 Repository 层创建方案
|
||||||
|
|
||||||
|
#### 现状问题
|
||||||
|
- 服务层直接操作数据库
|
||||||
|
- 没有明确的数据访问边界
|
||||||
|
|
||||||
|
#### 目标结构
|
||||||
|
```
|
||||||
|
src/data/repositories/
|
||||||
|
├── MessageRepository.ts # 消息数据访问
|
||||||
|
├── PostRepository.ts # 帖子数据访问
|
||||||
|
├── UserRepository.ts # 用户数据访问
|
||||||
|
└── interfaces/
|
||||||
|
├── IMessageRepository.ts
|
||||||
|
├── IPostRepository.ts
|
||||||
|
└── IUserRepository.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 职责划分
|
||||||
|
- **Repository**: 数据访问抽象,定义接口
|
||||||
|
- **DataSource**: 具体数据源实现(API、SQLite、Cache)
|
||||||
|
- **Service**: 业务逻辑编排,使用Repository
|
||||||
|
|
||||||
|
## 3. 实施计划
|
||||||
|
|
||||||
|
### 阶段1:基础设施(第1-2天)
|
||||||
|
- [ ] 创建目录结构
|
||||||
|
- [ ] 实现乐观更新工具函数
|
||||||
|
- [ ] 拆分 useResponsive hooks
|
||||||
|
|
||||||
|
### 阶段2:数据层重构(第3-5天)
|
||||||
|
- [ ] 创建 Repository 接口和实现
|
||||||
|
- [ ] 创建 DataSource 层
|
||||||
|
- [ ] 迁移数据库操作到 Repository
|
||||||
|
|
||||||
|
### 阶段3:核心业务重构(第6-10天)
|
||||||
|
- [ ] 重构 messageManager → UseCases + Store
|
||||||
|
- [ ] 创建 WebSocketClient
|
||||||
|
- [ ] 实现消息同步逻辑
|
||||||
|
|
||||||
|
### 阶段4:表现层重构(第11-12天)
|
||||||
|
- [ ] 解耦 MainNavigator
|
||||||
|
- [ ] 重构 userStore 使用乐观更新工具
|
||||||
|
- [ ] 更新所有屏幕组件
|
||||||
|
|
||||||
|
### 阶段5:测试与优化(第13-14天)
|
||||||
|
- [ ] 单元测试
|
||||||
|
- [ ] 集成测试
|
||||||
|
- [ ] 性能优化
|
||||||
|
|
||||||
|
## 4. 风险与应对
|
||||||
|
|
||||||
|
| 风险 | 影响 | 应对措施 |
|
||||||
|
|-----|------|---------|
|
||||||
|
| 重构引入bug | 高 | 每个模块重构后必须测试,保持API兼容 |
|
||||||
|
| 开发时间超期 | 中 | 分阶段交付,优先核心功能 |
|
||||||
|
| 团队成员不熟悉新架构 | 中 | 编写详细文档,代码审查 |
|
||||||
|
| 性能下降 | 低 | 添加性能监控,及时优化 |
|
||||||
|
|
||||||
|
## 5. 验收标准
|
||||||
|
|
||||||
|
- [ ] messageManager.ts 被删除,功能拆分到多个小模块
|
||||||
|
- [ ] userStore.ts 中无重复乐观更新代码
|
||||||
|
- [ ] useResponsive.ts 拆分为5+个专注hook
|
||||||
|
- [ ] MainNavigator.tsx 行数 < 300
|
||||||
|
- [ ] 所有单元测试通过
|
||||||
|
- [ ] 无TypeScript错误
|
||||||
|
- [ ] 性能不劣化(启动时间、内存占用)
|
||||||
@@ -10,7 +10,6 @@ RUN npx expo export --platform web --output-dir dist-web
|
|||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
COPY --from=builder /app/dist-web /usr/share/nginx/html
|
COPY --from=builder /app/dist-web /usr/share/nginx/html
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -1,21 +1,10 @@
|
|||||||
const appJson = require('./app.json');
|
const appJson = require('./app.json');
|
||||||
const { execSync } = require('child_process');
|
|
||||||
|
|
||||||
const isDevVariant = process.env.APP_VARIANT === 'dev';
|
const isDevVariant = process.env.APP_VARIANT === 'dev';
|
||||||
const releaseApiBaseUrl = 'https://withyou.littlelan.cn/api/v1';
|
const releaseApiBaseUrl = 'https://bbs.littlelan.cn/api/v1';
|
||||||
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
|
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
|
||||||
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1';
|
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1';
|
||||||
|
|
||||||
function getGitShortHash() {
|
|
||||||
try {
|
|
||||||
return execSync('git rev-parse --short=4 HEAD', { encoding: 'utf-8' }).trim();
|
|
||||||
} catch {
|
|
||||||
return '0000';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const gitBuildSuffix = getGitShortHash();
|
|
||||||
|
|
||||||
function toManifestUrl(baseUrl, portOverride) {
|
function toManifestUrl(baseUrl, portOverride) {
|
||||||
const parsed = new URL(baseUrl);
|
const parsed = new URL(baseUrl);
|
||||||
if (portOverride != null) {
|
if (portOverride != null) {
|
||||||
@@ -34,29 +23,6 @@ const devUpdatesUrl =
|
|||||||
|
|
||||||
const expo = appJson.expo;
|
const expo = appJson.expo;
|
||||||
|
|
||||||
// 检测是否为 Web 平台
|
|
||||||
const isWeb = process.env.EXPO_TARGET === 'web' || process.env.PLATFORM === 'web';
|
|
||||||
|
|
||||||
// 原生平台特有的插件(在 Web 端会报错)
|
|
||||||
const nativeOnlyPlugins = [
|
|
||||||
'expo-camera',
|
|
||||||
'expo-notifications',
|
|
||||||
'expo-background-fetch',
|
|
||||||
'expo-media-library',
|
|
||||||
'expo-image-picker',
|
|
||||||
'expo-video',
|
|
||||||
'expo-sqlite',
|
|
||||||
'./plugins/withMainActivityConfigChange',
|
|
||||||
];
|
|
||||||
|
|
||||||
// 过滤插件:Web 端排除原生插件
|
|
||||||
const filteredPlugins = isWeb
|
|
||||||
? expo.plugins.filter((plugin) => {
|
|
||||||
const pluginName = Array.isArray(plugin) ? plugin[0] : plugin;
|
|
||||||
return !nativeOnlyPlugins.includes(pluginName);
|
|
||||||
})
|
|
||||||
: expo.plugins;
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...expo,
|
...expo,
|
||||||
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
||||||
@@ -69,36 +35,15 @@ module.exports = {
|
|||||||
checkAutomatically: 'ON_LOAD',
|
checkAutomatically: 'ON_LOAD',
|
||||||
fallbackToCacheTimeout: 0,
|
fallbackToCacheTimeout: 0,
|
||||||
},
|
},
|
||||||
ios: {
|
ios: expo.ios,
|
||||||
...expo.ios,
|
|
||||||
buildNumber: gitBuildSuffix,
|
|
||||||
},
|
|
||||||
android: {
|
android: {
|
||||||
...expo.android,
|
...expo.android,
|
||||||
package: 'cn.qczlit.withyou',
|
package: 'skin.carrot.bbs',
|
||||||
versionCode: parseInt(gitBuildSuffix, 16),
|
|
||||||
},
|
},
|
||||||
// Web 端使用过滤后的插件
|
|
||||||
plugins: filteredPlugins,
|
|
||||||
extra: {
|
extra: {
|
||||||
...(expo.extra || {}),
|
...(expo.extra || {}),
|
||||||
appVariant: isDevVariant ? 'dev' : 'release',
|
appVariant: isDevVariant ? 'dev' : 'release',
|
||||||
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
|
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
|
||||||
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
||||||
},
|
},
|
||||||
// Web 端配置
|
|
||||||
...(isWeb && {
|
|
||||||
web: {
|
|
||||||
...expo.web,
|
|
||||||
build: {
|
|
||||||
...expo.web?.build,
|
|
||||||
// 配置 Web 端别名,避免加载原生模块
|
|
||||||
babel: {
|
|
||||||
dangerouslyAddModulePathsToTranspile: [
|
|
||||||
'react-native-webrtc',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|||||||
79
app.json
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "威友",
|
"name": "萝卜社区",
|
||||||
"slug": "qojo",
|
"slug": "qojo",
|
||||||
"version": "0.0.2",
|
"version": "1.0.11",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"scheme": "withyou",
|
"scheme": "carrotbbs",
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/splash-icon.png",
|
"image": "./assets/splash-icon.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
@@ -15,32 +15,28 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"NSMicrophoneUsageDescription": "允许威友访问您的麦克风以进行语音通话",
|
|
||||||
"UIBackgroundModes": [
|
"UIBackgroundModes": [
|
||||||
"fetch",
|
"fetch",
|
||||||
"remote-notification",
|
"remote-notification",
|
||||||
"fetch",
|
"fetch",
|
||||||
"remote-notification",
|
"remote-notification"
|
||||||
"audio"
|
|
||||||
],
|
],
|
||||||
"ITSAppUsesNonExemptEncryption": false
|
"ITSAppUsesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
"bundleIdentifier": "cn.qczlit.weiyou"
|
"bundleIdentifier": "skin.carrot.bbs"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"backgroundColor": "#0570F9",
|
"backgroundColor": "#E6F4FE",
|
||||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||||
"backgroundImage": "./assets/android-icon-background.png",
|
"backgroundImage": "./assets/android-icon-background.png",
|
||||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||||
},
|
},
|
||||||
"predictiveBackGestureEnabled": false,
|
"predictiveBackGestureEnabled": false,
|
||||||
"jsEngine": "hermes",
|
"package": "skin.carrot.bbs",
|
||||||
"package": "cn.qczlit.withyou",
|
"versionCode": 6,
|
||||||
"versionCode": 7,
|
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"VIBRATE",
|
"VIBRATE",
|
||||||
"RECORD_AUDIO",
|
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
"WAKE_LOCK",
|
"WAKE_LOCK",
|
||||||
"READ_EXTERNAL_STORAGE",
|
"READ_EXTERNAL_STORAGE",
|
||||||
@@ -56,28 +52,13 @@
|
|||||||
"android.permission.ACCESS_MEDIA_LOCATION",
|
"android.permission.ACCESS_MEDIA_LOCATION",
|
||||||
"android.permission.READ_MEDIA_IMAGES",
|
"android.permission.READ_MEDIA_IMAGES",
|
||||||
"android.permission.READ_MEDIA_VIDEO",
|
"android.permission.READ_MEDIA_VIDEO",
|
||||||
"android.permission.READ_MEDIA_AUDIO",
|
"android.permission.READ_MEDIA_AUDIO"
|
||||||
"android.permission.ACCESS_NETWORK_STATE",
|
|
||||||
"android.permission.ACCESS_WIFI_STATE",
|
|
||||||
"android.permission.POST_NOTIFICATIONS"
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"./plugins/withSigning",
|
|
||||||
"./plugins/withMainActivityConfigChange",
|
|
||||||
[
|
|
||||||
"./plugins/withForegroundService",
|
|
||||||
{
|
|
||||||
"notificationTitle": "威友",
|
|
||||||
"notificationBody": "正在后台同步消息",
|
|
||||||
"notificationIcon": "ic_notification",
|
|
||||||
"channelId": "withyou_sync_foreground",
|
|
||||||
"channelName": "消息同步"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"expo-router",
|
"expo-router",
|
||||||
{
|
{
|
||||||
@@ -91,28 +72,34 @@
|
|||||||
[
|
[
|
||||||
"expo-camera",
|
"expo-camera",
|
||||||
{
|
{
|
||||||
"cameraPermission": "允许威友访问您的相机以扫描二维码"
|
"cameraPermission": "允许萝卜社区访问您的相机以扫描二维码"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"expo-background-task",
|
"expo-notifications",
|
||||||
{
|
{
|
||||||
"minimumInterval": 15
|
"sounds": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"expo-background-fetch",
|
||||||
|
{
|
||||||
|
"minimumInterval": 900
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"expo-media-library",
|
"expo-media-library",
|
||||||
{
|
{
|
||||||
"photosPermission": "允许威友访问您的照片以发布内容",
|
"photosPermission": "允许萝卜社区访问您的照片以发布内容",
|
||||||
"savePhotosPermission": "允许威友保存照片到您的相册",
|
"savePhotosPermission": "允许萝卜社区保存照片到您的相册",
|
||||||
"isAccessMediaLocationEnabled": true
|
"isAccessMediaLocationEnabled": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"expo-image-picker",
|
"expo-image-picker",
|
||||||
{
|
{
|
||||||
"photosPermission": "允许威友访问您的照片以选择图片",
|
"photosPermission": "允许萝卜社区访问您的照片以选择图片",
|
||||||
"cameraPermission": "允许威友访问您的相机以拍摄图片",
|
"cameraPermission": "允许萝卜社区访问您的相机以拍摄图片",
|
||||||
"colors": {
|
"colors": {
|
||||||
"cropToolbarColor": "#111827",
|
"cropToolbarColor": "#111827",
|
||||||
"cropToolbarIconColor": "#ffffff",
|
"cropToolbarIconColor": "#ffffff",
|
||||||
@@ -137,29 +124,13 @@
|
|||||||
"proguardRules": "-keep class com.google.android.exoplayer2.** { *; }"
|
"proguardRules": "-keep class com.google.android.exoplayer2.** { *; }"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-font",
|
"expo-font"
|
||||||
[
|
|
||||||
"expo-notifications",
|
|
||||||
{
|
|
||||||
"color": "#E6F4FE",
|
|
||||||
"defaultChannel": "default",
|
|
||||||
"enableBackgroundRemoteNotifications": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
|
||||||
"./plugins/withJPush",
|
|
||||||
{
|
|
||||||
"appKey": "2cbe4da12e2c24aa3dca4610",
|
|
||||||
"channel": "developer-default"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
],
|
],
|
||||||
|
"jsEngine": "hermes",
|
||||||
"extra": {
|
"extra": {
|
||||||
"eas": {
|
"eas": {
|
||||||
"projectId": "65540196-d37d-437b-8496-227df0317069"
|
"projectId": "65540196-d37d-437b-8496-227df0317069"
|
||||||
},
|
}
|
||||||
"jpushAppKey": "2cbe4da12e2c24aa3dca4610",
|
|
||||||
"jpushChannel": "developer-default"
|
|
||||||
},
|
},
|
||||||
"owner": "qojo"
|
"owner": "qojo"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useMemo, useCallback } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Platform, useWindowDimensions } from 'react-native';
|
import { Platform, useWindowDimensions } from 'react-native';
|
||||||
import { Tabs, usePathname } from 'expo-router';
|
import { Tabs, usePathname } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import { useAppColors, shadows } from '../../../src/theme';
|
import { useAppColors, shadows } from '../../../src/theme';
|
||||||
import { BREAKPOINTS } from '../../../src/hooks';
|
import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
|
||||||
import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores';
|
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores';
|
||||||
|
|
||||||
const TAB_BAR_HEIGHT = 56;
|
const TAB_BAR_HEIGHT = 56;
|
||||||
const TAB_BAR_FLOATING_MARGIN = 12;
|
const TAB_BAR_FLOATING_MARGIN = 12;
|
||||||
@@ -19,17 +19,10 @@ export default function TabsLayout() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const unreadCount = useTotalUnreadCount();
|
const unreadCount = useTotalUnreadCount();
|
||||||
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
||||||
const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress);
|
|
||||||
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
|
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
|
||||||
|
|
||||||
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
|
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
|
||||||
|
|
||||||
const handleHomeTabPress = useCallback(() => {
|
|
||||||
if (pathname === '/home') {
|
|
||||||
triggerHomeTabPress();
|
|
||||||
}
|
|
||||||
}, [pathname, triggerHomeTabPress]);
|
|
||||||
|
|
||||||
const tabBarStyle = useMemo(() => {
|
const tabBarStyle = useMemo(() => {
|
||||||
if (hideTabBar) {
|
if (hideTabBar) {
|
||||||
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
|
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
|
||||||
@@ -88,9 +81,6 @@ export default function TabsLayout() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
listeners={{
|
|
||||||
tabPress: handleHomeTabPress,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="apps"
|
name="apps"
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
import { Stack } from 'expo-router';
|
|
||||||
|
|
||||||
export default function MaterialsStackLayout() {
|
|
||||||
return (
|
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
|
||||||
<Stack.Screen name="index" />
|
|
||||||
<Stack.Screen name="subject" />
|
|
||||||
<Stack.Screen name="detail" />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MaterialDetailScreen } from '../../../../../src/screens/material';
|
|
||||||
|
|
||||||
export default function MaterialDetailRoute() {
|
|
||||||
return <MaterialDetailScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MaterialsScreen } from '../../../../../src/screens/material';
|
|
||||||
|
|
||||||
export default function MaterialsRoute() {
|
|
||||||
return <MaterialsScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { SubjectMaterialsScreen } from '../../../../../src/screens/material';
|
|
||||||
|
|
||||||
export default function SubjectMaterialsRoute() {
|
|
||||||
return <SubjectMaterialsScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,41 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
import { Stack } from 'expo-router';
|
import { Stack } from 'expo-router';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
|
||||||
|
import { AppBackButton } from '../../../../src/components/common';
|
||||||
|
import { useAppColors } from '../../../../src/theme';
|
||||||
|
|
||||||
export default function ProfileStackLayout() {
|
export default function ProfileStackLayout() {
|
||||||
|
const router = useRouter();
|
||||||
|
const colors = useAppColors();
|
||||||
|
|
||||||
|
const headerOptions = useMemo(
|
||||||
|
() => ({
|
||||||
|
headerStyle: { backgroundColor: colors.background.paper },
|
||||||
|
headerTintColor: colors.text.primary,
|
||||||
|
headerTitleStyle: { fontWeight: '600' as const },
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerBackTitle: '',
|
||||||
|
}),
|
||||||
|
[colors]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack
|
<Stack
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
...headerOptions,
|
||||||
|
headerBackVisible: false,
|
||||||
|
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="settings" />
|
<Stack.Screen name="settings" options={{ headerShown: true, title: '设置' }} />
|
||||||
<Stack.Screen name="edit-profile" />
|
<Stack.Screen name="edit-profile" options={{ title: '编辑资料' }} />
|
||||||
<Stack.Screen name="account-security" />
|
<Stack.Screen name="account-security" options={{ title: '账号安全' }} />
|
||||||
<Stack.Screen name="my-posts" />
|
<Stack.Screen name="my-posts" options={{ title: '我的帖子' }} />
|
||||||
<Stack.Screen name="bookmarks" />
|
<Stack.Screen name="bookmarks" options={{ title: '收藏' }} />
|
||||||
<Stack.Screen name="notification-settings" />
|
<Stack.Screen name="notification-settings" options={{ title: '通知设置' }} />
|
||||||
<Stack.Screen name="blocked-users" />
|
<Stack.Screen name="blocked-users" options={{ title: '黑名单' }} />
|
||||||
<Stack.Screen name="chat-settings" />
|
|
||||||
<Stack.Screen name="about" />
|
|
||||||
<Stack.Screen name="terms" />
|
|
||||||
<Stack.Screen name="privacy" />
|
|
||||||
<Stack.Screen name="verification" />
|
|
||||||
<Stack.Screen name="data-storage" />
|
|
||||||
<Stack.Screen name="privacy-settings" />
|
|
||||||
<Stack.Screen name="account-deletion" />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { AboutScreen } from '../../../../src/screens/profile/AboutScreen';
|
|
||||||
|
|
||||||
export default function AboutRoute() {
|
|
||||||
return <AboutScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { AccountDeletionScreen } from '../../../../src/screens/profile';
|
|
||||||
|
|
||||||
export default function AccountDeletionRoute() {
|
|
||||||
return <AccountDeletionScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { ChatSettingsScreen } from '../../../../src/screens/profile/ChatSettingsScreen';
|
|
||||||
|
|
||||||
export default function ChatSettingsRoute() {
|
|
||||||
return <ChatSettingsScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { DataStorageScreen } from '../../../../src/screens/profile';
|
|
||||||
|
|
||||||
export default function DataStorageRoute() {
|
|
||||||
return <DataStorageScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { PrivacySettingsScreen } from '../../../../src/screens/profile';
|
|
||||||
|
|
||||||
export default function PrivacySettingsRoute() {
|
|
||||||
return <PrivacySettingsScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { PrivacyPolicyScreen } from '../../../../src/screens/profile/PrivacyPolicyScreen';
|
|
||||||
|
|
||||||
export default function PrivacyPolicyRoute() {
|
|
||||||
return <PrivacyPolicyScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { TermsOfServiceScreen } from '../../../../src/screens/profile/TermsOfServiceScreen';
|
|
||||||
|
|
||||||
export default function TermsOfServiceRoute() {
|
|
||||||
return <TermsOfServiceScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { VerificationSettingsScreen } from '@/screens/profile/VerificationSettingsScreen';
|
|
||||||
|
|
||||||
export default function VerificationSettingsPage() {
|
|
||||||
return <VerificationSettingsScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,80 +1,16 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Platform } from 'react-native';
|
|
||||||
import { Redirect } from 'expo-router';
|
import { Redirect } from 'expo-router';
|
||||||
|
|
||||||
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
||||||
import { messageManager, useAuthStore } from '../../src/stores';
|
import { messageManager, useAuthStore } from '../../src/stores';
|
||||||
import { jpushService } from '../../src/services/notification/jpushService';
|
|
||||||
|
|
||||||
function getDeviceType(): 'ios' | 'android' | 'web' {
|
|
||||||
switch (Platform.OS) {
|
|
||||||
case 'ios': return 'ios';
|
|
||||||
case 'android': return 'android';
|
|
||||||
default: return 'web';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getOrCreateDeviceID(): Promise<string> {
|
|
||||||
try {
|
|
||||||
const AsyncStorage = require('@react-native-async-storage/async-storage').default;
|
|
||||||
const DEVICE_ID_KEY = 'withyou_device_id';
|
|
||||||
let deviceId = await AsyncStorage.getItem(DEVICE_ID_KEY);
|
|
||||||
if (deviceId) return deviceId;
|
|
||||||
const timestamp = Date.now().toString(36);
|
|
||||||
const random = Math.random().toString(36).substring(2, 8);
|
|
||||||
deviceId = `${Platform.OS}_${timestamp}_${random}`;
|
|
||||||
await AsyncStorage.setItem(DEVICE_ID_KEY, deviceId);
|
|
||||||
return deviceId;
|
|
||||||
} catch {
|
|
||||||
return `${Platform.OS}_${Date.now()}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||||
const userID = useAuthStore((s) => s.currentUser?.id);
|
|
||||||
const deviceRegistered = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messageManager.initialize();
|
messageManager.initialize();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isAuthenticated || !userID) return;
|
|
||||||
|
|
||||||
if (!deviceRegistered.current) {
|
|
||||||
deviceRegistered.current = true;
|
|
||||||
|
|
||||||
if (Platform.OS === 'web') {
|
|
||||||
// Web: register device without push_token (JPush not available on web)
|
|
||||||
getOrCreateDeviceID().then((deviceId) => {
|
|
||||||
const { pushService } = require('../../src/services/notification/pushService');
|
|
||||||
pushService.registerDevice({
|
|
||||||
device_id: deviceId,
|
|
||||||
device_type: 'web',
|
|
||||||
device_name: 'Web Browser',
|
|
||||||
}).catch((err: any) => {
|
|
||||||
console.warn('[Push] web device register failed:', err?.message);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Mobile: JPush handles registration internally in initialize()
|
|
||||||
jpushService.initialize().then((ok) => {
|
|
||||||
if (ok && userID) {
|
|
||||||
jpushService.setAliasForUser(userID);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (!isAuthenticated) {
|
|
||||||
deviceRegistered.current = false;
|
|
||||||
jpushService.cleanup();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [isAuthenticated, userID]);
|
|
||||||
|
|
||||||
if (!isAuthenticated) {
|
if (!isAuthenticated) {
|
||||||
return <Redirect href="/login" />;
|
return <Redirect href="/login" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Redirect } from 'expo-router';
|
|||||||
|
|
||||||
import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell';
|
import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell';
|
||||||
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
||||||
import { BREAKPOINTS } from '../../src/hooks';
|
import { BREAKPOINTS } from '../../src/hooks/useResponsive';
|
||||||
import { messageManager, useAuthStore } from '../../src/stores';
|
import { messageManager, useAuthStore } from '../../src/stores';
|
||||||
|
|
||||||
export default function AppLayoutWeb() {
|
export default function AppLayoutWeb() {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { VerificationFormScreen } from '@/screens/auth/VerificationFormScreen';
|
|
||||||
|
|
||||||
export default function VerificationFormPage() {
|
|
||||||
return <VerificationFormScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { VerificationGuideScreen } from '@/screens/auth/VerificationGuideScreen';
|
|
||||||
|
|
||||||
export default function VerificationGuidePage() {
|
|
||||||
return <VerificationGuideScreen />;
|
|
||||||
}
|
|
||||||
177
app/_layout.tsx
@@ -6,27 +6,21 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
|||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
import { PaperProvider } from 'react-native-paper';
|
import { PaperProvider } from 'react-native-paper';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
import * as SystemUI from 'expo-system-ui';
|
import * as SystemUI from 'expo-system-ui';
|
||||||
import { useFonts } from 'expo-font';
|
|
||||||
|
|
||||||
import { registerNotificationPresentationHandler } from '@/services/notification';
|
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
|
||||||
import { api } from '@/services/core';
|
|
||||||
import { wsService } from '@/services/core';
|
|
||||||
import { EventSubscriber } from '../src/infrastructure/EventSubscriber';
|
|
||||||
import {
|
import {
|
||||||
ThemeBootstrap,
|
ThemeBootstrap,
|
||||||
useAppColors,
|
useAppColors,
|
||||||
usePaperThemeFromStore,
|
usePaperThemeFromStore,
|
||||||
useResolvedColorScheme,
|
useResolvedColorScheme,
|
||||||
} from '../src/theme';
|
} from '../src/theme';
|
||||||
|
import { AppBackButton } from '../src/components/common';
|
||||||
import AppPromptBar from '../src/components/common/AppPromptBar';
|
import AppPromptBar from '../src/components/common/AppPromptBar';
|
||||||
import AppDialogHost from '../src/components/common/AppDialogHost';
|
import AppDialogHost from '../src/components/common/AppDialogHost';
|
||||||
import { installAlertOverride } from '@/services/ui';
|
import { installAlertOverride } from '../src/services/alertOverride';
|
||||||
import { useAuthStore } from '../src/stores';
|
import { useAuthStore } from '../src/stores';
|
||||||
import { checkForAPKUpdate } from '@/services/platform';
|
|
||||||
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
|
|
||||||
import { jpushService } from '@/services/notification/jpushService';
|
|
||||||
|
|
||||||
registerNotificationPresentationHandler();
|
registerNotificationPresentationHandler();
|
||||||
|
|
||||||
@@ -54,34 +48,6 @@ if (Platform.OS === 'web' && typeof document !== 'undefined') {
|
|||||||
}
|
}
|
||||||
*:focus { outline: none !important; }
|
*:focus { outline: none !important; }
|
||||||
*:focus-visible { outline: none !important; }
|
*:focus-visible { outline: none !important; }
|
||||||
/* 修复移动端滑动问题 - 仅对根容器限制 */
|
|
||||||
html, body {
|
|
||||||
overscroll-behavior: none;
|
|
||||||
touch-action: manipulation;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
/* React Native Web 生成的滚动容器 - 允许双向滑动 */
|
|
||||||
[class*="css-view"] {
|
|
||||||
touch-action: auto;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
/* 水平滚动容器 */
|
|
||||||
[data-horizontal-scroll="true"],
|
|
||||||
div[style*="overflow-x"],
|
|
||||||
div[style*="overflow: scroll"],
|
|
||||||
div[style*="overflow: auto"] {
|
|
||||||
touch-action: pan-x pan-y;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
}
|
|
||||||
/* 手势区域 - 允许滑动手势 */
|
|
||||||
[data-gesture-area="true"] {
|
|
||||||
touch-action: pan-x pan-y;
|
|
||||||
}
|
|
||||||
/* 图片查看器手势区域 */
|
|
||||||
.react-native-image-viewer,
|
|
||||||
[data-image-viewer="true"] {
|
|
||||||
touch-action: pinch-zoom pan-x pan-y;
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
}
|
}
|
||||||
@@ -128,16 +94,15 @@ function SessionGate({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
function NotificationBootstrap() {
|
function NotificationBootstrap() {
|
||||||
const appState = useRef<AppStateStatus>(AppState.currentState);
|
const appState = useRef<AppStateStatus>(AppState.currentState);
|
||||||
const permissionRequested = useRef(false);
|
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(null);
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const initNotifications = async () => {
|
const initNotifications = async () => {
|
||||||
const { loadNotificationPreferences } = await import('@/services/notification');
|
const { loadNotificationPreferences } = await import('../src/services/notificationPreferences');
|
||||||
await loadNotificationPreferences();
|
await loadNotificationPreferences();
|
||||||
const { systemNotificationService } = await import('@/services/notification');
|
const { systemNotificationService } = await import('../src/services/systemNotificationService');
|
||||||
await systemNotificationService.initialize();
|
await systemNotificationService.initialize();
|
||||||
const { initBackgroundService } = await import('@/services/background');
|
const { initBackgroundService } = await import('../src/services/backgroundService');
|
||||||
await initBackgroundService();
|
await initBackgroundService();
|
||||||
|
|
||||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||||
@@ -147,86 +112,26 @@ function NotificationBootstrap() {
|
|||||||
appState.current = nextAppState;
|
appState.current = nextAppState;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for JPush notification taps — navigate to the relevant screen
|
notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener(
|
||||||
jpushService.onNotification((message) => {
|
(response) => {
|
||||||
console.log('[NotificationBootstrap] JPush notification:', message.notificationEventType, message);
|
void response.notification.request.content.data;
|
||||||
if (message.notificationEventType !== 'notificationOpened') return;
|
|
||||||
|
|
||||||
const extras = message.extras || {};
|
|
||||||
const notifType = extras.notification_type || message.notificationType;
|
|
||||||
|
|
||||||
if (notifType === 'chat_message' || notifType === 'chat') {
|
|
||||||
const conversationId = extras.conversation_id || extras.conversationId;
|
|
||||||
const senderId = extras.sender_id || extras.senderId;
|
|
||||||
const conversationType = extras.conversation_type || extras.conversationType;
|
|
||||||
const groupId = extras.group_id || extras.groupId;
|
|
||||||
const groupName = extras.group_name || extras.groupName;
|
|
||||||
const groupAvatar = extras.group_avatar || extras.groupAvatar;
|
|
||||||
|
|
||||||
if (conversationId) {
|
|
||||||
const isGroup = conversationType === 'group';
|
|
||||||
const q = new URLSearchParams();
|
|
||||||
if (isGroup) {
|
|
||||||
q.set('isGroupChat', '1');
|
|
||||||
if (groupId) q.set('groupId', groupId);
|
|
||||||
if (groupName) q.set('groupName', groupName);
|
|
||||||
if (groupAvatar) q.set('groupAvatar', groupAvatar);
|
|
||||||
} else if (senderId) {
|
|
||||||
q.set('userId', senderId);
|
|
||||||
}
|
}
|
||||||
const qs = q.toString();
|
);
|
||||||
router.push(`/chat/${encodeURIComponent(conversationId)}${qs ? `?${qs}` : ''}`);
|
|
||||||
} else {
|
const notificationReceivedSubscription = Notifications.addNotificationReceivedListener(
|
||||||
router.push('/messages/notifications');
|
(notification) => {
|
||||||
|
void notification;
|
||||||
}
|
}
|
||||||
} else {
|
);
|
||||||
router.push('/messages/notifications');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
|
notificationResponseListener.current?.remove();
|
||||||
|
notificationReceivedSubscription.remove();
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestPermissionOnLaunch = async () => {
|
|
||||||
if (permissionRequested.current) return;
|
|
||||||
permissionRequested.current = true;
|
|
||||||
|
|
||||||
if (Platform.OS === 'web') return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const hasPermission = await jpushService.checkNotificationPermission();
|
|
||||||
if (!hasPermission) {
|
|
||||||
await jpushService.requestNotificationPermission();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[NotificationBootstrap] request permission on launch failed:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
initNotifications();
|
initNotifications();
|
||||||
requestPermissionOnLaunch();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function APKUpdateBootstrap() {
|
|
||||||
const hasChecked = useRef(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasChecked.current) return;
|
|
||||||
hasChecked.current = true;
|
|
||||||
|
|
||||||
// 延迟执行,避免与启动流程冲突
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
checkForAPKUpdate().catch((error) => {
|
|
||||||
console.error('APK update check failed:', error);
|
|
||||||
});
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -234,42 +139,41 @@ function APKUpdateBootstrap() {
|
|||||||
|
|
||||||
function ThemedStack() {
|
function ThemedStack() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const colors = useAppColors();
|
||||||
const resolved = useResolvedColorScheme();
|
const resolved = useResolvedColorScheme();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||||||
<SystemChrome />
|
<SystemChrome />
|
||||||
<EventSubscriber />
|
|
||||||
<SessionGate>
|
<SessionGate>
|
||||||
<NotificationBootstrap />
|
<NotificationBootstrap />
|
||||||
<APKUpdateBootstrap />
|
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="(app)" options={{ headerShown: false }} />
|
<Stack.Screen name="(app)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen
|
|
||||||
name="terms"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="privacy"
|
|
||||||
options={{
|
|
||||||
headerShown: false,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="post/[postId]"
|
name="post/[postId]"
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: true,
|
||||||
|
title: '',
|
||||||
|
// 与 PostDetailScreen 的 SafeAreaView(background.default)同色,避免出现 header/内容之间的色差线
|
||||||
|
headerStyle: { backgroundColor: colors.background.default },
|
||||||
|
headerTintColor: colors.text.primary,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerBackVisible: false,
|
||||||
|
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="user/[userId]"
|
name="user/[userId]"
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: true,
|
||||||
|
title: '用户主页',
|
||||||
|
headerStyle: { backgroundColor: colors.background.paper },
|
||||||
|
headerTintColor: colors.text.primary,
|
||||||
|
headerBackVisible: false,
|
||||||
|
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -284,16 +188,6 @@ function ThemedProviders({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const [fontsLoaded] = useFonts({});
|
|
||||||
|
|
||||||
if (!fontsLoaded) {
|
|
||||||
return (
|
|
||||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
|
||||||
<ActivityIndicator />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
@@ -303,9 +197,6 @@ export default function RootLayout() {
|
|||||||
<ThemedStack />
|
<ThemedStack />
|
||||||
<AppPromptBar />
|
<AppPromptBar />
|
||||||
<AppDialogHost />
|
<AppDialogHost />
|
||||||
<IncomingCallModal />
|
|
||||||
<CallScreen />
|
|
||||||
<FloatingCallWindow />
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ThemedProviders>
|
</ThemedProviders>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ export default function Index() {
|
|||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
return <Redirect href="/home" />;
|
return <Redirect href="/home" />;
|
||||||
}
|
}
|
||||||
return <Redirect href="/welcome" />;
|
return <Redirect href="/login" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { PrivacyPolicyScreen } from '../src/screens/profile/PrivacyPolicyScreen';
|
|
||||||
|
|
||||||
export default function PrivacyRoute() {
|
|
||||||
return <PrivacyPolicyScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { TermsOfServiceScreen } from '../src/screens/profile/TermsOfServiceScreen';
|
|
||||||
|
|
||||||
export default function TermsRoute() {
|
|
||||||
return <TermsOfServiceScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { TradeDetailScreen } from '../../src/screens/trade/TradeDetailScreen';
|
|
||||||
import { useLocalSearchParams } from 'expo-router';
|
|
||||||
|
|
||||||
export default function TradeDetailRoute() {
|
|
||||||
const { tradeId } = useLocalSearchParams<{ tradeId: string }>();
|
|
||||||
return <TradeDetailScreen tradeId={tradeId} />;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
|
||||||
import { CreateTradeScreen } from '../../../src/screens/trade/CreateTradeScreen';
|
|
||||||
import { tradeService } from '../../../src/services/trade/tradeService';
|
|
||||||
import { Loading } from '../../../src/components/common';
|
|
||||||
import { View, Alert } from 'react-native';
|
|
||||||
import type { TradeItemDetailDTO } from '../../../src/types/trade';
|
|
||||||
|
|
||||||
export default function EditTradeRoute() {
|
|
||||||
const { tradeId } = useLocalSearchParams<{ tradeId: string }>();
|
|
||||||
const router = useRouter();
|
|
||||||
const [item, setItem] = useState<TradeItemDetailDTO | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!tradeId) return;
|
|
||||||
tradeService.getTradeItem(tradeId)
|
|
||||||
.then(data => setItem(data))
|
|
||||||
.catch(() => Alert.alert('错误', '加载商品信息失败'))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [tradeId]);
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
router.back();
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
const handleSuccess = useCallback(() => {
|
|
||||||
router.back();
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return <Loading fullScreen />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CreateTradeScreen
|
|
||||||
onClose={handleClose}
|
|
||||||
onSuccess={handleSuccess}
|
|
||||||
editItem={item}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { WelcomeScreen } from '../src/screens/auth';
|
|
||||||
|
|
||||||
export default function WelcomeRoute() {
|
|
||||||
return <WelcomeScreen />;
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.5 KiB |
BIN
assets/icon.png
|
Before Width: | Height: | Size: 385 KiB After Width: | Height: | Size: 895 KiB |
|
Before Width: | Height: | Size: 385 KiB After Width: | Height: | Size: 895 KiB |
3
eas.json
@@ -27,9 +27,6 @@
|
|||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"android": {
|
|
||||||
"buildType": "apk"
|
|
||||||
},
|
|
||||||
"ios": {
|
"ios": {
|
||||||
"resourceClass": "m-medium"
|
"resourceClass": "m-medium"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,11 @@
|
|||||||
/** @type {import('expo/metro-config').MetroConfig} */
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
const { getDefaultConfig } = require('expo/metro-config');
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const config = getDefaultConfig(__dirname);
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
// Add wasm asset support
|
// Add wasm asset support
|
||||||
config.resolver.assetExts.push('wasm');
|
config.resolver.assetExts.push('wasm');
|
||||||
|
|
||||||
// Fix for react-native-webrtc event-target-shim import warning
|
|
||||||
// The package doesn't properly export "./index" in its exports field
|
|
||||||
config.resolver.unstable_enablePackageExports = false;
|
|
||||||
|
|
||||||
// Support TypeScript path aliases (@/ -> ./src/)
|
|
||||||
config.resolver.alias = {
|
|
||||||
'@': path.resolve(__dirname, 'src'),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ensure platform-specific files are resolved correctly
|
|
||||||
config.resolver.platforms = ['ios', 'android', 'native', 'web'];
|
|
||||||
|
|
||||||
// Web shims for native-only React Native APIs that don't exist in react-native-web.
|
|
||||||
// Packages like react-native-pager-view import codegenNativeComponent from
|
|
||||||
// react-native/Libraries/Utilities/codegenNativeComponent, which calls
|
|
||||||
// requireNativeComponent internally. On web (react-native-web), these APIs
|
|
||||||
// don't exist, causing "(0 , _reactNativeWebDistIndex.requireNativeComponent)
|
|
||||||
// is not a function".
|
|
||||||
const webShimPaths = [
|
|
||||||
'react-native/Libraries/Utilities/codegenNativeComponent',
|
|
||||||
'react-native/Libraries/Utilities/codegenNativeCommands',
|
|
||||||
'react-native/Libraries/Types/CodegenTypes',
|
|
||||||
'react-native/Libraries/ReactNative/requireNativeComponent',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Packages that should be entirely replaced with web shims on web platform.
|
|
||||||
// These packages import requireNativeComponent directly from 'react-native'
|
|
||||||
// (which resolves to react-native-web on web) and don't have web implementations.
|
|
||||||
const webPackageShims = {
|
|
||||||
'react-native-webrtc': path.resolve(__dirname, 'web-shims/react-native-webrtc/index.js'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const originalResolveRequest = config.resolver.resolveRequest;
|
|
||||||
const shimDir = path.resolve(__dirname, 'web-shims');
|
|
||||||
|
|
||||||
config.resolver.resolveRequest = (context, moduleName, platform) => {
|
|
||||||
// Redirect entire native-only packages to web shims
|
|
||||||
if (platform === 'web' && webPackageShims[moduleName]) {
|
|
||||||
return {
|
|
||||||
type: 'sourceFile',
|
|
||||||
filePath: webPackageShims[moduleName],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redirect specific React Native internal modules to web shims
|
|
||||||
if (platform === 'web' && webShimPaths.some((p) => moduleName.endsWith(p))) {
|
|
||||||
// For requireNativeComponent, the path doesn't follow the Libraries/* pattern
|
|
||||||
if (moduleName.endsWith('react-native/Libraries/ReactNative/requireNativeComponent')) {
|
|
||||||
return {
|
|
||||||
type: 'sourceFile',
|
|
||||||
filePath: path.join(shimDir, 'react-native', 'Libraries', 'ReactNative', 'requireNativeComponent.js'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const shimName = moduleName.replace('react-native/Libraries/', '');
|
|
||||||
return {
|
|
||||||
type: 'sourceFile',
|
|
||||||
filePath: path.join(shimDir, 'react-native', 'Libraries', shimName + '.js'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (originalResolveRequest) {
|
|
||||||
return originalResolveRequest(context, moduleName, platform);
|
|
||||||
}
|
|
||||||
return context.resolveRequest(context, moduleName, platform);
|
|
||||||
};
|
|
||||||
|
|
||||||
// NOTE: enhanceMiddleware is marked as deprecated in Metro's types,
|
// NOTE: enhanceMiddleware is marked as deprecated in Metro's types,
|
||||||
// but there's currently no official alternative for custom dev server headers.
|
// but there's currently no official alternative for custom dev server headers.
|
||||||
// For production, configure COEP/COOP headers on your hosting platform.
|
// For production, configure COEP/COOP headers on your hosting platform.
|
||||||
|
|||||||
19
nginx.conf
@@ -1,19 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
|
||||||
|
|
||||||
gzip on;
|
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
|
||||||
gzip_min_length 256;
|
|
||||||
}
|
|
||||||
164
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "with_you",
|
"name": "carrot_bbs",
|
||||||
"version": "0.0.2",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "with_you",
|
"name": "carrot_bbs",
|
||||||
"version": "0.0.2",
|
"version": "1.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.1.1",
|
"@expo/vector-icons": "^15.1.1",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
@@ -14,11 +14,12 @@
|
|||||||
"@react-navigation/material-top-tabs": "^7.4.16",
|
"@react-navigation/material-top-tabs": "^7.4.16",
|
||||||
"@react-navigation/native": "^7.1.31",
|
"@react-navigation/native": "^7.1.31",
|
||||||
"@react-navigation/native-stack": "^7.14.2",
|
"@react-navigation/native-stack": "^7.14.2",
|
||||||
"@shopify/flash-list": "^2.3.1",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~55.0.4",
|
"expo": "~55.0.4",
|
||||||
|
"expo-background-fetch": "~55.0.10",
|
||||||
"expo-background-task": "~55.0.10",
|
"expo-background-task": "~55.0.10",
|
||||||
"expo-camera": "^55.0.10",
|
"expo-camera": "^55.0.10",
|
||||||
"expo-constants": "~55.0.7",
|
"expo-constants": "~55.0.7",
|
||||||
@@ -28,10 +29,9 @@
|
|||||||
"expo-haptics": "~55.0.8",
|
"expo-haptics": "~55.0.8",
|
||||||
"expo-image": "^55.0.5",
|
"expo-image": "^55.0.5",
|
||||||
"expo-image-picker": "^55.0.10",
|
"expo-image-picker": "^55.0.10",
|
||||||
"expo-intent-launcher": "~55.0.9",
|
|
||||||
"expo-linear-gradient": "^55.0.8",
|
"expo-linear-gradient": "^55.0.8",
|
||||||
"expo-media-library": "~55.0.9",
|
"expo-media-library": "~55.0.9",
|
||||||
"expo-notifications": "~55.0.0",
|
"expo-notifications": "^55.0.10",
|
||||||
"expo-router": "^55.0.4",
|
"expo-router": "^55.0.4",
|
||||||
"expo-sqlite": "~55.0.10",
|
"expo-sqlite": "~55.0.10",
|
||||||
"expo-status-bar": "~55.0.4",
|
"expo-status-bar": "~55.0.4",
|
||||||
@@ -39,8 +39,6 @@
|
|||||||
"expo-task-manager": "~55.0.9",
|
"expo-task-manager": "~55.0.9",
|
||||||
"expo-updates": "^55.0.12",
|
"expo-updates": "^55.0.12",
|
||||||
"expo-video": "^55.0.10",
|
"expo-video": "^55.0.10",
|
||||||
"jcore-react-native": "^2.3.5",
|
|
||||||
"jpush-react-native": "^3.2.6",
|
|
||||||
"katex": "^0.16.42",
|
"katex": "^0.16.42",
|
||||||
"markdown-it": "^14.1.1",
|
"markdown-it": "^14.1.1",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
@@ -53,10 +51,8 @@
|
|||||||
"react-native-reanimated": "^4.2.1",
|
"react-native-reanimated": "^4.2.1",
|
||||||
"react-native-safe-area-context": "~5.6.2",
|
"react-native-safe-area-context": "~5.6.2",
|
||||||
"react-native-screens": "~4.23.0",
|
"react-native-screens": "~4.23.0",
|
||||||
"react-native-share": "^12.2.6",
|
|
||||||
"react-native-sse": "^1.2.1",
|
"react-native-sse": "^1.2.1",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
"react-native-webrtc": "^124.0.7",
|
|
||||||
"react-native-webview": "13.16.0",
|
"react-native-webview": "13.16.0",
|
||||||
"react-native-worklets": "0.7.2",
|
"react-native-worklets": "0.7.2",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
@@ -1679,17 +1675,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/image-utils": {
|
"node_modules/@expo/image-utils": {
|
||||||
"version": "0.8.13",
|
"version": "0.8.12",
|
||||||
"resolved": "https://registry.npmmirror.com/@expo/image-utils/-/image-utils-0.8.13.tgz",
|
"resolved": "https://registry.npmmirror.com/@expo/image-utils/-/image-utils-0.8.12.tgz",
|
||||||
"integrity": "sha512-1I//yBQeTY6p0u1ihqGNDAr35EbSG8uFEupFrIF0jd++h9EWH33521yZJU1yE+mwGlzCb61g3ehu78siMhXBlA==",
|
"integrity": "sha512-3KguH7kyKqq7pNwLb9j6BBdD/bjmNwXZG/HPWT6GWIXbwrvAJt2JNyYTP5agWJ8jbbuys1yuCzmkX+TU6rmI7A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/require-utils": "^55.0.4",
|
|
||||||
"@expo/spawn-async": "^1.7.2",
|
"@expo/spawn-async": "^1.7.2",
|
||||||
"chalk": "^4.0.0",
|
"chalk": "^4.0.0",
|
||||||
"getenv": "^2.0.0",
|
"getenv": "^2.0.0",
|
||||||
"jimp-compact": "0.16.1",
|
"jimp-compact": "0.16.1",
|
||||||
"parse-png": "^2.1.0",
|
"parse-png": "^2.1.0",
|
||||||
|
"resolve-from": "^5.0.0",
|
||||||
"semver": "^7.6.0"
|
"semver": "^7.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2054,9 +2050,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@expo/require-utils": {
|
"node_modules/@expo/require-utils": {
|
||||||
"version": "55.0.4",
|
"version": "55.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/@expo/require-utils/-/require-utils-55.0.4.tgz",
|
"resolved": "https://registry.npmmirror.com/@expo/require-utils/-/require-utils-55.0.3.tgz",
|
||||||
"integrity": "sha512-JAANvXqV7MOysWeVWgaiDzikoyDjJWOV/ulOW60Zb3kXJfrx2oZOtGtDXDFKD1mXuahQgoM5QOjuZhF7gFRNjA==",
|
"integrity": "sha512-TS1m5tW45q4zoaTlt6DwmdYHxvFTIxoLrTHKOFrIirHIqIXnHCzpceg8wumiBi+ZXSaGY2gobTbfv+WVhJY6Fw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.20.0",
|
"@babel/code-frame": "^7.20.0",
|
||||||
@@ -3666,10 +3662,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@shopify/flash-list": {
|
"node_modules/@shopify/flash-list": {
|
||||||
"version": "2.3.1",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/@shopify/flash-list/-/flash-list-2.3.1.tgz",
|
"resolved": "https://registry.npmmirror.com/@shopify/flash-list/-/flash-list-2.0.2.tgz",
|
||||||
"integrity": "sha512-7oktg2NQR7KAODjFoDaWe8/OBzyYbdTE3zQTrUBMxjIbxHTHN7UXRX1hX3DHk8KvtkgQdRfZOV8Gjj2l4fGrXw==",
|
"integrity": "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@babel/runtime": "*",
|
"@babel/runtime": "*",
|
||||||
"react": "*",
|
"react": "*",
|
||||||
@@ -5525,9 +5524,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-application": {
|
"node_modules/expo-application": {
|
||||||
"version": "55.0.14",
|
"version": "55.0.10",
|
||||||
"resolved": "https://registry.npmmirror.com/expo-application/-/expo-application-55.0.14.tgz",
|
"resolved": "https://registry.npmmirror.com/expo-application/-/expo-application-55.0.10.tgz",
|
||||||
"integrity": "sha512-NgqDIt3eCf4aVLp1L6AcEanCYoyJeuBsGrgGSzOIvxAsOvp5X3SYKW3ROgpKUnLQEKMWlzwETpjsUGszcqkk8g==",
|
"integrity": "sha512-5ccf+S6hsQz+doi907TOJxKzV5AKgAgw004z4FoDWSoGhfab0LUPg6uyvOspuU4cbNvqw8EAy08hZbVO8nKc9Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"expo": "*"
|
"expo": "*"
|
||||||
@@ -5548,6 +5547,18 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-background-fetch": {
|
||||||
|
"version": "55.0.10",
|
||||||
|
"resolved": "https://registry.npmmirror.com/expo-background-fetch/-/expo-background-fetch-55.0.10.tgz",
|
||||||
|
"integrity": "sha512-JRSlQ2GEigUxKqHi11xLPz5/T02O7YzsV1xNKDOxr2SXSERmAN9WFD9l2F9SDw5tUz53ewI1kndHQ6FmSu+7Tw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"expo-task-manager": "~55.0.10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-background-task": {
|
"node_modules/expo-background-task": {
|
||||||
"version": "55.0.10",
|
"version": "55.0.10",
|
||||||
"resolved": "https://registry.npmmirror.com/expo-background-task/-/expo-background-task-55.0.10.tgz",
|
"resolved": "https://registry.npmmirror.com/expo-background-task/-/expo-background-task-55.0.10.tgz",
|
||||||
@@ -5581,11 +5592,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-constants": {
|
"node_modules/expo-constants": {
|
||||||
"version": "55.0.15",
|
"version": "55.0.9",
|
||||||
"resolved": "https://registry.npmmirror.com/expo-constants/-/expo-constants-55.0.15.tgz",
|
"resolved": "https://registry.npmmirror.com/expo-constants/-/expo-constants-55.0.9.tgz",
|
||||||
"integrity": "sha512-w394fcZLJjeKN+9ZnJzL/HiarE1nwZFDa+3S9frevh6Ur+MAAs9QDrcXhDrV8T3xqRzzYaqsP6Z8TFZ4efWN1A==",
|
"integrity": "sha512-iBiXjZeuU5S/8docQeNzsVvtDy4w0zlmXBpFEi1ypwugceEpdQQab65TVRbusXAcwpNVxCPMpNlDssYp0Pli2g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@expo/config": "~55.0.10",
|
||||||
"@expo/env": "~2.1.1"
|
"@expo/env": "~2.1.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -5735,15 +5747,6 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-intent-launcher": {
|
|
||||||
"version": "55.0.9",
|
|
||||||
"resolved": "https://registry.npmmirror.com/expo-intent-launcher/-/expo-intent-launcher-55.0.9.tgz",
|
|
||||||
"integrity": "sha512-s8k8dF8PfgzN0c+xzNTd9ceHh+/6mt2ncnMuqFaIIry2BeDGITbsgVijHr39eB5vS+KopCcOYYBYr+O1epx4TA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"peerDependencies": {
|
|
||||||
"expo": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/expo-json-utils": {
|
"node_modules/expo-json-utils": {
|
||||||
"version": "55.0.0",
|
"version": "55.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz",
|
||||||
@@ -5847,16 +5850,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/expo-notifications": {
|
"node_modules/expo-notifications": {
|
||||||
"version": "55.0.20",
|
"version": "55.0.13",
|
||||||
"resolved": "https://registry.npmmirror.com/expo-notifications/-/expo-notifications-55.0.20.tgz",
|
"resolved": "https://registry.npmmirror.com/expo-notifications/-/expo-notifications-55.0.13.tgz",
|
||||||
"integrity": "sha512-ENwHZtr2ApR4VaqwwYluEi+ocip2rIkZfHQVi263fZXW3WWVuPa+VxWKtT0KLcvWYGld8lEqwAHWmFWPS6aG7A==",
|
"integrity": "sha512-vbtSBcMkYtNTO+6WKdeOzysOqvtmiq/sQrUKJpYcB75m9hBFcAfI2klpXdUiGg5kMr/ygBmFENSolQt1B9QY8A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/image-utils": "^0.8.13",
|
"@expo/image-utils": "^0.8.12",
|
||||||
"abort-controller": "^3.0.0",
|
"abort-controller": "^3.0.0",
|
||||||
"badgin": "^1.1.5",
|
"badgin": "^1.1.5",
|
||||||
"expo-application": "~55.0.14",
|
"expo-application": "~55.0.10",
|
||||||
"expo-constants": "~55.0.15"
|
"expo-constants": "~55.0.8"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"expo": "*",
|
"expo": "*",
|
||||||
@@ -7354,15 +7357,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jcore-react-native": {
|
|
||||||
"version": "2.3.5",
|
|
||||||
"resolved": "https://registry.npmmirror.com/jcore-react-native/-/jcore-react-native-2.3.5.tgz",
|
|
||||||
"integrity": "sha512-rxkDPGiSyNVHjNibUGeqFaV07PbMqKZnoHJa0srt002QGUH4/9K4Ux2tK0EkqHLFvGFsJqGWwjaxEqQIPvoPTA==",
|
|
||||||
"license": "ISC",
|
|
||||||
"peerDependencies": {
|
|
||||||
"react-native": ">= 0.60"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jest-environment-node": {
|
"node_modules/jest-environment-node": {
|
||||||
"version": "29.7.0",
|
"version": "29.7.0",
|
||||||
"resolved": "https://registry.npmmirror.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz",
|
"resolved": "https://registry.npmmirror.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz",
|
||||||
@@ -7568,16 +7562,6 @@
|
|||||||
"@sideway/pinpoint": "^2.0.0"
|
"@sideway/pinpoint": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jpush-react-native": {
|
|
||||||
"version": "3.2.6",
|
|
||||||
"resolved": "https://registry.npmmirror.com/jpush-react-native/-/jpush-react-native-3.2.6.tgz",
|
|
||||||
"integrity": "sha512-dnrdfwsZcAYsQLknVMRMFml/f5cDUFlVKaqpgN41au+g2lxYvPyY7v15XZbNiMdnAjzzVgPhmUW8kuptkLtDXw==",
|
|
||||||
"license": "ISC",
|
|
||||||
"peerDependencies": {
|
|
||||||
"jcore-react-native": ">= 1.9.3",
|
|
||||||
"react-native": ">= 0.50"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -9753,15 +9737,6 @@
|
|||||||
"react-native": "*"
|
"react-native": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-native-share": {
|
|
||||||
"version": "12.2.6",
|
|
||||||
"resolved": "https://registry.npmmirror.com/react-native-share/-/react-native-share-12.2.6.tgz",
|
|
||||||
"integrity": "sha512-K9jZCQaTIqSNG37kMVygU1rflVMJm2g0ikslnbbmQ7EgsckYpw7ipePyp01E64hG+HrWNl+z9ZnFWLCC6H+Tiw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-native-sse": {
|
"node_modules/react-native-sse": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmmirror.com/react-native-sse/-/react-native-sse-1.2.1.tgz",
|
"resolved": "https://registry.npmmirror.com/react-native-sse/-/react-native-sse-1.2.1.tgz",
|
||||||
@@ -9814,55 +9789,6 @@
|
|||||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-native-webrtc": {
|
|
||||||
"version": "124.0.7",
|
|
||||||
"resolved": "https://registry.npmmirror.com/react-native-webrtc/-/react-native-webrtc-124.0.7.tgz",
|
|
||||||
"integrity": "sha512-gnXPdbUS8IkKHq9WNaWptW/yy5s6nMyI6cNn90LXdobPVCgYSk6NA2uUGdT4c4J14BRgaFA95F+cR28tUPkMVA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"base64-js": "1.5.1",
|
|
||||||
"debug": "4.3.4",
|
|
||||||
"event-target-shim": "6.0.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"react-native": ">=0.60.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-native-webrtc/node_modules/debug": {
|
|
||||||
"version": "4.3.4",
|
|
||||||
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.4.tgz",
|
|
||||||
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "2.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-native-webrtc/node_modules/event-target-shim": {
|
|
||||||
"version": "6.0.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-6.0.2.tgz",
|
|
||||||
"integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.13.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/mysticatea"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-native-webrtc/node_modules/ms": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/react-native-webview": {
|
"node_modules/react-native-webview": {
|
||||||
"version": "13.16.0",
|
"version": "13.16.0",
|
||||||
"resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz",
|
"resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz",
|
||||||
|
|||||||
14
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "with_you",
|
"name": "carrot_bbs",
|
||||||
"version": "0.0.2",
|
"version": "1.0.1",
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -20,11 +20,12 @@
|
|||||||
"@react-navigation/material-top-tabs": "^7.4.16",
|
"@react-navigation/material-top-tabs": "^7.4.16",
|
||||||
"@react-navigation/native": "^7.1.31",
|
"@react-navigation/native": "^7.1.31",
|
||||||
"@react-navigation/native-stack": "^7.14.2",
|
"@react-navigation/native-stack": "^7.14.2",
|
||||||
"@shopify/flash-list": "^2.3.1",
|
"@shopify/flash-list": "2.0.2",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~55.0.4",
|
"expo": "~55.0.4",
|
||||||
|
"expo-background-fetch": "~55.0.10",
|
||||||
"expo-background-task": "~55.0.10",
|
"expo-background-task": "~55.0.10",
|
||||||
"expo-camera": "^55.0.10",
|
"expo-camera": "^55.0.10",
|
||||||
"expo-constants": "~55.0.7",
|
"expo-constants": "~55.0.7",
|
||||||
@@ -34,10 +35,9 @@
|
|||||||
"expo-haptics": "~55.0.8",
|
"expo-haptics": "~55.0.8",
|
||||||
"expo-image": "^55.0.5",
|
"expo-image": "^55.0.5",
|
||||||
"expo-image-picker": "^55.0.10",
|
"expo-image-picker": "^55.0.10",
|
||||||
"expo-intent-launcher": "~55.0.9",
|
|
||||||
"expo-linear-gradient": "^55.0.8",
|
"expo-linear-gradient": "^55.0.8",
|
||||||
"expo-media-library": "~55.0.9",
|
"expo-media-library": "~55.0.9",
|
||||||
"expo-notifications": "~55.0.0",
|
"expo-notifications": "^55.0.10",
|
||||||
"expo-router": "^55.0.4",
|
"expo-router": "^55.0.4",
|
||||||
"expo-sqlite": "~55.0.10",
|
"expo-sqlite": "~55.0.10",
|
||||||
"expo-status-bar": "~55.0.4",
|
"expo-status-bar": "~55.0.4",
|
||||||
@@ -45,8 +45,6 @@
|
|||||||
"expo-task-manager": "~55.0.9",
|
"expo-task-manager": "~55.0.9",
|
||||||
"expo-updates": "^55.0.12",
|
"expo-updates": "^55.0.12",
|
||||||
"expo-video": "^55.0.10",
|
"expo-video": "^55.0.10",
|
||||||
"jcore-react-native": "^2.3.5",
|
|
||||||
"jpush-react-native": "^3.2.6",
|
|
||||||
"katex": "^0.16.42",
|
"katex": "^0.16.42",
|
||||||
"markdown-it": "^14.1.1",
|
"markdown-it": "^14.1.1",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
@@ -59,10 +57,8 @@
|
|||||||
"react-native-reanimated": "^4.2.1",
|
"react-native-reanimated": "^4.2.1",
|
||||||
"react-native-safe-area-context": "~5.6.2",
|
"react-native-safe-area-context": "~5.6.2",
|
||||||
"react-native-screens": "~4.23.0",
|
"react-native-screens": "~4.23.0",
|
||||||
"react-native-share": "^12.2.6",
|
|
||||||
"react-native-sse": "^1.2.1",
|
"react-native-sse": "^1.2.1",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
"react-native-webrtc": "^124.0.7",
|
|
||||||
"react-native-webview": "13.16.0",
|
"react-native-webview": "13.16.0",
|
||||||
"react-native-worklets": "0.7.2",
|
"react-native-worklets": "0.7.2",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
|
|||||||
394
plans/architecture-comparison-report.md
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
# Messages模块与PostService/Manager架构对比分析报告
|
||||||
|
|
||||||
|
## 一、Messages模块架构分析
|
||||||
|
|
||||||
|
### 1.1 架构层次结构
|
||||||
|
|
||||||
|
Messages模块采用了**清晰的分层架构**,各层职责明确:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 表现层 (Screens) │
|
||||||
|
│ ChatScreen, MessageListScreen, NotificationsScreen │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Hooks层 │
|
||||||
|
│ useDifferentialMessages, useChatScreen │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 用例层 (UseCases) │
|
||||||
|
│ ProcessMessageUseCase │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 领域层 (Entities) │
|
||||||
|
│ Message, Conversation, GroupNotice │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 仓库层 (Repositories) │
|
||||||
|
│ MessageRepository, IMessageRepository │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 数据源层 (DataSources) │
|
||||||
|
│ SSEClient, LocalDataSource, ApiDataSource │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 核心组件详解
|
||||||
|
|
||||||
|
#### 1.2.1 领域实体层 (Core/Entities)
|
||||||
|
- **Message.ts**: 定义消息、会话、群通知等核心领域模型
|
||||||
|
- 包含工厂函数:`createMessage`, `createConversation`
|
||||||
|
- 包含业务逻辑函数:`isMessageFromCurrentUser`, `shouldIncrementUnread`, `buildTextContent`
|
||||||
|
|
||||||
|
#### 1.2.2 用例层 (Core/UseCases)
|
||||||
|
- **ProcessMessageUseCase**: 核心业务逻辑编排器
|
||||||
|
- 订阅SSE事件(chat, group_message, read, recall, typing, group_notice等)
|
||||||
|
- 消息去重处理(processedMessageIds Set)
|
||||||
|
- 已读状态保护(pendingReadMap + 版本号机制)
|
||||||
|
- 用户信息缓存与去重请求(pendingUserRequests Map)
|
||||||
|
- 事件发布订阅模式(subscribers Set)
|
||||||
|
|
||||||
|
#### 1.2.3 仓库层 (Data/Repositories)
|
||||||
|
- **IMessageRepository**: 定义数据访问接口
|
||||||
|
- 消息操作:getMessages, saveMessage, updateMessageStatus, markAsRead
|
||||||
|
- 会话操作:getConversations, saveConversation, updateUnreadCount
|
||||||
|
- 同步操作:syncMessages, getServerLastSeq
|
||||||
|
|
||||||
|
- **MessageRepository**: 实现接口
|
||||||
|
- 封装SQLite数据库操作
|
||||||
|
- 消息与CachedMessage的转换
|
||||||
|
|
||||||
|
#### 1.2.4 映射器层 (Data/Mappers)
|
||||||
|
- **MessageMapper**: 数据转换
|
||||||
|
- `fromApiResponse`: API响应 → 应用模型
|
||||||
|
- `fromDbRecord`: 数据库记录 → 应用模型
|
||||||
|
- `toDbRecord`: 应用模型 → 数据库记录
|
||||||
|
- `toApiRequest`: 应用模型 → API请求
|
||||||
|
- 创建消息片段:`createTextSegment`, `createImageSegment`
|
||||||
|
|
||||||
|
#### 1.2.5 差异计算基础设施 (Infrastructure/Diff)
|
||||||
|
- **MessageDiffCalculator**: 消息列表差异计算
|
||||||
|
- 计算added, updated, deleted, moved, unchanged
|
||||||
|
- 变化比例检测(changeRatio > 0.5 时触发重置)
|
||||||
|
- 增量差异计算(基于缓存)
|
||||||
|
|
||||||
|
- **MessageUpdateBatcher**: 批量更新处理器
|
||||||
|
- 16ms批量间隔(约60fps)
|
||||||
|
- 去重和合并更新
|
||||||
|
- 100ms最大等待时间
|
||||||
|
- 统计信息:totalBatches, totalUpdates, averageBatchSize
|
||||||
|
|
||||||
|
- **types.ts**: 完整的类型定义
|
||||||
|
- MessageUpdateType枚举:ADD, UPDATE, DELETE, MOVE, BATCH_*
|
||||||
|
- DiffResult, DiffConfig等接口
|
||||||
|
|
||||||
|
#### 1.2.6 Hook层
|
||||||
|
- **useDifferentialMessages**: 差异更新Hook
|
||||||
|
- 集成DiffCalculator和Batcher
|
||||||
|
- 自动处理批量更新
|
||||||
|
- 提供flush, reset, forceUpdate方法
|
||||||
|
- 支持配置:enableDiff, enableBatching, maxMessageCount, changeRatioThreshold
|
||||||
|
|
||||||
|
### 1.3 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
SSE事件 → ProcessMessageUseCase → 事件发布 → useChatScreen → useDifferentialMessages
|
||||||
|
↓ ↓
|
||||||
|
MessageRepository 差异计算 + 批量处理
|
||||||
|
↓ ↓
|
||||||
|
SQLite数据库 优化后的消息列表
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 状态管理方式
|
||||||
|
|
||||||
|
- **发布-订阅模式**:ProcessMessageUseCase作为事件中心
|
||||||
|
- **本地SQLite缓存**:消息持久化存储
|
||||||
|
- **内存状态**:通过Hook返回,组件直接消费
|
||||||
|
- **差异更新**:通过MessageDiffCalculator + MessageUpdateBatcher减少重渲染
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、PostService/Manager架构分析
|
||||||
|
|
||||||
|
### 2.1 架构层次结构
|
||||||
|
|
||||||
|
Post模块采用了**扁平化架构**,Service和Manager层职责混合:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 表现层 (Screens) │
|
||||||
|
│ HomeScreen, PostDetailScreen, CreatePostScreen │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Hooks层 │
|
||||||
|
│ useCursorPagination (通用), usePrefetch │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Store层 (Zustand) │
|
||||||
|
│ useUserStore (直接操作状态), postManager (缓存管理) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Service层 │
|
||||||
|
│ postService (API调用 + 状态更新) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 数据源 │
|
||||||
|
│ API (直接调用) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 核心组件详解
|
||||||
|
|
||||||
|
#### 2.2.1 Service层 (Services)
|
||||||
|
- **postService.ts**: 帖子服务
|
||||||
|
- CRUD操作:getPosts, getPost, createPost, updatePost, deletePost
|
||||||
|
- 互动操作:likePost, unlikePost, favoritePost, unfavoritePost
|
||||||
|
- 分页支持:传统分页 + 游标分页
|
||||||
|
- **问题**:直接操作useUserStore(违反分层原则)
|
||||||
|
|
||||||
|
#### 2.2.2 Manager层 (Stores)
|
||||||
|
- **postManager.ts**: 帖子缓存管理器
|
||||||
|
- 继承CacheBus(发布订阅基类)
|
||||||
|
- 内存缓存:listCache, detailCache
|
||||||
|
- 请求去重:pendingRequests Map
|
||||||
|
- TTL管理:LIST_TTL=30s, DETAIL_TTL=60s
|
||||||
|
- 后台刷新机制
|
||||||
|
|
||||||
|
#### 2.2.3 Store层 (Zustand)
|
||||||
|
- **useUserStore**: 用户状态存储
|
||||||
|
- 直接存储posts数组
|
||||||
|
- 帖子操作副作用直接修改状态
|
||||||
|
- 混合了用户数据和帖子数据
|
||||||
|
|
||||||
|
#### 2.2.4 映射器层 (Data/Mappers)
|
||||||
|
- **PostMapper**: 数据转换
|
||||||
|
- `fromApiResponse`: API响应 → 应用模型
|
||||||
|
- `fromApiResponseList`: 批量转换
|
||||||
|
- `toApiRequest`: 应用模型 → API请求
|
||||||
|
- 相比MessageMapper缺少数据库记录转换方法
|
||||||
|
|
||||||
|
### 2.3 数据流
|
||||||
|
|
||||||
|
```
|
||||||
|
HomeScreen → useCursorPagination → postService.getPostsCursor
|
||||||
|
↓
|
||||||
|
useUserStore.setState(posts)
|
||||||
|
↓
|
||||||
|
PostCard组件渲染
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 状态管理方式
|
||||||
|
|
||||||
|
- **Zustand Store**:集中式状态管理
|
||||||
|
- **内存缓存**:postManager提供应用级缓存
|
||||||
|
- **请求去重**:dedupe方法防止重复请求
|
||||||
|
- **直接修改**:postService直接调用useUserStore.setState
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、主要架构差异
|
||||||
|
|
||||||
|
### 3.1 分层复杂度
|
||||||
|
|
||||||
|
| 维度 | Messages模块 | Post模块 |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| 层次深度 | 6层(Entity→UseCase→Repository→Mapper→Hook→Screen) | 4层(Service→Store→Hook→Screen) |
|
||||||
|
| 用例层 | 独立ProcessMessageUseCase | 无 |
|
||||||
|
| 仓库接口 | IMessageRepository抽象接口 | 无 |
|
||||||
|
| 基础设施 | Diff模块(Calculator + Batcher) | 无 |
|
||||||
|
|
||||||
|
### 3.2 数据持久化
|
||||||
|
|
||||||
|
| 维度 | Messages模块 | Post模块 |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| 本地存储 | SQLite数据库 | 无 |
|
||||||
|
| 缓存抽象 | MessageRepository封装 | postManager内存缓存 |
|
||||||
|
| 离线支持 | 完整 | 不支持 |
|
||||||
|
|
||||||
|
### 3.3 实时更新
|
||||||
|
|
||||||
|
| 维度 | Messages模块 | Post模块 |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| 推送机制 | SSE实时推送 | 轮询/下拉刷新 |
|
||||||
|
| 事件订阅 | ProcessMessageUseCase发布订阅 | 无 |
|
||||||
|
| 增量更新 | MessageDiffCalculator | 无 |
|
||||||
|
|
||||||
|
### 3.4 状态管理
|
||||||
|
|
||||||
|
| 维度 | Messages模块 | Post模块 |
|
||||||
|
|------|-------------|---------|
|
||||||
|
| 管理方式 | 发布订阅 + Hook局部状态 | Zustand全局Store |
|
||||||
|
| 状态来源 | useChatScreen, useDifferentialMessages | useUserStore |
|
||||||
|
| 批量更新 | MessageUpdateBatcher | 无 |
|
||||||
|
| 差异检测 | MessageDiffCalculator | 无 |
|
||||||
|
|
||||||
|
### 3.5 依赖方向
|
||||||
|
|
||||||
|
**Messages模块(正确)**:
|
||||||
|
```
|
||||||
|
Hook → UseCase → Repository → DataSource
|
||||||
|
↓
|
||||||
|
Entity(不依赖外部)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Post模块(问题)**:
|
||||||
|
```
|
||||||
|
Service → Store(循环依赖风险)
|
||||||
|
↓
|
||||||
|
API直接调用
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 核心问题总结
|
||||||
|
|
||||||
|
| # | 问题 | Messages | Post |
|
||||||
|
|---|------|---------|------|
|
||||||
|
| 1 | 违反分层原则 | 无 | postService直接操作useUserStore |
|
||||||
|
| 2 | 缺少UseCase层 | ProcessMessageUseCase | 无业务逻辑编排 |
|
||||||
|
| 3 | 缺少Repository抽象 | IMessageRepository | 无数据访问接口 |
|
||||||
|
| 4 | 无差异更新 | useDifferentialMessages | 无 |
|
||||||
|
| 5 | 无批量处理 | MessageUpdateBatcher | 无 |
|
||||||
|
| 6 | 无本地持久化 | SQLite | 无 |
|
||||||
|
| 7 | 无实时推送 | SSE | 无 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、对齐建议
|
||||||
|
|
||||||
|
### 4.1 架构重构目标
|
||||||
|
|
||||||
|
将Post模块对齐到Messages模块的架构模式:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ 表现层 (Screens) │
|
||||||
|
│ HomeScreen, PostDetailScreen │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Hooks层 │
|
||||||
|
│ useDifferentialPosts (新增) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 用例层 (UseCases) │
|
||||||
|
│ ProcessPostUseCase (新增) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 领域层 (Entities) │
|
||||||
|
│ Post, PostComment (新增) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ 仓库层 (Repositories) │
|
||||||
|
│ PostRepository, IPostRepository (新增) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Service层 │
|
||||||
|
│ postService (仅保留API调用,移除状态操作) │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 具体改造项
|
||||||
|
|
||||||
|
#### 4.2.1 创建Post领域实体
|
||||||
|
```typescript
|
||||||
|
// src/core/entities/Post.ts
|
||||||
|
export interface Post {
|
||||||
|
id: string;
|
||||||
|
authorId: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
images: string[];
|
||||||
|
likeCount: number;
|
||||||
|
commentCount: number;
|
||||||
|
// ... 其他字段
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2.2 创建ProcessPostUseCase
|
||||||
|
```typescript
|
||||||
|
// src/core/usecases/ProcessPostUseCase.ts
|
||||||
|
class ProcessPostUseCase {
|
||||||
|
// 帖子增删改查
|
||||||
|
// 点赞/收藏逻辑
|
||||||
|
// 事件发布订阅
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2.3 创建IPostRepository接口
|
||||||
|
```typescript
|
||||||
|
// src/data/repositories/interfaces/IPostRepository.ts
|
||||||
|
interface IPostRepository {
|
||||||
|
getPosts(type: string, page: number, pageSize: number): Promise<Post[]>;
|
||||||
|
savePost(post: Post): Promise<void>;
|
||||||
|
updatePost(postId: string, updates: Partial<Post>): Promise<void>;
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2.4 创建PostRepository实现
|
||||||
|
```typescript
|
||||||
|
// src/data/repositories/PostRepository.ts
|
||||||
|
// 封装SQLite操作,实现IPostRepository接口
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2.5 创建useDifferentialPosts Hook
|
||||||
|
```typescript
|
||||||
|
// src/hooks/useDifferentialPosts.ts
|
||||||
|
// 类似useDifferentialMessages,处理帖子列表差异更新
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2.6 改造postService
|
||||||
|
- 移除对useUserStore的直接依赖
|
||||||
|
- 仅保留API调用职责
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、架构对比图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "Messages模块 [理想架构]"
|
||||||
|
E1[Entity<br/>Message.ts]
|
||||||
|
U1[UseCase<br/>ProcessMessageUseCase]
|
||||||
|
R1[Repository<br/>MessageRepository]
|
||||||
|
M1[Mapper<br/>MessageMapper]
|
||||||
|
H1[Hook<br/>useDifferentialMessages]
|
||||||
|
D1[Diff基础设施<br/>DiffCalculator + Batcher]
|
||||||
|
S1[(SQLite)]
|
||||||
|
|
||||||
|
E1 --> U1
|
||||||
|
U1 --> R1
|
||||||
|
U1 --> D1
|
||||||
|
R1 --> S1
|
||||||
|
M1 --> R1
|
||||||
|
D1 --> H1
|
||||||
|
H1 --> S1
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Post模块 [当前架构]"
|
||||||
|
E2[PostMapper]
|
||||||
|
SV2[postService<br/>直接操作Store]
|
||||||
|
ST2[useUserStore<br/>Zustand]
|
||||||
|
H2[Hook<br/>useCursorPagination]
|
||||||
|
PM2[postManager<br/>内存缓存]
|
||||||
|
|
||||||
|
E2 --> SV2
|
||||||
|
SV2 --> ST2
|
||||||
|
H2 --> SV2
|
||||||
|
PM2 --> ST2
|
||||||
|
end
|
||||||
|
|
||||||
|
style E1 fill:#90EE90
|
||||||
|
style U1 fill:#90EE90
|
||||||
|
style R1 fill:#90EE90
|
||||||
|
style D1 fill:#90EE90
|
||||||
|
style H1 fill:#90EE90
|
||||||
|
style S1 fill:#90EE90
|
||||||
|
|
||||||
|
style SV2 fill:#FFB6C1
|
||||||
|
style ST2 fill:#FFB6C1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、结论
|
||||||
|
|
||||||
|
Messages模块是一个**架构完善**的模块,具备:
|
||||||
|
1. 清晰的分层架构
|
||||||
|
2. 独立的业务逻辑编排层(UseCase)
|
||||||
|
3. 完整的数据持久化(SQLite)
|
||||||
|
4. 高效的差异更新机制
|
||||||
|
5. 实时事件推送能力
|
||||||
|
|
||||||
|
Post模块当前存在以下问题:
|
||||||
|
1. **违反分层原则**:Service直接操作Store
|
||||||
|
2. **缺少UseCase层**:业务逻辑分散
|
||||||
|
3. **无数据抽象**:缺少Repository接口
|
||||||
|
4. **无差异更新**:每次全量更新导致性能问题
|
||||||
|
5. **无本地持久化**:无法离线使用
|
||||||
|
|
||||||
|
建议按照对齐建议逐步重构Post模块,使其架构与Messages模块对齐。
|
||||||
@@ -1,467 +0,0 @@
|
|||||||
const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const FOREGROUND_SERVICE_PERMISSION = 'android.permission.FOREGROUND_SERVICE';
|
|
||||||
const FOREGROUND_SERVICE_DATA_SYNC = 'android.permission.FOREGROUND_SERVICE_DATA_SYNC';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expo Config Plugin:为 Android 添加前台服务保活
|
|
||||||
*
|
|
||||||
* 功能:
|
|
||||||
* 1. 添加 FOREGROUND_SERVICE 相关权限
|
|
||||||
* 2. 在 AndroidManifest 中声明 Service
|
|
||||||
* 3. 生成 Kotlin 原生服务代码
|
|
||||||
* 4. 生成 React Native Bridge 模块
|
|
||||||
*
|
|
||||||
* 参考:Element Android 的 GuardAndroidService
|
|
||||||
*/
|
|
||||||
const withForegroundService = (config, options = {}) => {
|
|
||||||
const {
|
|
||||||
notificationTitle = '威友',
|
|
||||||
notificationBody = '正在后台同步消息',
|
|
||||||
notificationIcon = 'ic_notification',
|
|
||||||
channelId = 'withyou_sync_foreground',
|
|
||||||
channelName = '消息同步',
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// 1. 添加权限和 Service 声明到 AndroidManifest
|
|
||||||
config = withAndroidManifest(config, (config) => {
|
|
||||||
const manifest = config.modResults;
|
|
||||||
|
|
||||||
// 添加权限声明
|
|
||||||
const permissions = [
|
|
||||||
FOREGROUND_SERVICE_PERMISSION,
|
|
||||||
FOREGROUND_SERVICE_DATA_SYNC,
|
|
||||||
];
|
|
||||||
|
|
||||||
permissions.forEach((permission) => {
|
|
||||||
const existingPermission = manifest.manifest['uses-permission']?.find(
|
|
||||||
(p) => p.$['android:name'] === permission
|
|
||||||
);
|
|
||||||
if (!existingPermission) {
|
|
||||||
if (!manifest.manifest['uses-permission']) {
|
|
||||||
manifest.manifest['uses-permission'] = [];
|
|
||||||
}
|
|
||||||
manifest.manifest['uses-permission'].push({
|
|
||||||
$: { 'android:name': permission },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加 Service 声明
|
|
||||||
if (!manifest.manifest.application[0].service) {
|
|
||||||
manifest.manifest.application[0].service = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingService = manifest.manifest.application[0].service.find(
|
|
||||||
(s) => s.$['android:name'] === '.SyncForegroundService'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!existingService) {
|
|
||||||
manifest.manifest.application[0].service.push({
|
|
||||||
$: {
|
|
||||||
'android:name': '.SyncForegroundService',
|
|
||||||
'android:enabled': 'true',
|
|
||||||
'android:exported': 'false',
|
|
||||||
'android:foregroundServiceType': 'dataSync',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 生成 Kotlin 原生代码
|
|
||||||
config = withDangerousMod(config, [
|
|
||||||
'android',
|
|
||||||
async (config) => {
|
|
||||||
const projectRoot = config.modRequest.projectRoot;
|
|
||||||
const packageName = config.android.package;
|
|
||||||
const kotlinPath = path.join(
|
|
||||||
projectRoot,
|
|
||||||
'android/app/src/main/java',
|
|
||||||
packageName.replace(/\./g, '/')
|
|
||||||
);
|
|
||||||
|
|
||||||
// 确保目录存在
|
|
||||||
if (!fs.existsSync(kotlinPath)) {
|
|
||||||
fs.mkdirSync(kotlinPath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成 SyncForegroundService.kt
|
|
||||||
const serviceCode = generateServiceCode(packageName, {
|
|
||||||
notificationTitle,
|
|
||||||
notificationBody,
|
|
||||||
notificationIcon,
|
|
||||||
channelId,
|
|
||||||
channelName,
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(kotlinPath, 'SyncForegroundService.kt'),
|
|
||||||
serviceCode
|
|
||||||
);
|
|
||||||
|
|
||||||
// 生成 ForegroundServiceModule.kt (React Native Bridge)
|
|
||||||
const moduleCode = generateModuleCode(packageName);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(kotlinPath, 'ForegroundServiceModule.kt'),
|
|
||||||
moduleCode
|
|
||||||
);
|
|
||||||
|
|
||||||
// 生成 ForegroundServicePackage.kt
|
|
||||||
const packageCode = generatePackageCode(packageName);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(kotlinPath, 'ForegroundServicePackage.kt'),
|
|
||||||
packageCode
|
|
||||||
);
|
|
||||||
|
|
||||||
// 修改 MainApplication.kt 注册模块
|
|
||||||
const mainAppPath = path.join(kotlinPath, 'MainApplication.kt');
|
|
||||||
if (fs.existsSync(mainAppPath)) {
|
|
||||||
let content = fs.readFileSync(mainAppPath, 'utf-8');
|
|
||||||
|
|
||||||
// 添加 import
|
|
||||||
if (!content.includes('ForegroundServicePackage')) {
|
|
||||||
content = content.replace(
|
|
||||||
/package .*\n/,
|
|
||||||
`$&\nimport ${packageName}.ForegroundServicePackage\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在 packages 列表中添加
|
|
||||||
if (!content.includes('ForegroundServicePackage()')) {
|
|
||||||
// 查找 PackageList(this).packages.apply 块
|
|
||||||
content = content.replace(
|
|
||||||
/(PackageList\(this\)\.packages\.apply\s*\{)/,
|
|
||||||
`$1\n // 前台服务模块\n add(ForegroundServicePackage())`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(mainAppPath, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建通知图标目录和文件
|
|
||||||
const drawablePath = path.join(projectRoot, 'android/app/src/main/res/drawable');
|
|
||||||
if (!fs.existsSync(drawablePath)) {
|
|
||||||
fs.mkdirSync(drawablePath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconPath = path.join(drawablePath, 'ic_notification.xml');
|
|
||||||
if (!fs.existsSync(iconPath)) {
|
|
||||||
fs.writeFileSync(iconPath, generateNotificationIcon());
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return config;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成前台服务 Kotlin 代码
|
|
||||||
*/
|
|
||||||
function generateServiceCode(packageName, options) {
|
|
||||||
const { notificationTitle, notificationBody, notificationIcon, channelId, channelName } = options;
|
|
||||||
|
|
||||||
return `package ${packageName}
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 前台同步服务
|
|
||||||
*
|
|
||||||
* 在通知栏显示常驻通知,防止应用进程被系统杀死
|
|
||||||
* 类似 Element Android 的 GuardAndroidService 和 V2Ray 的保活机制
|
|
||||||
*/
|
|
||||||
class SyncForegroundService : Service() {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val NOTIFICATION_ID = 1001
|
|
||||||
const val CHANNEL_ID = "${channelId}"
|
|
||||||
const val CHANNEL_NAME = "${channelName}"
|
|
||||||
|
|
||||||
const val ACTION_START = "${packageName}.foreground.START"
|
|
||||||
const val ACTION_STOP = "${packageName}.foreground.STOP"
|
|
||||||
const val ACTION_UPDATE = "${packageName}.foreground.UPDATE"
|
|
||||||
|
|
||||||
const val EXTRA_TITLE = "title"
|
|
||||||
const val EXTRA_BODY = "body"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动前台服务
|
|
||||||
*/
|
|
||||||
fun start(context: Context, title: String = "${notificationTitle}", body: String = "${notificationBody}") {
|
|
||||||
val intent = Intent(context, SyncForegroundService::class.java).apply {
|
|
||||||
action = ACTION_START
|
|
||||||
putExtra(EXTRA_TITLE, title)
|
|
||||||
putExtra(EXTRA_BODY, body)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
ContextCompat.startForegroundService(context, intent)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.e("SyncForegroundService", "启动前台服务失败", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止前台服务
|
|
||||||
*/
|
|
||||||
fun stop(context: Context) {
|
|
||||||
val intent = Intent(context, SyncForegroundService::class.java).apply {
|
|
||||||
action = ACTION_STOP
|
|
||||||
}
|
|
||||||
context.startService(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新通知内容
|
|
||||||
*/
|
|
||||||
fun update(context: Context, title: String, body: String) {
|
|
||||||
val intent = Intent(context, SyncForegroundService::class.java).apply {
|
|
||||||
action = ACTION_UPDATE
|
|
||||||
putExtra(EXTRA_TITLE, title)
|
|
||||||
putExtra(EXTRA_BODY, body)
|
|
||||||
}
|
|
||||||
context.startService(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var notificationTitle: String = "${notificationTitle}"
|
|
||||||
private var notificationBody: String = "${notificationBody}"
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
createNotificationChannel()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
when (intent?.action) {
|
|
||||||
ACTION_START -> {
|
|
||||||
notificationTitle = intent.getStringExtra(EXTRA_TITLE) ?: "${notificationTitle}"
|
|
||||||
notificationBody = intent.getStringExtra(EXTRA_BODY) ?: "${notificationBody}"
|
|
||||||
startForegroundCompat()
|
|
||||||
}
|
|
||||||
ACTION_STOP -> {
|
|
||||||
stopForegroundCompat()
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
ACTION_UPDATE -> {
|
|
||||||
notificationTitle = intent.getStringExtra(EXTRA_TITLE) ?: notificationTitle
|
|
||||||
notificationBody = intent.getStringExtra(EXTRA_BODY) ?: notificationBody
|
|
||||||
updateNotification()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// START_STICKY: 服务被杀后自动重启
|
|
||||||
return START_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动前台服务(兼容 Android Q+)
|
|
||||||
*/
|
|
||||||
private fun startForegroundCompat() {
|
|
||||||
val notification = buildNotification()
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
startForeground(
|
|
||||||
NOTIFICATION_ID,
|
|
||||||
notification,
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止前台服务(兼容 Android N+)
|
|
||||||
*/
|
|
||||||
private fun stopForegroundCompat() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
stopForeground(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建通知渠道
|
|
||||||
*/
|
|
||||||
private fun createNotificationChannel() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
CHANNEL_NAME,
|
|
||||||
NotificationManager.IMPORTANCE_MIN // 最低重要性,不打扰用户
|
|
||||||
).apply {
|
|
||||||
description = "后台消息同步服务"
|
|
||||||
setSound(null, null)
|
|
||||||
setShowBadge(false)
|
|
||||||
enableLights(false)
|
|
||||||
enableVibration(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
|
||||||
manager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建通知
|
|
||||||
*/
|
|
||||||
private fun buildNotification(): Notification {
|
|
||||||
// 点击通知打开应用
|
|
||||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
launchIntent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
|
||||||
.setContentTitle(notificationTitle)
|
|
||||||
.setContentText(notificationBody)
|
|
||||||
.setSmallIcon(R.drawable.${notificationIcon})
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
|
||||||
.setOngoing(true) // 不可滑动清除
|
|
||||||
.setShowWhen(false)
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新通知内容
|
|
||||||
*/
|
|
||||||
private fun updateNotification() {
|
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
|
||||||
manager.notify(NOTIFICATION_ID, buildNotification())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成 React Native Bridge 模块代码
|
|
||||||
*/
|
|
||||||
function generateModuleCode(packageName) {
|
|
||||||
return `package ${packageName}
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.facebook.react.bridge.ReactApplicationContext
|
|
||||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
||||||
import com.facebook.react.bridge.ReactMethod
|
|
||||||
import com.facebook.react.bridge.Promise
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React Native Bridge: 前台服务控制模块
|
|
||||||
*/
|
|
||||||
class ForegroundServiceModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
|
||||||
|
|
||||||
override fun getName(): String = "ForegroundService"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动前台服务
|
|
||||||
*/
|
|
||||||
@ReactMethod
|
|
||||||
fun start(title: String, body: String, promise: Promise) {
|
|
||||||
try {
|
|
||||||
SyncForegroundService.start(reactApplicationContext, title, body)
|
|
||||||
promise.resolve(true)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止前台服务
|
|
||||||
*/
|
|
||||||
@ReactMethod
|
|
||||||
fun stop(promise: Promise) {
|
|
||||||
try {
|
|
||||||
SyncForegroundService.stop(reactApplicationContext)
|
|
||||||
promise.resolve(true)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新通知内容
|
|
||||||
*/
|
|
||||||
@ReactMethod
|
|
||||||
fun update(title: String, body: String, promise: Promise) {
|
|
||||||
try {
|
|
||||||
SyncForegroundService.update(reactApplicationContext, title, body)
|
|
||||||
promise.resolve(true)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成 React Native Package 代码
|
|
||||||
*/
|
|
||||||
function generatePackageCode(packageName) {
|
|
||||||
return `package ${packageName}
|
|
||||||
|
|
||||||
import com.facebook.react.ReactPackage
|
|
||||||
import com.facebook.react.bridge.NativeModule
|
|
||||||
import com.facebook.react.bridge.ReactApplicationContext
|
|
||||||
import com.facebook.react.uimanager.ViewManager
|
|
||||||
|
|
||||||
class ForegroundServicePackage : ReactPackage {
|
|
||||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
|
||||||
return listOf(ForegroundServiceModule(reactContext))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成通知图标 XML
|
|
||||||
*/
|
|
||||||
function generateNotificationIcon() {
|
|
||||||
return `<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<!-- 背景圆 -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#4CAF50"
|
|
||||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
|
|
||||||
<!-- 对勾符号 -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"/>
|
|
||||||
</vector>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = withForegroundService;
|
|
||||||
@@ -1,283 +0,0 @@
|
|||||||
const {
|
|
||||||
withAndroidManifest,
|
|
||||||
withDangerousMod,
|
|
||||||
withAppDelegate,
|
|
||||||
withInfoPlist,
|
|
||||||
withAppBuildGradle,
|
|
||||||
} = require('@expo/config-plugins');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const withJPush = (config, options = {}) => {
|
|
||||||
const { appKey, channel = 'developer-default' } = options;
|
|
||||||
|
|
||||||
if (!appKey) {
|
|
||||||
throw new Error('JPush appKey is required in plugin options');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Android: Inject manifestPlaceholders into app/build.gradle
|
|
||||||
config = withAppBuildGradle(config, (config) => {
|
|
||||||
const block = '\n manifestPlaceholders = [\n' +
|
|
||||||
' JPUSH_APPKEY: "' + appKey + '",\n' +
|
|
||||||
' JPUSH_CHANNEL: "' + channel + '"\n' +
|
|
||||||
' ]';
|
|
||||||
|
|
||||||
const contents = config.modResults.contents;
|
|
||||||
|
|
||||||
if (contents.includes('manifestPlaceholders')) {
|
|
||||||
config.modResults.contents = contents.replace(
|
|
||||||
/manifestPlaceholders\s*=\s*\[[\s\S]*?\]/,
|
|
||||||
block.trim()
|
|
||||||
);
|
|
||||||
} else if (contents.includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
|
||||||
const lines = contents.split('\n');
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
if (lines[i].includes('REACT_NATIVE_RELEASE_LEVEL')) {
|
|
||||||
lines.splice(i + 1, 0, block);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
config.modResults.contents = lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Android: Add JPush metadata and permissions to AndroidManifest
|
|
||||||
config = withAndroidManifest(config, (config) => {
|
|
||||||
const manifest = config.modResults;
|
|
||||||
|
|
||||||
if (!manifest.manifest['uses-permission']) {
|
|
||||||
manifest.manifest['uses-permission'] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const requiredPermissions = [
|
|
||||||
'android.permission.RECEIVE_USER_PRESENT',
|
|
||||||
'android.permission.POST_NOTIFICATIONS',
|
|
||||||
];
|
|
||||||
|
|
||||||
requiredPermissions.forEach((permission) => {
|
|
||||||
const exists = manifest.manifest['uses-permission'].find(
|
|
||||||
(p) => p.$['android:name'] === permission
|
|
||||||
);
|
|
||||||
if (!exists) {
|
|
||||||
manifest.manifest['uses-permission'].push({
|
|
||||||
$: { 'android:name': permission },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. iOS: Add JPush initialization to AppDelegate
|
|
||||||
config = withAppDelegate(config, (config) => {
|
|
||||||
const { contents } = config.modResults;
|
|
||||||
|
|
||||||
if (contents.includes('JPush') || contents.includes('JCoreModule')) {
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSwift = contents.includes('class AppDelegate') || contents.includes('@main');
|
|
||||||
|
|
||||||
if (isSwift) {
|
|
||||||
let newContents = contents;
|
|
||||||
|
|
||||||
if (!newContents.includes('import JPushRN')) {
|
|
||||||
if (newContents.includes('import UIKit')) {
|
|
||||||
newContents = newContents.replace(
|
|
||||||
/(import UIKit\n)/,
|
|
||||||
`$1import UserNotifications\n`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
newContents = newContents.replace(
|
|
||||||
/(internal import Expo\n)/,
|
|
||||||
`$1import UserNotifications\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Swift: Add JPush init in application(_:didFinishLaunchingWithOptions:)
|
|
||||||
// Match various Swift signatures
|
|
||||||
const swiftDidFinishPatterns = [
|
|
||||||
/public override func application\(\s*_ application: UIApplication,\s*didFinishLaunchingWithOptions launchOptions: \[UIApplication\.LaunchOptionsKey: Any\]\? = nil\s*\) -> Bool \{/,
|
|
||||||
/func application\(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: \[UIApplication\.LaunchOptionsKey: Any\]\?\?\) -> Bool \{/,
|
|
||||||
];
|
|
||||||
|
|
||||||
let didInsertInit = false;
|
|
||||||
for (const pattern of swiftDidFinishPatterns) {
|
|
||||||
if (pattern.test(newContents)) {
|
|
||||||
newContents = newContents.replace(
|
|
||||||
pattern,
|
|
||||||
`$&\n // JPush initialization\n #if DEBUG\n JCoreModule.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)\n #else\n JCoreModule.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)\n #endif`
|
|
||||||
);
|
|
||||||
didInsertInit = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: find the didFinishLaunchingWithOptions method start and insert after the opening brace
|
|
||||||
if (!didInsertInit && newContents.includes('didFinishLaunchingWithOptions')) {
|
|
||||||
const lines = newContents.split('\n');
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
if (lines[i].includes('didFinishLaunchingWithOptions') && lines[i].includes('{')) {
|
|
||||||
lines.splice(i + 1, 0,
|
|
||||||
' // JPush initialization',
|
|
||||||
' #if DEBUG',
|
|
||||||
` JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: false)`,
|
|
||||||
' #else',
|
|
||||||
` JPUSHService.setup(withOption: launchOptions, appKey: "${appKey}", channel: "${channel}", apsForProduction: true)`,
|
|
||||||
' #endif',
|
|
||||||
' UNUserNotificationCenter.current().delegate = self'
|
|
||||||
);
|
|
||||||
newContents = lines.join('\n');
|
|
||||||
didInsertInit = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also add delegate = self after regex-based insertion if not already present
|
|
||||||
if (didInsertInit && !newContents.includes('UNUserNotificationCenter.current().delegate')) {
|
|
||||||
newContents = newContents.replace(
|
|
||||||
/(#endif\n)(\s+let delegate)/,
|
|
||||||
`$1 UNUserNotificationCenter.current().delegate = self\n$2`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Swift: Add delegate methods before the closing brace of the class
|
|
||||||
const jpushSwiftMethods = `
|
|
||||||
// JPush: Register for remote notifications
|
|
||||||
override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
|
||||||
JPUSHService.registerDeviceToken(deviceToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
|
||||||
NSLog("did fail to register for remote notifications with error: %@", error.localizedDescription)
|
|
||||||
}
|
|
||||||
|
|
||||||
// JPush: Handle remote notification
|
|
||||||
override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
|
||||||
JPUSHService.handleRemoteNotification(userInfo)
|
|
||||||
completionHandler(.newData)
|
|
||||||
}
|
|
||||||
|
|
||||||
// UNUserNotificationCenter delegate: foreground notification display
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
|
|
||||||
completionHandler([.alert, .badge, .sound])
|
|
||||||
}
|
|
||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
|
||||||
JPUSHService.handleRemoteNotification(response.notification.request.content.userInfo)
|
|
||||||
completionHandler()
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (!newContents.includes('JPUSHService.registerDeviceToken')) {
|
|
||||||
// Find the closing brace of the AppDelegate class
|
|
||||||
const lastBraceIndex = newContents.lastIndexOf('}');
|
|
||||||
if (lastBraceIndex !== -1) {
|
|
||||||
newContents = newContents.slice(0, lastBraceIndex) + jpushSwiftMethods + '\n' + newContents.slice(lastBraceIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
config.modResults.contents = newContents;
|
|
||||||
} else {
|
|
||||||
// ObjC AppDelegate
|
|
||||||
let newContents = contents;
|
|
||||||
|
|
||||||
if (!newContents.includes('#import <UserNotifications/UserNotifications.h>')) {
|
|
||||||
newContents = newContents.replace(
|
|
||||||
/(#import "AppDelegate.h")/,
|
|
||||||
`$1\n#import <UserNotifications/UserNotifications.h>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!newContents.includes('#import <jcore-react-native/JCoreModule.h>')) {
|
|
||||||
newContents = newContents.replace(
|
|
||||||
/(#import "AppDelegate.h")/,
|
|
||||||
`$1\n#import <jcore-react-native/JCoreModule.h>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const didFinishLaunchPattern = /(- \(BOOL\)application:\(UIApplication \*\)application didFinishLaunchingWithOptions:\(NSDictionary \*\)launchOptions\s*\{)/;
|
|
||||||
if (didFinishLaunchPattern.test(newContents)) {
|
|
||||||
newContents = newContents.replace(
|
|
||||||
didFinishLaunchPattern,
|
|
||||||
`$1\n // JPush initialization\n #if DEBUG\n [JCoreModule setupWithOption:launchOptions appKey:@"${appKey}" channel:@"${channel}" apsForProduction:NO];\n #else\n [JCoreModule setupWithOption:launchOptions appKey:@"${appKey}" channel:@"${channel}" apsForProduction:YES];\n #endif\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const jpushMethods = `
|
|
||||||
// JPush: Register for remote notifications
|
|
||||||
- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
|
|
||||||
[JCoreModule registerDeviceToken:deviceToken];
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error {
|
|
||||||
NSLog(@"did fail to register for remote notifications with error: %@", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
// JPush: Handle remote notification
|
|
||||||
- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler {
|
|
||||||
[JCoreModule didReceiveRemoteNotification:userInfo];
|
|
||||||
completionHandler(UIBackgroundFetchResultNewData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// UNUserNotificationCenter delegate
|
|
||||||
- (void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions))completionHandler {
|
|
||||||
completionHandler(UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionBadge | UNNotificationPresentationOptionSound);
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)userNotificationCenter:(UNUserNotificationCenter *)center didReceiveNotificationResponse:(UNNotificationResponse *)response withCompletionHandler:(void (^)(void))completionHandler {
|
|
||||||
[JCoreModule didReceiveRemoteNotification:response.notification.request.content.userInfo];
|
|
||||||
completionHandler();
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
if (!newContents.includes('JCoreModule registerDeviceToken')) {
|
|
||||||
newContents = newContents.replace(
|
|
||||||
/@end\s*$/,
|
|
||||||
`${jpushMethods}\n@end\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
config.modResults.contents = newContents;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. iOS: Add bridging header for Swift if needed
|
|
||||||
config = withDangerousMod(config, [
|
|
||||||
'ios',
|
|
||||||
async (config) => {
|
|
||||||
const appDelegatePath = path.join(config.modRequest.platformProjectRoot, 'app', 'AppDelegate.swift');
|
|
||||||
if (fs.existsSync(appDelegatePath)) {
|
|
||||||
const bridgingHeaderSearch = path.join(config.modRequest.platformProjectRoot, 'app', 'app-Bridging-Header.h');
|
|
||||||
if (fs.existsSync(bridgingHeaderSearch)) {
|
|
||||||
let bridgingContent = fs.readFileSync(bridgingHeaderSearch, 'utf-8');
|
|
||||||
if (!bridgingContent.includes('JCoreModule')) {
|
|
||||||
bridgingContent += '\n#import "JPUSHService.h"\n#import <UserNotifications/UserNotifications.h>\n';
|
|
||||||
fs.writeFileSync(bridgingHeaderSearch, bridgingContent);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 5. iOS: Add required capabilities and permissions to Info.plist
|
|
||||||
config = withInfoPlist(config, (config) => {
|
|
||||||
config.modResults.UIBackgroundModes = config.modResults.UIBackgroundModes || [];
|
|
||||||
if (!config.modResults.UIBackgroundModes.includes('remote-notification')) {
|
|
||||||
config.modResults.UIBackgroundModes.push('remote-notification');
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
return config;
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = withJPush;
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
const { withMainActivity } = require('@expo/config-plugins');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 在 MainActivity 中添加 onConfigurationChanged 方法
|
|
||||||
* 用于监听系统深色模式变化并通知 React Native
|
|
||||||
*/
|
|
||||||
const withMainActivityConfigChange = (config) => {
|
|
||||||
return withMainActivity(config, async (config) => {
|
|
||||||
const { contents } = config.modResults;
|
|
||||||
|
|
||||||
// 检查是否已经添加了 onConfigurationChanged
|
|
||||||
if (contents.includes('onConfigurationChanged')) {
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 需要添加的 imports
|
|
||||||
const importsToAdd = `import android.content.Intent
|
|
||||||
import android.content.res.Configuration`;
|
|
||||||
|
|
||||||
// 需要添加的方法
|
|
||||||
const methodToAdd = `
|
|
||||||
/**
|
|
||||||
* 监听系统配置变化(包括深色模式切换),并广播给 React Native
|
|
||||||
* 这对于 Appearance API 正确检测系统主题至关重要
|
|
||||||
*/
|
|
||||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
|
||||||
super.onConfigurationChanged(newConfig)
|
|
||||||
val intent = Intent("onConfigurationChanged")
|
|
||||||
intent.putExtra("newConfig", newConfig)
|
|
||||||
sendBroadcast(intent)
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
let newContents = contents;
|
|
||||||
|
|
||||||
// 添加 imports(在已有 imports 后面)
|
|
||||||
if (!newContents.includes('import android.content.Intent')) {
|
|
||||||
// 找到最后一个 import 语句
|
|
||||||
const importRegex = /import [^\n]+\n/g;
|
|
||||||
const imports = newContents.match(importRegex);
|
|
||||||
if (imports && imports.length > 0) {
|
|
||||||
const lastImport = imports[imports.length - 1];
|
|
||||||
const lastImportIndex = newContents.lastIndexOf(lastImport);
|
|
||||||
const insertPosition = lastImportIndex + lastImport.length;
|
|
||||||
newContents =
|
|
||||||
newContents.slice(0, insertPosition) +
|
|
||||||
importsToAdd +
|
|
||||||
'\n' +
|
|
||||||
newContents.slice(insertPosition);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在类体的开头添加 onConfigurationChanged 方法
|
|
||||||
// 找到类定义后的第一个方法或属性
|
|
||||||
const classBodyRegex = /class MainActivity\s*:\s*ReactActivity\s*\(\)\s*\{([\s\S]*)/;
|
|
||||||
const match = newContents.match(classBodyRegex);
|
|
||||||
if (match) {
|
|
||||||
// 找到 onCreate 方法之前插入
|
|
||||||
const onCreateIndex = newContents.indexOf('override fun onCreate');
|
|
||||||
if (onCreateIndex !== -1) {
|
|
||||||
newContents =
|
|
||||||
newContents.slice(0, onCreateIndex) +
|
|
||||||
methodToAdd +
|
|
||||||
newContents.slice(onCreateIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
config.modResults.contents = newContents;
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = withMainActivityConfigChange;
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
const { withDangerousMod } = require('@expo/config-plugins');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const withSigning = (config, options = {}) => {
|
|
||||||
const { teamId = 'X3964MGXHG' } = options;
|
|
||||||
|
|
||||||
config = withDangerousMod(config, [
|
|
||||||
'ios',
|
|
||||||
async (config) => {
|
|
||||||
const platformRoot = config.modRequest.platformProjectRoot;
|
|
||||||
|
|
||||||
// 1. Inject CODE_SIGN_STYLE and DEVELOPMENT_TEAM into pbxproj
|
|
||||||
const pbxprojPath = path.join(platformRoot, 'app.xcodeproj', 'project.pbxproj');
|
|
||||||
if (fs.existsSync(pbxprojPath)) {
|
|
||||||
let contents = fs.readFileSync(pbxprojPath, 'utf-8');
|
|
||||||
const entitlementsLine = 'CODE_SIGN_ENTITLEMENTS = app/app.entitlements;';
|
|
||||||
const signingBlock = `${entitlementsLine}\n\t\t\t\tCODE_SIGN_STYLE = Automatic;\n\t\t\t\tDEVELOPMENT_TEAM = ${teamId};`;
|
|
||||||
contents = contents.replaceAll(entitlementsLine, signingBlock);
|
|
||||||
fs.writeFileSync(pbxprojPath, contents);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Set aps-environment to production in entitlements
|
|
||||||
const entitlementsPath = path.join(platformRoot, 'app', 'app.entitlements');
|
|
||||||
if (fs.existsSync(entitlementsPath)) {
|
|
||||||
let entitlements = fs.readFileSync(entitlementsPath, 'utf-8');
|
|
||||||
entitlements = entitlements.replace(
|
|
||||||
/<key>aps-environment<\/key>\s*<string>development<\/string>/,
|
|
||||||
'<key>aps-environment</key>\n\t<string>production</string>'
|
|
||||||
);
|
|
||||||
fs.writeFileSync(entitlementsPath, entitlements);
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return config;
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = withSigning;
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
@@ -187,7 +187,7 @@ export function AppDesktopShell() {
|
|||||||
>
|
>
|
||||||
<View style={styles.sidebarHeader}>
|
<View style={styles.sidebarHeader}>
|
||||||
<MaterialCommunityIcons name="carrot" size={32} color={colors.primary.main} />
|
<MaterialCommunityIcons name="carrot" size={32} color={colors.primary.main} />
|
||||||
{!isCollapsed && <Text style={styles.logoText}>威友</Text>}
|
{!isCollapsed && <Text style={styles.logoText}>胡萝卜BBS</Text>}
|
||||||
</View>
|
</View>
|
||||||
<ScrollView style={styles.sidebarContent} showsVerticalScrollIndicator={false}>
|
<ScrollView style={styles.sidebarContent} showsVerticalScrollIndicator={false}>
|
||||||
{NAV_ITEMS.map((item) => {
|
{NAV_ITEMS.map((item) => {
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import React, { useImperativeHandle, useCallback } from 'react';
|
|
||||||
import { View, ScrollView, StyleSheet } from 'react-native';
|
|
||||||
|
|
||||||
import { useAppColors, spacing } from '../../../theme';
|
|
||||||
import { MessageSegment } from '../../../types';
|
|
||||||
import { EditorBlock } from './blockEditorTypes';
|
|
||||||
import TextBlockInput from './TextBlockInput';
|
|
||||||
import ImageBlockView from './ImageBlockView';
|
|
||||||
import { useBlockEditor } from './useBlockEditor';
|
|
||||||
|
|
||||||
export interface BlockEditorHandle {
|
|
||||||
insertImage: () => Promise<void>;
|
|
||||||
insertCameraPhoto: () => Promise<void>;
|
|
||||||
insertTextAtCursor: (text: string) => void;
|
|
||||||
getSegments: () => MessageSegment[];
|
|
||||||
getContent: () => string;
|
|
||||||
hasUploadingImages: () => boolean;
|
|
||||||
focus: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BlockEditorProps {
|
|
||||||
placeholder?: string;
|
|
||||||
maxLength?: number;
|
|
||||||
style?: any;
|
|
||||||
textStyle?: any;
|
|
||||||
onContentChange?: (content: string) => void;
|
|
||||||
initialBlocks?: EditorBlock[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const BlockEditor = React.forwardRef<BlockEditorHandle, BlockEditorProps>(
|
|
||||||
({ placeholder = '添加正文', maxLength = 2000, style, textStyle, onContentChange, initialBlocks }, ref) => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
const editor = useBlockEditor(initialBlocks);
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
|
||||||
insertImage: editor.insertImage,
|
|
||||||
insertCameraPhoto: editor.insertCameraPhoto,
|
|
||||||
insertTextAtCursor: editor.insertTextAtCursor,
|
|
||||||
getSegments: editor.getSegments,
|
|
||||||
getContent: editor.getContent,
|
|
||||||
hasUploadingImages: editor.hasUploadingImages,
|
|
||||||
focus: () => {
|
|
||||||
const firstTextBlock = editor.blocks.find(b => b.type === 'text');
|
|
||||||
if (firstTextBlock) {
|
|
||||||
editor.focusBlock(firstTextBlock.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handleTextChange = useCallback(
|
|
||||||
(blockId: string, text: string, mentions: any) => {
|
|
||||||
editor.updateTextBlock(blockId, text, mentions);
|
|
||||||
onContentChange?.(editor.getContent());
|
|
||||||
},
|
|
||||||
[editor.updateTextBlock, editor.getContent, onContentChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRemoveImage = useCallback(
|
|
||||||
(blockId: string) => {
|
|
||||||
editor.removeImageBlock(blockId);
|
|
||||||
onContentChange?.(editor.getContent());
|
|
||||||
},
|
|
||||||
[editor.removeImageBlock, editor.getContent, onContentChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.container, style]}>
|
|
||||||
<ScrollView
|
|
||||||
style={styles.scroll}
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
nestedScrollEnabled
|
|
||||||
>
|
|
||||||
{editor.blocks.map(block => {
|
|
||||||
if (block.type === 'text') {
|
|
||||||
return (
|
|
||||||
<TextBlockInput
|
|
||||||
key={block.id}
|
|
||||||
block={block}
|
|
||||||
isActive={editor.activeBlockId === block.id}
|
|
||||||
placeholder={placeholder}
|
|
||||||
maxLength={maxLength}
|
|
||||||
onTextChange={handleTextChange}
|
|
||||||
onFocusBlock={editor.setActiveBlockId}
|
|
||||||
onSelectMention={() => {}}
|
|
||||||
inputRef={(ref) => editor.registerInputRef(block.id, ref)}
|
|
||||||
style={textStyle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ImageBlockView
|
|
||||||
key={block.id}
|
|
||||||
block={block}
|
|
||||||
onRemove={handleRemoveImage}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
BlockEditor.displayName = 'BlockEditor';
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flexGrow: 1,
|
|
||||||
},
|
|
||||||
scroll: {
|
|
||||||
flexGrow: 1,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
|
||||||
flexGrow: 1,
|
|
||||||
paddingBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default BlockEditor;
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { View, TouchableOpacity, StyleSheet, Dimensions, ActivityIndicator } from 'react-native';
|
|
||||||
import { Image as ExpoImage } from 'expo-image';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
|
|
||||||
import { useAppColors, spacing, borderRadius } from '../../../theme';
|
|
||||||
import type { ImageBlock } from './blockEditorTypes';
|
|
||||||
|
|
||||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
|
||||||
const IMAGE_MAX_WIDTH = SCREEN_WIDTH - 32;
|
|
||||||
|
|
||||||
interface ImageBlockViewProps {
|
|
||||||
block: ImageBlock;
|
|
||||||
onRemove: (blockId: string) => void;
|
|
||||||
style?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ImageBlockView: React.FC<ImageBlockViewProps> = ({ block, onRemove, style }) => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
|
|
||||||
const aspectRatio = block.width && block.height
|
|
||||||
? block.width / block.height
|
|
||||||
: 4 / 3;
|
|
||||||
const displayWidth = IMAGE_MAX_WIDTH;
|
|
||||||
const displayHeight = displayWidth / aspectRatio;
|
|
||||||
|
|
||||||
const imageSource = block.remoteUrl
|
|
||||||
? { uri: block.remoteUrl }
|
|
||||||
: { uri: block.localUri };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.container, style]}>
|
|
||||||
<ExpoImage
|
|
||||||
source={imageSource}
|
|
||||||
style={{
|
|
||||||
width: displayWidth,
|
|
||||||
height: displayHeight,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
}}
|
|
||||||
contentFit="cover"
|
|
||||||
cachePolicy="memory-disk"
|
|
||||||
transition={200}
|
|
||||||
/>
|
|
||||||
{block.uploading && (
|
|
||||||
<View style={styles.uploadingOverlay}>
|
|
||||||
<ActivityIndicator size="small" color={colors.text.inverse} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.removeButton}
|
|
||||||
onPress={() => onRemove(block.id)}
|
|
||||||
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
|
||||||
>
|
|
||||||
<View style={styles.removeButtonInner}>
|
|
||||||
<MaterialCommunityIcons name="close" size={14} color={colors.text.inverse} />
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
marginTop: 4,
|
|
||||||
marginBottom: 0,
|
|
||||||
position: 'relative',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
uploadingOverlay: {
|
|
||||||
...StyleSheet.absoluteFillObject,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
},
|
|
||||||
removeButton: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 8,
|
|
||||||
right: 8 + (SCREEN_WIDTH - IMAGE_MAX_WIDTH) / 2,
|
|
||||||
zIndex: 10,
|
|
||||||
},
|
|
||||||
removeButtonInner: {
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
borderRadius: borderRadius.full,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ImageBlockView;
|
|
||||||
@@ -1,276 +0,0 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
TextInput,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
StyleSheet,
|
|
||||||
} from 'react-native';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
|
|
||||||
import Text from '../../common/Text';
|
|
||||||
import Avatar from '../../common/Avatar';
|
|
||||||
import { useAppColors, spacing, fontSizes, borderRadius } from '../../../theme';
|
|
||||||
import { useAuthStore } from '../../../stores';
|
|
||||||
import { authService } from '../../../services/auth';
|
|
||||||
import type { TextBlock } from './blockEditorTypes';
|
|
||||||
|
|
||||||
interface MentionUser {
|
|
||||||
id: string;
|
|
||||||
nickname: string;
|
|
||||||
avatar: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TextBlockInputProps {
|
|
||||||
block: TextBlock;
|
|
||||||
isActive: boolean;
|
|
||||||
placeholder: string;
|
|
||||||
maxLength: number;
|
|
||||||
onTextChange: (blockId: string, text: string, mentions: Map<number, { endIndex: number; userId: string; nickname: string }>) => void;
|
|
||||||
onFocusBlock: (blockId: string) => void;
|
|
||||||
onSelectMention: (blockId: string, userId: string, nickname: string, mentionStartIndex: number) => void;
|
|
||||||
inputRef: (ref: TextInput | null) => void;
|
|
||||||
style?: any;
|
|
||||||
minHeight?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TextBlockInput: React.FC<TextBlockInputProps> = ({
|
|
||||||
block,
|
|
||||||
isActive,
|
|
||||||
placeholder,
|
|
||||||
maxLength,
|
|
||||||
onTextChange,
|
|
||||||
onFocusBlock,
|
|
||||||
onSelectMention,
|
|
||||||
inputRef,
|
|
||||||
style,
|
|
||||||
minHeight,
|
|
||||||
}) => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
const currentUser = useAuthStore(s => s.currentUser);
|
|
||||||
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
const [suggestionMode, setSuggestionMode] = useState<'none' | 'mention'>('none');
|
|
||||||
const [mentionQuery, setMentionQuery] = useState('');
|
|
||||||
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
|
|
||||||
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loaded && currentUser?.id) {
|
|
||||||
loadFollowing();
|
|
||||||
setLoaded(true);
|
|
||||||
}
|
|
||||||
}, [currentUser?.id, loaded]);
|
|
||||||
|
|
||||||
const loadFollowing = async () => {
|
|
||||||
if (!currentUser?.id) return;
|
|
||||||
try {
|
|
||||||
const list = await authService.getFollowingList(currentUser.id, 1, 200);
|
|
||||||
setFollowingUsers(
|
|
||||||
list.map(u => ({
|
|
||||||
id: u.id,
|
|
||||||
nickname: u.nickname || u.username || '',
|
|
||||||
avatar: u.avatar || null,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredUsers = followingUsers.filter(u =>
|
|
||||||
u.nickname.toLowerCase().includes(mentionQuery.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChangeText = useCallback(
|
|
||||||
(newText: string) => {
|
|
||||||
const cursorPos = newText.length;
|
|
||||||
|
|
||||||
// Check for @ trigger
|
|
||||||
let atStart = -1;
|
|
||||||
for (let i = cursorPos - 1; i >= 0; i--) {
|
|
||||||
if (newText[i] === '@') {
|
|
||||||
atStart = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (newText[i] === ' ' || newText[i] === '\n') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (atStart >= 0) {
|
|
||||||
const query = newText.slice(atStart + 1, cursorPos);
|
|
||||||
if (!query.includes(' ') && !query.includes('\n')) {
|
|
||||||
setMentionQuery(query);
|
|
||||||
setMentionStartIndex(atStart);
|
|
||||||
setSuggestionMode('mention');
|
|
||||||
} else {
|
|
||||||
setSuggestionMode('none');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setSuggestionMode('none');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update mentions when text changes: remap existing mentions
|
|
||||||
const newMentions = new Map<number, { endIndex: number; userId: string; nickname: string }>();
|
|
||||||
// Simple approach: only keep mentions tracked by this block's own activeMentions
|
|
||||||
// The parent handles the canonical mentions state
|
|
||||||
block.activeMentions.forEach((mention, start) => {
|
|
||||||
if (start < newText.length) {
|
|
||||||
const mentionText = `@${mention.nickname} `;
|
|
||||||
const expectedEnd = start + mentionText.length;
|
|
||||||
// Check if the mention text is still intact
|
|
||||||
if (newText.slice(start, expectedEnd) === mentionText) {
|
|
||||||
newMentions.set(start, { ...mention, endIndex: expectedEnd });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
onTextChange(block.id, newText, newMentions);
|
|
||||||
},
|
|
||||||
[block.id, block.activeMentions, onTextChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectMention = useCallback(
|
|
||||||
(mentionUser: MentionUser) => {
|
|
||||||
if (mentionStartIndex < 0) return;
|
|
||||||
|
|
||||||
const before = block.text.slice(0, mentionStartIndex);
|
|
||||||
const mentionText = `@${mentionUser.nickname} `;
|
|
||||||
const newText = before + mentionText;
|
|
||||||
|
|
||||||
const newMentions = new Map(block.activeMentions);
|
|
||||||
newMentions.set(mentionStartIndex, {
|
|
||||||
endIndex: before.length + mentionText.length,
|
|
||||||
userId: mentionUser.id,
|
|
||||||
nickname: mentionUser.nickname,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also remove any mention that was partially covered
|
|
||||||
const toRemove: number[] = [];
|
|
||||||
newMentions.forEach((m, start) => {
|
|
||||||
if (start !== mentionStartIndex && start >= mentionStartIndex && start < before.length + mentionText.length) {
|
|
||||||
toRemove.push(start);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
toRemove.forEach(k => newMentions.delete(k));
|
|
||||||
|
|
||||||
onTextChange(block.id, newText, newMentions);
|
|
||||||
setSuggestionMode('none');
|
|
||||||
setMentionQuery('');
|
|
||||||
setMentionStartIndex(-1);
|
|
||||||
|
|
||||||
onSelectMention(block.id, mentionUser.id, mentionUser.nickname, mentionStartIndex);
|
|
||||||
},
|
|
||||||
[block.id, block.text, mentionStartIndex, onTextChange, onSelectMention],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View>
|
|
||||||
<TextInput
|
|
||||||
ref={inputRef}
|
|
||||||
value={block.text}
|
|
||||||
onChangeText={handleChangeText}
|
|
||||||
placeholder={placeholder}
|
|
||||||
placeholderTextColor={colors.text.hint}
|
|
||||||
maxLength={maxLength}
|
|
||||||
multiline
|
|
||||||
textAlignVertical="top"
|
|
||||||
scrollEnabled={false}
|
|
||||||
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
|
|
||||||
onFocus={() => onFocusBlock(block.id)}
|
|
||||||
style={[
|
|
||||||
styles.input,
|
|
||||||
{
|
|
||||||
color: colors.text.primary,
|
|
||||||
minHeight: minHeight,
|
|
||||||
},
|
|
||||||
style,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{suggestionMode === 'mention' && isActive && filteredUsers.length > 0 && (
|
|
||||||
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
|
||||||
<ScrollView
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
nestedScrollEnabled
|
|
||||||
style={styles.mentionList}
|
|
||||||
>
|
|
||||||
{filteredUsers.map(item => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={item.id}
|
|
||||||
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
|
|
||||||
onPress={() => handleSelectMention(item)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Avatar source={item.avatar} size={36} name={item.nickname} />
|
|
||||||
<View style={styles.mentionInfo}>
|
|
||||||
<Text style={[styles.mentionName, { color: colors.text.primary }]}>
|
|
||||||
{item.nickname}
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.mentionHint, { color: colors.text.hint }]}>
|
|
||||||
点击提及
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<MaterialCommunityIcons name="at" size={18} color={colors.primary.main} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{suggestionMode === 'mention' && isActive && filteredUsers.length === 0 && (
|
|
||||||
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
|
||||||
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
|
|
||||||
没有找到匹配的关注用户
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
input: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
paddingHorizontal: 0,
|
|
||||||
paddingVertical: 0,
|
|
||||||
textAlignVertical: 'top' as const,
|
|
||||||
},
|
|
||||||
mentionPanel: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
maxHeight: 200,
|
|
||||||
marginHorizontal: spacing.sm,
|
|
||||||
},
|
|
||||||
mentionList: {
|
|
||||||
maxHeight: 200,
|
|
||||||
},
|
|
||||||
mentionItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
||||||
},
|
|
||||||
mentionInfo: {
|
|
||||||
flex: 1,
|
|
||||||
marginLeft: spacing.sm,
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
mentionName: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
mentionHint: {
|
|
||||||
fontSize: fontSizes.xs,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
emptyText: {
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
padding: spacing.md,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default TextBlockInput;
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
import { MessageSegment, AtSegmentData } from '../../../types';
|
|
||||||
|
|
||||||
export interface TextBlock {
|
|
||||||
id: string;
|
|
||||||
type: 'text';
|
|
||||||
text: string;
|
|
||||||
activeMentions: Map<number, { endIndex: number; userId: string; nickname: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImageBlock {
|
|
||||||
id: string;
|
|
||||||
type: 'image';
|
|
||||||
localUri: string;
|
|
||||||
remoteUrl?: string;
|
|
||||||
uploading: boolean;
|
|
||||||
width?: number;
|
|
||||||
height?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EditorBlock = TextBlock | ImageBlock;
|
|
||||||
|
|
||||||
export function generateBlockId(): string {
|
|
||||||
return `blk_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createInitialTextBlock(): TextBlock {
|
|
||||||
return { id: generateBlockId(), type: 'text', text: '', activeMentions: new Map() };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function remapMentionsBefore(
|
|
||||||
mentions: Map<number, { endIndex: number; userId: string; nickname: string }>,
|
|
||||||
cursorPos: number,
|
|
||||||
): Map<number, { endIndex: number; userId: string; nickname: string }> {
|
|
||||||
const result = new Map<number, { endIndex: number; userId: string; nickname: string }>();
|
|
||||||
for (const [start, mention] of mentions) {
|
|
||||||
if (mention.endIndex <= cursorPos) {
|
|
||||||
result.set(start, { ...mention });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function remapMentionsAfter(
|
|
||||||
mentions: Map<number, { endIndex: number; userId: string; nickname: string }>,
|
|
||||||
cursorPos: number,
|
|
||||||
textLength: number,
|
|
||||||
): Map<number, { endIndex: number; userId: string; nickname: string }> {
|
|
||||||
const result = new Map<number, { endIndex: number; userId: string; nickname: string }>();
|
|
||||||
for (const [start, mention] of mentions) {
|
|
||||||
if (start >= cursorPos) {
|
|
||||||
result.set(start - cursorPos, {
|
|
||||||
...mention,
|
|
||||||
endIndex: mention.endIndex - cursorPos,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeMentions(
|
|
||||||
m1: Map<number, { endIndex: number; userId: string; nickname: string }>,
|
|
||||||
m1TextLength: number,
|
|
||||||
m2: Map<number, { endIndex: number; userId: string; nickname: string }>,
|
|
||||||
): Map<number, { endIndex: number; userId: string; nickname: string }> {
|
|
||||||
const result = new Map(m1);
|
|
||||||
for (const [start, mention] of m2) {
|
|
||||||
result.set(start + m1TextLength, {
|
|
||||||
...mention,
|
|
||||||
endIndex: mention.endIndex + m1TextLength,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function blockTextToSegments(
|
|
||||||
text: string,
|
|
||||||
activeMentions: Map<number, { endIndex: number; userId: string; nickname: string }>,
|
|
||||||
): MessageSegment[] {
|
|
||||||
if (activeMentions.size === 0) {
|
|
||||||
return text.trim() ? [{ type: 'text', data: { text } }] : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const segments: MessageSegment[] = [];
|
|
||||||
const sorted = Array.from(activeMentions.entries()).sort((a, b) => a[0] - b[0]);
|
|
||||||
let lastEnd = 0;
|
|
||||||
|
|
||||||
for (const [startIdx, mention] of sorted) {
|
|
||||||
if (startIdx > lastEnd) {
|
|
||||||
segments.push({ type: 'text', data: { text: text.slice(lastEnd, startIdx) } });
|
|
||||||
}
|
|
||||||
segments.push({
|
|
||||||
type: 'at',
|
|
||||||
data: { user_id: mention.userId, nickname: mention.nickname } as AtSegmentData,
|
|
||||||
});
|
|
||||||
lastEnd = mention.endIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastEnd < text.length) {
|
|
||||||
segments.push({ type: 'text', data: { text: text.slice(lastEnd) } });
|
|
||||||
}
|
|
||||||
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { MessageSegment } from '../../../types';
|
|
||||||
import { EditorBlock, TextBlock, ImageBlock, blockTextToSegments, generateBlockId } from './blockEditorTypes';
|
|
||||||
|
|
||||||
export function segmentsToBlocks(segments: MessageSegment[]): EditorBlock[] {
|
|
||||||
if (!segments || segments.length === 0) return [];
|
|
||||||
|
|
||||||
const blocks: EditorBlock[] = [];
|
|
||||||
|
|
||||||
for (const seg of segments) {
|
|
||||||
if (seg.type === 'image' && seg.data?.url) {
|
|
||||||
const imageBlock: ImageBlock = {
|
|
||||||
id: generateBlockId(),
|
|
||||||
type: 'image',
|
|
||||||
localUri: seg.data.url,
|
|
||||||
remoteUrl: seg.data.url,
|
|
||||||
uploading: false,
|
|
||||||
width: seg.data.width,
|
|
||||||
height: seg.data.height,
|
|
||||||
};
|
|
||||||
blocks.push(imageBlock);
|
|
||||||
} else if (seg.type === 'text') {
|
|
||||||
const text = seg.data?.text || seg.data?.content || '';
|
|
||||||
if (text) {
|
|
||||||
const textBlock: TextBlock = {
|
|
||||||
id: generateBlockId(),
|
|
||||||
type: 'text',
|
|
||||||
text,
|
|
||||||
activeMentions: new Map(),
|
|
||||||
};
|
|
||||||
blocks.push(textBlock);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Skip at/other segment types — mentions from long posts are not editable in block editor
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure at least one text block
|
|
||||||
if (blocks.length === 0) {
|
|
||||||
blocks.push({ id: generateBlockId(), type: 'text', text: '', activeMentions: new Map() });
|
|
||||||
}
|
|
||||||
|
|
||||||
return blocks;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function blocksToSegments(blocks: EditorBlock[]): MessageSegment[] {
|
|
||||||
const segments: MessageSegment[] = [];
|
|
||||||
|
|
||||||
for (const block of blocks) {
|
|
||||||
if (block.type === 'text') {
|
|
||||||
const textSegments = blockTextToSegments(block.text, block.activeMentions);
|
|
||||||
segments.push(...textSegments);
|
|
||||||
} else if (block.type === 'image') {
|
|
||||||
if (block.remoteUrl) {
|
|
||||||
segments.push({
|
|
||||||
type: 'image',
|
|
||||||
data: {
|
|
||||||
url: block.remoteUrl,
|
|
||||||
width: block.width,
|
|
||||||
height: block.height,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return mergeAdjacentTextSegments(segments);
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeAdjacentTextSegments(segments: MessageSegment[]): MessageSegment[] {
|
|
||||||
if (segments.length === 0) return segments;
|
|
||||||
|
|
||||||
const merged: MessageSegment[] = [];
|
|
||||||
|
|
||||||
for (const seg of segments) {
|
|
||||||
const last = merged[merged.length - 1];
|
|
||||||
if (
|
|
||||||
last &&
|
|
||||||
last.type === 'text' &&
|
|
||||||
seg.type === 'text'
|
|
||||||
) {
|
|
||||||
const lastText = last.data?.text || last.data?.content || '';
|
|
||||||
const curText = seg.data?.text || seg.data?.content || '';
|
|
||||||
last.data = { text: lastText + curText };
|
|
||||||
} else {
|
|
||||||
merged.push({ ...seg });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function blocksToContent(blocks: EditorBlock[]): string {
|
|
||||||
return blocks
|
|
||||||
.filter((b): b is typeof b & { type: 'text' } => b.type === 'text')
|
|
||||||
.map(b => b.text)
|
|
||||||
.join('\n')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default } from './BlockEditor';
|
|
||||||
export type { BlockEditorHandle } from './BlockEditor';
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
import { useState, useCallback, useRef } from 'react';
|
|
||||||
import { TextInput, Alert } from 'react-native';
|
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
|
||||||
|
|
||||||
import { uploadService } from '@/services/upload';
|
|
||||||
import type { EditorBlock, TextBlock, ImageBlock } from './blockEditorTypes';
|
|
||||||
import {
|
|
||||||
generateBlockId,
|
|
||||||
createInitialTextBlock,
|
|
||||||
remapMentionsBefore,
|
|
||||||
remapMentionsAfter,
|
|
||||||
mergeMentions,
|
|
||||||
} from './blockEditorTypes';
|
|
||||||
import { blocksToSegments, blocksToContent } from './blocksToSegments';
|
|
||||||
|
|
||||||
interface MentionData {
|
|
||||||
endIndex: number;
|
|
||||||
userId: string;
|
|
||||||
nickname: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBlockEditor(initialBlocks?: EditorBlock[]) {
|
|
||||||
const [blocks, setBlocks] = useState<EditorBlock[]>(initialBlocks && initialBlocks.length > 0 ? initialBlocks : [createInitialTextBlock()]);
|
|
||||||
const [activeBlockId, setActiveBlockId] = useState<string | null>(null);
|
|
||||||
const inputRefs = useRef<Map<string, TextInput>>(new Map());
|
|
||||||
const blockCursors = useRef<Map<string, { start: number; end: number }>>(new Map());
|
|
||||||
|
|
||||||
const updateTextBlock = useCallback(
|
|
||||||
(blockId: string, text: string, mentions: Map<number, MentionData>) => {
|
|
||||||
setBlocks(prev =>
|
|
||||||
prev.map(b =>
|
|
||||||
b.id === blockId && b.type === 'text'
|
|
||||||
? { ...b, text, activeMentions: mentions }
|
|
||||||
: b
|
|
||||||
)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const focusBlock = useCallback((blockId: string) => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const ref = inputRefs.current.get(blockId);
|
|
||||||
ref?.focus();
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const removeImageBlock = useCallback((blockId: string) => {
|
|
||||||
setBlocks(prev => {
|
|
||||||
const index = prev.findIndex(b => b.id === blockId);
|
|
||||||
if (index < 0) return prev;
|
|
||||||
|
|
||||||
const updated = prev.filter(b => b.id !== blockId);
|
|
||||||
|
|
||||||
// Merge adjacent text blocks if both neighbors are text
|
|
||||||
const before = updated[index - 1];
|
|
||||||
const after = updated[index]; // was index + 1 before removal
|
|
||||||
|
|
||||||
if (
|
|
||||||
before && after &&
|
|
||||||
before.type === 'text' && after.type === 'text'
|
|
||||||
) {
|
|
||||||
const mergedMentions = mergeMentions(
|
|
||||||
before.activeMentions,
|
|
||||||
before.text.length,
|
|
||||||
after.activeMentions,
|
|
||||||
);
|
|
||||||
const mergedBlock: TextBlock = {
|
|
||||||
...before,
|
|
||||||
text: before.text + after.text,
|
|
||||||
activeMentions: mergedMentions,
|
|
||||||
};
|
|
||||||
updated[index - 1] = mergedBlock;
|
|
||||||
updated.splice(index, 1);
|
|
||||||
setActiveBlockId(mergedBlock.id);
|
|
||||||
requestAnimationFrame(() => focusBlock(mergedBlock.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure at least one text block exists
|
|
||||||
if (updated.length === 0) {
|
|
||||||
const newBlock = createInitialTextBlock();
|
|
||||||
updated.push(newBlock);
|
|
||||||
setActiveBlockId(newBlock.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
}, [focusBlock]);
|
|
||||||
|
|
||||||
const uploadImageByLocalUri = useCallback(
|
|
||||||
async (asset: ImagePicker.ImagePickerAsset) => {
|
|
||||||
try {
|
|
||||||
const uploadResult = await uploadService.uploadImage({
|
|
||||||
uri: asset.uri,
|
|
||||||
type: asset.mimeType || 'image/jpeg',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (uploadResult) {
|
|
||||||
setBlocks(prev =>
|
|
||||||
prev.map(b =>
|
|
||||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
|
||||||
? {
|
|
||||||
...b,
|
|
||||||
remoteUrl: uploadResult.url,
|
|
||||||
uploading: false,
|
|
||||||
width: uploadResult.width ?? b.width,
|
|
||||||
height: uploadResult.height ?? b.height,
|
|
||||||
}
|
|
||||||
: b
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Upload failed
|
|
||||||
setBlocks(prev =>
|
|
||||||
prev.map(b =>
|
|
||||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
|
||||||
? { ...b, uploading: false }
|
|
||||||
: b
|
|
||||||
)
|
|
||||||
);
|
|
||||||
Alert.alert('上传失败', '图片上传失败,请重试');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setBlocks(prev =>
|
|
||||||
prev.map(b =>
|
|
||||||
b.type === 'image' && b.localUri === asset.uri && !b.remoteUrl
|
|
||||||
? { ...b, uploading: false }
|
|
||||||
: b
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const insertImagesFromAssets = useCallback(
|
|
||||||
(assets: ImagePicker.ImagePickerAsset[]) => {
|
|
||||||
if (!assets.length) return;
|
|
||||||
|
|
||||||
setBlocks(prev => {
|
|
||||||
let updated = [...prev];
|
|
||||||
let currentActiveId = activeBlockId;
|
|
||||||
let insertAfterIndex = updated.findIndex(b => b.id === currentActiveId);
|
|
||||||
|
|
||||||
if (insertAfterIndex < 0) {
|
|
||||||
insertAfterIndex = updated.length - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const asset of assets) {
|
|
||||||
const activeBlock = updated[insertAfterIndex];
|
|
||||||
const cursorPos = activeBlock && activeBlock.type === 'text'
|
|
||||||
? (blockCursors.current.get(activeBlock.id)?.start ?? activeBlock.text.length)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
// Create image block
|
|
||||||
const imageBlockId = generateBlockId();
|
|
||||||
const imageBlock: ImageBlock = {
|
|
||||||
id: imageBlockId,
|
|
||||||
type: 'image',
|
|
||||||
localUri: asset.uri,
|
|
||||||
uploading: true,
|
|
||||||
width: asset.width,
|
|
||||||
height: asset.height,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (activeBlock && activeBlock.type === 'text') {
|
|
||||||
const textBefore = activeBlock.text.slice(0, cursorPos);
|
|
||||||
const textAfter = activeBlock.text.slice(cursorPos);
|
|
||||||
const mentionsBefore = remapMentionsBefore(activeBlock.activeMentions, cursorPos);
|
|
||||||
const mentionsAfter = remapMentionsAfter(activeBlock.activeMentions, cursorPos, activeBlock.text.length);
|
|
||||||
|
|
||||||
// Create new text block for text after cursor
|
|
||||||
const newTextBlockId = generateBlockId();
|
|
||||||
const newTextBlock: TextBlock = {
|
|
||||||
id: newTextBlockId,
|
|
||||||
type: 'text',
|
|
||||||
text: textAfter,
|
|
||||||
activeMentions: mentionsAfter,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update current block to text before cursor
|
|
||||||
updated[insertAfterIndex] = {
|
|
||||||
...activeBlock,
|
|
||||||
text: textBefore,
|
|
||||||
activeMentions: mentionsBefore,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Insert image block and new text block
|
|
||||||
updated.splice(insertAfterIndex + 1, 0, imageBlock, newTextBlock);
|
|
||||||
|
|
||||||
// Update index for next image insertion
|
|
||||||
insertAfterIndex = insertAfterIndex + 2;
|
|
||||||
|
|
||||||
// Focus the new text block after the image
|
|
||||||
setActiveBlockId(newTextBlockId);
|
|
||||||
requestAnimationFrame(() => focusBlock(newTextBlockId));
|
|
||||||
} else {
|
|
||||||
// Insert after the current block (which is an image)
|
|
||||||
updated.splice(insertAfterIndex + 1, 0, imageBlock);
|
|
||||||
insertAfterIndex = insertAfterIndex + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start async uploads
|
|
||||||
for (const asset of assets) {
|
|
||||||
uploadImageByLocalUri(asset);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[activeBlockId, focusBlock, uploadImageByLocalUri],
|
|
||||||
);
|
|
||||||
|
|
||||||
const insertImage = useCallback(async () => {
|
|
||||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
||||||
if (!permissionResult.granted) {
|
|
||||||
Alert.alert('权限不足', '需要访问相册权限来选择图片');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await ImagePicker.launchImageLibraryAsync({
|
|
||||||
mediaTypes: 'images',
|
|
||||||
allowsMultipleSelection: true,
|
|
||||||
selectionLimit: 0,
|
|
||||||
quality: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.canceled && result.assets) {
|
|
||||||
insertImagesFromAssets(result.assets);
|
|
||||||
}
|
|
||||||
}, [insertImagesFromAssets]);
|
|
||||||
|
|
||||||
const insertCameraPhoto = useCallback(async () => {
|
|
||||||
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
|
|
||||||
if (!permissionResult.granted) {
|
|
||||||
Alert.alert('权限不足', '需要访问相机权限来拍照');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await ImagePicker.launchCameraAsync({
|
|
||||||
allowsEditing: true,
|
|
||||||
quality: 0.8,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.canceled && result.assets && result.assets[0]) {
|
|
||||||
insertImagesFromAssets([result.assets[0]]);
|
|
||||||
}
|
|
||||||
}, [insertImagesFromAssets]);
|
|
||||||
|
|
||||||
const insertTextAtCursor = useCallback((text: string) => {
|
|
||||||
const activeId = activeBlockId;
|
|
||||||
if (!activeId) return;
|
|
||||||
|
|
||||||
setBlocks(prev =>
|
|
||||||
prev.map(b => {
|
|
||||||
if (b.id !== activeId || b.type !== 'text') return b;
|
|
||||||
|
|
||||||
const cursorPos = blockCursors.current.get(b.id)?.start ?? b.text.length;
|
|
||||||
const newText = b.text.slice(0, cursorPos) + text + b.text.slice(cursorPos);
|
|
||||||
|
|
||||||
// Shift mentions after cursor
|
|
||||||
const newMentions = new Map<number, MentionData>();
|
|
||||||
b.activeMentions.forEach((mention, start) => {
|
|
||||||
if (start >= cursorPos) {
|
|
||||||
newMentions.set(start + text.length, {
|
|
||||||
...mention,
|
|
||||||
endIndex: mention.endIndex + text.length,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
newMentions.set(start, mention);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const newPosition = cursorPos + text.length;
|
|
||||||
blockCursors.current.set(b.id, { start: newPosition, end: newPosition });
|
|
||||||
|
|
||||||
return { ...b, text: newText, activeMentions: newMentions };
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}, [activeBlockId]);
|
|
||||||
|
|
||||||
const getSegments = useCallback(() => blocksToSegments(blocks), [blocks]);
|
|
||||||
const getContent = useCallback(() => blocksToContent(blocks), [blocks]);
|
|
||||||
const hasUploadingImages = useCallback(() => blocks.some(b => b.type === 'image' && b.uploading), [blocks]);
|
|
||||||
|
|
||||||
const registerInputRef = useCallback((blockId: string, ref: TextInput | null) => {
|
|
||||||
if (ref) {
|
|
||||||
inputRefs.current.set(blockId, ref);
|
|
||||||
} else {
|
|
||||||
inputRefs.current.delete(blockId);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const updateCursor = useCallback((blockId: string, selection: { start: number; end: number }) => {
|
|
||||||
blockCursors.current.set(blockId, selection);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
blocks,
|
|
||||||
activeBlockId,
|
|
||||||
setActiveBlockId,
|
|
||||||
updateTextBlock,
|
|
||||||
removeImageBlock,
|
|
||||||
insertImage,
|
|
||||||
insertCameraPhoto,
|
|
||||||
insertTextAtCursor,
|
|
||||||
focusBlock,
|
|
||||||
getSegments,
|
|
||||||
getContent,
|
|
||||||
hasUploadingImages,
|
|
||||||
registerInputRef,
|
|
||||||
updateCursor,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -6,19 +6,19 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native';
|
import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { formatChatTime } from '../../utils/formatTime';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { zhCN } from 'date-fns/locale';
|
||||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||||
import { Comment, CommentImage } from '../../types';
|
import { Comment, CommentImage } from '../../types';
|
||||||
import Text from '../common/Text';
|
import Text from '../common/Text';
|
||||||
import Avatar from '../common/Avatar';
|
import Avatar from '../common/Avatar';
|
||||||
import { CompactImageGrid, ImageGridItem } from '../common';
|
import { CompactImageGrid, ImageGridItem } from '../common';
|
||||||
import PostContentRenderer from './PostContentRenderer';
|
|
||||||
|
|
||||||
interface CommentItemProps {
|
interface CommentItemProps {
|
||||||
comment: Comment;
|
comment: Comment;
|
||||||
onUserPress: () => void;
|
onUserPress: () => void;
|
||||||
onReply: () => void;
|
onReply: () => void;
|
||||||
onLike: (comment: Comment) => void; // 点赞回调,传入评论对象
|
onLike: () => void;
|
||||||
floorNumber?: number; // 楼层号
|
floorNumber?: number; // 楼层号
|
||||||
isAuthor?: boolean; // 是否是楼主
|
isAuthor?: boolean; // 是否是楼主
|
||||||
replyToUser?: string; // 回复给哪位用户
|
replyToUser?: string; // 回复给哪位用户
|
||||||
@@ -29,7 +29,6 @@ interface CommentItemProps {
|
|||||||
onDelete?: (comment: Comment) => void; // 删除评论的回调
|
onDelete?: (comment: Comment) => void; // 删除评论的回调
|
||||||
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
|
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
|
||||||
currentUserId?: string; // 当前用户ID,用于判断子评论作者
|
currentUserId?: string; // 当前用户ID,用于判断子评论作者
|
||||||
onReport?: (comment: Comment) => void; // 举报评论的回调
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCommentItemStyles(colors: AppColors) {
|
function createCommentItemStyles(colors: AppColors) {
|
||||||
@@ -127,7 +126,7 @@ function createCommentItemStyles(colors: AppColors) {
|
|||||||
actionButton: {
|
actionButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginRight: spacing.sm,
|
marginRight: spacing.lg,
|
||||||
paddingVertical: 4,
|
paddingVertical: 4,
|
||||||
paddingRight: spacing.xs,
|
paddingRight: spacing.xs,
|
||||||
},
|
},
|
||||||
@@ -142,6 +141,7 @@ function createCommentItemStyles(colors: AppColors) {
|
|||||||
paddingVertical: spacing.xs,
|
paddingVertical: spacing.xs,
|
||||||
borderLeftWidth: 2,
|
borderLeftWidth: 2,
|
||||||
borderLeftColor: colors.divider,
|
borderLeftColor: colors.divider,
|
||||||
|
backgroundColor: colors.background.default,
|
||||||
},
|
},
|
||||||
subReplyItem: {
|
subReplyItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -152,8 +152,8 @@ function createCommentItemStyles(colors: AppColors) {
|
|||||||
marginBottom: 0,
|
marginBottom: 0,
|
||||||
},
|
},
|
||||||
subReplyBody: {
|
subReplyBody: {
|
||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.sm,
|
||||||
lineHeight: 22,
|
lineHeight: 20,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
subReplyContent: {
|
subReplyContent: {
|
||||||
@@ -175,10 +175,10 @@ function createCommentItemStyles(colors: AppColors) {
|
|||||||
paddingVertical: spacing.xs,
|
paddingVertical: spacing.xs,
|
||||||
},
|
},
|
||||||
replyToText: {
|
replyToText: {
|
||||||
fontSize: fontSizes.sm,
|
fontSize: fontSizes.xs,
|
||||||
},
|
},
|
||||||
replyToName: {
|
replyToName: {
|
||||||
fontSize: fontSizes.sm,
|
fontSize: fontSizes.xs,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
subReplyActions: {
|
subReplyActions: {
|
||||||
@@ -214,11 +214,21 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onImagePress,
|
onImagePress,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
onReport,
|
|
||||||
}) => {
|
}) => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
|
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (dateString: string): string => {
|
||||||
|
try {
|
||||||
|
return formatDistanceToNow(new Date(dateString), {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: zhCN,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 格式化数字
|
// 格式化数字
|
||||||
const formatNumber = (num: number): string => {
|
const formatNumber = (num: number): string => {
|
||||||
@@ -467,7 +477,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
{targetNickname && (
|
{targetNickname && (
|
||||||
<>
|
<>
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.replyToText}>
|
<Text variant="caption" color={colors.text.hint} style={styles.replyToText}>
|
||||||
{' '}回复{' '}
|
{' '}回复
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="caption" color={colors.primary.main} style={styles.replyToName}>
|
<Text variant="caption" color={colors.primary.main} style={styles.replyToName}>
|
||||||
{targetNickname}
|
{targetNickname}
|
||||||
@@ -477,7 +487,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
{/* 显示回复内容(如果有文字) */}
|
{/* 显示回复内容(如果有文字) */}
|
||||||
{reply.content ? (
|
{reply.content ? (
|
||||||
<Text variant="body" color={colors.text.primary} selectable style={styles.subReplyBody}>
|
<Text variant="body" color={colors.text.primary} style={styles.subReplyBody}>
|
||||||
{reply.content}
|
{reply.content}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -485,30 +495,11 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
{renderSubReplyImages(reply)}
|
{renderSubReplyImages(reply)}
|
||||||
{/* 子评论操作按钮 */}
|
{/* 子评论操作按钮 */}
|
||||||
<View style={styles.subReplyActions}>
|
<View style={styles.subReplyActions}>
|
||||||
{/* 点赞按钮 */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.subActionButton}
|
|
||||||
onPress={() => onLike?.(reply)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={reply.is_liked ? 'heart' : 'heart-outline'}
|
|
||||||
size={14}
|
|
||||||
color={reply.is_liked ? colors.error.main : colors.text.hint}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
variant="caption"
|
|
||||||
color={reply.is_liked ? colors.error.main : colors.text.hint}
|
|
||||||
style={styles.subActionText}
|
|
||||||
>
|
|
||||||
{reply.likes_count > 0 ? formatNumber(reply.likes_count) : '赞'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.subActionButton}
|
style={styles.subActionButton}
|
||||||
onPress={() => onReplyPress?.(reply)}
|
onPress={() => onReplyPress?.(reply)}
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons name="reply" size={14} color={colors.text.hint} />
|
<MaterialCommunityIcons name="reply" size={12} color={colors.text.hint} />
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
||||||
回复
|
回复
|
||||||
</Text>
|
</Text>
|
||||||
@@ -522,7 +513,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
>
|
>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name={isDeleting ? 'loading' : 'delete-outline'}
|
name={isDeleting ? 'loading' : 'delete-outline'}
|
||||||
size={14}
|
size={12}
|
||||||
color={colors.text.hint}
|
color={colors.text.hint}
|
||||||
/>
|
/>
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
||||||
@@ -530,17 +521,6 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
{!isSubReplyAuthor && onReport && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.subActionButton}
|
|
||||||
onPress={() => onReport(reply)}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons name="flag-outline" size={14} color={colors.text.hint} />
|
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
|
||||||
举报
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -584,7 +564,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
{renderBadges()}
|
{renderBadges()}
|
||||||
<Text style={styles.metaDot}>·</Text>
|
<Text style={styles.metaDot}>·</Text>
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
|
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
|
||||||
{formatChatTime(comment.created_at || '')}
|
{formatTime(comment.created_at || '')}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
{renderFloorNumber()}
|
{renderFloorNumber()}
|
||||||
@@ -601,17 +581,9 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
|
|
||||||
{/* 评论文本 - 非气泡样式 */}
|
{/* 评论文本 - 非气泡样式 */}
|
||||||
<View style={styles.commentContent}>
|
<View style={styles.commentContent}>
|
||||||
{comment.segments && comment.segments.length > 0 ? (
|
<Text variant="body" color={colors.text.primary} style={styles.text}>
|
||||||
<PostContentRenderer
|
|
||||||
content={comment.content}
|
|
||||||
segments={comment.segments}
|
|
||||||
textStyle={[styles.text, { color: colors.text.primary }]}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Text variant="body" color={colors.text.primary} selectable style={styles.text}>
|
|
||||||
{comment.content}
|
{comment.content}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 评论图片 */}
|
{/* 评论图片 */}
|
||||||
@@ -620,7 +592,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
{/* 操作按钮 - 更紧凑 */}
|
{/* 操作按钮 - 更紧凑 */}
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{/* 点赞 */}
|
{/* 点赞 */}
|
||||||
<TouchableOpacity style={styles.actionButton} onPress={() => onLike(comment)}>
|
<TouchableOpacity style={styles.actionButton} onPress={onLike}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name={comment.is_liked ? 'heart' : 'heart-outline'}
|
name={comment.is_liked ? 'heart' : 'heart-outline'}
|
||||||
size={14}
|
size={14}
|
||||||
@@ -643,19 +615,6 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* 举报按钮 - 只对非评论作者显示 */}
|
|
||||||
{!isCommentAuthor && onReport && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.actionButton}
|
|
||||||
onPress={() => onReport(comment)}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons name="flag-outline" size={14} color={colors.text.hint} />
|
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
|
|
||||||
举报
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 删除按钮 - 只对评论作者显示 */}
|
{/* 删除按钮 - 只对评论作者显示 */}
|
||||||
{isCommentAuthor && (
|
{isCommentAuthor && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -16,14 +16,13 @@ import React, { useMemo, useState, memo, useEffect } from 'react';
|
|||||||
import { View, TouchableOpacity, StyleSheet, Alert, TextStyle } from 'react-native';
|
import { View, TouchableOpacity, StyleSheet, Alert, TextStyle } from 'react-native';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { PostCardProps, PostCardAction } from './types';
|
import { PostCardProps, PostCardAction } from './types';
|
||||||
import { ImageGridItem, SmartImage, ImageGrid } from '../../common';
|
import { ImageGridItem, SmartImage } from '../../common';
|
||||||
import Text from '../../common/Text';
|
import Text from '../../common/Text';
|
||||||
import HighlightText from '../../common/HighlightText';
|
|
||||||
import Avatar from '../../common/Avatar';
|
import Avatar from '../../common/Avatar';
|
||||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme';
|
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme';
|
||||||
import { getPreviewImageUrl } from '../../../utils/imageHelper';
|
import { getPreviewImageUrl } from '../../../utils/imageHelper';
|
||||||
import PostContentRenderer from '../PostContentRenderer';
|
import PostImages from './components/PostImages';
|
||||||
import { useResponsive } from '../../../hooks';
|
import { useResponsive } from '../../../hooks/useResponsive';
|
||||||
import { formatPostCreatedAtString } from '../../../core/entities/Post';
|
import { formatPostCreatedAtString } from '../../../core/entities/Post';
|
||||||
|
|
||||||
/** 列表相对时间:独立定时刷新,避免外层 PostCard.memo 挡住「分钟前」更新 */
|
/** 列表相对时间:独立定时刷新,避免外层 PostCard.memo 挡住「分钟前」更新 */
|
||||||
@@ -54,13 +53,6 @@ function imagesSignature(
|
|||||||
return images.map((i) => i.id).join('\u001f');
|
return images.map((i) => i.id).join('\u001f');
|
||||||
}
|
}
|
||||||
|
|
||||||
function segmentSignature(
|
|
||||||
segments: PostCardProps['post']['segments'] | undefined
|
|
||||||
): string {
|
|
||||||
if (!segments?.length) return '';
|
|
||||||
return segments.map(s => `${s.type}:${s.type === 'image' ? (s.data as any)?.url || '' : ''}`).join('\u001f');
|
|
||||||
}
|
|
||||||
|
|
||||||
function authorSignature(author: PostCardProps['post']['author']): string {
|
function authorSignature(author: PostCardProps['post']['author']): string {
|
||||||
if (!author) return '';
|
if (!author) return '';
|
||||||
return [author.id, author.nickname ?? '', author.avatar ?? ''].join('\u001f');
|
return [author.id, author.nickname ?? '', author.avatar ?? ''].join('\u001f');
|
||||||
@@ -92,7 +84,6 @@ function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolea
|
|||||||
if (prev.isAuthor !== next.isAuthor) return false;
|
if (prev.isAuthor !== next.isAuthor) return false;
|
||||||
if (prev.style !== next.style) return false;
|
if (prev.style !== next.style) return false;
|
||||||
if (featuresComparable(prev.features) !== featuresComparable(next.features)) return false;
|
if (featuresComparable(prev.features) !== featuresComparable(next.features)) return false;
|
||||||
if (prev.highlightKeyword !== next.highlightKeyword) return false;
|
|
||||||
|
|
||||||
const a = prev.post;
|
const a = prev.post;
|
||||||
const b = next.post;
|
const b = next.post;
|
||||||
@@ -116,7 +107,6 @@ function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolea
|
|||||||
if (!!a.is_favorited !== !!b.is_favorited) return false;
|
if (!!a.is_favorited !== !!b.is_favorited) return false;
|
||||||
if (authorSignature(a.author) !== authorSignature(b.author)) return false;
|
if (authorSignature(a.author) !== authorSignature(b.author)) return false;
|
||||||
if (imagesSignature(a.images) !== imagesSignature(b.images)) return false;
|
if (imagesSignature(a.images) !== imagesSignature(b.images)) return false;
|
||||||
if (segmentSignature(a.segments) !== segmentSignature(b.segments)) return false;
|
|
||||||
if (topCommentSignature(a.top_comment) !== topCommentSignature(b.top_comment)) return false;
|
if (topCommentSignature(a.top_comment) !== topCommentSignature(b.top_comment)) return false;
|
||||||
if (channelSignature(a.channel) !== channelSignature(b.channel)) return false;
|
if (channelSignature(a.channel) !== channelSignature(b.channel)) return false;
|
||||||
|
|
||||||
@@ -216,9 +206,6 @@ function createPostCardStyles(colors: AppColors) {
|
|||||||
marginBottom: spacing.xs,
|
marginBottom: spacing.xs,
|
||||||
lineHeight: fontSizes.md * 1.45,
|
lineHeight: fontSizes.md * 1.45,
|
||||||
},
|
},
|
||||||
contentRenderer: {
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
expandBtn: {
|
expandBtn: {
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
@@ -293,10 +280,10 @@ function createPostCardStyles(colors: AppColors) {
|
|||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
activeActionText: {
|
activeActionText: {
|
||||||
color: colors.primary.main,
|
color: colors.error.main,
|
||||||
},
|
},
|
||||||
activeActionTextMerged: {
|
activeActionTextMerged: {
|
||||||
color: colors.primary.main,
|
color: colors.error.main,
|
||||||
fontSize: fontSizes.sm,
|
fontSize: fontSizes.sm,
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
@@ -387,10 +374,6 @@ function createPostCardStyles(colors: AppColors) {
|
|||||||
fontSize: fontSizes.sm,
|
fontSize: fontSizes.sm,
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
highlight: {
|
|
||||||
backgroundColor: `${colors.warning.main}40`,
|
|
||||||
color: colors.text.primary,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +391,6 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
isAuthor = false,
|
isAuthor = false,
|
||||||
index,
|
index,
|
||||||
style,
|
style,
|
||||||
highlightKeyword,
|
|
||||||
} = normalizedProps;
|
} = normalizedProps;
|
||||||
|
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
@@ -433,22 +415,6 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
const rawContent = post.content ?? '';
|
const rawContent = post.content ?? '';
|
||||||
const { isDesktop, isTablet, isWideScreen, isLandscape } = useResponsive();
|
const { isDesktop, isTablet, isWideScreen, isLandscape } = useResponsive();
|
||||||
|
|
||||||
const displayContent = useMemo(() => {
|
|
||||||
if (!post.segments?.length) return rawContent;
|
|
||||||
const segs = post.segments;
|
|
||||||
let result = '';
|
|
||||||
for (const s of segs) {
|
|
||||||
if (s.type === 'image') {
|
|
||||||
result += '[图片]';
|
|
||||||
} else if (s.type === 'text') {
|
|
||||||
result += (s.data as any)?.text || (s.data as any)?.content || '';
|
|
||||||
} else if (s.type === 'at') {
|
|
||||||
result += `@${(s.data as any)?.nickname || '某人'}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result || rawContent;
|
|
||||||
}, [post.segments, rawContent]);
|
|
||||||
|
|
||||||
const handleCardPress = () => emit({ type: 'press' });
|
const handleCardPress = () => emit({ type: 'press' });
|
||||||
const handleUserPress = () => emit({ type: 'userPress' });
|
const handleUserPress = () => emit({ type: 'userPress' });
|
||||||
const handleLike = () => emit({ type: post.is_liked ? 'unlike' : 'like' });
|
const handleLike = () => emit({ type: post.is_liked ? 'unlike' : 'like' });
|
||||||
@@ -478,17 +444,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const segmentImages: ImageGridItem[] = (post.segments || [])
|
const images: ImageGridItem[] = Array.isArray(post.images)
|
||||||
.filter((s: any) => s.type === 'image' && s.data?.url)
|
|
||||||
.map((s: any, i: number) => ({
|
|
||||||
id: `seg-${i}`,
|
|
||||||
url: s.data.url,
|
|
||||||
width: s.data.width,
|
|
||||||
height: s.data.height,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const images: ImageGridItem[] = [
|
|
||||||
...Array.isArray(post.images)
|
|
||||||
? post.images.map((img) => ({
|
? post.images.map((img) => ({
|
||||||
id: img.id,
|
id: img.id,
|
||||||
url: img.url,
|
url: img.url,
|
||||||
@@ -498,9 +454,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
width: img.width,
|
width: img.width,
|
||||||
height: img.height,
|
height: img.height,
|
||||||
}))
|
}))
|
||||||
: [],
|
: [];
|
||||||
...segmentImages,
|
|
||||||
];
|
|
||||||
|
|
||||||
const formatNumber = (num: number): string => {
|
const formatNumber = (num: number): string => {
|
||||||
if (num >= 10000) return `${(num / 10000).toFixed(1)}w`;
|
if (num >= 10000) return `${(num / 10000).toFixed(1)}w`;
|
||||||
@@ -509,13 +463,6 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getResponsiveMaxLength = () => {
|
const getResponsiveMaxLength = () => {
|
||||||
// 搜索高亮模式下展示更多内容
|
|
||||||
if (highlightKeyword) {
|
|
||||||
if (isWideScreen) return 500;
|
|
||||||
if (isDesktop) return 400;
|
|
||||||
if (isTablet) return 350;
|
|
||||||
return 220;
|
|
||||||
}
|
|
||||||
if (isWideScreen) return 300;
|
if (isWideScreen) return 300;
|
||||||
if (isDesktop) return 250;
|
if (isDesktop) return 250;
|
||||||
if (isTablet) return 200;
|
if (isTablet) return 200;
|
||||||
@@ -523,60 +470,22 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const contentNumberOfLines = useMemo(() => {
|
const contentNumberOfLines = useMemo(() => {
|
||||||
if (highlightKeyword) {
|
|
||||||
if (isWideScreen) return 12;
|
|
||||||
if (isDesktop) return 10;
|
|
||||||
if (isTablet) return 8;
|
|
||||||
return 5;
|
|
||||||
}
|
|
||||||
if (isWideScreen) return 8;
|
if (isWideScreen) return 8;
|
||||||
if (isDesktop) return 6;
|
if (isDesktop) return 6;
|
||||||
if (isTablet) return 5;
|
if (isTablet) return 5;
|
||||||
return 3;
|
return 3;
|
||||||
}, [isWideScreen, isDesktop, isTablet, highlightKeyword]);
|
}, [isWideScreen, isDesktop, isTablet]);
|
||||||
|
|
||||||
const getSearchExcerpt = (value: string, keyword: string): string => {
|
|
||||||
const maxLength = getResponsiveMaxLength();
|
|
||||||
if (value.length <= maxLength) return value;
|
|
||||||
|
|
||||||
const lowerValue = value.toLowerCase();
|
|
||||||
const lowerKeyword = keyword.toLowerCase();
|
|
||||||
const index = lowerValue.indexOf(lowerKeyword);
|
|
||||||
|
|
||||||
if (index === -1) {
|
|
||||||
return `${value.substring(0, maxLength)}...`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const context = Math.floor((maxLength - keyword.length) / 2);
|
|
||||||
let start = Math.max(0, index - context);
|
|
||||||
let end = Math.min(value.length, index + keyword.length + context);
|
|
||||||
|
|
||||||
if (start > 0) {
|
|
||||||
while (start > 0 && value[start] !== ' ' && value[start] !== '\n') start--;
|
|
||||||
if (value[start] === ' ' || value[start] === '\n') start++;
|
|
||||||
}
|
|
||||||
if (end < value.length) {
|
|
||||||
while (end < value.length && value[end] !== ' ' && value[end] !== '\n') end++;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prefix = start > 0 ? '...' : '';
|
|
||||||
const suffix = end < value.length ? '...' : '';
|
|
||||||
return `${prefix}${value.substring(start, end)}${suffix}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTruncatedContent = (value: string): string => {
|
const getTruncatedContent = (value: string): string => {
|
||||||
const maxLength = getResponsiveMaxLength();
|
const maxLength = getResponsiveMaxLength();
|
||||||
if (value.length <= maxLength || isExpanded) return value;
|
if (value.length <= maxLength || isExpanded) return value;
|
||||||
if (highlightKeyword) {
|
|
||||||
return getSearchExcerpt(value, highlightKeyword);
|
|
||||||
}
|
|
||||||
return `${value.substring(0, maxLength)}...`;
|
return `${value.substring(0, maxLength)}...`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldTruncate = !showGrid && !isCompact && displayContent.length > getResponsiveMaxLength();
|
const shouldTruncate = !showGrid && !isCompact && rawContent.length > getResponsiveMaxLength();
|
||||||
const content = useMemo(
|
const content = useMemo(
|
||||||
() => (showGrid || isCompact ? displayContent : getTruncatedContent(displayContent)),
|
() => (showGrid || isCompact ? rawContent : getTruncatedContent(rawContent)),
|
||||||
[displayContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
|
[rawContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderImages = () => {
|
const renderImages = () => {
|
||||||
@@ -598,24 +507,10 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ImageGrid
|
<PostImages
|
||||||
images={images.map(img => ({
|
images={post.images || []}
|
||||||
id: img.id,
|
|
||||||
url: img.url || '',
|
|
||||||
thumbnail_url: img.thumbnail_url,
|
|
||||||
preview_url: img.preview_url,
|
|
||||||
preview_url_large: img.preview_url_large,
|
|
||||||
width: img.width,
|
|
||||||
height: img.height,
|
|
||||||
}))}
|
|
||||||
maxDisplayCount={isWideScreen ? 12 : 9}
|
|
||||||
mode="auto"
|
|
||||||
gap={isDesktop ? 4 : 2}
|
|
||||||
borderRadius={isDesktop ? borderRadius.xl : borderRadius.md}
|
|
||||||
showMoreOverlay={true}
|
|
||||||
onImagePress={handleImagePress}
|
|
||||||
displayMode="list"
|
displayMode="list"
|
||||||
usePreview={true}
|
onImagePress={handleImagePress}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -661,7 +556,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
) : (
|
) : (
|
||||||
<View style={styles.gridNoImagePreview}>
|
<View style={styles.gridNoImagePreview}>
|
||||||
<Text style={styles.gridNoImageText} numberOfLines={6}>
|
<Text style={styles.gridNoImageText} numberOfLines={6}>
|
||||||
{highlightKeyword ? <HighlightText text={contentPreview} keyword={highlightKeyword} style={styles.highlight} /> : contentPreview}
|
{contentPreview}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -675,7 +570,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
|
|
||||||
{!!post.title && (
|
{!!post.title && (
|
||||||
<Text style={styles.gridTitleMain} numberOfLines={hasImage ? 2 : 3}>
|
<Text style={styles.gridTitleMain} numberOfLines={hasImage ? 2 : 3}>
|
||||||
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} style={styles.highlight} /> : post.title}
|
{post.title}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -694,14 +589,8 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
<Text style={styles.gridUsername} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
|
<Text style={styles.gridUsername} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<View style={styles.gridLikeArea}>
|
<View style={styles.gridLikeArea}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons name="heart-outline" size={14} color={colors.text.secondary} />
|
||||||
name={post.is_liked ? 'thumb-up' : 'thumb-up-outline'}
|
<Text style={styles.gridLikeCount}>{formatNumber(post.likes_count || 0)}</Text>
|
||||||
size={14}
|
|
||||||
color={post.is_liked ? colors.primary.main : colors.text.secondary}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.gridLikeCount, post.is_liked ? { color: colors.primary.main } : {}]}>
|
|
||||||
{formatNumber(post.likes_count || 0)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -747,7 +636,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
|
|
||||||
{!!post.title && (
|
{!!post.title && (
|
||||||
<Text style={styles.title} numberOfLines={isCompact ? 2 : undefined}>
|
<Text style={styles.title} numberOfLines={isCompact ? 2 : undefined}>
|
||||||
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} style={styles.highlight} /> : post.title}
|
{post.title}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -757,7 +646,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
style={styles.content}
|
style={styles.content}
|
||||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||||
>
|
>
|
||||||
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content}
|
{content}
|
||||||
</Text>
|
</Text>
|
||||||
{shouldTruncate && (
|
{shouldTruncate && (
|
||||||
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
|
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
|
||||||
@@ -767,7 +656,6 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 仅当 segments 中不含 image 时才渲染独立的图片区域 */}
|
|
||||||
{renderImages()}
|
{renderImages()}
|
||||||
{renderTopComment()}
|
{renderTopComment()}
|
||||||
|
|
||||||
@@ -789,9 +677,9 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
<View style={styles.actionButtons}>
|
<View style={styles.actionButtons}>
|
||||||
<TouchableOpacity style={styles.actionBtn} onPress={handleLike}>
|
<TouchableOpacity style={styles.actionBtn} onPress={handleLike}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name={post.is_liked ? 'thumb-up' : 'thumb-up-outline'}
|
name={post.is_liked ? 'heart' : 'heart-outline'}
|
||||||
size={18}
|
size={18}
|
||||||
color={post.is_liked ? colors.primary.main : colors.text.secondary}
|
color={post.is_liked ? colors.error.main : colors.text.secondary}
|
||||||
/>
|
/>
|
||||||
<Text style={post.is_liked ? styles.activeActionTextMerged : styles.actionText}>
|
<Text style={post.is_liked ? styles.activeActionTextMerged : styles.actionText}>
|
||||||
{post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'}
|
{post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'}
|
||||||
@@ -806,7 +694,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={styles.actionBtn} onPress={handleBookmark}>
|
<TouchableOpacity style={styles.actionBtn} onPress={handleBookmark}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name={post.is_favorited ? 'star' : 'star-outline'}
|
name={post.is_favorited ? 'bookmark' : 'bookmark-outline'}
|
||||||
size={18}
|
size={18}
|
||||||
color={post.is_favorited ? colors.warning.main : colors.text.secondary}
|
color={post.is_favorited ? colors.warning.main : colors.text.secondary}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet } from 'react-native';
|
import { View, StyleSheet } from 'react-native';
|
||||||
import { spacing, borderRadius } from '../../../../theme';
|
import { spacing, borderRadius } from '../../../../theme';
|
||||||
import { useResponsive } from '../../../../hooks';
|
import { useResponsive } from '../../../../hooks/useResponsive';
|
||||||
import { ImageGrid, ImageGridItem } from '../../../common';
|
import { ImageGrid, ImageGridItem } from '../../../common';
|
||||||
import { PostImagesProps } from '../types';
|
import { PostImagesProps } from '../types';
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ export type PostCardActionType =
|
|||||||
| 'unbookmark' // 取消收藏
|
| 'unbookmark' // 取消收藏
|
||||||
| 'share' // 分享
|
| 'share' // 分享
|
||||||
| 'imagePress' // 点击图片
|
| 'imagePress' // 点击图片
|
||||||
| 'delete' // 删除
|
| 'delete'; // 删除
|
||||||
| 'report'; // 举报
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action payload 类型
|
* Action payload 类型
|
||||||
@@ -118,9 +117,6 @@ export interface PostCardProps {
|
|||||||
/** 索引(用于虚拟化列表优化) */
|
/** 索引(用于虚拟化列表优化) */
|
||||||
index?: number;
|
index?: number;
|
||||||
|
|
||||||
/** 搜索高亮关键词 */
|
|
||||||
highlightKeyword?: string;
|
|
||||||
|
|
||||||
/** 自定义样式 */
|
/** 自定义样式 */
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
}
|
}
|
||||||
@@ -167,7 +163,7 @@ export interface PostContentProps {
|
|||||||
* PostImages 子组件 Props
|
* PostImages 子组件 Props
|
||||||
*/
|
*/
|
||||||
export interface PostImagesProps {
|
export interface PostImagesProps {
|
||||||
images: (PostImageDTO | { id?: string; url: string; thumbnail_url?: string; preview_url?: string; preview_url_large?: string; width?: number; height?: number })[];
|
images: PostImageDTO[];
|
||||||
displayMode: 'list' | 'grid';
|
displayMode: 'list' | 'grid';
|
||||||
onImagePress: (images: ImageGridItem[], index: number) => void;
|
onImagePress: (images: ImageGridItem[], index: number) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,299 +0,0 @@
|
|||||||
/**
|
|
||||||
* PostContentRenderer - 帖子内容渲染器
|
|
||||||
* 根据 segments 渲染富文本内容(@提及、投票、内嵌图片等)
|
|
||||||
* 降级:如果 segments 为空但 content 非空,直接显示纯文本
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
|
||||||
import { View, TouchableOpacity, StyleSheet, TextStyle, StyleProp, Dimensions } from 'react-native';
|
|
||||||
import { Image as ExpoImage } from 'expo-image';
|
|
||||||
|
|
||||||
import Text from '../common/Text';
|
|
||||||
import HighlightText from '../common/HighlightText';
|
|
||||||
import { useAppColors } from '../../theme';
|
|
||||||
import {
|
|
||||||
MessageSegment,
|
|
||||||
AtSegmentData,
|
|
||||||
VoteSegmentData,
|
|
||||||
PostRefSegmentData,
|
|
||||||
ImageSegmentData,
|
|
||||||
partitionSegments,
|
|
||||||
} from '../../types';
|
|
||||||
import PostRefCard from './PostRefCard';
|
|
||||||
|
|
||||||
interface PostContentRendererProps {
|
|
||||||
content?: string;
|
|
||||||
segments?: MessageSegment[];
|
|
||||||
numberOfLines?: number;
|
|
||||||
onMentionPress?: (userId: string) => void;
|
|
||||||
onPostRefPress?: (postId: string) => void;
|
|
||||||
onImagePress?: (url: string) => void;
|
|
||||||
style?: any;
|
|
||||||
textStyle?: any;
|
|
||||||
highlightKeyword?: string;
|
|
||||||
highlightStyle?: StyleProp<TextStyle>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SCREEN_WIDTH = Dimensions.get('window').width;
|
|
||||||
const INLINE_IMAGE_MAX_WIDTH = SCREEN_WIDTH - 32;
|
|
||||||
|
|
||||||
function hasInlineImages(segments?: MessageSegment[]): boolean {
|
|
||||||
if (!segments) return false;
|
|
||||||
return segments.some(s => s.type === 'image');
|
|
||||||
}
|
|
||||||
|
|
||||||
const PostContentRenderer: React.FC<PostContentRendererProps> = ({
|
|
||||||
content,
|
|
||||||
segments,
|
|
||||||
numberOfLines,
|
|
||||||
onMentionPress,
|
|
||||||
onPostRefPress,
|
|
||||||
onImagePress,
|
|
||||||
style,
|
|
||||||
textStyle,
|
|
||||||
highlightKeyword,
|
|
||||||
highlightStyle,
|
|
||||||
}) => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
const hasImages = hasInlineImages(segments);
|
|
||||||
|
|
||||||
if (!segments || segments.length === 0) {
|
|
||||||
return (
|
|
||||||
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
|
||||||
{highlightKeyword && content ? (
|
|
||||||
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
|
|
||||||
) : (
|
|
||||||
content || ''
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasComplexContent = hasImages ||
|
|
||||||
segments.some(s => s.type === 'vote' || s.type === 'post_ref');
|
|
||||||
|
|
||||||
if (!hasComplexContent || (numberOfLines && !hasImages)) {
|
|
||||||
const elements: React.ReactNode[] = [];
|
|
||||||
let keyIndex = 0;
|
|
||||||
|
|
||||||
for (const segment of segments) {
|
|
||||||
const key = `seg_${keyIndex++}`;
|
|
||||||
switch (segment.type) {
|
|
||||||
case 'text': {
|
|
||||||
const text = segment.data?.text || segment.data?.content || '';
|
|
||||||
if (text) {
|
|
||||||
elements.push(
|
|
||||||
<Text key={key} selectable style={textStyle}>
|
|
||||||
{highlightKeyword ? (
|
|
||||||
<HighlightText text={text} keyword={highlightKeyword} style={highlightStyle} />
|
|
||||||
) : (
|
|
||||||
text
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'at': {
|
|
||||||
const atData = segment.data as AtSegmentData;
|
|
||||||
const userId = atData?.user_id;
|
|
||||||
const isAtAll = userId === 'all';
|
|
||||||
const displayName = atData?.nickname || (isAtAll ? '所有人' : '某人');
|
|
||||||
elements.push(
|
|
||||||
<Text
|
|
||||||
key={key}
|
|
||||||
selectable
|
|
||||||
style={[
|
|
||||||
textStyle,
|
|
||||||
{ color: colors.primary.main, fontWeight: '500' },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
@{displayName}{' '}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'image':
|
|
||||||
case 'link':
|
|
||||||
case 'face':
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (elements.length === 0) {
|
|
||||||
return (
|
|
||||||
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
|
||||||
{highlightKeyword && content ? (
|
|
||||||
<HighlightText text={content} keyword={highlightKeyword} style={highlightStyle} />
|
|
||||||
) : (
|
|
||||||
content || ''
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (numberOfLines) {
|
|
||||||
return (
|
|
||||||
<View style={style}>
|
|
||||||
<Text numberOfLines={numberOfLines} selectable style={textStyle}>
|
|
||||||
{elements}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <View style={[styles.container, style]}>{elements}</View>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const chunks = partitionSegments(segments);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.chunkContainer, style]}>
|
|
||||||
{chunks.map((chunk, i) => {
|
|
||||||
if (chunk.kind === 'inline') {
|
|
||||||
return (
|
|
||||||
<Text key={`inl-${i}`} selectable style={textStyle}>
|
|
||||||
{chunk.parts.map((segment, j) => {
|
|
||||||
const segKey = `inl-${i}-${j}`;
|
|
||||||
switch (segment.type) {
|
|
||||||
case 'text': {
|
|
||||||
const text = segment.data?.text || segment.data?.content || '';
|
|
||||||
return highlightKeyword ? (
|
|
||||||
<HighlightText key={segKey} text={text} keyword={highlightKeyword} style={highlightStyle} />
|
|
||||||
) : (
|
|
||||||
text
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'at': {
|
|
||||||
const atData = segment.data as AtSegmentData;
|
|
||||||
const userId = atData?.user_id;
|
|
||||||
const displayName = atData?.nickname || (userId === 'all' ? '所有人' : '某人');
|
|
||||||
return (
|
|
||||||
<Text
|
|
||||||
key={segKey}
|
|
||||||
style={[textStyle, { color: colors.primary.main, fontWeight: '500' }]}
|
|
||||||
onPress={() => onMentionPress?.(userId)}
|
|
||||||
>
|
|
||||||
@{displayName}{' '}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chunk.kind === 'images') {
|
|
||||||
return (
|
|
||||||
<View key={`imgs-${i}`} style={styles.inlineImagesContainer}>
|
|
||||||
{chunk.parts.map((segment, j) => {
|
|
||||||
const imgData = segment.data as ImageSegmentData;
|
|
||||||
const url = imgData?.url || '';
|
|
||||||
if (!url) return null;
|
|
||||||
|
|
||||||
const aspectRatio = imgData.width && imgData.height
|
|
||||||
? imgData.width / imgData.height
|
|
||||||
: 4 / 3;
|
|
||||||
const displayWidth = INLINE_IMAGE_MAX_WIDTH;
|
|
||||||
const displayHeight = displayWidth / aspectRatio;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={`img-${i}-${j}`}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
onPress={() => onImagePress?.(url)}
|
|
||||||
style={j < chunk.parts.length - 1 ? styles.imageSpacing : undefined}
|
|
||||||
>
|
|
||||||
<ExpoImage
|
|
||||||
source={{ uri: url }}
|
|
||||||
style={{
|
|
||||||
width: displayWidth,
|
|
||||||
height: displayHeight,
|
|
||||||
borderRadius: 8,
|
|
||||||
}}
|
|
||||||
contentFit="cover"
|
|
||||||
cachePolicy="memory-disk"
|
|
||||||
transition={200}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { segment } = chunk;
|
|
||||||
switch (segment.type) {
|
|
||||||
case 'vote': {
|
|
||||||
const voteData = segment.data as VoteSegmentData;
|
|
||||||
if (!voteData?.options?.length) return null;
|
|
||||||
return (
|
|
||||||
<View key={`blk-${i}`} style={[styles.voteBlock, { borderColor: colors.divider }]}>
|
|
||||||
<Text style={[styles.voteTitle, { color: colors.text.secondary }]}>
|
|
||||||
投票({voteData.options.length} 个选项)
|
|
||||||
</Text>
|
|
||||||
{voteData.options.map((opt, k) => (
|
|
||||||
<Text key={`vote_opt_${k}`} selectable style={[styles.voteOption, { color: colors.text.primary }]}>
|
|
||||||
{k + 1}. {opt.content}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'post_ref': {
|
|
||||||
const refData = segment.data as PostRefSegmentData;
|
|
||||||
return (
|
|
||||||
<PostRefCard
|
|
||||||
key={`blk-${i}`}
|
|
||||||
data={refData}
|
|
||||||
compact={false}
|
|
||||||
onPress={onPostRefPress}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
},
|
|
||||||
chunkContainer: {
|
|
||||||
flexDirection: 'column',
|
|
||||||
},
|
|
||||||
inlineImagesContainer: {
|
|
||||||
marginVertical: 8,
|
|
||||||
},
|
|
||||||
imageSpacing: {
|
|
||||||
marginBottom: 6,
|
|
||||||
},
|
|
||||||
voteBlock: {
|
|
||||||
width: '100%',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 10,
|
|
||||||
marginTop: 4,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
voteTitle: {
|
|
||||||
fontSize: 13,
|
|
||||||
marginBottom: 6,
|
|
||||||
},
|
|
||||||
voteOption: {
|
|
||||||
fontSize: 14,
|
|
||||||
paddingVertical: 2,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default PostContentRenderer;
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
/**
|
|
||||||
* PostMentionInput - 帖子内容输入框(支持 @提及)
|
|
||||||
* 复用关注列表加载逻辑
|
|
||||||
* 检测输入中的 @ 字符,弹出关注者列表供选择
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
TextInput,
|
|
||||||
ScrollView,
|
|
||||||
TouchableOpacity,
|
|
||||||
StyleSheet,
|
|
||||||
Platform,
|
|
||||||
} from 'react-native';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
|
|
||||||
import Text from '../common/Text';
|
|
||||||
import Avatar from '../common/Avatar';
|
|
||||||
import { useAppColors, spacing, fontSizes, borderRadius } from '../../theme';
|
|
||||||
import { useAuthStore } from '../../stores';
|
|
||||||
import { authService } from '../../services/auth';
|
|
||||||
import { postService } from '../../services/post/postService';
|
|
||||||
import { MessageSegment, AtSegmentData } from '../../types';
|
|
||||||
|
|
||||||
interface MentionUser {
|
|
||||||
id: string;
|
|
||||||
nickname: string;
|
|
||||||
avatar: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SuggestPost {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
channel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SuggestionMode = 'none' | 'mention' | 'postref';
|
|
||||||
|
|
||||||
interface PostMentionInputProps {
|
|
||||||
value: string;
|
|
||||||
onChangeText: (text: string) => void;
|
|
||||||
onSegmentsChange: (segments: MessageSegment[]) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
maxLength?: number;
|
|
||||||
style?: any;
|
|
||||||
minHeight?: number;
|
|
||||||
autoExpand?: boolean;
|
|
||||||
onPostRefPasted?: (postId: string, title: string) => void;
|
|
||||||
onSubmitEditing?: () => void;
|
|
||||||
returnKeyType?: 'default' | 'send' | 'done';
|
|
||||||
}
|
|
||||||
|
|
||||||
const PostMentionInput: React.FC<PostMentionInputProps> = ({
|
|
||||||
value,
|
|
||||||
onChangeText,
|
|
||||||
onSegmentsChange,
|
|
||||||
placeholder = '说点什么...',
|
|
||||||
maxLength = 2000,
|
|
||||||
style,
|
|
||||||
minHeight = 100,
|
|
||||||
autoExpand = false,
|
|
||||||
onPostRefPasted,
|
|
||||||
onSubmitEditing,
|
|
||||||
returnKeyType = 'default',
|
|
||||||
}) => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
const currentUser = useAuthStore(s => s.currentUser);
|
|
||||||
const [followingUsers, setFollowingUsers] = useState<MentionUser[]>([]);
|
|
||||||
const [suggestPosts, setSuggestPosts] = useState<SuggestPost[]>([]);
|
|
||||||
const [suggestionMode, setSuggestionMode] = useState<SuggestionMode>('none');
|
|
||||||
const [mentionQuery, setMentionQuery] = useState('');
|
|
||||||
const [mentionStartIndex, setMentionStartIndex] = useState(-1);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
const [postSearchTimer, setPostSearchTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const inputRef = useRef<TextInput>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!loaded && currentUser?.id) {
|
|
||||||
loadFollowing();
|
|
||||||
setLoaded(true);
|
|
||||||
}
|
|
||||||
}, [currentUser?.id, loaded]);
|
|
||||||
|
|
||||||
const loadFollowing = async () => {
|
|
||||||
if (!currentUser?.id) return;
|
|
||||||
try {
|
|
||||||
const list = await authService.getFollowingList(currentUser.id, 1, 200);
|
|
||||||
setFollowingUsers(
|
|
||||||
list.map(u => ({
|
|
||||||
id: u.id,
|
|
||||||
nickname: u.nickname || u.username || '',
|
|
||||||
avatar: u.avatar || null,
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
} catch (_) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredUsers = followingUsers.filter(u =>
|
|
||||||
u.nickname.toLowerCase().includes(mentionQuery.toLowerCase())
|
|
||||||
);
|
|
||||||
|
|
||||||
const activeMentions = useRef(new Map<number, { endIndex: number; userId: string; nickname: string }>());
|
|
||||||
const segmentsRef = useRef<MessageSegment[]>([]);
|
|
||||||
|
|
||||||
const rebuildSegments = useCallback(
|
|
||||||
(text: string) => {
|
|
||||||
const mentions = activeMentions.current;
|
|
||||||
if (mentions.size === 0) {
|
|
||||||
onSegmentsChange(text.trim() ? [{ type: 'text', data: { text } }] : []);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const segments: MessageSegment[] = [];
|
|
||||||
const sorted = Array.from(mentions.entries()).sort((a, b) => a[0] - b[0]);
|
|
||||||
let lastEnd = 0;
|
|
||||||
|
|
||||||
for (const [startIdx, mention] of sorted) {
|
|
||||||
if (startIdx > lastEnd) {
|
|
||||||
segments.push({ type: 'text', data: { text: text.slice(lastEnd, startIdx) } });
|
|
||||||
}
|
|
||||||
segments.push({
|
|
||||||
type: 'at',
|
|
||||||
data: { user_id: mention.userId, nickname: mention.nickname } as AtSegmentData,
|
|
||||||
});
|
|
||||||
lastEnd = mention.endIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastEnd < text.length) {
|
|
||||||
segments.push({ type: 'text', data: { text: text.slice(lastEnd) } });
|
|
||||||
}
|
|
||||||
|
|
||||||
onSegmentsChange(segments);
|
|
||||||
},
|
|
||||||
[onSegmentsChange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChangeText = useCallback(
|
|
||||||
(text: string) => {
|
|
||||||
onChangeText(text);
|
|
||||||
|
|
||||||
const cursorPos = text.length;
|
|
||||||
|
|
||||||
// Check for # trigger (post ref search)
|
|
||||||
let hashStart = -1;
|
|
||||||
for (let i = cursorPos - 1; i >= 0; i--) {
|
|
||||||
if (text[i] === '#') {
|
|
||||||
hashStart = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (text[i] === ' ' || text[i] === '\n') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hashStart >= 0) {
|
|
||||||
const query = text.slice(hashStart + 1, cursorPos);
|
|
||||||
if (query.length >= 1 && !query.includes(' ') && !query.includes('\n')) {
|
|
||||||
if (postSearchTimer) {
|
|
||||||
clearTimeout(postSearchTimer);
|
|
||||||
}
|
|
||||||
const timer = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const results = await postService.suggestPosts(query, 8);
|
|
||||||
setSuggestPosts(results);
|
|
||||||
setSuggestionMode('postref');
|
|
||||||
setMentionStartIndex(hashStart);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
setPostSearchTimer(timer);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for @ trigger (mention)
|
|
||||||
let atStart = -1;
|
|
||||||
for (let i = cursorPos - 1; i >= 0; i--) {
|
|
||||||
if (text[i] === '@') {
|
|
||||||
atStart = i;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (text[i] === ' ' || text[i] === '\n') {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (atStart >= 0) {
|
|
||||||
const query = text.slice(atStart + 1, cursorPos);
|
|
||||||
if (!query.includes(' ') && !query.includes('\n')) {
|
|
||||||
setMentionQuery(query);
|
|
||||||
setMentionStartIndex(atStart);
|
|
||||||
setSuggestionMode('mention');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setSuggestionMode('none');
|
|
||||||
rebuildSegments(text);
|
|
||||||
},
|
|
||||||
[onChangeText, rebuildSegments, postSearchTimer],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectMention = useCallback(
|
|
||||||
(mentionUser: MentionUser) => {
|
|
||||||
if (mentionStartIndex < 0) return;
|
|
||||||
|
|
||||||
const before = value.slice(0, mentionStartIndex);
|
|
||||||
const mentionText = `@${mentionUser.nickname} `;
|
|
||||||
const newText = before + mentionText;
|
|
||||||
|
|
||||||
activeMentions.current.set(mentionStartIndex, {
|
|
||||||
endIndex: before.length + mentionText.length,
|
|
||||||
userId: mentionUser.id,
|
|
||||||
nickname: mentionUser.nickname,
|
|
||||||
});
|
|
||||||
|
|
||||||
onChangeText(newText);
|
|
||||||
setSuggestionMode('none');
|
|
||||||
setMentionQuery('');
|
|
||||||
setMentionStartIndex(-1);
|
|
||||||
|
|
||||||
rebuildSegments(newText);
|
|
||||||
|
|
||||||
inputRef.current?.focus();
|
|
||||||
},
|
|
||||||
[value, mentionStartIndex, onChangeText, rebuildSegments],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSelectPost = useCallback(
|
|
||||||
(post: SuggestPost) => {
|
|
||||||
if (mentionStartIndex < 0) return;
|
|
||||||
|
|
||||||
const before = value.slice(0, mentionStartIndex);
|
|
||||||
const refText = `#${post.title} `;
|
|
||||||
const newText = before + refText;
|
|
||||||
|
|
||||||
onChangeText(newText);
|
|
||||||
setSuggestionMode('none');
|
|
||||||
setSuggestPosts([]);
|
|
||||||
setMentionStartIndex(-1);
|
|
||||||
|
|
||||||
if (onPostRefPasted) {
|
|
||||||
onPostRefPasted(post.id, post.title);
|
|
||||||
}
|
|
||||||
|
|
||||||
rebuildSegments(newText);
|
|
||||||
|
|
||||||
inputRef.current?.focus();
|
|
||||||
},
|
|
||||||
[value, mentionStartIndex, onChangeText, rebuildSegments, onPostRefPasted],
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderMentionItem = ({ item }: { item: MentionUser }) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
|
|
||||||
onPress={() => handleSelectMention(item)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Avatar source={item.avatar} size={36} name={item.nickname} />
|
|
||||||
<View style={styles.mentionInfo}>
|
|
||||||
<Text style={[styles.mentionName, { color: colors.text.primary }]}>
|
|
||||||
{item.nickname}
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.mentionHint, { color: colors.text.hint }]}>
|
|
||||||
点击提及
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<MaterialCommunityIcons name="at" size={18} color={colors.primary.main} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.container, style]}>
|
|
||||||
<TextInput
|
|
||||||
ref={inputRef}
|
|
||||||
value={value}
|
|
||||||
onChangeText={handleChangeText}
|
|
||||||
placeholder={placeholder}
|
|
||||||
placeholderTextColor={colors.text.hint}
|
|
||||||
maxLength={maxLength}
|
|
||||||
multiline
|
|
||||||
textAlignVertical="top"
|
|
||||||
scrollEnabled={!autoExpand}
|
|
||||||
returnKeyType={returnKeyType}
|
|
||||||
onSubmitEditing={onSubmitEditing}
|
|
||||||
blurOnSubmit={false}
|
|
||||||
style={[
|
|
||||||
styles.input,
|
|
||||||
autoExpand && styles.inputAutoExpand,
|
|
||||||
{
|
|
||||||
color: colors.text.primary,
|
|
||||||
backgroundColor: autoExpand ? 'transparent' : colors.background.default,
|
|
||||||
minHeight,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{suggestionMode === 'mention' && filteredUsers.length > 0 && (
|
|
||||||
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
|
||||||
<ScrollView
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
nestedScrollEnabled
|
|
||||||
style={styles.mentionList}
|
|
||||||
>
|
|
||||||
{filteredUsers.map(item => (
|
|
||||||
<React.Fragment key={item.id}>
|
|
||||||
{renderMentionItem({ item })}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{suggestionMode === 'mention' && filteredUsers.length === 0 && (
|
|
||||||
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
|
||||||
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
|
|
||||||
没有找到匹配的关注用户
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{suggestionMode === 'postref' && suggestPosts.length > 0 && (
|
|
||||||
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
|
||||||
<ScrollView
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
nestedScrollEnabled
|
|
||||||
style={styles.mentionList}
|
|
||||||
>
|
|
||||||
{suggestPosts.map(item => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={item.id}
|
|
||||||
style={[styles.mentionItem, { borderBottomColor: colors.divider }]}
|
|
||||||
onPress={() => handleSelectPost(item)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons name="file-document-outline" size={18} color={colors.primary.main} />
|
|
||||||
<View style={styles.mentionInfo}>
|
|
||||||
<Text style={[styles.mentionName, { color: colors.text.primary }]} numberOfLines={1}>
|
|
||||||
{item.title}
|
|
||||||
</Text>
|
|
||||||
<Text style={[styles.mentionHint, { color: colors.text.hint }]}>
|
|
||||||
点击引用帖子
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</ScrollView>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{suggestionMode === 'postref' && suggestPosts.length === 0 && (
|
|
||||||
<View style={[styles.mentionPanel, { backgroundColor: colors.background.paper, borderColor: colors.divider }]}>
|
|
||||||
<Text style={[styles.emptyText, { color: colors.text.hint }]}>
|
|
||||||
没有找到匹配的帖子
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flexGrow: 1,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
padding: spacing.sm,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
minHeight: 100,
|
|
||||||
textAlignVertical: 'top' as const,
|
|
||||||
},
|
|
||||||
inputAutoExpand: {
|
|
||||||
borderRadius: 0,
|
|
||||||
paddingHorizontal: 0,
|
|
||||||
paddingTop: spacing.sm,
|
|
||||||
paddingBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
mentionPanel: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
maxHeight: 200,
|
|
||||||
marginTop: spacing.xs,
|
|
||||||
},
|
|
||||||
mentionList: {
|
|
||||||
maxHeight: 200,
|
|
||||||
},
|
|
||||||
mentionItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
||||||
},
|
|
||||||
mentionInfo: {
|
|
||||||
flex: 1,
|
|
||||||
marginLeft: spacing.sm,
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
mentionName: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
mentionHint: {
|
|
||||||
fontSize: fontSizes.xs,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
emptyText: {
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
padding: spacing.md,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default PostMentionInput;
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import React, { useCallback } from 'react';
|
|
||||||
import { View, TouchableOpacity, StyleSheet, ActivityIndicator } from 'react-native';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
|
|
||||||
import Text from '../common/Text';
|
|
||||||
import { useAppColors, spacing, borderRadius } from '../../theme';
|
|
||||||
import type { PostRefSegmentData } from '../../types';
|
|
||||||
|
|
||||||
interface PostRefCardProps {
|
|
||||||
data: PostRefSegmentData;
|
|
||||||
compact?: boolean;
|
|
||||||
onPress?: (postId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const PostRefCard: React.FC<PostRefCardProps> = ({ data, compact = false, onPress }) => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const handlePress = useCallback(() => {
|
|
||||||
if (onPress) {
|
|
||||||
onPress(data.post_id);
|
|
||||||
} else {
|
|
||||||
router.push(`/post/${data.post_id}` as any);
|
|
||||||
}
|
|
||||||
}, [data.post_id, onPress, router]);
|
|
||||||
|
|
||||||
const isDeleted = data.status === 'deleted';
|
|
||||||
const isHidden = data.status === 'hidden';
|
|
||||||
const isUnavailable = isDeleted || isHidden || !data.accessible;
|
|
||||||
const authorName = data.author?.nickname || '';
|
|
||||||
|
|
||||||
if (isUnavailable) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.container,
|
|
||||||
{ backgroundColor: colors.background.default, borderColor: colors.divider },
|
|
||||||
compact && styles.containerCompact,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons name="link-off" size={16} color={colors.text.hint} />
|
|
||||||
<Text style={[styles.unavailableText, { color: colors.text.hint }]} numberOfLines={1}>
|
|
||||||
{isDeleted ? '该帖子已删除' : isHidden ? '该帖子不可见' : '请登录后查看'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.container,
|
|
||||||
{ backgroundColor: colors.background.default, borderColor: colors.primary.light },
|
|
||||||
compact && styles.containerCompact,
|
|
||||||
]}
|
|
||||||
onPress={handlePress}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<View style={[styles.iconWrap, { backgroundColor: colors.primary.light }]}>
|
|
||||||
<MaterialCommunityIcons name="file-document-outline" size={14} color={colors.primary.main} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.contentWrap}>
|
|
||||||
<Text
|
|
||||||
style={[styles.title, { color: colors.text.primary }]}
|
|
||||||
numberOfLines={compact ? 1 : 2}
|
|
||||||
>
|
|
||||||
{data.title || '帖子'}
|
|
||||||
</Text>
|
|
||||||
{authorName ? (
|
|
||||||
<Text style={[styles.author, { color: colors.text.secondary }]} numberOfLines={1}>
|
|
||||||
@{authorName}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
<MaterialCommunityIcons name="chevron-right" size={16} color={colors.text.hint} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
marginTop: spacing.xs,
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
containerCompact: {
|
|
||||||
paddingVertical: spacing.xs,
|
|
||||||
paddingHorizontal: spacing.sm,
|
|
||||||
},
|
|
||||||
iconWrap: {
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
borderRadius: 4,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginRight: spacing.sm,
|
|
||||||
},
|
|
||||||
contentWrap: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: '500',
|
|
||||||
lineHeight: 18,
|
|
||||||
},
|
|
||||||
author: {
|
|
||||||
fontSize: 11,
|
|
||||||
lineHeight: 14,
|
|
||||||
marginTop: 1,
|
|
||||||
},
|
|
||||||
unavailableText: {
|
|
||||||
fontSize: 13,
|
|
||||||
marginLeft: spacing.sm,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default PostRefCard;
|
|
||||||
@@ -7,13 +7,12 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Modal,
|
Modal,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Platform,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||||
import { blurActiveElement } from '../../infrastructure/platform';
|
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -134,85 +133,22 @@ function createQrScannerStyles(colors: AppColors) {
|
|||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
webNotSupportedContainer: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: spacing.xl,
|
|
||||||
backgroundColor: '#000',
|
|
||||||
},
|
|
||||||
webNotSupportedText: {
|
|
||||||
marginTop: spacing.lg,
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
color: 'rgba(255,255,255,0.72)',
|
|
||||||
textAlign: 'center',
|
|
||||||
lineHeight: 24,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Web 端不支持扫码组件
|
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||||
const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
|
||||||
const themeColors = useAppColors();
|
|
||||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
blurActiveElement();
|
|
||||||
}
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
|
||||||
<View style={styles.container}>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
|
||||||
<MaterialCommunityIcons name="close" size={24} color="#fff" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={styles.headerTitle}>扫码登录</Text>
|
|
||||||
<View style={styles.placeholder} />
|
|
||||||
</View>
|
|
||||||
<View style={styles.webNotSupportedContainer}>
|
|
||||||
<MaterialCommunityIcons name="web-off" size={64} color="rgba(255,255,255,0.5)" />
|
|
||||||
<Text style={styles.webNotSupportedText}>
|
|
||||||
扫码登录功能暂不支持网页端使用{'\n'}
|
|
||||||
请下载手机 App 体验完整功能
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity style={styles.permissionButton} onPress={onClose}>
|
|
||||||
<Text style={styles.permissionButtonText}>知道了</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 原生端扫码组件
|
|
||||||
const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const themeColors = useAppColors();
|
const themeColors = useAppColors();
|
||||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
// 动态导入 expo-camera,避免 Web 端加载
|
|
||||||
const [cameraModule, setCameraModule] = useState<typeof import('expo-camera') | null>(null);
|
|
||||||
const [permissionStatus, setPermissionStatus] = useState<'undetermined' | 'granted' | 'denied'>('undetermined');
|
|
||||||
const [scanned, setScanned] = useState(false);
|
const [scanned, setScanned] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible && Platform.OS !== 'web') {
|
if (visible) {
|
||||||
import('expo-camera').then((module) => {
|
setScanned(false);
|
||||||
setCameraModule(module);
|
if (!permission?.granted) {
|
||||||
module.Camera.getCameraPermissionsAsync().then((result) => {
|
requestPermission();
|
||||||
setPermissionStatus(result.granted ? 'granted' : result.canAskAgain ? 'undetermined' : 'denied');
|
|
||||||
if (!result.granted && result.canAskAgain) {
|
|
||||||
module.Camera.requestCameraPermissionsAsync().then((reqResult) => {
|
|
||||||
setPermissionStatus(reqResult.granted ? 'granted' : 'denied');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}).catch((err) => {
|
|
||||||
console.warn('Failed to load expo-camera:', err);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
@@ -221,7 +157,7 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
|
|||||||
setScanned(true);
|
setScanned(true);
|
||||||
onClose();
|
onClose();
|
||||||
|
|
||||||
if (data.startsWith('withyou://qrcode/login')) {
|
if (data.startsWith('carrotbbs://qrcode/login')) {
|
||||||
const sessionId = extractSessionId(data);
|
const sessionId = extractSessionId(data);
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
router.push(hrefs.hrefQrLoginConfirm(sessionId));
|
router.push(hrefs.hrefQrLoginConfirm(sessionId));
|
||||||
@@ -242,7 +178,7 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!cameraModule || permissionStatus !== 'granted') {
|
if (!permission?.granted) {
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -256,16 +192,7 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
|
|||||||
<View style={styles.permissionContainer}>
|
<View style={styles.permissionContainer}>
|
||||||
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
|
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
|
||||||
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
|
||||||
style={styles.permissionButton}
|
|
||||||
onPress={() => {
|
|
||||||
if (cameraModule) {
|
|
||||||
cameraModule.Camera.requestCameraPermissionsAsync().then((result) => {
|
|
||||||
setPermissionStatus(result.granted ? 'granted' : 'denied');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={styles.permissionButtonText}>授予权限</Text>
|
<Text style={styles.permissionButtonText}>授予权限</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -274,8 +201,6 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { CameraView } = cameraModule;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -318,9 +243,4 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 根据平台导出不同组件
|
|
||||||
export const QRCodeScanner: React.FC<QRCodeScannerProps> = Platform.OS === 'web'
|
|
||||||
? QRCodeScannerWeb
|
|
||||||
: QRCodeScannerNative;
|
|
||||||
|
|
||||||
export default QRCodeScanner;
|
export default QRCodeScanner;
|
||||||
|
|||||||
@@ -1,493 +0,0 @@
|
|||||||
/**
|
|
||||||
* 举报对话框组件
|
|
||||||
* 支持举报帖子、评论、消息
|
|
||||||
* 提供友好的用户界面和流畅的交互体验
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
View,
|
|
||||||
TouchableOpacity,
|
|
||||||
StyleSheet,
|
|
||||||
ScrollView,
|
|
||||||
TextInput,
|
|
||||||
ActivityIndicator,
|
|
||||||
Alert,
|
|
||||||
Platform,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
} from 'react-native';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
import { useAppColors } from '../../../theme';
|
|
||||||
import { spacing, borderRadius, fontSizes, shadows } from '../../../theme';
|
|
||||||
import { reportService, ReportReason, ReportTargetType, REPORT_REASONS } from '@/services/post';
|
|
||||||
import { blurActiveElement } from '../../../infrastructure/platform';
|
|
||||||
import Text from '../../common/Text';
|
|
||||||
|
|
||||||
export interface ReportDialogProps {
|
|
||||||
visible: boolean;
|
|
||||||
targetType: ReportTargetType;
|
|
||||||
targetId: string;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 目标类型配置
|
|
||||||
const TARGET_CONFIG: Record<ReportTargetType, { label: string; icon: string; color: string }> = {
|
|
||||||
post: { label: '帖子', icon: 'file-document-outline', color: '#FF6B35' },
|
|
||||||
comment: { label: '评论', icon: 'comment-text-outline', color: '#4CAF50' },
|
|
||||||
message: { label: '消息', icon: 'message-text-outline', color: '#2196F3' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// 举报原因详细配置(带图标和说明)
|
|
||||||
const REPORT_REASONS_DETAILED = [
|
|
||||||
{ value: 'spam' as ReportReason, label: '垃圾广告', icon: 'email-newsletter', description: '包含广告、推广或重复内容' },
|
|
||||||
{ value: 'inappropriate' as ReportReason, label: '违规内容', icon: 'alert-circle-outline', description: '包含违法、色情或暴力内容' },
|
|
||||||
{ value: 'harassment' as ReportReason, label: '辱骂攻击', icon: 'account-off-outline', description: '包含人身攻击、骚扰或歧视' },
|
|
||||||
{ value: 'misinformation' as ReportReason, label: '虚假信息', icon: 'alert-outline', description: '包含谣言、诈骗或误导信息' },
|
|
||||||
{ value: 'other' as ReportReason, label: '其他原因', icon: 'dots-horizontal-circle-outline', description: '其他需要举报的情况' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ReportDialog: React.FC<ReportDialogProps> = ({
|
|
||||||
visible,
|
|
||||||
targetType,
|
|
||||||
targetId,
|
|
||||||
onClose,
|
|
||||||
onSuccess,
|
|
||||||
}) => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
const styles = useStyles(colors);
|
|
||||||
const [selectedReason, setSelectedReason] = useState<ReportReason | null>(null);
|
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const targetConfig = TARGET_CONFIG[targetType];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
blurActiveElement();
|
|
||||||
}
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
// 判断补充说明是否必填(选择"其他原因"时必填)
|
|
||||||
const isDescriptionRequired = selectedReason === 'other';
|
|
||||||
const descriptionPlaceholder = isDescriptionRequired
|
|
||||||
? '请详细描述您举报的原因(必填)...'
|
|
||||||
: '请提供更多详细信息(选填)...';
|
|
||||||
|
|
||||||
// 提交举报
|
|
||||||
const handleSubmit = useCallback(async () => {
|
|
||||||
if (!selectedReason) {
|
|
||||||
Alert.alert('提示', '请选择举报原因', [{ text: '确定' }]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDescriptionRequired && !description.trim()) {
|
|
||||||
Alert.alert('提示', '请选择"其他原因"时,请详细描述举报原因', [{ text: '确定' }]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
const result = await reportService.createReport(
|
|
||||||
targetType,
|
|
||||||
targetId,
|
|
||||||
selectedReason,
|
|
||||||
description.trim() || undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
Alert.alert('举报成功', '感谢您的反馈,我们会尽快处理', [
|
|
||||||
{
|
|
||||||
text: '确定',
|
|
||||||
onPress: () => {
|
|
||||||
onClose();
|
|
||||||
onSuccess?.();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
Alert.alert('举报失败', '请稍后重试', [{ text: '确定' }]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Submit report error:', error);
|
|
||||||
Alert.alert('举报失败', '网络错误,请稍后重试', [{ text: '确定' }]);
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}, [selectedReason, description, targetType, targetId, onClose, onSuccess]);
|
|
||||||
|
|
||||||
// 重置状态
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
setSelectedReason(null);
|
|
||||||
setDescription('');
|
|
||||||
onClose();
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const selectedReasonData = useMemo(
|
|
||||||
() => REPORT_REASONS_DETAILED.find(r => r.value === selectedReason),
|
|
||||||
[selectedReason]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent
|
|
||||||
animationType="fade"
|
|
||||||
onRequestClose={handleClose}
|
|
||||||
>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
||||||
style={styles.keyboardView}
|
|
||||||
>
|
|
||||||
<View style={styles.overlay}>
|
|
||||||
<View style={styles.container}>
|
|
||||||
{/* 头部 */}
|
|
||||||
<View style={styles.header}>
|
|
||||||
<View style={styles.headerContent}>
|
|
||||||
<View style={[styles.headerIcon, { backgroundColor: targetConfig.color + '15' }]}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={targetConfig.icon as any}
|
|
||||||
size={20}
|
|
||||||
color={targetConfig.color}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={styles.headerText}>
|
|
||||||
<Text style={styles.title}>举报{targetConfig.label}</Text>
|
|
||||||
<Text style={styles.subtitle}>请选择合适的举报原因</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.closeButton}
|
|
||||||
onPress={handleClose}
|
|
||||||
disabled={submitting}
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="close"
|
|
||||||
size={24}
|
|
||||||
color={colors.chat.textSecondary}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 内容 */}
|
|
||||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
|
||||||
{/* 举报原因选择 */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>举报原因</Text>
|
|
||||||
<View style={styles.reasonList}>
|
|
||||||
{REPORT_REASONS_DETAILED.map((item) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={item.value}
|
|
||||||
style={[
|
|
||||||
styles.reasonItem,
|
|
||||||
selectedReason === item.value && [
|
|
||||||
styles.reasonItemSelected,
|
|
||||||
{ borderColor: colors.primary.main + '40' }
|
|
||||||
],
|
|
||||||
]}
|
|
||||||
onPress={() => setSelectedReason(item.value)}
|
|
||||||
disabled={submitting}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<View style={styles.reasonLeft}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.reasonIcon,
|
|
||||||
selectedReason === item.value && styles.reasonIconSelected,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={item.icon as any}
|
|
||||||
size={20}
|
|
||||||
color={selectedReason === item.value ? colors.primary.main : colors.chat.textSecondary}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={styles.reasonTextContainer}>
|
|
||||||
<Text style={[
|
|
||||||
styles.reasonLabel,
|
|
||||||
...(selectedReason === item.value ? [styles.reasonLabelSelected] : []),
|
|
||||||
]}>
|
|
||||||
{item.label}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.reasonDescription}>{item.description}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.radio,
|
|
||||||
selectedReason === item.value && styles.radioSelected,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{selectedReason === item.value && (
|
|
||||||
<View style={styles.radioDot} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 补充说明 */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<View style={styles.sectionHeader}>
|
|
||||||
<Text style={styles.sectionTitle}>
|
|
||||||
补充说明{isDescriptionRequired ? ' *' : ''}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.charCount}>{description.length}/500</Text>
|
|
||||||
</View>
|
|
||||||
<TextInput
|
|
||||||
style={[
|
|
||||||
styles.textInput,
|
|
||||||
isDescriptionRequired && !description && styles.textInputError,
|
|
||||||
]}
|
|
||||||
placeholder={descriptionPlaceholder}
|
|
||||||
placeholderTextColor={colors.chat.textPlaceholder}
|
|
||||||
multiline
|
|
||||||
maxLength={500}
|
|
||||||
value={description}
|
|
||||||
onChangeText={setDescription}
|
|
||||||
editable={!submitting}
|
|
||||||
textAlignVertical="top"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* 底部按钮 */}
|
|
||||||
<View style={styles.footer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.cancelButton}
|
|
||||||
onPress={handleClose}
|
|
||||||
disabled={submitting}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Text style={styles.cancelButtonText}>取消</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.submitButton,
|
|
||||||
(!selectedReason || submitting) && styles.submitButtonDisabled,
|
|
||||||
]}
|
|
||||||
onPress={handleSubmit}
|
|
||||||
disabled={!selectedReason || submitting}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
|
||||||
) : (
|
|
||||||
<Text style={styles.submitButtonText}>提交举报</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 创建样式
|
|
||||||
const useStyles = (colors: ReturnType<typeof useAppColors>) =>
|
|
||||||
StyleSheet.create({
|
|
||||||
keyboardView: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
overlay: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: spacing.lg,
|
|
||||||
},
|
|
||||||
container: {
|
|
||||||
backgroundColor: colors.chat.card,
|
|
||||||
borderRadius: borderRadius.xl,
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: 420,
|
|
||||||
maxHeight: '85%',
|
|
||||||
...shadows.lg,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: spacing.lg,
|
|
||||||
paddingBottom: spacing.md,
|
|
||||||
},
|
|
||||||
headerContent: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: spacing.md,
|
|
||||||
},
|
|
||||||
headerIcon: {
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
headerText: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: fontSizes.xl,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.chat.textPrimary,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
color: colors.chat.textSecondary,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
closeButton: {
|
|
||||||
padding: spacing.xs,
|
|
||||||
borderRadius: borderRadius.full,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
padding: spacing.lg,
|
|
||||||
paddingTop: 0,
|
|
||||||
maxHeight: 400,
|
|
||||||
},
|
|
||||||
section: {
|
|
||||||
marginBottom: spacing.lg,
|
|
||||||
},
|
|
||||||
sectionHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.chat.textPrimary,
|
|
||||||
},
|
|
||||||
reasonList: {
|
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
|
||||||
reasonItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: spacing.md,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'transparent',
|
|
||||||
backgroundColor: colors.chat.surfaceMuted,
|
|
||||||
},
|
|
||||||
reasonItemSelected: {
|
|
||||||
backgroundColor: colors.primary.main + '08',
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
reasonLeft: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
flex: 1,
|
|
||||||
gap: spacing.md,
|
|
||||||
},
|
|
||||||
reasonIcon: {
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
backgroundColor: colors.chat.surfaceRaised,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
reasonIconSelected: {
|
|
||||||
backgroundColor: colors.primary.main + '15',
|
|
||||||
},
|
|
||||||
reasonTextContainer: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
reasonLabel: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: colors.chat.textPrimary,
|
|
||||||
},
|
|
||||||
reasonLabelSelected: {
|
|
||||||
color: colors.primary.main,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
reasonDescription: {
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
color: colors.chat.textSecondary,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
radio: {
|
|
||||||
width: 22,
|
|
||||||
height: 22,
|
|
||||||
borderRadius: 11,
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: colors.chat.border,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginLeft: spacing.sm,
|
|
||||||
},
|
|
||||||
radioSelected: {
|
|
||||||
borderColor: colors.primary.main,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
},
|
|
||||||
radioDot: {
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
borderRadius: 5,
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
},
|
|
||||||
textInput: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.chat.border,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
padding: spacing.md,
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
color: colors.chat.textPrimary,
|
|
||||||
minHeight: 100,
|
|
||||||
backgroundColor: colors.chat.surfaceMuted,
|
|
||||||
},
|
|
||||||
textInputError: {
|
|
||||||
borderColor: colors.error.main,
|
|
||||||
backgroundColor: colors.error.main + '08',
|
|
||||||
},
|
|
||||||
charCount: {
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
color: colors.chat.textSecondary,
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
padding: spacing.lg,
|
|
||||||
gap: spacing.md,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: colors.chat.borderLight,
|
|
||||||
},
|
|
||||||
cancelButton: {
|
|
||||||
flex: 1,
|
|
||||||
paddingVertical: spacing.md,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.chat.border,
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: colors.chat.surfaceMuted,
|
|
||||||
},
|
|
||||||
cancelButtonText: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: colors.chat.textSecondary,
|
|
||||||
},
|
|
||||||
submitButton: {
|
|
||||||
flex: 1,
|
|
||||||
paddingVertical: spacing.md,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
submitButtonDisabled: {
|
|
||||||
backgroundColor: colors.background.disabled,
|
|
||||||
},
|
|
||||||
submitButtonText: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
color: '#FFFFFF',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ReportDialog;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default } from './ReportDialog';
|
|
||||||
export type { ReportDialogProps } from './ReportDialog';
|
|
||||||
@@ -16,52 +16,59 @@ interface SearchBarProps {
|
|||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
compact?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSearchBarStyles(colors: AppColors, compact: boolean) {
|
function createSearchBarStyles(colors: AppColors) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: `${colors.primary.main}08`,
|
backgroundColor: colors.background.paper,
|
||||||
borderRadius: borderRadius.full,
|
borderRadius: borderRadius.full,
|
||||||
paddingHorizontal: compact ? spacing.sm : spacing.md,
|
paddingHorizontal: spacing.xs,
|
||||||
height: compact ? 38 : 48,
|
height: 46,
|
||||||
borderWidth: 1.5,
|
borderWidth: 1,
|
||||||
borderColor: 'transparent',
|
borderColor: colors.divider,
|
||||||
|
shadowColor: 'transparent',
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0,
|
||||||
|
shadowRadius: 0,
|
||||||
|
elevation: 0,
|
||||||
},
|
},
|
||||||
containerFocused: {
|
containerFocused: {
|
||||||
borderColor: `${colors.primary.main}50`,
|
borderColor: colors.primary.main,
|
||||||
backgroundColor: `${colors.primary.main}10`,
|
shadowColor: 'transparent',
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0,
|
||||||
|
shadowRadius: 0,
|
||||||
|
elevation: 0,
|
||||||
},
|
},
|
||||||
searchIconWrap: {
|
searchIconWrap: {
|
||||||
width: compact ? 22 : 32,
|
width: 30,
|
||||||
height: compact ? 22 : 32,
|
height: 30,
|
||||||
marginLeft: compact ? 2 : 0,
|
marginLeft: spacing.xs,
|
||||||
marginRight: compact ? 6 : spacing.sm,
|
marginRight: spacing.xs,
|
||||||
borderRadius: borderRadius.full,
|
borderRadius: borderRadius.full,
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: `${colors.text.secondary}12`,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
searchIconWrapFocused: {
|
searchIconWrapFocused: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: `${colors.primary.main}1A`,
|
||||||
},
|
},
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: compact ? fontSizes.md : fontSizes.lg,
|
fontSize: fontSizes.md,
|
||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
paddingVertical: compact ? 0 : spacing.sm,
|
paddingVertical: spacing.sm + 1,
|
||||||
paddingHorizontal: compact ? 2 : 0,
|
paddingHorizontal: spacing.xs,
|
||||||
fontWeight: '500',
|
|
||||||
},
|
},
|
||||||
clearButton: {
|
clearButton: {
|
||||||
width: 22,
|
width: 24,
|
||||||
height: 22,
|
height: 24,
|
||||||
marginLeft: spacing.xs,
|
marginHorizontal: spacing.xs,
|
||||||
borderRadius: borderRadius.full,
|
borderRadius: borderRadius.full,
|
||||||
backgroundColor: `${colors.text.secondary}20`,
|
backgroundColor: `${colors.text.secondary}14`,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
@@ -76,10 +83,9 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
|||||||
onFocus,
|
onFocus,
|
||||||
onBlur,
|
onBlur,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
compact = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const styles = useMemo(() => createSearchBarStyles(colors, compact), [colors, compact]);
|
const styles = useMemo(() => createSearchBarStyles(colors), [colors]);
|
||||||
const [isFocused, setIsFocused] = useState(false);
|
const [isFocused, setIsFocused] = useState(false);
|
||||||
|
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
@@ -97,7 +103,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
|||||||
<View style={[styles.searchIconWrap, isFocused && styles.searchIconWrapFocused]}>
|
<View style={[styles.searchIconWrap, isFocused && styles.searchIconWrapFocused]}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="magnify"
|
name="magnify"
|
||||||
size={compact ? 16 : 18}
|
size={18}
|
||||||
color={isFocused ? colors.primary.main : colors.text.secondary}
|
color={isFocused ? colors.primary.main : colors.text.secondary}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import React, { useEffect, useMemo } from 'react';
|
|
||||||
import { Share as RNShare, Platform } from 'react-native';
|
|
||||||
|
|
||||||
export interface ShareSheetProps {
|
|
||||||
visible: boolean;
|
|
||||||
postUrl?: string;
|
|
||||||
postTitle?: string;
|
|
||||||
onClose: () => void;
|
|
||||||
onShareAction?: (actionKey: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ShareSheet: React.FC<ShareSheetProps> = ({
|
|
||||||
visible,
|
|
||||||
postUrl,
|
|
||||||
postTitle,
|
|
||||||
onClose,
|
|
||||||
onShareAction,
|
|
||||||
}) => {
|
|
||||||
|
|
||||||
const shareText = useMemo(() => {
|
|
||||||
const title = postTitle || '威友帖子';
|
|
||||||
return `${title} ${postUrl || ''}`;
|
|
||||||
}, [postTitle, postUrl]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!visible) return;
|
|
||||||
|
|
||||||
const doShare = async () => {
|
|
||||||
try {
|
|
||||||
await RNShare.share(
|
|
||||||
{
|
|
||||||
message: shareText,
|
|
||||||
url: Platform.OS === 'ios' ? postUrl : undefined,
|
|
||||||
title: postTitle || '分享帖子',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
subject: postTitle,
|
|
||||||
dialogTitle: '分享到',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
onShareAction?.('system_share');
|
|
||||||
} catch {
|
|
||||||
// User cancelled
|
|
||||||
} finally {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
doShare();
|
|
||||||
}, [visible, shareText, postUrl, postTitle, onShareAction, onClose]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ShareSheet;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default } from './ShareSheet';
|
|
||||||
export type { ShareSheetProps } from './ShareSheet';
|
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* SystemMessageItem 系统消息项组件 - 扁平化风格
|
* SystemMessageItem 系统消息项组件 - 美化版
|
||||||
* 根据系统消息类型显示不同图标和内容
|
* 根据系统消息类型显示不同图标和内容
|
||||||
* 与登录、注册、设置页面风格保持一致
|
* 采用卡片式设计,符合胡萝卜BBS整体风格
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { formatChatTime } from '../../utils/formatTime';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
import { zhCN } from 'date-fns/locale';
|
||||||
|
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||||
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
|
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
|
||||||
import Text from '../common/Text';
|
import Text from '../common/Text';
|
||||||
import Avatar from '../common/Avatar';
|
import Avatar from '../common/Avatar';
|
||||||
@@ -123,13 +124,17 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
container: {
|
container: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
padding: 16,
|
padding: spacing.md,
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
borderBottomWidth: 1,
|
borderRadius: borderRadius.lg,
|
||||||
borderBottomColor: colors.divider || '#E5E5EA',
|
marginHorizontal: spacing.md,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
...shadows.sm,
|
||||||
},
|
},
|
||||||
unreadContainer: {
|
unreadContainer: {
|
||||||
backgroundColor: colors.primary.main + '0A',
|
backgroundColor: colors.primary.light + '08',
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: colors.primary.main,
|
||||||
},
|
},
|
||||||
unreadIndicator: {
|
unreadIndicator: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -138,19 +143,19 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
marginTop: -4,
|
marginTop: -4,
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
borderRadius: 4,
|
borderRadius: borderRadius.full,
|
||||||
backgroundColor: colors.primary.main,
|
backgroundColor: colors.primary.main,
|
||||||
},
|
},
|
||||||
iconContainer: {
|
iconContainer: {
|
||||||
marginRight: 14,
|
marginRight: spacing.md,
|
||||||
},
|
},
|
||||||
avatarWrapper: {
|
avatarWrapper: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
iconWrapper: {
|
iconWrapper: {
|
||||||
width: 44,
|
width: 48,
|
||||||
height: 44,
|
height: 48,
|
||||||
borderRadius: 12,
|
borderRadius: borderRadius.lg,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
@@ -158,9 +163,9 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: -2,
|
bottom: -2,
|
||||||
right: -2,
|
right: -2,
|
||||||
width: 18,
|
width: 20,
|
||||||
height: 18,
|
height: 20,
|
||||||
borderRadius: 9,
|
borderRadius: borderRadius.full,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
@@ -175,17 +180,17 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 4,
|
marginBottom: spacing.xs,
|
||||||
},
|
},
|
||||||
titleLeft: {
|
titleLeft: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginRight: 8,
|
marginRight: spacing.sm,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
fontSize: 15,
|
fontSize: fontSizes.md,
|
||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
},
|
},
|
||||||
unreadTitle: {
|
unreadTitle: {
|
||||||
@@ -195,10 +200,10 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
statusBadge: {
|
statusBadge: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: spacing.xs,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: 4,
|
borderRadius: borderRadius.sm,
|
||||||
marginLeft: 6,
|
marginLeft: spacing.xs,
|
||||||
},
|
},
|
||||||
statusText: {
|
statusText: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
@@ -206,59 +211,55 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
marginLeft: 2,
|
marginLeft: 2,
|
||||||
},
|
},
|
||||||
time: {
|
time: {
|
||||||
fontSize: 13,
|
fontSize: fontSizes.sm,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
color: colors.text.hint,
|
|
||||||
},
|
},
|
||||||
messageContent: {
|
messageContent: {
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
fontSize: 14,
|
fontSize: fontSizes.sm,
|
||||||
color: colors.text.secondary,
|
|
||||||
},
|
},
|
||||||
extraInfo: {
|
extraInfo: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: 8,
|
marginTop: spacing.xs,
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.primary.light + '12',
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: 6,
|
paddingVertical: spacing.xs,
|
||||||
borderRadius: 8,
|
borderRadius: borderRadius.md,
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
},
|
},
|
||||||
extraText: {
|
extraText: {
|
||||||
marginLeft: 6,
|
marginLeft: spacing.xs,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
fontSize: 13,
|
|
||||||
},
|
},
|
||||||
actionsRow: {
|
actionsRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
marginTop: 12,
|
marginTop: spacing.sm,
|
||||||
gap: 10,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
actionBtn: {
|
actionBtn: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 8,
|
paddingVertical: spacing.xs,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: spacing.md,
|
||||||
borderRadius: 10,
|
borderRadius: borderRadius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
gap: 4,
|
gap: spacing.xs,
|
||||||
},
|
},
|
||||||
rejectBtn: {
|
rejectBtn: {
|
||||||
borderColor: colors.error.main + '40',
|
borderColor: `${colors.error.main}55`,
|
||||||
backgroundColor: colors.error.light + '20',
|
backgroundColor: `${colors.error.main}18`,
|
||||||
},
|
},
|
||||||
approveBtn: {
|
approveBtn: {
|
||||||
borderColor: colors.success.main + '40',
|
borderColor: `${colors.success.main}55`,
|
||||||
backgroundColor: colors.success.light + '20',
|
backgroundColor: `${colors.success.main}18`,
|
||||||
},
|
},
|
||||||
actionBtnText: {
|
actionBtnText: {
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
fontSize: 13,
|
|
||||||
},
|
},
|
||||||
arrowContainer: {
|
arrowContainer: {
|
||||||
marginLeft: 8,
|
marginLeft: spacing.sm,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
height: 20,
|
height: 20,
|
||||||
@@ -328,6 +329,18 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
|||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const styles = useMemo(() => createSystemMessageStyles(colors), [colors]);
|
const styles = useMemo(() => createSystemMessageStyles(colors), [colors]);
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = (dateString: string): string => {
|
||||||
|
try {
|
||||||
|
return formatDistanceToNow(new Date(dateString), {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: zhCN,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const { icon, color, bgColor } = getSystemMessageIcon(message.system_type, colors);
|
const { icon, color, bgColor } = getSystemMessageIcon(message.system_type, colors);
|
||||||
const title = getSystemMessageTitle(message);
|
const title = getSystemMessageTitle(message);
|
||||||
const { extra_data } = message;
|
const { extra_data } = message;
|
||||||
@@ -428,7 +441,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
|||||||
{getStatusBadge()}
|
{getStatusBadge()}
|
||||||
</View>
|
</View>
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.time}>
|
<Text variant="caption" color={colors.text.hint} style={styles.time}>
|
||||||
{formatChatTime(message.created_at)}
|
{formatTime(message.created_at)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -135,21 +135,26 @@ function createTabBarStyles(colors: AppColors) {
|
|||||||
modernContainer: {
|
modernContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
borderBottomWidth: 1,
|
borderRadius: borderRadius.xl,
|
||||||
borderBottomColor: colors.divider,
|
marginHorizontal: spacing.lg,
|
||||||
alignItems: 'center',
|
marginVertical: spacing.md,
|
||||||
paddingRight: spacing.xs,
|
padding: spacing.xs,
|
||||||
|
shadowColor: colors.chat.shadow,
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.08,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 3,
|
||||||
},
|
},
|
||||||
modernTab: {
|
modernTab: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
backgroundColor: 'transparent',
|
|
||||||
},
|
},
|
||||||
modernTabActive: {
|
modernTabActive: {
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: colors.primary.main + '15',
|
||||||
},
|
},
|
||||||
modernTabContent: {
|
modernTabContent: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -164,17 +169,15 @@ function createTabBarStyles(colors: AppColors) {
|
|||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
},
|
},
|
||||||
modernTabTextActive: {
|
modernTabTextActive: {
|
||||||
fontWeight: '600',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
modernTabIndicator: {
|
modernTabIndicator: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 0,
|
bottom: 4,
|
||||||
left: '25%',
|
width: 20,
|
||||||
right: '25%',
|
|
||||||
height: 3,
|
height: 3,
|
||||||
backgroundColor: colors.primary.main,
|
backgroundColor: colors.primary.main,
|
||||||
borderTopLeftRadius: borderRadius.sm,
|
borderRadius: borderRadius.full,
|
||||||
borderTopRightRadius: borderRadius.sm,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -203,7 +206,7 @@ const TabBar: React.FC<TabBarProps> = ({
|
|||||||
key={index}
|
key={index}
|
||||||
style={[styles.modernTab, isActive && styles.modernTabActive]}
|
style={[styles.modernTab, isActive && styles.modernTabActive]}
|
||||||
onPress={() => onTabChange(index)}
|
onPress={() => onTabChange(index)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.85}
|
||||||
>
|
>
|
||||||
<View style={styles.modernTabContent}>
|
<View style={styles.modernTabContent}>
|
||||||
{icon && (
|
{icon && (
|
||||||
|
|||||||
@@ -1,382 +0,0 @@
|
|||||||
import React, { memo } from 'react';
|
|
||||||
import { View, TouchableOpacity, Image, StyleSheet, Pressable } from 'react-native';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
import { useAppColors, spacing, borderRadius, fontSizes, type AppColors } from '../../../theme';
|
|
||||||
import { Text } from '../../common';
|
|
||||||
import type { TradeItemDTO } from '../../../types/trade';
|
|
||||||
import { TRADE_CATEGORY_MAP, TRADE_CONDITION_MAP, TRADE_TYPE_MAP } from '../../../types/trade';
|
|
||||||
|
|
||||||
export interface TradeCardProps {
|
|
||||||
item: TradeItemDTO;
|
|
||||||
variant?: 'list' | 'grid';
|
|
||||||
onPress?: (id: string) => void;
|
|
||||||
onFavorite?: (id: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPrice(price?: number | null): string {
|
|
||||||
if (price == null) return '面议';
|
|
||||||
return `¥${price.toFixed(price % 1 === 0 ? 0 : 2)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTradeCardStyles(colors: AppColors) {
|
|
||||||
return StyleSheet.create({
|
|
||||||
gridCard: {
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
borderRadius: borderRadius.lg,
|
|
||||||
overflow: 'hidden',
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.08,
|
|
||||||
shadowRadius: 6,
|
|
||||||
elevation: 3,
|
|
||||||
},
|
|
||||||
listCard: {
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
borderRadius: borderRadius.lg,
|
|
||||||
overflow: 'hidden',
|
|
||||||
padding: spacing.md,
|
|
||||||
},
|
|
||||||
imageContainer: {
|
|
||||||
position: 'relative',
|
|
||||||
aspectRatio: 1,
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
minHeight: 140,
|
|
||||||
},
|
|
||||||
listImageContainer: {
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
image: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
resizeMode: 'cover',
|
|
||||||
},
|
|
||||||
// 包邮/标签 badge 放在图片左下角,黄色背景
|
|
||||||
shippingBadge: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 6,
|
|
||||||
left: 6,
|
|
||||||
paddingHorizontal: 5,
|
|
||||||
paddingVertical: 2,
|
|
||||||
borderRadius: borderRadius.sm,
|
|
||||||
backgroundColor: '#FFE066',
|
|
||||||
},
|
|
||||||
shippingBadgeText: {
|
|
||||||
color: '#5C4B00',
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
typeBadge: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 6,
|
|
||||||
left: 6,
|
|
||||||
paddingHorizontal: 6,
|
|
||||||
paddingVertical: 2,
|
|
||||||
borderRadius: borderRadius.sm,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
|
||||||
sellBadge: {
|
|
||||||
backgroundColor: `${colors.success.main}CC`,
|
|
||||||
},
|
|
||||||
buyBadge: {
|
|
||||||
backgroundColor: `${colors.primary.main}CC`,
|
|
||||||
},
|
|
||||||
typeBadgeText: {
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: fontSizes.xs,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
statusBadge: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 6,
|
|
||||||
right: 6,
|
|
||||||
paddingHorizontal: 6,
|
|
||||||
paddingVertical: 2,
|
|
||||||
borderRadius: borderRadius.sm,
|
|
||||||
backgroundColor: `${colors.warning.main}BB`,
|
|
||||||
},
|
|
||||||
statusBadgeText: {
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: fontSizes.xs,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
gridContent: {
|
|
||||||
paddingHorizontal: spacing.sm,
|
|
||||||
paddingTop: spacing.xs,
|
|
||||||
paddingBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
titleRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
gap: spacing.xs,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: colors.text.primary,
|
|
||||||
lineHeight: 20,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
priceInline: {
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#FF4D4F',
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
priceRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'baseline',
|
|
||||||
gap: 4,
|
|
||||||
marginTop: spacing.xs,
|
|
||||||
},
|
|
||||||
price: {
|
|
||||||
fontSize: fontSizes['2xl'],
|
|
||||||
fontWeight: '700',
|
|
||||||
color: '#FF4D4F',
|
|
||||||
},
|
|
||||||
originalPrice: {
|
|
||||||
fontSize: fontSizes.xs,
|
|
||||||
color: colors.text.hint,
|
|
||||||
textDecorationLine: 'line-through',
|
|
||||||
},
|
|
||||||
negotiable: {
|
|
||||||
fontSize: fontSizes.lg,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text.secondary,
|
|
||||||
},
|
|
||||||
wantCount: {
|
|
||||||
fontSize: fontSizes.xs,
|
|
||||||
color: colors.text.hint,
|
|
||||||
marginLeft: 4,
|
|
||||||
},
|
|
||||||
tagsRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: 4,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
tag: {
|
|
||||||
paddingHorizontal: 5,
|
|
||||||
paddingVertical: 1,
|
|
||||||
borderRadius: borderRadius.sm,
|
|
||||||
backgroundColor: `${colors.primary.main}10`,
|
|
||||||
},
|
|
||||||
tagText: {
|
|
||||||
fontSize: 10,
|
|
||||||
color: colors.primary.main,
|
|
||||||
},
|
|
||||||
conditionTag: {
|
|
||||||
backgroundColor: `${colors.success.main}10`,
|
|
||||||
},
|
|
||||||
conditionTagText: {
|
|
||||||
fontSize: 10,
|
|
||||||
color: colors.success.main,
|
|
||||||
},
|
|
||||||
// 促销标签样式(如"24小时发货")
|
|
||||||
promoTag: {
|
|
||||||
paddingHorizontal: 5,
|
|
||||||
paddingVertical: 1,
|
|
||||||
borderRadius: borderRadius.sm,
|
|
||||||
backgroundColor: '#FFF2F0',
|
|
||||||
borderWidth: 0.5,
|
|
||||||
borderColor: '#FFCCC7',
|
|
||||||
},
|
|
||||||
promoTagText: {
|
|
||||||
fontSize: 10,
|
|
||||||
color: '#FF4D4F',
|
|
||||||
},
|
|
||||||
bottomRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
marginTop: spacing.xs,
|
|
||||||
},
|
|
||||||
authorRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: spacing.xs,
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
avatar: {
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
borderRadius: 9,
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
},
|
|
||||||
authorName: {
|
|
||||||
fontSize: 11,
|
|
||||||
color: colors.text.secondary,
|
|
||||||
flexShrink: 1,
|
|
||||||
},
|
|
||||||
statsRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
},
|
|
||||||
statItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 2,
|
|
||||||
},
|
|
||||||
statText: {
|
|
||||||
fontSize: 11,
|
|
||||||
color: colors.text.hint,
|
|
||||||
},
|
|
||||||
favButton: {
|
|
||||||
padding: 2,
|
|
||||||
},
|
|
||||||
listContent: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
},
|
|
||||||
listLayout: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: spacing.md,
|
|
||||||
},
|
|
||||||
imagePlaceholder: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const TradeCardBase: React.FC<TradeCardProps> = ({ item, variant = 'list', onPress, onFavorite }) => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
const styles = createTradeCardStyles(colors);
|
|
||||||
|
|
||||||
const firstImage = item.images?.[0];
|
|
||||||
const isSell = item.trade_type === 'sell';
|
|
||||||
const isActive = item.status === 'active';
|
|
||||||
const conditionLabel = item.condition ? TRADE_CONDITION_MAP[item.condition as keyof typeof TRADE_CONDITION_MAP] : null;
|
|
||||||
const categoryLabel = TRADE_CATEGORY_MAP[item.category] || item.category;
|
|
||||||
|
|
||||||
const renderImage = (containerStyle: any, imageStyle: any) => (
|
|
||||||
<View style={containerStyle}>
|
|
||||||
{firstImage ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: firstImage.preview_url || firstImage.url }}
|
|
||||||
style={imageStyle}
|
|
||||||
defaultSource={undefined}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View style={[imageStyle, styles.imagePlaceholder]}>
|
|
||||||
<MaterialCommunityIcons name="image-outline" size={32} color={colors.text.hint} />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{/* 包邮标签 */}
|
|
||||||
{item.is_free_shipping && (
|
|
||||||
<View style={styles.shippingBadge}>
|
|
||||||
<Text style={styles.shippingBadgeText}>包邮</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<View style={[styles.typeBadge, isSell ? styles.sellBadge : styles.buyBadge]}>
|
|
||||||
<Text style={styles.typeBadgeText}>{TRADE_TYPE_MAP[item.trade_type]}</Text>
|
|
||||||
</View>
|
|
||||||
{!isActive && (
|
|
||||||
<View style={styles.statusBadge}>
|
|
||||||
<Text style={styles.statusBadgeText}>
|
|
||||||
{item.status === 'sold' ? '已售' : item.status === 'bought' ? '已收' : item.status === 'offline' ? '下架' : ''}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderTitleRow = () => (
|
|
||||||
<View style={styles.titleRow}>
|
|
||||||
<Text style={styles.title} numberOfLines={2}>{item.title}</Text>
|
|
||||||
<Text style={styles.priceInline}>
|
|
||||||
{item.price != null ? formatPrice(item.price) : '面议'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderTags = () => {
|
|
||||||
if (!conditionLabel && !isSell && !item.is_quick_ship) return null;
|
|
||||||
return (
|
|
||||||
<View style={styles.tagsRow}>
|
|
||||||
{conditionLabel && isSell && (
|
|
||||||
<View style={[styles.tag, styles.conditionTag]}>
|
|
||||||
<Text style={styles.conditionTagText}>{conditionLabel}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{item.is_quick_ship && (
|
|
||||||
<View style={styles.promoTag}>
|
|
||||||
<Text style={styles.promoTagText}>24小时发货</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderBottom = () => (
|
|
||||||
<View style={styles.bottomRow}>
|
|
||||||
<View style={styles.authorRow}>
|
|
||||||
{item.author?.avatar ? (
|
|
||||||
<Image source={{ uri: item.author.avatar }} style={styles.avatar} />
|
|
||||||
) : (
|
|
||||||
<View style={[styles.avatar, { backgroundColor: colors.primary.main + '33' }]} />
|
|
||||||
)}
|
|
||||||
<Text style={styles.authorName} numberOfLines={1}>{item.author?.nickname || '匿名'}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.statsRow}>
|
|
||||||
{(item.good_reputation || 0) > 0 && (
|
|
||||||
<View style={[styles.promoTag, { backgroundColor: '#FFF7E6', borderColor: '#FFD591' }]}>
|
|
||||||
<Text style={[styles.promoTagText, { color: '#FA8C16' }]}>回头客好评过百</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
<View style={styles.tag}>
|
|
||||||
<Text style={styles.tagText}>{categoryLabel}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (variant === 'grid') {
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.gridCard}
|
|
||||||
onPress={() => onPress?.(item.id)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
{renderImage(styles.imageContainer, styles.image)}
|
|
||||||
<View style={styles.gridContent}>
|
|
||||||
{renderTitleRow()}
|
|
||||||
{renderTags()}
|
|
||||||
{renderBottom()}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// grid variant is used in masonry layout; list variant below is unused but kept for compatibility
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.listCard}
|
|
||||||
onPress={() => onPress?.(item.id)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<View style={styles.listLayout}>
|
|
||||||
{renderImage(styles.listImageContainer, styles.image)}
|
|
||||||
<View style={styles.listContent}>
|
|
||||||
{renderTitleRow()}
|
|
||||||
{renderTags()}
|
|
||||||
{renderBottom()}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const TradeCard = memo(TradeCardBase);
|
|
||||||
|
|
||||||
export default TradeCard;
|
|
||||||
@@ -1,52 +1,50 @@
|
|||||||
/**
|
/**
|
||||||
* UserProfileHeader 用户资料头部组件 - Twitter/X 风格
|
* UserProfileHeader 用户资料头部组件 - 美化版(响应式适配)
|
||||||
* 显示用户封面、头像、昵称、简介、关注/粉丝数
|
* 显示用户封面、头像、昵称、简介、关注/粉丝数
|
||||||
* 采用 Twitter/X 扁平化设计,左对齐布局
|
* 采用现代卡片式设计,渐变封面,悬浮头像
|
||||||
* 支持互关状态显示
|
* 支持互关状态显示
|
||||||
|
* 在宽屏下显示更大的头像和封面
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo, useState, useRef, useCallback } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Image,
|
Image,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
Modal,
|
|
||||||
Pressable,
|
|
||||||
Dimensions,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { User, UserIdentity, VerificationStatus } from '../../types';
|
import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
|
||||||
|
import { User } from '../../types';
|
||||||
import Text from '../common/Text';
|
import Text from '../common/Text';
|
||||||
|
import Button from '../common/Button';
|
||||||
import Avatar from '../common/Avatar';
|
import Avatar from '../common/Avatar';
|
||||||
import { ImageGallery } from '../common';
|
|
||||||
import type { GalleryImageItem } from '../common';
|
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive } from '../../hooks';
|
||||||
|
|
||||||
interface UserProfileHeaderProps {
|
interface UserProfileHeaderProps {
|
||||||
user: User;
|
user: User;
|
||||||
isCurrentUser?: boolean;
|
isCurrentUser?: boolean;
|
||||||
isBlocked?: boolean;
|
|
||||||
onFollow: () => void;
|
onFollow: () => void;
|
||||||
onSettings?: () => void;
|
onSettings?: () => void;
|
||||||
onEditProfile?: () => void;
|
onEditProfile?: () => void;
|
||||||
onMessage?: () => void;
|
onMessage?: () => void;
|
||||||
onBlock?: () => void;
|
onMore?: () => void; // 点击更多按钮
|
||||||
onFollowingPress?: () => void;
|
onPostsPress?: () => void; // 点击帖子数(可选)
|
||||||
onFollowersPress?: () => void;
|
onFollowingPress?: () => void; // 点击关注数
|
||||||
onAvatarPress?: () => void;
|
onFollowersPress?: () => void; // 点击粉丝数
|
||||||
|
onAvatarPress?: () => void; // 点击头像编辑按钮
|
||||||
}
|
}
|
||||||
|
|
||||||
const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
||||||
user,
|
user,
|
||||||
isCurrentUser = false,
|
isCurrentUser = false,
|
||||||
isBlocked = false,
|
|
||||||
onFollow,
|
onFollow,
|
||||||
onSettings,
|
onSettings,
|
||||||
onEditProfile,
|
onEditProfile,
|
||||||
onMessage,
|
onMessage,
|
||||||
onBlock,
|
onMore,
|
||||||
|
onPostsPress,
|
||||||
onFollowingPress,
|
onFollowingPress,
|
||||||
onFollowersPress,
|
onFollowersPress,
|
||||||
onAvatarPress,
|
onAvatarPress,
|
||||||
@@ -54,146 +52,110 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
|||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const styles = useMemo(() => createUserProfileHeaderStyles(colors), [colors]);
|
const styles = useMemo(() => createUserProfileHeaderStyles(colors), [colors]);
|
||||||
const { isWideScreen, isDesktop, width } = useResponsive();
|
const { isWideScreen, isDesktop, width } = useResponsive();
|
||||||
const [menuVisible, setMenuVisible] = useState(false);
|
|
||||||
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
|
|
||||||
const moreButtonRef = useRef<View>(null);
|
|
||||||
const [galleryVisible, setGalleryVisible] = useState(false);
|
|
||||||
const [galleryImages, setGalleryImages] = useState<GalleryImageItem[]>([]);
|
|
||||||
const [galleryIndex, setGalleryIndex] = useState(0);
|
|
||||||
|
|
||||||
|
// 格式化数字
|
||||||
const formatCount = (count: number | undefined): string => {
|
const formatCount = (count: number | undefined): string => {
|
||||||
if (count === undefined || count === null) return '0';
|
if (count === undefined || count === null) {
|
||||||
if (count >= 10000) return `${(count / 10000).toFixed(1)}w`;
|
return '0';
|
||||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
|
}
|
||||||
|
if (count >= 10000) {
|
||||||
|
return `${(count / 10000).toFixed(1)}w`;
|
||||||
|
}
|
||||||
|
if (count >= 1000) {
|
||||||
|
return `${(count / 1000).toFixed(1)}k`;
|
||||||
|
}
|
||||||
return count.toString();
|
return count.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
const getPostsCount = (): number => user.posts_count ?? 0;
|
// 获取帖子数量
|
||||||
const getFollowersCount = (): number => user.followers_count ?? 0;
|
const getPostsCount = (): number => {
|
||||||
const getFollowingCount = (): number => user.following_count ?? 0;
|
return user.posts_count ?? 0;
|
||||||
const getIsFollowing = (): boolean => user.is_following ?? false;
|
};
|
||||||
const getIsFollowingMe = (): boolean => user.is_following_me ?? false;
|
|
||||||
|
|
||||||
const getButtonConfig = (): { title: string; variant: 'primary' | 'outline' | 'ghost'; icon?: string } => {
|
// 获取粉丝数量
|
||||||
|
const getFollowersCount = (): number => {
|
||||||
|
return user.followers_count ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取关注数量
|
||||||
|
const getFollowingCount = (): number => {
|
||||||
|
return user.following_count ?? 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查是否关注
|
||||||
|
const getIsFollowing = (): boolean => {
|
||||||
|
return user.is_following ?? false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查对方是否关注了我
|
||||||
|
const getIsFollowingMe = (): boolean => {
|
||||||
|
return user.is_following_me ?? false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取按钮配置(类似B站的互关逻辑)
|
||||||
|
const getButtonConfig = (): { title: string; variant: 'primary' | 'outline'; icon?: string } => {
|
||||||
const isFollowing = getIsFollowing();
|
const isFollowing = getIsFollowing();
|
||||||
const isFollowingMe = getIsFollowingMe();
|
const isFollowingMe = getIsFollowingMe();
|
||||||
|
|
||||||
if (isFollowing && isFollowingMe) {
|
if (isFollowing && isFollowingMe) {
|
||||||
|
// 已互关
|
||||||
return { title: '互相关注', variant: 'outline', icon: 'account-check' };
|
return { title: '互相关注', variant: 'outline', icon: 'account-check' };
|
||||||
} else if (isFollowing) {
|
} else if (isFollowing) {
|
||||||
|
// 已关注但对方未回关
|
||||||
return { title: '已关注', variant: 'outline', icon: 'check' };
|
return { title: '已关注', variant: 'outline', icon: 'check' };
|
||||||
} else if (isFollowingMe) {
|
} else if (isFollowingMe) {
|
||||||
|
// 对方关注了我,但我没关注对方 - 显示回关
|
||||||
return { title: '回关', variant: 'primary', icon: 'plus' };
|
return { title: '回关', variant: 'primary', icon: 'plus' };
|
||||||
} else {
|
} else {
|
||||||
|
// 互不关注
|
||||||
return { title: '关注', variant: 'primary', icon: 'plus' };
|
return { title: '关注', variant: 'primary', icon: 'plus' };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const coverHeight = isDesktop ? 280 : isWideScreen ? 240 : (width * 10) / 16;
|
// 根据屏幕尺寸计算封面高度
|
||||||
const avatarSize = isDesktop ? 140 : isWideScreen ? 120 : 88;
|
const coverHeight = isDesktop ? 240 : isWideScreen ? 200 : (width * 9) / 16;
|
||||||
|
|
||||||
const handleMorePress = useCallback(() => {
|
// 根据屏幕尺寸计算头像大小
|
||||||
moreButtonRef.current?.measure((_fx, _fy, _w, h, px, py) => {
|
const avatarSize = isDesktop ? 120 : isWideScreen ? 100 : 90;
|
||||||
const screenWidth = Dimensions.get('window').width;
|
|
||||||
const menuWidth = 180;
|
|
||||||
let x = px + _w / 2 - menuWidth / 2;
|
|
||||||
if (x + menuWidth > screenWidth - 12) {
|
|
||||||
x = screenWidth - menuWidth - 12;
|
|
||||||
}
|
|
||||||
if (x < 12) x = 12;
|
|
||||||
setMenuPosition({ x, y: py + h + 4 });
|
|
||||||
setMenuVisible(true);
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBlockPress = useCallback(() => {
|
const renderStatItem = ({
|
||||||
setMenuVisible(false);
|
value,
|
||||||
onBlock?.();
|
label,
|
||||||
}, [onBlock]);
|
onPress,
|
||||||
|
}: {
|
||||||
const openImageGallery = useCallback((images: GalleryImageItem[], index: number = 0) => {
|
value: string;
|
||||||
setGalleryImages(images);
|
label: string;
|
||||||
setGalleryIndex(index);
|
onPress?: () => void;
|
||||||
setGalleryVisible(true);
|
}) => {
|
||||||
}, []);
|
const content = (
|
||||||
|
<View style={styles.statContent}>
|
||||||
const handleCoverPress = useCallback(() => {
|
<Text variant="h3" style={styles.statNumber}>{value}</Text>
|
||||||
if (user.cover_url) {
|
<Text variant="caption" color={colors.text.secondary} style={styles.statLabel}>
|
||||||
openImageGallery([{ id: 'cover', url: user.cover_url }], 0);
|
{label}
|
||||||
}
|
|
||||||
}, [user.cover_url, openImageGallery]);
|
|
||||||
|
|
||||||
const handleAvatarPressPreview = useCallback(() => {
|
|
||||||
if (user.avatar) {
|
|
||||||
openImageGallery([{ id: 'avatar', url: user.avatar }], 0);
|
|
||||||
}
|
|
||||||
}, [user.avatar, openImageGallery]);
|
|
||||||
|
|
||||||
const renderStatLink = (count: number, label: string, onPress?: () => void) => {
|
|
||||||
if (!onPress) {
|
|
||||||
return (
|
|
||||||
<View style={styles.statItem}>
|
|
||||||
<Text style={styles.statNumber}>{formatCount(count)}</Text>
|
|
||||||
<Text style={styles.statLabel}>{label}</Text>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<TouchableOpacity style={styles.statItem} onPress={onPress} activeOpacity={0.7}>
|
|
||||||
<Text style={styles.statNumber}>{formatCount(count)}</Text>
|
|
||||||
<Text style={styles.statLabel}>{label}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderDropdownMenu = () => (
|
|
||||||
<Modal
|
|
||||||
visible={menuVisible}
|
|
||||||
transparent
|
|
||||||
animationType="fade"
|
|
||||||
onRequestClose={() => setMenuVisible(false)}
|
|
||||||
>
|
|
||||||
<Pressable style={styles.menuBackdrop} onPress={() => setMenuVisible(false)}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.menuContainer,
|
|
||||||
{ left: menuPosition.x, top: menuPosition.y },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.menuItem}
|
|
||||||
onPress={handleBlockPress}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={isBlocked ? 'account-check-outline' : 'account-off-outline'}
|
|
||||||
size={18}
|
|
||||||
color={isBlocked ? colors.success.main : colors.error.main}
|
|
||||||
/>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.menuItemText,
|
|
||||||
{ color: isBlocked ? colors.success.main : colors.error.main },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isBlocked ? '取消拉黑' : '拉黑用户'}
|
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</Pressable>
|
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (onPress) {
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.statItem, styles.statItemTouchable]}
|
||||||
|
onPress={onPress}
|
||||||
|
activeOpacity={0.75}
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <View style={styles.statItem}>{content}</View>;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* 全宽封面背景 */}
|
{/* 渐变封面背景 */}
|
||||||
<View style={[styles.coverContainer, { height: coverHeight }]}>
|
<View style={[styles.coverContainer, { height: coverHeight }]}>
|
||||||
<TouchableOpacity
|
<View style={styles.coverTouchable}>
|
||||||
style={styles.coverTouchable}
|
|
||||||
onPress={handleCoverPress}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
disabled={!user.cover_url}
|
|
||||||
>
|
|
||||||
{user.cover_url ? (
|
{user.cover_url ? (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: user.cover_url }}
|
source={{ uri: user.cover_url }}
|
||||||
@@ -201,141 +163,157 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
|||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={[styles.coverFallback, { backgroundColor: colors.primary.main }]}>
|
<LinearGradient
|
||||||
<MaterialCommunityIcons name="image-outline" size={48} color="rgba(255,255,255,0.4)" />
|
colors={['#FF8F66', '#FF6B35', '#E5521D']}
|
||||||
</View>
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.gradient}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</View>
|
||||||
|
|
||||||
{/* 顶部导航栏按钮 */}
|
{/* 设置按钮 */}
|
||||||
<View style={styles.topBar}>
|
|
||||||
{isCurrentUser && onSettings && (
|
{isCurrentUser && onSettings && (
|
||||||
<TouchableOpacity style={styles.iconButton} onPress={onSettings}>
|
<TouchableOpacity style={styles.settingsButton} onPress={onSettings}>
|
||||||
<MaterialCommunityIcons name="cog-outline" size={20} color={colors.text.primary} />
|
<MaterialCommunityIcons name="cog-outline" size={22} color={colors.text.inverse} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 装饰性波浪 */}
|
||||||
|
<View style={styles.waveDecoration}>
|
||||||
|
<View style={styles.wave} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 用户信息区 */}
|
{/* 用户信息卡片 */}
|
||||||
<View style={styles.infoSection}>
|
<View style={[
|
||||||
{/* 头像与操作按钮行 */}
|
styles.profileCard,
|
||||||
<View style={styles.avatarActionRow}>
|
isWideScreen && styles.profileCardWide,
|
||||||
<View style={[styles.avatarWrapper, { marginTop: -avatarSize / 2 }]}>
|
]}>
|
||||||
<TouchableOpacity
|
{/* 悬浮头像 */}
|
||||||
style={[styles.avatarContainer, { width: avatarSize, height: avatarSize }]}
|
<View style={[
|
||||||
onPress={isCurrentUser && onAvatarPress ? onAvatarPress : handleAvatarPressPreview}
|
styles.avatarWrapper,
|
||||||
activeOpacity={0.9}
|
isWideScreen && styles.avatarWrapperWide,
|
||||||
disabled={isCurrentUser ? !onAvatarPress : !user.avatar}
|
]}>
|
||||||
>
|
<View style={styles.avatarContainer}>
|
||||||
<Avatar
|
<Avatar
|
||||||
source={user.avatar}
|
source={user.avatar}
|
||||||
size={avatarSize - 6}
|
size={avatarSize}
|
||||||
name={user.nickname}
|
name={user.nickname}
|
||||||
/>
|
/>
|
||||||
{isCurrentUser && onAvatarPress && (
|
{isCurrentUser && onAvatarPress && (
|
||||||
<View style={styles.editAvatarButton} pointerEvents="none">
|
<TouchableOpacity style={styles.editAvatarButton} onPress={onAvatarPress}>
|
||||||
<MaterialCommunityIcons name="camera" size={14} color={colors.text.inverse} />
|
<MaterialCommunityIcons name="camera" size={14} color={colors.text.inverse} />
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.actionButtons}>
|
{/* 用户名和简介 */}
|
||||||
{isCurrentUser ? (
|
<View style={styles.userInfo}>
|
||||||
<TouchableOpacity style={styles.editProfileButton} onPress={onEditProfile} activeOpacity={0.8}>
|
<Text variant="h2" style={[
|
||||||
<Text style={styles.editProfileText}>编辑资料</Text>
|
styles.nickname,
|
||||||
</TouchableOpacity>
|
isWideScreen ? styles.nicknameWide : {},
|
||||||
) : (
|
]}>
|
||||||
<View style={styles.buttonRow}>
|
{user.nickname}
|
||||||
<View ref={moreButtonRef} collapsable={false}>
|
</Text>
|
||||||
<TouchableOpacity style={styles.iconButtonSmall} onPress={handleMorePress}>
|
<Text variant="caption" color={colors.text.secondary} style={styles.username}>
|
||||||
<MaterialCommunityIcons name="dots-horizontal" size={20} color={colors.text.primary} />
|
@{user.username}
|
||||||
</TouchableOpacity>
|
</Text>
|
||||||
</View>
|
|
||||||
<TouchableOpacity style={styles.iconButtonSmall} onPress={onMessage}>
|
{user.bio ? (
|
||||||
<MaterialCommunityIcons name="email-outline" size={18} color={colors.text.primary} />
|
<Text variant="body" color={colors.text.secondary} style={[
|
||||||
</TouchableOpacity>
|
styles.bio,
|
||||||
<TouchableOpacity
|
isWideScreen ? styles.bioWide : {},
|
||||||
style={[
|
]}>
|
||||||
styles.followBtn,
|
{user.bio}
|
||||||
getButtonConfig().variant === 'outline' && styles.followBtnOutline,
|
</Text>
|
||||||
getButtonConfig().variant === 'primary' && styles.followBtnPrimary,
|
) : (
|
||||||
]}
|
<Text variant="body" color={colors.text.hint} style={styles.bioPlaceholder}>
|
||||||
onPress={onFollow}
|
这个人很懒,还没有写简介~
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.followBtnText,
|
|
||||||
...(getButtonConfig().variant === 'primary' ? [styles.followBtnTextPrimary] : []),
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{getButtonConfig().title}
|
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 用户名 */}
|
{/* 个人信息标签 */}
|
||||||
<View style={styles.nameSection}>
|
|
||||||
<View style={styles.nicknameRow}>
|
|
||||||
<Text style={styles.nickname}>{user.nickname}</Text>
|
|
||||||
<VerificationBadge identity={user.identity} verificationStatus={user.verification_status} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.username}>@{user.username}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 简介 */}
|
|
||||||
{user.bio ? (
|
|
||||||
<Text style={styles.bio}>{user.bio}</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* 元信息 */}
|
|
||||||
<View style={styles.metaInfo}>
|
<View style={styles.metaInfo}>
|
||||||
{user.location?.trim() ? (
|
{user.location?.trim() ? (
|
||||||
<View style={styles.metaItem}>
|
<View style={styles.metaTag}>
|
||||||
<MaterialCommunityIcons name="map-marker-outline" size={14} color={colors.text.hint} />
|
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.primary.main} />
|
||||||
<Text style={styles.metaText}>{user.location}</Text>
|
<Text variant="caption" color={colors.primary.main} style={styles.metaTagText}>
|
||||||
|
{user.location}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{user.website?.trim() ? (
|
{user.website?.trim() ? (
|
||||||
<View style={styles.metaItem}>
|
<View style={styles.metaTag}>
|
||||||
<MaterialCommunityIcons name="link-variant" size={14} color={colors.info.main} />
|
<MaterialCommunityIcons name="link-variant" size={12} color={colors.info.main} />
|
||||||
<Text style={[styles.metaText, styles.metaLink]}>{user.website.replace(/^https?:\/\//, '')}</Text>
|
<Text variant="caption" color={colors.info.main} style={styles.metaTagText}>
|
||||||
|
{user.website.replace(/^https?:\/\//, '')}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
<View style={styles.metaItem}>
|
<View style={styles.metaTag}>
|
||||||
<MaterialCommunityIcons name="calendar-outline" size={14} color={colors.text.hint} />
|
<MaterialCommunityIcons name="calendar-outline" size={12} color={colors.text.secondary} />
|
||||||
<Text style={styles.metaText}>
|
<Text variant="caption" color={colors.text.secondary} style={styles.metaTagText}>
|
||||||
{(() => {
|
加入于 {new Date(user.created_at || Date.now()).getFullYear()}年
|
||||||
const d = new Date(user.created_at || Date.now());
|
|
||||||
if (Number.isNaN(d.getTime())) return '加入于 未知时间';
|
|
||||||
return `加入于 ${d.getFullYear()}年${d.getMonth() + 1}月`;
|
|
||||||
})()}
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 统计数据 - Twitter 风格内联 */}
|
{/* 统计数据 - 卡片式 */}
|
||||||
<View style={styles.statsRow}>
|
<View style={[
|
||||||
{renderStatLink(getFollowingCount(), '正在关注', onFollowingPress)}
|
styles.statsCard,
|
||||||
{renderStatLink(getFollowersCount(), '关注者', onFollowersPress)}
|
isWideScreen && styles.statsCardWide,
|
||||||
</View>
|
]}>
|
||||||
|
{renderStatItem({
|
||||||
|
value: formatCount(getPostsCount()),
|
||||||
|
label: '帖子',
|
||||||
|
onPress: onPostsPress,
|
||||||
|
})}
|
||||||
|
<View style={styles.statDivider} />
|
||||||
|
{renderStatItem({
|
||||||
|
value: formatCount(getFollowingCount()),
|
||||||
|
label: '关注',
|
||||||
|
onPress: onFollowingPress,
|
||||||
|
})}
|
||||||
|
<View style={styles.statDivider} />
|
||||||
|
{renderStatItem({
|
||||||
|
value: formatCount(getFollowersCount()),
|
||||||
|
label: '粉丝',
|
||||||
|
onPress: onFollowersPress,
|
||||||
|
})}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{renderDropdownMenu()}
|
{/* 操作按钮 */}
|
||||||
|
<View style={styles.actionButtons}>
|
||||||
{/* 图片预览 */}
|
{isCurrentUser ? (
|
||||||
<ImageGallery
|
<View style={styles.buttonRow} />
|
||||||
visible={galleryVisible}
|
) : (
|
||||||
images={galleryImages}
|
<View style={StyleSheet.flatten([
|
||||||
initialIndex={galleryIndex}
|
styles.buttonRow,
|
||||||
onClose={() => setGalleryVisible(false)}
|
isWideScreen && styles.buttonRowWide,
|
||||||
enableSave
|
])}>
|
||||||
|
<Button
|
||||||
|
title={getButtonConfig().title}
|
||||||
|
onPress={onFollow}
|
||||||
|
variant={getButtonConfig().variant}
|
||||||
|
style={StyleSheet.flatten([
|
||||||
|
styles.followButton,
|
||||||
|
isWideScreen && styles.followButtonWide,
|
||||||
|
])}
|
||||||
|
icon={getButtonConfig().icon}
|
||||||
/>
|
/>
|
||||||
|
<TouchableOpacity style={styles.messageButton} onPress={onMessage}>
|
||||||
|
<MaterialCommunityIcons name="message-text-outline" size={20} color={colors.primary.main} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.moreButton} onPress={onMore}>
|
||||||
|
<MaterialCommunityIcons name="dots-horizontal" size={24} color={colors.text.secondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -343,12 +321,11 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
|||||||
function createUserProfileHeaderStyles(colors: AppColors) {
|
function createUserProfileHeaderStyles(colors: AppColors) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.default,
|
||||||
},
|
},
|
||||||
// 封面
|
|
||||||
coverContainer: {
|
coverContainer: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
backgroundColor: colors.background.default,
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
coverTouchable: {
|
coverTouchable: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -358,58 +335,67 @@ function createUserProfileHeaderStyles(colors: AppColors) {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
coverFallback: {
|
gradient: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
},
|
||||||
|
settingsButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: spacing.lg,
|
||||||
|
right: spacing.lg,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
// 顶部栏
|
waveDecoration: {
|
||||||
topBar: {
|
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
flexDirection: 'row',
|
height: 40,
|
||||||
justifyContent: 'flex-end',
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingTop: spacing.md,
|
|
||||||
},
|
},
|
||||||
iconButton: {
|
wave: {
|
||||||
width: 36,
|
width: '100%',
|
||||||
height: 36,
|
height: '100%',
|
||||||
borderRadius: borderRadius.full,
|
backgroundColor: colors.background.default,
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.85)',
|
borderTopLeftRadius: 30,
|
||||||
justifyContent: 'center',
|
borderTopRightRadius: 30,
|
||||||
alignItems: 'center',
|
|
||||||
...shadows.sm,
|
|
||||||
},
|
},
|
||||||
// 信息区
|
profileCard: {
|
||||||
infoSection: {
|
backgroundColor: colors.background.paper,
|
||||||
paddingHorizontal: spacing.lg,
|
marginHorizontal: spacing.md,
|
||||||
paddingTop: spacing.sm,
|
marginTop: -50,
|
||||||
paddingBottom: spacing.lg,
|
borderRadius: borderRadius.xl,
|
||||||
|
padding: spacing.lg,
|
||||||
|
...shadows.md,
|
||||||
},
|
},
|
||||||
avatarActionRow: {
|
profileCardWide: {
|
||||||
flexDirection: 'row',
|
marginHorizontal: spacing.lg,
|
||||||
justifyContent: 'space-between',
|
marginTop: -60,
|
||||||
alignItems: 'flex-end',
|
padding: spacing.xl,
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
},
|
||||||
avatarWrapper: {
|
avatarWrapper: {
|
||||||
// 负边距在 inline style 中计算
|
alignItems: 'center',
|
||||||
|
marginTop: -60,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
avatarWrapperWide: {
|
||||||
|
marginTop: -80,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
},
|
},
|
||||||
avatarContainer: {
|
avatarContainer: {
|
||||||
borderRadius: borderRadius.full,
|
position: 'relative',
|
||||||
|
padding: 4,
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
padding: 3,
|
borderRadius: 50,
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
},
|
||||||
editAvatarButton: {
|
editAvatarButton: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 4,
|
bottom: 0,
|
||||||
right: 4,
|
right: 0,
|
||||||
width: 28,
|
width: 28,
|
||||||
height: 28,
|
height: 28,
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
@@ -419,207 +405,140 @@ function createUserProfileHeaderStyles(colors: AppColors) {
|
|||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: colors.background.paper,
|
borderColor: colors.background.paper,
|
||||||
},
|
},
|
||||||
// 操作按钮
|
userInfo: {
|
||||||
actionButtons: {
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
nickname: {
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
nicknameWide: {
|
||||||
|
fontSize: fontSizes['3xl'],
|
||||||
|
},
|
||||||
|
username: {
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
bio: {
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
bioWide: {
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
lineHeight: 24,
|
||||||
|
maxWidth: 600,
|
||||||
|
},
|
||||||
|
bioPlaceholder: {
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
fontStyle: 'italic',
|
||||||
|
},
|
||||||
|
metaInfo: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
metaTag: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: spacing.sm,
|
backgroundColor: colors.background.default,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
},
|
||||||
|
metaTagText: {
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
fontSize: fontSizes.xs,
|
||||||
|
},
|
||||||
|
statsCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
paddingHorizontal: spacing.xs,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
statsCardWide: {
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
statItem: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 58,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
statItemTouchable: {
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
},
|
||||||
|
statContent: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.xs,
|
||||||
|
},
|
||||||
|
statNumber: {
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
statLabel: {
|
||||||
|
fontSize: fontSizes.xs,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
statDivider: {
|
||||||
|
width: 1,
|
||||||
|
height: 24,
|
||||||
|
backgroundColor: colors.divider + '55',
|
||||||
|
},
|
||||||
|
actionButtons: {
|
||||||
|
marginTop: spacing.sm,
|
||||||
},
|
},
|
||||||
buttonRow: {
|
buttonRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
iconButtonSmall: {
|
buttonRowWide: {
|
||||||
width: 38,
|
|
||||||
height: 38,
|
|
||||||
borderRadius: borderRadius.full,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.divider,
|
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
editProfileButton: {
|
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: borderRadius.full,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.divider,
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
},
|
|
||||||
editProfileText: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text.primary,
|
|
||||||
},
|
|
||||||
followBtn: {
|
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: borderRadius.full,
|
|
||||||
minWidth: 80,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
followBtnPrimary: {
|
|
||||||
backgroundColor: colors.text.primary,
|
|
||||||
},
|
|
||||||
followBtnOutline: {
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.divider,
|
|
||||||
},
|
|
||||||
followBtnText: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: colors.text.primary,
|
|
||||||
},
|
|
||||||
followBtnTextPrimary: {
|
|
||||||
color: colors.background.paper,
|
|
||||||
},
|
|
||||||
// 名字
|
|
||||||
nameSection: {
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
nicknameRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: spacing.xs,
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
nickname: {
|
|
||||||
fontSize: fontSizes['2xl'],
|
|
||||||
fontWeight: '800',
|
|
||||||
color: colors.text.primary,
|
|
||||||
},
|
|
||||||
username: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
color: colors.text.hint,
|
|
||||||
},
|
|
||||||
// 简介
|
|
||||||
bio: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
color: colors.text.primary,
|
|
||||||
lineHeight: 20,
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
// 元信息
|
|
||||||
metaInfo: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
gap: spacing.md,
|
gap: spacing.md,
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
},
|
||||||
metaItem: {
|
editButton: {
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
},
|
|
||||||
metaText: {
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
color: colors.text.hint,
|
|
||||||
},
|
|
||||||
metaLink: {
|
|
||||||
color: colors.info.main,
|
|
||||||
},
|
|
||||||
// 统计
|
|
||||||
statsRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: spacing.lg,
|
|
||||||
marginTop: spacing.xs,
|
|
||||||
},
|
|
||||||
statItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 4,
|
|
||||||
},
|
|
||||||
statNumber: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: colors.text.primary,
|
|
||||||
},
|
|
||||||
statLabel: {
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
color: colors.text.hint,
|
|
||||||
},
|
|
||||||
// 下拉菜单
|
|
||||||
menuBackdrop: {
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: 'transparent',
|
|
||||||
},
|
},
|
||||||
menuContainer: {
|
followButton: {
|
||||||
position: 'absolute',
|
flex: 1,
|
||||||
minWidth: 160,
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
borderRadius: borderRadius.lg,
|
|
||||||
...shadows.lg,
|
|
||||||
borderWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderColor: colors.divider,
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
},
|
||||||
menuItem: {
|
followButtonWide: {
|
||||||
flexDirection: 'row',
|
flex: 0,
|
||||||
|
minWidth: 120,
|
||||||
|
},
|
||||||
|
messageButton: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
backgroundColor: colors.primary.light + '20',
|
||||||
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: spacing.sm,
|
|
||||||
paddingVertical: spacing.md,
|
|
||||||
paddingHorizontal: spacing.lg,
|
|
||||||
},
|
},
|
||||||
menuItemText: {
|
moreButton: {
|
||||||
fontSize: fontSizes.md,
|
width: 44,
|
||||||
fontWeight: '600',
|
height: 44,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
backgroundColor: colors.background.default,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
settingsButtonOnly: {
|
||||||
|
alignSelf: 'center',
|
||||||
|
padding: spacing.sm,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
import { shadows } from '../../theme';
|
// 使用 React.memo 避免不必要的重新渲染
|
||||||
|
|
||||||
// ==================== VerificationBadge ====================
|
|
||||||
|
|
||||||
interface VerificationBadgeProps {
|
|
||||||
identity?: UserIdentity;
|
|
||||||
verificationStatus?: VerificationStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IDENTITY_CONFIG: Record<
|
|
||||||
UserIdentity,
|
|
||||||
{ label: string; color: string; bg: string }
|
|
||||||
> = {
|
|
||||||
general: { label: '未认证', color: '#9CA3AF', bg: '#F3F4F6' },
|
|
||||||
student: { label: '学生认证', color: '#10B981', bg: '#D1FAE5' },
|
|
||||||
teacher: { label: '教师认证', color: '#3B82F6', bg: '#DBEAFE' },
|
|
||||||
staff: { label: '官方', color: '#D4AF37', bg: '#FFF8DC' },
|
|
||||||
alumni: { label: '校友认证', color: '#F59E0B', bg: '#FEF3C7' },
|
|
||||||
external: { label: '未认证', color: '#9CA3AF', bg: '#F3F4F6' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const VerificationBadge: React.FC<VerificationBadgeProps> = ({
|
|
||||||
identity,
|
|
||||||
verificationStatus,
|
|
||||||
}) => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
|
|
||||||
// 默认值:未认证状态
|
|
||||||
const effectiveIdentity = identity || 'general';
|
|
||||||
const effectiveStatus = verificationStatus || 'none';
|
|
||||||
|
|
||||||
const isApproved = effectiveStatus === 'approved';
|
|
||||||
// none 表示未认证,统一用 general 的未认证样式
|
|
||||||
const displayIdentity = isApproved ? effectiveIdentity : 'general';
|
|
||||||
const config = IDENTITY_CONFIG[displayIdentity] || IDENTITY_CONFIG.general;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={{ width: 22, height: 22, justifyContent: 'center', alignItems: 'center' }}>
|
|
||||||
{isApproved ? (
|
|
||||||
<MaterialCommunityIcons name="check-decagram" size={22} color={config.color} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<MaterialCommunityIcons name="decagram" size={22} color="#9CA3AF" />
|
|
||||||
<MaterialCommunityIcons name="decagram-outline" size={22} color="#9CA3AF" style={{ position: 'absolute' }} />
|
|
||||||
<Text style={{ position: 'absolute', fontSize: 11, fontWeight: '800', color: '#FFFFFF', marginTop: 1 }}>?</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MemoizedUserProfileHeader = React.memo(UserProfileHeader);
|
const MemoizedUserProfileHeader = React.memo(UserProfileHeader);
|
||||||
|
|
||||||
export default MemoizedUserProfileHeader;
|
export default MemoizedUserProfileHeader;
|
||||||
|
|||||||
@@ -22,10 +22,3 @@ export { default as TabBar } from './TabBar';
|
|||||||
export { default as VoteCard } from './VoteCard';
|
export { default as VoteCard } from './VoteCard';
|
||||||
export { default as VoteEditor } from './VoteEditor';
|
export { default as VoteEditor } from './VoteEditor';
|
||||||
export { default as VotePreview } from './VotePreview';
|
export { default as VotePreview } from './VotePreview';
|
||||||
export { default as PostContentRenderer } from './PostContentRenderer';
|
|
||||||
export { default as PostMentionInput } from './PostMentionInput';
|
|
||||||
export { default as BlockEditor } from './BlockEditor';
|
|
||||||
export type { BlockEditorHandle } from './BlockEditor';
|
|
||||||
export { default as ReportDialog } from './ReportDialog';
|
|
||||||
export { default as ShareSheet } from './ShareSheet';
|
|
||||||
export { TradeCard } from './TradeCard/TradeCard';
|
|
||||||
|
|||||||
@@ -1,412 +0,0 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
Image,
|
|
||||||
StatusBar,
|
|
||||||
} from 'react-native';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
import { callStore } from '../../stores/call';
|
|
||||||
import { RTCView } from 'react-native-webrtc';
|
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
const CallScreen: React.FC = () => {
|
|
||||||
const currentCall = callStore((s) => s.currentCall);
|
|
||||||
const callDuration = callStore((s) => s.callDuration);
|
|
||||||
const peerStream = callStore((s) => s.peerStream);
|
|
||||||
const localStream = callStore((s) => s.localStream);
|
|
||||||
const endCall = callStore((s) => s.endCall);
|
|
||||||
const toggleMute = callStore((s) => s.toggleMute);
|
|
||||||
const toggleSpeaker = callStore((s) => s.toggleSpeaker);
|
|
||||||
const toggleVideo = callStore((s) => s.toggleVideo);
|
|
||||||
const isMinimized = callStore((s) => s.isMinimized);
|
|
||||||
const toggleMinimize = callStore((s) => s.toggleMinimize);
|
|
||||||
|
|
||||||
// Track whether peer has video
|
|
||||||
const [hasPeerVideo, setHasPeerVideo] = useState(false);
|
|
||||||
// Track whether local has video
|
|
||||||
const [hasLocalVideo, setHasLocalVideo] = useState(false);
|
|
||||||
|
|
||||||
// Check peer stream for video tracks
|
|
||||||
useEffect(() => {
|
|
||||||
if (peerStream) {
|
|
||||||
const videoTracks = peerStream.getVideoTracks();
|
|
||||||
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
|
|
||||||
setHasPeerVideo(hasVideo);
|
|
||||||
} else {
|
|
||||||
setHasPeerVideo(false);
|
|
||||||
}
|
|
||||||
}, [peerStream]);
|
|
||||||
// Check local stream for video tracks
|
|
||||||
useEffect(() => {
|
|
||||||
if (localStream) {
|
|
||||||
const videoTracks = localStream.getVideoTracks();
|
|
||||||
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
|
|
||||||
setHasLocalVideo(hasVideo);
|
|
||||||
} else {
|
|
||||||
setHasLocalVideo(false);
|
|
||||||
}
|
|
||||||
}, [localStream]);
|
|
||||||
// Don't render full screen when minimized or no call
|
|
||||||
if (!currentCall || isMinimized) return null;
|
|
||||||
const getStatusText = (): string => {
|
|
||||||
switch (currentCall.status) {
|
|
||||||
case 'calling':
|
|
||||||
return '正在等待对方接听...';
|
|
||||||
case 'ringing':
|
|
||||||
return '来电响铃中...';
|
|
||||||
case 'connecting':
|
|
||||||
return '连接中...';
|
|
||||||
case 'connected':
|
|
||||||
return formatDuration(callDuration);
|
|
||||||
case 'reconnecting':
|
|
||||||
return '网络重连中...';
|
|
||||||
case 'ended':
|
|
||||||
return '通话已结束';
|
|
||||||
case 'failed':
|
|
||||||
return '连接失败';
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleEndCall = () => {
|
|
||||||
endCall('hangup');
|
|
||||||
};
|
|
||||||
const handleToggleMute = () => {
|
|
||||||
toggleMute();
|
|
||||||
};
|
|
||||||
const handleToggleSpeaker = () => {
|
|
||||||
toggleSpeaker();
|
|
||||||
};
|
|
||||||
const handleToggleVideo = () => {
|
|
||||||
toggleVideo();
|
|
||||||
};
|
|
||||||
const handleMinimize = () => {
|
|
||||||
toggleMinimize();
|
|
||||||
};
|
|
||||||
// Determine if we should show video UI
|
|
||||||
const showRemoteVideo = hasPeerVideo;
|
|
||||||
// Use currentCall.isVideoEnabled directly for local video
|
|
||||||
// This is more reliable than checking localStream.getVideoTracks()
|
|
||||||
// because the stream object reference may not trigger useEffect properly
|
|
||||||
const showLocalVideo = currentCall?.isVideoEnabled && localStream;
|
|
||||||
const isVideoCallActive = showRemoteVideo || showLocalVideo;
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
|
||||||
{/* Background */}
|
|
||||||
<View style={styles.background} />
|
|
||||||
{/* Remote video - full screen when peer has video */}
|
|
||||||
{showRemoteVideo && peerStream && (
|
|
||||||
<RTCView
|
|
||||||
streamURL={peerStream.toURL()}
|
|
||||||
style={styles.fullScreenVideo}
|
|
||||||
objectFit="cover"
|
|
||||||
mirror={false}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{/* Local video - picture in picture */}
|
|
||||||
{showLocalVideo && localStream && (
|
|
||||||
<View style={styles.localVideoContainer}>
|
|
||||||
<RTCView
|
|
||||||
streamURL={localStream.toURL()}
|
|
||||||
style={styles.localVideo}
|
|
||||||
objectFit="cover"
|
|
||||||
mirror={true}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{/* Top area: minimize button */}
|
|
||||||
<View style={styles.topBar}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.topButton}
|
|
||||||
onPress={handleMinimize}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
{/* Center: Peer info - only show when no video */}
|
|
||||||
{!isVideoCallActive && (
|
|
||||||
<View style={styles.centerArea}>
|
|
||||||
<View style={styles.avatarOuter}>
|
|
||||||
{currentCall.peerAvatar ? (
|
|
||||||
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
|
|
||||||
) : (
|
|
||||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
|
||||||
<Text style={styles.avatarText}>
|
|
||||||
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<Text style={styles.peerName} numberOfLines={1}>
|
|
||||||
{currentCall.peerName || '未知用户'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.status}>{getStatusText()}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{/* Peer name overlay when video is active */}
|
|
||||||
{isVideoCallActive && (
|
|
||||||
<View style={styles.videoOverlayInfo}>
|
|
||||||
<Text style={styles.videoPeerName} numberOfLines={1}>
|
|
||||||
{currentCall.peerName || '未知用户'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.videoStatus}>{getStatusText()}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{/* Bottom controls */}
|
|
||||||
<View style={styles.controls}>
|
|
||||||
<View style={styles.controlRow}>
|
|
||||||
{/* Mute */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]}
|
|
||||||
onPress={handleToggleMute}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={currentCall.isMuted ? 'microphone-off' : 'microphone'}
|
|
||||||
size={26}
|
|
||||||
color={currentCall.isMuted ? '#333' : '#fff'}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={[styles.controlLabel, currentCall.isMuted && styles.controlLabelActive]}>
|
|
||||||
{currentCall.isMuted ? '取消静音' : '静音'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{/* Video toggle */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.controlButton, hasLocalVideo && styles.controlButtonActive]}
|
|
||||||
onPress={handleToggleVideo}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<View style={[styles.controlCircle, hasLocalVideo && styles.controlCircleActive]}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={hasLocalVideo ? 'video' : 'video-off'}
|
|
||||||
size={26}
|
|
||||||
color={hasLocalVideo ? '#333' : '#fff'}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={[styles.controlLabel, hasLocalVideo && styles.controlLabelActive]}>
|
|
||||||
{hasLocalVideo ? '关闭摄像头' : '打开摄像头'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{/* Speaker */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
|
|
||||||
onPress={handleToggleSpeaker}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={currentCall.isSpeakerOn ? 'volume-high' : 'cellphone'}
|
|
||||||
size={26}
|
|
||||||
color={currentCall.isSpeakerOn ? '#333' : '#fff'}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={[styles.controlLabel, currentCall.isSpeakerOn && styles.controlLabelActive]}>
|
|
||||||
{currentCall.isSpeakerOn ? '免提' : '听筒'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
{/* End call - prominent red button */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.endCallButton}
|
|
||||||
onPress={handleEndCall}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<View style={styles.endCallCircle}>
|
|
||||||
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.endCallLabel}>挂断</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONTROL_CIRCLE_SIZE = 56;
|
|
||||||
const END_CALL_SIZE = 64;
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
...StyleSheet.absoluteFillObject,
|
|
||||||
backgroundColor: '#1A1A2E',
|
|
||||||
zIndex: 9999,
|
|
||||||
},
|
|
||||||
background: {
|
|
||||||
...StyleSheet.absoluteFillObject,
|
|
||||||
backgroundColor: '#1A1A2E',
|
|
||||||
},
|
|
||||||
topBar: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 50,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
height: 44,
|
|
||||||
zIndex: 100,
|
|
||||||
},
|
|
||||||
topButton: {
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 22,
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
centerArea: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: '28%',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 30,
|
|
||||||
},
|
|
||||||
avatarOuter: {
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
avatar: {
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
borderRadius: 50,
|
|
||||||
backgroundColor: '#3A3A5C',
|
|
||||||
},
|
|
||||||
avatarPlaceholder: {
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
avatarText: {
|
|
||||||
fontSize: 40,
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
peerName: {
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#FFFFFF',
|
|
||||||
marginBottom: 6,
|
|
||||||
textAlign: 'center',
|
|
||||||
maxWidth: 280,
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'rgba(255, 255, 255, 0.5)',
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
},
|
|
||||||
fullScreenVideo: {
|
|
||||||
...StyleSheet.absoluteFillObject,
|
|
||||||
backgroundColor: '#1A1A2E',
|
|
||||||
},
|
|
||||||
localVideoContainer: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 100,
|
|
||||||
right: 20,
|
|
||||||
width: 120,
|
|
||||||
height: 160,
|
|
||||||
borderRadius: 16,
|
|
||||||
overflow: 'hidden',
|
|
||||||
backgroundColor: '#2A2A4E',
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
|
||||||
zIndex: 50,
|
|
||||||
},
|
|
||||||
localVideo: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
videoOverlayInfo: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 100,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
alignItems: 'center',
|
|
||||||
zIndex: 50,
|
|
||||||
},
|
|
||||||
videoPeerName: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#FFFFFF',
|
|
||||||
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
textShadowOffset: { width: 0, height: 1 },
|
|
||||||
textShadowRadius: 2,
|
|
||||||
},
|
|
||||||
videoStatus: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'rgba(255, 255, 255, 0.8)',
|
|
||||||
marginTop: 4,
|
|
||||||
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
textShadowOffset: { width: 0, height: 1 },
|
|
||||||
textShadowRadius: 2,
|
|
||||||
},
|
|
||||||
controls: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 60,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
alignItems: 'center',
|
|
||||||
zIndex: 100,
|
|
||||||
},
|
|
||||||
controlRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 24,
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
controlButton: {
|
|
||||||
alignItems: 'center',
|
|
||||||
width: 70,
|
|
||||||
},
|
|
||||||
controlCircle: {
|
|
||||||
width: 56,
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 28,
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.12)',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
controlCircleActive: {
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
},
|
|
||||||
controlButtonActive: {},
|
|
||||||
controlLabel: {
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'rgba(255, 255, 255, 0.55)',
|
|
||||||
marginTop: 8,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
controlLabelActive: {
|
|
||||||
color: '#FFFFFF',
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
endCallButton: {
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
endCallCircle: {
|
|
||||||
width: 64,
|
|
||||||
height: 64,
|
|
||||||
borderRadius: 32,
|
|
||||||
backgroundColor: '#E54D42',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
endCallLabel: {
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'rgba(255, 255, 255, 0.55)',
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
export default CallScreen;
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
StatusBar,
|
|
||||||
} from 'react-native';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
import { callStore } from '../../stores/call';
|
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const CallScreen: React.FC = () => {
|
|
||||||
const currentCall = callStore((s) => s.currentCall);
|
|
||||||
const callDuration = callStore((s) => s.callDuration);
|
|
||||||
const endCall = callStore((s) => s.endCall);
|
|
||||||
const isMinimized = callStore((s) => s.isMinimized);
|
|
||||||
|
|
||||||
// Don't render full screen when minimized or no call
|
|
||||||
if (!currentCall || isMinimized) return null;
|
|
||||||
|
|
||||||
const getStatusText = (): string => {
|
|
||||||
switch (currentCall.status) {
|
|
||||||
case 'ringing':
|
|
||||||
return '通话功能暂不支持网页端';
|
|
||||||
case 'connecting':
|
|
||||||
return '通话功能暂不支持网页端';
|
|
||||||
case 'connected':
|
|
||||||
return '通话功能暂不支持网页端';
|
|
||||||
case 'ended':
|
|
||||||
return '通话已结束';
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEndCall = () => {
|
|
||||||
endCall('hangup');
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
|
||||||
|
|
||||||
{/* Background - single consistent color */}
|
|
||||||
<View style={styles.background} />
|
|
||||||
|
|
||||||
{/* Center: Peer info */}
|
|
||||||
<View style={styles.centerArea}>
|
|
||||||
<View style={styles.avatarOuter}>
|
|
||||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
|
||||||
<MaterialCommunityIcons name="web-off" size={50} color="#fff" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.peerName} numberOfLines={1}>
|
|
||||||
{currentCall.peerName || '未知用户'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.status}>{getStatusText()}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Bottom controls */}
|
|
||||||
<View style={styles.controls}>
|
|
||||||
{/* End call - prominent red button */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.endCallButton}
|
|
||||||
onPress={handleEndCall}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<View style={styles.endCallCircle}>
|
|
||||||
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.endCallLabel}>关闭</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const END_CALL_SIZE = 64;
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
...StyleSheet.absoluteFillObject,
|
|
||||||
backgroundColor: '#1A1A2E',
|
|
||||||
zIndex: 9999,
|
|
||||||
},
|
|
||||||
background: {
|
|
||||||
...StyleSheet.absoluteFillObject,
|
|
||||||
backgroundColor: '#1A1A2E',
|
|
||||||
},
|
|
||||||
centerArea: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: '28%',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 30,
|
|
||||||
},
|
|
||||||
avatarOuter: {
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
avatar: {
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
borderRadius: 50,
|
|
||||||
backgroundColor: '#3A3A5C',
|
|
||||||
},
|
|
||||||
avatarPlaceholder: {
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
avatarText: {
|
|
||||||
fontSize: 40,
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
peerName: {
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#FFFFFF',
|
|
||||||
marginBottom: 6,
|
|
||||||
textAlign: 'center',
|
|
||||||
maxWidth: 280,
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'rgba(255, 255, 255, 0.5)',
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
},
|
|
||||||
controls: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 60,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
endCallButton: {
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
endCallCircle: {
|
|
||||||
width: END_CALL_SIZE,
|
|
||||||
height: END_CALL_SIZE,
|
|
||||||
borderRadius: END_CALL_SIZE / 2,
|
|
||||||
backgroundColor: '#E54D42',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
endCallLabel: {
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'rgba(255, 255, 255, 0.55)',
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default CallScreen;
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
Image,
|
|
||||||
} from 'react-native';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
import { callStore } from '../../stores/call';
|
|
||||||
|
|
||||||
const formatDuration = (seconds: number): string => {
|
|
||||||
const mins = Math.floor(seconds / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const FloatingCallWindow: React.FC = () => {
|
|
||||||
const currentCall = callStore((s) => s.currentCall);
|
|
||||||
const callDuration = callStore((s) => s.callDuration);
|
|
||||||
const isMinimized = callStore((s) => s.isMinimized);
|
|
||||||
const toggleMinimize = callStore((s) => s.toggleMinimize);
|
|
||||||
const endCall = callStore((s) => s.endCall);
|
|
||||||
|
|
||||||
// Only show when there's an active call and it's minimized
|
|
||||||
if (!currentCall || !isMinimized) return null;
|
|
||||||
|
|
||||||
const getStatusText = (): string => {
|
|
||||||
switch (currentCall.status) {
|
|
||||||
case 'calling':
|
|
||||||
return '等待接听...';
|
|
||||||
case 'ringing':
|
|
||||||
return '来电响铃...';
|
|
||||||
case 'connecting':
|
|
||||||
return '连接中...';
|
|
||||||
case 'connected':
|
|
||||||
return formatDuration(callDuration);
|
|
||||||
case 'reconnecting':
|
|
||||||
return '重连中...';
|
|
||||||
case 'ended':
|
|
||||||
return '已结束';
|
|
||||||
case 'failed':
|
|
||||||
return '连接失败';
|
|
||||||
default:
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.window}
|
|
||||||
onPress={toggleMinimize}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
{/* Avatar */}
|
|
||||||
<View style={styles.avatarContainer}>
|
|
||||||
{currentCall.peerAvatar ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: currentCall.peerAvatar }}
|
|
||||||
style={styles.avatar}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
|
||||||
<Text style={styles.avatarText}>
|
|
||||||
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Info */}
|
|
||||||
<View style={styles.info}>
|
|
||||||
<Text style={styles.name} numberOfLines={1}>
|
|
||||||
{currentCall.peerName || '未知用户'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.status}>{getStatusText()}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* End call button */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.endButton}
|
|
||||||
onPress={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
endCall('hangup');
|
|
||||||
}}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons name="phone-hangup" size={20} color="#fff" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: 100,
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
zIndex: 10000,
|
|
||||||
},
|
|
||||||
window: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: '#2A2A4E',
|
|
||||||
borderRadius: 16,
|
|
||||||
padding: 12,
|
|
||||||
shadowColor: '#000',
|
|
||||||
shadowOffset: { width: 0, height: 4 },
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 8,
|
|
||||||
},
|
|
||||||
avatarContainer: {
|
|
||||||
marginRight: 12,
|
|
||||||
},
|
|
||||||
avatar: {
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
borderRadius: 22,
|
|
||||||
backgroundColor: '#3A3A5C',
|
|
||||||
},
|
|
||||||
avatarPlaceholder: {
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
avatarText: {
|
|
||||||
fontSize: 18,
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#FFFFFF',
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: 'rgba(255, 255, 255, 0.6)',
|
|
||||||
},
|
|
||||||
endButton: {
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: 18,
|
|
||||||
backgroundColor: '#E54D42',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginLeft: 8,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default FloatingCallWindow;
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
Image,
|
|
||||||
Animated,
|
|
||||||
Modal,
|
|
||||||
StatusBar,
|
|
||||||
Dimensions,
|
|
||||||
Platform,
|
|
||||||
} from 'react-native';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
import { callStore } from '../../stores/call';
|
|
||||||
import { blurActiveElement } from '../../infrastructure/platform';
|
|
||||||
|
|
||||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
|
||||||
|
|
||||||
const IncomingCallModal: React.FC = () => {
|
|
||||||
const incomingCall = callStore((s) => s.incomingCall);
|
|
||||||
const acceptCall = callStore((s) => s.acceptCall);
|
|
||||||
const rejectCall = callStore((s) => s.rejectCall);
|
|
||||||
|
|
||||||
const pulseAnim = useRef(new Animated.Value(1)).current;
|
|
||||||
const rippleAnim = useRef(new Animated.Value(0)).current;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (incomingCall) {
|
|
||||||
blurActiveElement();
|
|
||||||
const pulse = Animated.loop(
|
|
||||||
Animated.sequence([
|
|
||||||
Animated.timing(pulseAnim, {
|
|
||||||
toValue: 1.06,
|
|
||||||
duration: 1000,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(pulseAnim, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: 1000,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
const ripple = Animated.loop(
|
|
||||||
Animated.sequence([
|
|
||||||
Animated.timing(rippleAnim, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: 2000,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(rippleAnim, {
|
|
||||||
toValue: 0,
|
|
||||||
duration: 0,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
])
|
|
||||||
);
|
|
||||||
|
|
||||||
Animated.parallel([pulse, ripple]).start();
|
|
||||||
return () => {
|
|
||||||
pulse.stop();
|
|
||||||
ripple.stop();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [incomingCall, pulseAnim, rippleAnim]);
|
|
||||||
|
|
||||||
const handleAccept = () => {
|
|
||||||
acceptCall();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReject = () => {
|
|
||||||
rejectCall();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!incomingCall) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={!!incomingCall}
|
|
||||||
transparent
|
|
||||||
animationType="fade"
|
|
||||||
statusBarTranslucent
|
|
||||||
>
|
|
||||||
<View style={styles.overlay}>
|
|
||||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
|
||||||
|
|
||||||
{/* Avatar with ripple effect - positioned in upper area */}
|
|
||||||
<View style={styles.avatarSection}>
|
|
||||||
{/* Ripple rings */}
|
|
||||||
{[0, 1, 2].map((i) => (
|
|
||||||
<Animated.View
|
|
||||||
key={i}
|
|
||||||
style={[
|
|
||||||
styles.rippleRing,
|
|
||||||
{
|
|
||||||
transform: [
|
|
||||||
{
|
|
||||||
scale: rippleAnim.interpolate({
|
|
||||||
inputRange: [0, 1],
|
|
||||||
outputRange: [1, 1.5 + i * 0.2],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
opacity: rippleAnim.interpolate({
|
|
||||||
inputRange: [0, 0.4, 1],
|
|
||||||
outputRange: [0.25, 0.12, 0],
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<Animated.View
|
|
||||||
style={[styles.avatarContainer, { transform: [{ scale: pulseAnim }] }]}
|
|
||||||
>
|
|
||||||
{incomingCall.callerAvatar ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: incomingCall.callerAvatar }}
|
|
||||||
style={styles.avatar}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
|
||||||
<Text style={styles.avatarText}>
|
|
||||||
{incomingCall.callerName?.charAt(0).toUpperCase() || '?'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Caller info */}
|
|
||||||
<View style={styles.infoSection}>
|
|
||||||
<Text style={styles.callerName} numberOfLines={1}>
|
|
||||||
{incomingCall.callerName || '未知用户'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.callType}>
|
|
||||||
{incomingCall.callType === 'video' ? '视频通话' : '语音通话'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* Bottom controls */}
|
|
||||||
<View style={styles.controls}>
|
|
||||||
{/* Decline */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.actionButton}
|
|
||||||
onPress={handleReject}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<View style={styles.declineCircle}>
|
|
||||||
<MaterialCommunityIcons name="phone-hangup" size={28} color="#fff" />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.actionLabel}>拒绝</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* Accept */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.actionButton}
|
|
||||||
onPress={handleAccept}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<View style={styles.acceptCircle}>
|
|
||||||
<MaterialCommunityIcons name="phone" size={28} color="#fff" />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.actionLabel}>接听</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AVATAR_SIZE = 100;
|
|
||||||
const ACTION_CIRCLE_SIZE = 60;
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
overlay: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: '#0D0D0D',
|
|
||||||
},
|
|
||||||
avatarSection: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: '18%',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: AVATAR_SIZE + 60,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
rippleRing: {
|
|
||||||
position: 'absolute',
|
|
||||||
width: AVATAR_SIZE + 16,
|
|
||||||
height: AVATAR_SIZE + 16,
|
|
||||||
borderRadius: (AVATAR_SIZE + 16) / 2,
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: '#4CD964',
|
|
||||||
},
|
|
||||||
avatarContainer: {
|
|
||||||
width: AVATAR_SIZE + 8,
|
|
||||||
height: AVATAR_SIZE + 8,
|
|
||||||
borderRadius: (AVATAR_SIZE + 8) / 2,
|
|
||||||
backgroundColor: '#4CD964',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
avatar: {
|
|
||||||
width: AVATAR_SIZE,
|
|
||||||
height: AVATAR_SIZE,
|
|
||||||
borderRadius: AVATAR_SIZE / 2,
|
|
||||||
backgroundColor: '#3A3A5C',
|
|
||||||
},
|
|
||||||
avatarPlaceholder: {
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
avatarText: {
|
|
||||||
fontSize: 36,
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
infoSection: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: '42%',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: 40,
|
|
||||||
},
|
|
||||||
callerName: {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#FFFFFF',
|
|
||||||
marginBottom: 6,
|
|
||||||
textAlign: 'center',
|
|
||||||
maxWidth: 280,
|
|
||||||
},
|
|
||||||
callType: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: 'rgba(255, 255, 255, 0.5)',
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
},
|
|
||||||
controls: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '12%',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 60,
|
|
||||||
},
|
|
||||||
actionButton: {
|
|
||||||
alignItems: 'center',
|
|
||||||
width: 80,
|
|
||||||
},
|
|
||||||
declineCircle: {
|
|
||||||
width: ACTION_CIRCLE_SIZE,
|
|
||||||
height: ACTION_CIRCLE_SIZE,
|
|
||||||
borderRadius: ACTION_CIRCLE_SIZE / 2,
|
|
||||||
backgroundColor: '#E54D42',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
acceptCircle: {
|
|
||||||
width: ACTION_CIRCLE_SIZE,
|
|
||||||
height: ACTION_CIRCLE_SIZE,
|
|
||||||
borderRadius: ACTION_CIRCLE_SIZE / 2,
|
|
||||||
backgroundColor: '#4CD964',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
actionLabel: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: 'rgba(255, 255, 255, 0.6)',
|
|
||||||
marginTop: 10,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default IncomingCallModal;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { default as CallScreen } from './CallScreen';
|
|
||||||
export { default as IncomingCallModal } from './IncomingCallModal';
|
|
||||||
export { default as FloatingCallWindow } from './FloatingCallWindow';
|
|
||||||
@@ -15,14 +15,12 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Pressable,
|
Pressable,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Platform,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import {
|
import {
|
||||||
useResponsive,
|
useResponsive,
|
||||||
useBreakpointGTE,
|
useBreakpointGTE,
|
||||||
FineBreakpointKey,
|
FineBreakpointKey,
|
||||||
} from '../../hooks';
|
} from '../../hooks/useResponsive';
|
||||||
import { blurActiveElement } from '../../infrastructure/platform';
|
|
||||||
|
|
||||||
export interface AdaptiveLayoutProps {
|
export interface AdaptiveLayoutProps {
|
||||||
/** 主内容区 */
|
/** 主内容区 */
|
||||||
@@ -161,9 +159,6 @@ export function AdaptiveLayout({
|
|||||||
// 抽屉动画
|
// 抽屉动画
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDrawerOpen) {
|
if (isDrawerOpen) {
|
||||||
if (isDrawerOpen) {
|
|
||||||
blurActiveElement();
|
|
||||||
}
|
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.timing(slideAnim, {
|
Animated.timing(slideAnim, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
@@ -364,4 +359,44 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 简化的侧边栏布局组件
|
||||||
|
* 仅包含主内容和侧边栏,无头部底部
|
||||||
|
*/
|
||||||
|
export interface SidebarLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
sidebar: React.ReactNode;
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
contentStyle?: StyleProp<ViewStyle>;
|
||||||
|
sidebarStyle?: StyleProp<ViewStyle>;
|
||||||
|
sidebarWidth?: number;
|
||||||
|
showSidebarBreakpoint?: FineBreakpointKey;
|
||||||
|
sidebarPosition?: 'left' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarLayout({
|
||||||
|
children,
|
||||||
|
sidebar,
|
||||||
|
style,
|
||||||
|
contentStyle,
|
||||||
|
sidebarStyle,
|
||||||
|
sidebarWidth = 280,
|
||||||
|
showSidebarBreakpoint = 'lg',
|
||||||
|
sidebarPosition = 'left',
|
||||||
|
}: SidebarLayoutProps) {
|
||||||
|
return (
|
||||||
|
<AdaptiveLayout
|
||||||
|
sidebar={sidebar}
|
||||||
|
style={style}
|
||||||
|
contentStyle={contentStyle}
|
||||||
|
sidebarStyle={sidebarStyle}
|
||||||
|
sidebarWidth={sidebarWidth}
|
||||||
|
showSidebarBreakpoint={showSidebarBreakpoint}
|
||||||
|
sidebarPosition={sidebarPosition}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AdaptiveLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default AdaptiveLayout;
|
export default AdaptiveLayout;
|
||||||
|
|||||||
@@ -2,55 +2,56 @@ import React from 'react';
|
|||||||
import { TouchableOpacity, StyleProp, ViewStyle, StyleSheet } from 'react-native';
|
import { TouchableOpacity, StyleProp, ViewStyle, StyleSheet } from 'react-native';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
|
||||||
import { useAppColors } from '../../theme';
|
import { borderRadius, spacing, useAppColors } from '../../theme';
|
||||||
|
|
||||||
type AppBackButtonIcon = 'chevron-left' | 'arrow-left' | 'close';
|
type AppBackButtonIcon = 'arrow-left' | 'close';
|
||||||
|
|
||||||
interface AppBackButtonProps {
|
interface AppBackButtonProps {
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
icon?: AppBackButtonIcon;
|
icon?: AppBackButtonIcon;
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
iconColor?: string;
|
iconColor?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
hitSlop?: number;
|
hitSlop?: number;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppBackButton: React.FC<AppBackButtonProps> = ({
|
const AppBackButton: React.FC<AppBackButtonProps> = ({
|
||||||
onPress,
|
onPress,
|
||||||
icon = 'chevron-left',
|
icon = 'arrow-left',
|
||||||
style,
|
style,
|
||||||
iconColor: iconColorProp,
|
iconColor: iconColorProp,
|
||||||
|
backgroundColor: backgroundColorProp,
|
||||||
hitSlop = 8,
|
hitSlop = 8,
|
||||||
testID,
|
testID,
|
||||||
}) => {
|
}) => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const iconColor = iconColorProp ?? colors.text.primary;
|
const iconColor = iconColorProp ?? colors.text.primary;
|
||||||
|
const backgroundColor = backgroundColorProp ?? colors.background.paper;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
style={[styles.button, style]}
|
style={[styles.button, { backgroundColor }, style]}
|
||||||
activeOpacity={0.6}
|
activeOpacity={0.75}
|
||||||
hitSlop={hitSlop}
|
hitSlop={hitSlop}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={icon === 'close' ? '关闭' : '返回'}
|
accessibilityLabel={icon === 'close' ? '关闭' : '返回'}
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons name={icon} size={36} color={iconColor} />
|
<MaterialCommunityIcons name={icon} size={22} color={iconColor} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
button: {
|
button: {
|
||||||
width: 40,
|
width: 36,
|
||||||
height: 40,
|
height: 36,
|
||||||
|
borderRadius: borderRadius.full,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginLeft: -12,
|
marginLeft: spacing.xs,
|
||||||
marginRight: 0,
|
|
||||||
padding: 0,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View, Platform } from 'react-native';
|
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import type { AlertButton } from 'react-native';
|
import type { AlertButton } from 'react-native';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
|
||||||
import { bindDialogListener, DialogPayload } from '@/services/ui';
|
import { bindDialogListener, DialogPayload } from '../../services/dialogService';
|
||||||
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
|
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
|
||||||
import { blurActiveElement } from '../../infrastructure/platform';
|
|
||||||
|
|
||||||
function createDialogHostStyles(colors: AppColors) {
|
function createDialogHostStyles(colors: AppColors) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
@@ -106,12 +105,6 @@ const AppDialogHost: React.FC = () => {
|
|||||||
return unbind;
|
return unbind;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (dialog) {
|
|
||||||
blurActiveElement();
|
|
||||||
}
|
|
||||||
}, [dialog]);
|
|
||||||
|
|
||||||
const actions = useMemo<AlertButton[]>(() => {
|
const actions = useMemo<AlertButton[]>(() => {
|
||||||
if (!dialog?.actions?.length) return [{ text: '确定' }];
|
if (!dialog?.actions?.length) return [{ text: '确定' }];
|
||||||
return dialog.actions;
|
return dialog.actions;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native
|
|||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
|
||||||
import { bindPromptListener, PromptPayload, PromptType } from '@/services/ui';
|
import { bindPromptListener, PromptPayload, PromptType } from '../../services/promptService';
|
||||||
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
|
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
|
||||||
|
|
||||||
interface PromptState extends PromptPayload {
|
interface PromptState extends PromptPayload {
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import React, { useMemo } from 'react';
|
|
||||||
import { Text, TextStyle, StyleProp } from 'react-native';
|
|
||||||
|
|
||||||
interface HighlightTextProps {
|
|
||||||
text: string;
|
|
||||||
keyword: string;
|
|
||||||
style?: StyleProp<TextStyle>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const HighlightText: React.FC<HighlightTextProps> = ({ text, keyword, style }) => {
|
|
||||||
const parts = useMemo(() => {
|
|
||||||
if (!text || !keyword) return [{ text, highlight: false }];
|
|
||||||
|
|
||||||
const result: Array<{ text: string; highlight: boolean }> = [];
|
|
||||||
const lowerText = text.toLowerCase();
|
|
||||||
const lowerKeyword = keyword.toLowerCase();
|
|
||||||
let lastIndex = 0;
|
|
||||||
|
|
||||||
let searchFrom = 0;
|
|
||||||
while (searchFrom < lowerText.length) {
|
|
||||||
const index = lowerText.indexOf(lowerKeyword, searchFrom);
|
|
||||||
if (index === -1) break;
|
|
||||||
|
|
||||||
if (index > lastIndex) {
|
|
||||||
result.push({ text: text.substring(lastIndex, index), highlight: false });
|
|
||||||
}
|
|
||||||
result.push({ text: text.substring(index, index + keyword.length), highlight: true });
|
|
||||||
lastIndex = index + keyword.length;
|
|
||||||
searchFrom = lastIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastIndex < text.length) {
|
|
||||||
result.push({ text: text.substring(lastIndex), highlight: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.length > 0 ? result : [{ text, highlight: false }];
|
|
||||||
}, [text, keyword]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Text>
|
|
||||||
{parts.map((part, i) =>
|
|
||||||
part.highlight ? (
|
|
||||||
<Text key={i} style={style}>
|
|
||||||
{part.text}
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text key={i}>{part.text}</Text>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HighlightText;
|
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
Alert,
|
Alert,
|
||||||
Platform,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Image as ExpoImage } from 'expo-image';
|
import { Image as ExpoImage } from 'expo-image';
|
||||||
import {
|
import {
|
||||||
@@ -33,7 +32,6 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|||||||
import * as MediaLibrary from 'expo-media-library';
|
import * as MediaLibrary from 'expo-media-library';
|
||||||
import { File, Paths } from 'expo-file-system';
|
import { File, Paths } from 'expo-file-system';
|
||||||
import { spacing, borderRadius, fontSizes } from '../../theme';
|
import { spacing, borderRadius, fontSizes } from '../../theme';
|
||||||
import { blurActiveElement } from '../../infrastructure/platform';
|
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -128,7 +126,6 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
// 打开/关闭时重置状态
|
// 打开/关闭时重置状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
blurActiveElement();
|
|
||||||
setCurrentIndex(initialIndex);
|
setCurrentIndex(initialIndex);
|
||||||
setShowControls(true);
|
setShowControls(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
@@ -218,7 +215,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
const allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
const allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||||
const fileExt = allowedExts.includes(ext) ? ext : 'jpg';
|
const fileExt = allowedExts.includes(ext) ? ext : 'jpg';
|
||||||
|
|
||||||
const fileName = `withyou_${Date.now()}.${fileExt}`;
|
const fileName = `carrot_${Date.now()}.${fileExt}`;
|
||||||
const destination = new File(Paths.cache, fileName);
|
const destination = new File(Paths.cache, fileName);
|
||||||
|
|
||||||
// File.downloadFileAsync 是新版 expo-file-system/next 的静态方法
|
// File.downloadFileAsync 是新版 expo-file-system/next 的静态方法
|
||||||
@@ -361,7 +358,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
onRequestClose={requestClose}
|
onRequestClose={requestClose}
|
||||||
statusBarTranslucent
|
statusBarTranslucent
|
||||||
>
|
>
|
||||||
<GestureHandlerRootView style={styles.root} data-image-viewer="true">
|
<GestureHandlerRootView style={styles.root}>
|
||||||
<View style={[styles.container, { backgroundColor: `rgba(0, 0, 0, ${backgroundOpacity})` }]}>
|
<View style={[styles.container, { backgroundColor: `rgba(0, 0, 0, ${backgroundOpacity})` }]}>
|
||||||
{/* 顶部控制栏 */}
|
{/* 顶部控制栏 */}
|
||||||
{showControls && (
|
{showControls && (
|
||||||
|
|||||||
@@ -132,10 +132,10 @@ function createImageGridStyles(colors: AppColors) {
|
|||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
},
|
},
|
||||||
gridItem2: {
|
gridItem2: {
|
||||||
width: '48.5%',
|
width: '49%',
|
||||||
},
|
},
|
||||||
gridItem3: {
|
gridItem3: {
|
||||||
width: '31.8%',
|
width: '32.5%',
|
||||||
},
|
},
|
||||||
masonryContainer: {
|
masonryContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { ViewStyle } from 'react-native';
|
|
||||||
import PagerViewNative from 'react-native-pager-view';
|
|
||||||
|
|
||||||
type PagerViewProps = {
|
|
||||||
style?: ViewStyle;
|
|
||||||
initialPage?: number;
|
|
||||||
onPageSelected?: (e: { nativeEvent: { position: number } }) => void;
|
|
||||||
overdrag?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PagerView = React.forwardRef<PagerViewNative, PagerViewProps>(
|
|
||||||
({ style, initialPage, onPageSelected, overdrag, children }, ref) => {
|
|
||||||
return (
|
|
||||||
<PagerViewNative
|
|
||||||
ref={ref}
|
|
||||||
style={style}
|
|
||||||
initialPage={initialPage}
|
|
||||||
onPageSelected={onPageSelected}
|
|
||||||
overdrag={overdrag}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</PagerViewNative>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
PagerView.displayName = 'PagerView';
|
|
||||||
|
|
||||||
export default PagerView;
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import React, { ForwardedRef } from 'react';
|
|
||||||
import { View, ViewStyle } from 'react-native';
|
|
||||||
|
|
||||||
type PagerViewProps = {
|
|
||||||
style?: ViewStyle;
|
|
||||||
initialPage?: number;
|
|
||||||
onPageSelected?: (e: { nativeEvent: { position: number } }) => void;
|
|
||||||
overdrag?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
testID?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PagerViewRef = {
|
|
||||||
setPage: (page: number) => void;
|
|
||||||
setPageWithoutAnimation: (page: number) => void;
|
|
||||||
setScrollEnabled: (scrollEnabled: boolean) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
function PagerViewInternal(
|
|
||||||
{ style, children }: PagerViewProps,
|
|
||||||
_ref: ForwardedRef<PagerViewRef>
|
|
||||||
) {
|
|
||||||
const childrenArray = React.Children.toArray(children);
|
|
||||||
return (
|
|
||||||
<View style={style}>
|
|
||||||
{childrenArray[0]}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PagerView = React.forwardRef(PagerViewInternal);
|
|
||||||
PagerView.displayName = 'PagerView';
|
|
||||||
|
|
||||||
export default PagerView;
|
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleProp, ViewStyle } from 'react-native';
|
import { View, StyleProp, ViewStyle } from 'react-native';
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive } from '../../hooks/useResponsive';
|
||||||
|
|
||||||
export interface ResponsiveContainerProps {
|
export interface ResponsiveContainerProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
useColumnCount,
|
useColumnCount,
|
||||||
useResponsiveSpacing,
|
useResponsiveSpacing,
|
||||||
FineBreakpointKey,
|
FineBreakpointKey,
|
||||||
} from '../../hooks';
|
} from '../../hooks/useResponsive';
|
||||||
|
|
||||||
export interface ResponsiveGridProps {
|
export interface ResponsiveGridProps {
|
||||||
/** 子元素 */
|
/** 子元素 */
|
||||||
|
|||||||
212
src/components/common/ResponsiveStack.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* 响应式堆叠布局组件
|
||||||
|
* 移动端垂直堆叠,平板/桌面端水平排列
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
StyleProp,
|
||||||
|
ViewStyle,
|
||||||
|
StyleSheet,
|
||||||
|
FlexAlignType,
|
||||||
|
} from 'react-native';
|
||||||
|
import {
|
||||||
|
useResponsive,
|
||||||
|
useResponsiveSpacing,
|
||||||
|
FineBreakpointKey,
|
||||||
|
useBreakpointGTE,
|
||||||
|
} from '../../hooks/useResponsive';
|
||||||
|
|
||||||
|
export type StackDirection = 'horizontal' | 'vertical' | 'responsive';
|
||||||
|
export type StackAlignment = 'start' | 'center' | 'end' | 'stretch' | 'baseline';
|
||||||
|
export type StackJustify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
|
||||||
|
|
||||||
|
export interface ResponsiveStackProps {
|
||||||
|
/** 子元素 */
|
||||||
|
children: React.ReactNode;
|
||||||
|
/** 布局方向 */
|
||||||
|
direction?: StackDirection;
|
||||||
|
/** 切换为水平布局的断点(仅在 direction='responsive' 时有效) */
|
||||||
|
horizontalBreakpoint?: FineBreakpointKey;
|
||||||
|
/** 间距 */
|
||||||
|
gap?: Partial<Record<FineBreakpointKey, number>> | number;
|
||||||
|
/** 是否允许换行 */
|
||||||
|
wrap?: boolean;
|
||||||
|
/** 对齐方式(交叉轴) */
|
||||||
|
align?: StackAlignment;
|
||||||
|
/** 分布方式(主轴) */
|
||||||
|
justify?: StackJustify;
|
||||||
|
/** 自定义样式 */
|
||||||
|
style?: StyleProp<ViewStyle>;
|
||||||
|
/** 子元素样式 */
|
||||||
|
itemStyle?: StyleProp<ViewStyle>;
|
||||||
|
/** 是否反转顺序 */
|
||||||
|
reverse?: boolean;
|
||||||
|
/** 是否等分空间 */
|
||||||
|
equalItem?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alignMap: Record<StackAlignment, FlexAlignType> = {
|
||||||
|
start: 'flex-start',
|
||||||
|
center: 'center',
|
||||||
|
end: 'flex-end',
|
||||||
|
stretch: 'stretch',
|
||||||
|
baseline: 'baseline',
|
||||||
|
};
|
||||||
|
|
||||||
|
const justifyMap: Record<StackJustify, 'flex-start' | 'center' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly'> = {
|
||||||
|
start: 'flex-start',
|
||||||
|
center: 'center',
|
||||||
|
end: 'flex-end',
|
||||||
|
between: 'space-between',
|
||||||
|
around: 'space-around',
|
||||||
|
evenly: 'space-evenly',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 响应式堆叠布局组件
|
||||||
|
*
|
||||||
|
* - 移动端垂直堆叠
|
||||||
|
* - 平板/桌面端水平排列
|
||||||
|
* - 支持间距和换行配置
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 基础用法 - 自动响应式
|
||||||
|
* <ResponsiveStack>
|
||||||
|
* <Item1 />
|
||||||
|
* <Item2 />
|
||||||
|
* <Item3 />
|
||||||
|
* </ResponsiveStack>
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 自定义断点和间距
|
||||||
|
* <ResponsiveStack
|
||||||
|
* direction="responsive"
|
||||||
|
* horizontalBreakpoint="md"
|
||||||
|
* gap={{ xs: 8, md: 16, lg: 24 }}
|
||||||
|
* align="center"
|
||||||
|
* justify="between"
|
||||||
|
* >
|
||||||
|
* <Item1 />
|
||||||
|
* <Item2 />
|
||||||
|
* </ResponsiveStack>
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 固定水平方向
|
||||||
|
* <ResponsiveStack direction="horizontal" wrap gap={16}>
|
||||||
|
* {items.map(item => <Tag key={item.id} {...item} />)}
|
||||||
|
* </ResponsiveStack>
|
||||||
|
*/
|
||||||
|
export function ResponsiveStack({
|
||||||
|
children,
|
||||||
|
direction = 'responsive',
|
||||||
|
horizontalBreakpoint = 'lg',
|
||||||
|
gap: gapConfig,
|
||||||
|
wrap = false,
|
||||||
|
align = 'stretch',
|
||||||
|
justify = 'start',
|
||||||
|
style,
|
||||||
|
itemStyle,
|
||||||
|
reverse = false,
|
||||||
|
equalItem = false,
|
||||||
|
}: ResponsiveStackProps) {
|
||||||
|
const { isMobile, isTablet } = useResponsive();
|
||||||
|
const isHorizontalBreakpoint = useBreakpointGTE(horizontalBreakpoint);
|
||||||
|
|
||||||
|
// 计算间距
|
||||||
|
const gap = useMemo(() => {
|
||||||
|
if (typeof gapConfig === 'number') {
|
||||||
|
return gapConfig;
|
||||||
|
}
|
||||||
|
// 使用 hook 获取响应式间距
|
||||||
|
return gapConfig;
|
||||||
|
}, [gapConfig]);
|
||||||
|
|
||||||
|
const responsiveGap = useResponsiveSpacing(typeof gap === 'number' ? undefined : gap);
|
||||||
|
const finalGap = typeof gap === 'number' ? gap : responsiveGap;
|
||||||
|
|
||||||
|
// 确定布局方向
|
||||||
|
const isHorizontal = useMemo(() => {
|
||||||
|
if (direction === 'horizontal') return true;
|
||||||
|
if (direction === 'vertical') return false;
|
||||||
|
// direction === 'responsive'
|
||||||
|
return isHorizontalBreakpoint;
|
||||||
|
}, [direction, isHorizontalBreakpoint]);
|
||||||
|
|
||||||
|
// 构建容器样式
|
||||||
|
const containerStyle = useMemo((): ViewStyle => {
|
||||||
|
const flexDirection = isHorizontal
|
||||||
|
? (reverse ? 'row-reverse' : 'row')
|
||||||
|
: (reverse ? 'column-reverse' : 'column');
|
||||||
|
|
||||||
|
return {
|
||||||
|
flexDirection,
|
||||||
|
flexWrap: wrap ? 'wrap' : 'nowrap',
|
||||||
|
alignItems: alignMap[align],
|
||||||
|
justifyContent: justifyMap[justify],
|
||||||
|
gap: finalGap,
|
||||||
|
};
|
||||||
|
}, [isHorizontal, reverse, wrap, align, justify, finalGap]);
|
||||||
|
|
||||||
|
// 处理子元素
|
||||||
|
const processedChildren = useMemo(() => {
|
||||||
|
const childrenArray = React.Children.toArray(children);
|
||||||
|
|
||||||
|
return childrenArray.map((child, index) => {
|
||||||
|
if (!React.isValidElement(child)) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
const childStyle: ViewStyle = {};
|
||||||
|
|
||||||
|
if (equalItem) {
|
||||||
|
childStyle.flex = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果不是最后一个元素,添加间距
|
||||||
|
// 注意:使用 gap 后不需要手动添加 margin
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
key={child.key ?? `stack-item-${index}`}
|
||||||
|
style={[equalItem && styles.equalItem, itemStyle, childStyle]}
|
||||||
|
>
|
||||||
|
{child}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, [children, equalItem, itemStyle]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, containerStyle, style]}>
|
||||||
|
{processedChildren}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
equalItem: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0, // 防止 flex item 溢出
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 水平堆叠组件(快捷方式)
|
||||||
|
*/
|
||||||
|
export function HStack(props: Omit<ResponsiveStackProps, 'direction'>) {
|
||||||
|
return <ResponsiveStack {...props} direction="horizontal" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 垂直堆叠组件(快捷方式)
|
||||||
|
*/
|
||||||
|
export function VStack(props: Omit<ResponsiveStackProps, 'direction'>) {
|
||||||
|
return <ResponsiveStack {...props} direction="vertical" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ResponsiveStack;
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { View, StyleSheet } from 'react-native';
|
|
||||||
import { useAppColors, spacing } from '../../theme';
|
|
||||||
import { AppBackButton, Text } from './index';
|
|
||||||
|
|
||||||
interface SimpleHeaderProps {
|
|
||||||
title: string;
|
|
||||||
onBack: () => void;
|
|
||||||
rightComponent?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SimpleHeader: React.FC<SimpleHeaderProps> = ({ title, onBack, rightComponent }) => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.header, { backgroundColor: colors.background.paper }]}>
|
|
||||||
<AppBackButton onPress={onBack} style={styles.backButton} />
|
|
||||||
<Text style={[styles.title, { color: colors.text.primary }]} numberOfLines={1}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
{rightComponent ? (
|
|
||||||
<View style={styles.rightContainer}>
|
|
||||||
{rightComponent}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View style={styles.rightPlaceholder} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
|
||||||
header: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
height: 52,
|
|
||||||
paddingHorizontal: spacing.xs,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginLeft: spacing.xs,
|
|
||||||
},
|
|
||||||
rightContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
rightPlaceholder: {
|
|
||||||
width: 40,
|
|
||||||
},
|
|
||||||
backButton: {
|
|
||||||
marginLeft: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
/**
|
|
||||||
* 步骤指示器组件 - 极简扁平设计
|
|
||||||
* 与下方表单风格统一
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
|
|
||||||
import { useAppColors, type AppColors } from '../../theme';
|
|
||||||
|
|
||||||
export interface Step {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StepIndicatorProps {
|
|
||||||
steps: Step[];
|
|
||||||
currentStep: number;
|
|
||||||
containerStyle?: ViewStyle;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StepIndicator: React.FC<StepIndicatorProps> = ({
|
|
||||||
steps,
|
|
||||||
currentStep,
|
|
||||||
containerStyle,
|
|
||||||
}) => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
const styles = createStyles(colors);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.container, containerStyle]}>
|
|
||||||
{steps.map((step, index) => {
|
|
||||||
const isCompleted = index < currentStep;
|
|
||||||
const isCurrent = index === currentStep;
|
|
||||||
const isPending = index > currentStep;
|
|
||||||
const isLast = index === steps.length - 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={step.id}>
|
|
||||||
<View style={styles.stepWrapper}>
|
|
||||||
{/* 步骤编号/状态 */}
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.stepBadge,
|
|
||||||
isCompleted && styles.stepBadgeCompleted,
|
|
||||||
isCurrent && styles.stepBadgeCurrent,
|
|
||||||
isPending && styles.stepBadgePending,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.stepBadgeText,
|
|
||||||
isCompleted && styles.stepBadgeTextCompleted,
|
|
||||||
isCurrent && styles.stepBadgeTextCurrent,
|
|
||||||
isPending && styles.stepBadgeTextPending,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isCompleted ? '✓' : index + 1}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 步骤标题 */}
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.stepTitle,
|
|
||||||
isCompleted && styles.stepTitleCompleted,
|
|
||||||
isCurrent && styles.stepTitleCurrent,
|
|
||||||
isPending && styles.stepTitlePending,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{step.title}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 连接线 */}
|
|
||||||
{!isLast && (
|
|
||||||
<View style={styles.connector}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.connectorLine,
|
|
||||||
isCompleted && styles.connectorLineCompleted,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 注册流程专用步骤指示器
|
|
||||||
export const RegisterStepIndicator: React.FC<{
|
|
||||||
currentStep: number;
|
|
||||||
containerStyle?: ViewStyle;
|
|
||||||
}> = ({ currentStep, containerStyle }) => {
|
|
||||||
const steps: Step[] = [
|
|
||||||
{ id: 0, title: '输入邮箱' },
|
|
||||||
{ id: 1, title: '验证码' },
|
|
||||||
{ id: 2, title: '设置信息' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StepIndicator
|
|
||||||
steps={steps}
|
|
||||||
currentStep={currentStep}
|
|
||||||
containerStyle={containerStyle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function createStyles(colors: AppColors) {
|
|
||||||
return StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
width: '100%',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 8,
|
|
||||||
},
|
|
||||||
stepWrapper: {
|
|
||||||
alignItems: 'center',
|
|
||||||
minWidth: 80,
|
|
||||||
},
|
|
||||||
// 步骤徽章
|
|
||||||
stepBadge: {
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: 14,
|
|
||||||
backgroundColor: '#F5F5F7',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderWidth: 0,
|
|
||||||
},
|
|
||||||
stepBadgeCompleted: {
|
|
||||||
backgroundColor: '#FF6B35',
|
|
||||||
},
|
|
||||||
stepBadgeCurrent: {
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: '#FF6B35',
|
|
||||||
},
|
|
||||||
stepBadgePending: {
|
|
||||||
backgroundColor: '#F5F5F7',
|
|
||||||
},
|
|
||||||
stepBadgeText: {
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#999',
|
|
||||||
},
|
|
||||||
stepBadgeTextCompleted: {
|
|
||||||
color: '#FFFFFF',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
stepBadgeTextCurrent: {
|
|
||||||
color: '#FF6B35',
|
|
||||||
},
|
|
||||||
stepBadgeTextPending: {
|
|
||||||
color: '#BBB',
|
|
||||||
},
|
|
||||||
// 步骤标题
|
|
||||||
stepTitle: {
|
|
||||||
marginTop: 8,
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#999',
|
|
||||||
fontWeight: '500',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
stepTitleCompleted: {
|
|
||||||
color: '#FF6B35',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
stepTitleCurrent: {
|
|
||||||
color: '#333',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
stepTitlePending: {
|
|
||||||
color: '#BBB',
|
|
||||||
},
|
|
||||||
// 连接线
|
|
||||||
connector: {
|
|
||||||
width: 60,
|
|
||||||
height: 28,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
connectorLine: {
|
|
||||||
width: '100%',
|
|
||||||
height: 2,
|
|
||||||
backgroundColor: '#E5E5E5',
|
|
||||||
borderRadius: 1,
|
|
||||||
},
|
|
||||||
connectorLineCompleted: {
|
|
||||||
backgroundColor: '#FF6B35',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StepIndicator;
|
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle, Platform } from 'react-native';
|
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native';
|
||||||
import { useAppColors, fontSizes, fontWeights } from '../../theme';
|
import { useAppColors, fontSizes } from '../../theme';
|
||||||
|
|
||||||
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
|
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
|
||||||
|
|
||||||
@@ -15,46 +15,39 @@ interface CustomTextProps extends Omit<TextProps, 'style'> {
|
|||||||
color?: string;
|
color?: string;
|
||||||
numberOfLines?: number;
|
numberOfLines?: number;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
style?: TextStyle | TextStyle[] | (TextStyle | undefined)[];
|
style?: TextStyle | TextStyle[];
|
||||||
weight?: 'regular' | 'medium' | 'semibold' | 'bold';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantStyles: Record<TextVariant, object> = {
|
const variantStyles: Record<TextVariant, object> = {
|
||||||
h1: {
|
h1: {
|
||||||
fontSize: fontSizes['4xl'],
|
fontSize: fontSizes['4xl'],
|
||||||
fontWeight: fontWeights.bold,
|
fontWeight: '700',
|
||||||
lineHeight: fontSizes['4xl'] * 1.3,
|
lineHeight: fontSizes['4xl'] * 1.4,
|
||||||
letterSpacing: -0.5,
|
|
||||||
},
|
},
|
||||||
h2: {
|
h2: {
|
||||||
fontSize: fontSizes['3xl'],
|
fontSize: fontSizes['3xl'],
|
||||||
fontWeight: fontWeights.semibold,
|
fontWeight: '600',
|
||||||
lineHeight: fontSizes['3xl'] * 1.3,
|
lineHeight: fontSizes['3xl'] * 1.4,
|
||||||
letterSpacing: -0.3,
|
|
||||||
},
|
},
|
||||||
h3: {
|
h3: {
|
||||||
fontSize: fontSizes['2xl'],
|
fontSize: fontSizes['2xl'],
|
||||||
fontWeight: fontWeights.semibold,
|
fontWeight: '600',
|
||||||
lineHeight: fontSizes['2xl'] * 1.3,
|
lineHeight: fontSizes['2xl'] * 1.3,
|
||||||
letterSpacing: -0.2,
|
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
fontWeight: fontWeights.regular,
|
fontWeight: '400',
|
||||||
lineHeight: fontSizes.md * 1.6,
|
lineHeight: fontSizes.md * 1.5,
|
||||||
letterSpacing: 0.1,
|
|
||||||
},
|
},
|
||||||
caption: {
|
caption: {
|
||||||
fontSize: fontSizes.sm,
|
fontSize: fontSizes.sm,
|
||||||
fontWeight: fontWeights.regular,
|
fontWeight: '400',
|
||||||
lineHeight: fontSizes.sm * 1.5,
|
lineHeight: fontSizes.sm * 1.4,
|
||||||
letterSpacing: 0.2,
|
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: fontSizes.xs,
|
fontSize: fontSizes.xs,
|
||||||
fontWeight: fontWeights.medium,
|
fontWeight: '500',
|
||||||
lineHeight: fontSizes.xs * 1.4,
|
lineHeight: fontSizes.xs * 1.4,
|
||||||
letterSpacing: 0.3,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,7 +55,6 @@ const Text: React.FC<CustomTextProps> = ({
|
|||||||
children,
|
children,
|
||||||
variant = 'body',
|
variant = 'body',
|
||||||
color,
|
color,
|
||||||
weight,
|
|
||||||
numberOfLines,
|
numberOfLines,
|
||||||
onPress,
|
onPress,
|
||||||
style,
|
style,
|
||||||
@@ -72,7 +64,6 @@ const Text: React.FC<CustomTextProps> = ({
|
|||||||
const textStyle = [
|
const textStyle = [
|
||||||
styles.base,
|
styles.base,
|
||||||
variantStyles[variant],
|
variantStyles[variant],
|
||||||
weight ? { fontWeight: fontWeights[weight] } : null,
|
|
||||||
color ? { color } : { color: colors.text.primary },
|
color ? { color } : { color: colors.text.primary },
|
||||||
style,
|
style,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
/**
|
|
||||||
* 验证码输入组件
|
|
||||||
* 参考设计:4-6位数字验证码,每个数字独立显示在一个方框中
|
|
||||||
* 支持自动聚焦、粘贴、删除回退
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useRef, useEffect, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
TextInput,
|
|
||||||
StyleSheet,
|
|
||||||
Keyboard,
|
|
||||||
Platform,
|
|
||||||
Text,
|
|
||||||
ViewStyle,
|
|
||||||
useWindowDimensions,
|
|
||||||
} from 'react-native';
|
|
||||||
import { useAppColors, type AppColors } from '../../theme';
|
|
||||||
|
|
||||||
interface VerificationCodeInputProps {
|
|
||||||
value: string;
|
|
||||||
onChangeValue: (value: string) => void;
|
|
||||||
length?: number; // 验证码位数,默认4位
|
|
||||||
autoFocus?: boolean;
|
|
||||||
onComplete?: (value: string) => void; // 输入完成回调
|
|
||||||
containerStyle?: ViewStyle;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_CELL_WIDTH = 56;
|
|
||||||
const MIN_CELL_WIDTH = 32;
|
|
||||||
const CELL_HEIGHT = 64;
|
|
||||||
const CELL_MARGIN = 8;
|
|
||||||
const CONTAINER_PADDING = 24 * 2; // matches parent scrollContent paddingHorizontal
|
|
||||||
const EXTRA_MARGIN = 32; // additional safety margin
|
|
||||||
|
|
||||||
export const VerificationCodeInput: React.FC<VerificationCodeInputProps> = ({
|
|
||||||
value,
|
|
||||||
onChangeValue,
|
|
||||||
length = 4,
|
|
||||||
autoFocus = true,
|
|
||||||
onComplete,
|
|
||||||
containerStyle,
|
|
||||||
disabled = false,
|
|
||||||
}) => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
const { width: screenWidth } = useWindowDimensions();
|
|
||||||
|
|
||||||
const availableWidth = screenWidth - CONTAINER_PADDING - EXTRA_MARGIN;
|
|
||||||
const maxCellWidth = Math.floor(availableWidth / length) - CELL_MARGIN;
|
|
||||||
const cellWidth = Math.max(MIN_CELL_WIDTH, Math.min(DEFAULT_CELL_WIDTH, maxCellWidth));
|
|
||||||
|
|
||||||
const styles = createStyles(colors, cellWidth);
|
|
||||||
|
|
||||||
// 为每个数字创建一个 ref
|
|
||||||
const inputRefs = useRef<(TextInput | null)[]>(Array(length).fill(null));
|
|
||||||
|
|
||||||
// 将 value 分割为单个字符数组
|
|
||||||
const chars = value.split('').slice(0, length);
|
|
||||||
// 填充空位
|
|
||||||
while (chars.length < length) {
|
|
||||||
chars.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取当前聚焦的位置
|
|
||||||
const getFocusedIndex = useCallback(() => {
|
|
||||||
return Math.min(value.length, length - 1);
|
|
||||||
}, [value.length, length]);
|
|
||||||
|
|
||||||
// 聚焦到指定位置
|
|
||||||
const focusInput = useCallback((index: number) => {
|
|
||||||
if (index >= 0 && index < length) {
|
|
||||||
inputRefs.current[index]?.focus();
|
|
||||||
}
|
|
||||||
}, [length]);
|
|
||||||
|
|
||||||
// 自动聚焦第一个输入框
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoFocus && !disabled) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
focusInput(0);
|
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [autoFocus, disabled, focusInput]);
|
|
||||||
|
|
||||||
// 当值改变时,自动聚焦到下一个输入框
|
|
||||||
useEffect(() => {
|
|
||||||
if (value.length < length) {
|
|
||||||
focusInput(value.length);
|
|
||||||
}
|
|
||||||
// 当输入完成时触发回调
|
|
||||||
if (value.length === length && onComplete) {
|
|
||||||
onComplete(value);
|
|
||||||
}
|
|
||||||
}, [value, length, onComplete, focusInput]);
|
|
||||||
|
|
||||||
// 处理文本变化
|
|
||||||
const handleChangeText = (text: string, index: number) => {
|
|
||||||
if (disabled) return;
|
|
||||||
|
|
||||||
// 只允许数字
|
|
||||||
const numericText = text.replace(/[^0-9]/g, '');
|
|
||||||
|
|
||||||
if (numericText.length === 0) {
|
|
||||||
// 删除操作
|
|
||||||
if (value.length > index) {
|
|
||||||
// 删除当前位置的字符
|
|
||||||
const newValue = value.slice(0, index) + value.slice(index + 1);
|
|
||||||
onChangeValue(newValue);
|
|
||||||
// 聚焦到前一个输入框
|
|
||||||
if (index > 0) {
|
|
||||||
focusInput(index - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (numericText.length === 1) {
|
|
||||||
// 单个字符输入
|
|
||||||
let newValue: string;
|
|
||||||
if (index < value.length) {
|
|
||||||
// 替换已有字符
|
|
||||||
newValue = value.slice(0, index) + numericText + value.slice(index + 1);
|
|
||||||
} else {
|
|
||||||
// 追加新字符
|
|
||||||
newValue = value + numericText;
|
|
||||||
}
|
|
||||||
onChangeValue(newValue.slice(0, length));
|
|
||||||
// 自动聚焦到下一个
|
|
||||||
if (index < length - 1) {
|
|
||||||
focusInput(index + 1);
|
|
||||||
}
|
|
||||||
} else if (numericText.length > 1) {
|
|
||||||
// 粘贴操作:尝试将粘贴的内容作为完整验证码
|
|
||||||
const newValue = numericText.slice(0, length);
|
|
||||||
onChangeValue(newValue);
|
|
||||||
// 聚焦到最后一个或下一个
|
|
||||||
const focusIndex = Math.min(newValue.length, length - 1);
|
|
||||||
focusInput(focusIndex);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理按键事件(用于删除键)
|
|
||||||
const handleKeyPress = (e: any, index: number) => {
|
|
||||||
if (disabled) return;
|
|
||||||
|
|
||||||
if (e.nativeEvent.key === 'Backspace') {
|
|
||||||
if (value.length > index) {
|
|
||||||
// 删除当前位置的字符
|
|
||||||
const newValue = value.slice(0, index) + value.slice(index + 1);
|
|
||||||
onChangeValue(newValue);
|
|
||||||
} else if (index > 0 && value.length === index) {
|
|
||||||
// 当前位置没有字符,删除前一个
|
|
||||||
const newValue = value.slice(0, -1);
|
|
||||||
onChangeValue(newValue);
|
|
||||||
focusInput(index - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 聚焦时全选文本
|
|
||||||
const handleFocus = (index: number) => {
|
|
||||||
if (disabled) return;
|
|
||||||
inputRefs.current[index]?.setNativeProps({
|
|
||||||
selection: { start: 0, end: 1 },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.container, containerStyle]}>
|
|
||||||
<View style={styles.cellsContainer}>
|
|
||||||
{chars.map((char, index) => {
|
|
||||||
const isFocused = value.length === index;
|
|
||||||
const isFilled = char !== '';
|
|
||||||
const isLastFilled = index === value.length - 1 && isFilled;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={index}
|
|
||||||
style={[
|
|
||||||
styles.cell,
|
|
||||||
isFocused && styles.cellFocused,
|
|
||||||
isFilled && styles.cellFilled,
|
|
||||||
disabled && styles.cellDisabled,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
ref={(ref) => {
|
|
||||||
inputRefs.current[index] = ref;
|
|
||||||
}}
|
|
||||||
style={styles.cellInput}
|
|
||||||
value={char}
|
|
||||||
onChangeText={(text) => handleChangeText(text, index)}
|
|
||||||
onKeyPress={(e) => handleKeyPress(e, index)}
|
|
||||||
onFocus={() => handleFocus(index)}
|
|
||||||
keyboardType="number-pad"
|
|
||||||
maxLength={1}
|
|
||||||
selectTextOnFocus
|
|
||||||
editable={!disabled}
|
|
||||||
caretHidden={true}
|
|
||||||
textContentType="oneTimeCode" // iOS 自动填充支持
|
|
||||||
/>
|
|
||||||
{/* 光标指示器 */}
|
|
||||||
{isFocused && !isFilled && !disabled && (
|
|
||||||
<View style={styles.cursor} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function createStyles(colors: AppColors, cellWidth: number) {
|
|
||||||
return StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
cellsContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
cell: {
|
|
||||||
width: cellWidth,
|
|
||||||
height: CELL_HEIGHT,
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1.5,
|
|
||||||
borderColor: colors.divider,
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
marginHorizontal: CELL_MARGIN / 2,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
cellFocused: {
|
|
||||||
borderColor: colors.primary.main,
|
|
||||||
backgroundColor: `${colors.primary.main}10`,
|
|
||||||
},
|
|
||||||
cellFilled: {
|
|
||||||
borderColor: colors.primary.main,
|
|
||||||
},
|
|
||||||
cellDisabled: {
|
|
||||||
opacity: 0.5,
|
|
||||||
backgroundColor: colors.background.disabled,
|
|
||||||
},
|
|
||||||
cellInput: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
textAlign: 'center',
|
|
||||||
fontSize: cellWidth >= 48 ? 28 : cellWidth >= 40 ? 24 : 20,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text.primary,
|
|
||||||
padding: 0,
|
|
||||||
},
|
|
||||||
cursor: {
|
|
||||||
position: 'absolute',
|
|
||||||
width: 2,
|
|
||||||
height: 28,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
borderRadius: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default VerificationCodeInput;
|
|
||||||