Compare commits
106 Commits
master
...
cbe708b53b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbe708b53b | ||
|
|
7e0436a799 | ||
|
|
48f31e6617 | ||
|
|
7c7aaf9108 | ||
|
|
1d9c312c6c | ||
|
|
d42083a4aa | ||
|
|
d0ea2c5aea | ||
|
|
81ee9ba75b | ||
|
|
d4c3e1f268 | ||
|
|
ea9e51b0b0 | ||
|
|
7f606e14dd | ||
|
|
64493baf7f | ||
|
|
d879eea7d1 | ||
|
|
e18cac2dca | ||
|
|
3196972596 | ||
|
|
f5f9c3a619 | ||
|
|
c0ebebe3da | ||
|
|
8a271b2376 | ||
|
|
4213d13b8f | ||
|
|
490a99ab3c | ||
|
|
0f289e3182 | ||
|
|
d2120a257d | ||
|
|
cf9feeae68 | ||
|
|
2432f7bc1e | ||
|
|
be0c304711 | ||
|
|
b7ce9e3b9a | ||
|
|
296e649fbb | ||
|
|
88d9ffe9da | ||
|
|
9745d2209f | ||
|
|
2015a1e9dd | ||
|
|
ba89b3dc94 | ||
|
|
0e4306c3ac | ||
|
|
f478fa3fd3 | ||
|
|
e0b43ce60d | ||
|
|
0e2945b86b | ||
|
|
e519346261 | ||
|
|
1989cc8519 | ||
|
|
6cbd2092ac | ||
|
|
5815f1a5f7 | ||
|
|
e9d7098ad0 | ||
|
|
2dc6936aac | ||
|
|
5fa5403d6a | ||
|
|
de0afa93a1 | ||
|
|
eccfd67550 | ||
|
|
6b91a7ead1 | ||
|
|
a6a4198ac5 | ||
|
|
19054d64b3 | ||
|
|
f16f001f6c | ||
|
|
ad19bc2af7 | ||
|
|
e0d28535f4 | ||
|
|
738e75a79d | ||
|
|
b2c3d5e54e | ||
|
|
eb4e1c080d | ||
|
|
dcae47218f | ||
|
|
437dab3b6e | ||
|
|
1c3797ea7d | ||
|
|
30c493c88f | ||
|
|
b16759a147 | ||
|
|
6910fdec70 | ||
|
|
c00b915e5f | ||
|
|
57d7c7405c | ||
|
|
4f31926eb5 | ||
|
|
fe6a03da5d | ||
|
|
2adc9360a5 | ||
|
|
6610d2f173 | ||
|
|
4b5ce1ba21 | ||
|
|
6f84e17772 | ||
|
|
9bbed8cf5e | ||
|
|
be8f6de8cf | ||
|
|
96a5207cf8 | ||
|
|
25194313ae | ||
|
|
445c1c5561 | ||
|
|
accf7c04e8 | ||
|
|
542d385d08 | ||
|
|
82c2970a85 | ||
|
|
189b977fac | ||
|
|
69717ea407 | ||
|
|
775deeb9c4 | ||
|
|
e8651215f7 | ||
|
|
72842352d9 | ||
|
|
c771bd9755 | ||
|
|
5614b4078a | ||
|
|
20e9d69540 | ||
|
|
259de04f3e | ||
|
|
94c11062f0 | ||
|
|
1bee7ea551 | ||
|
|
2ef267a897 | ||
|
|
774b5c4b47 | ||
|
|
e0ee29caf8 | ||
|
|
584d98307c | ||
| e969e5bad4 | |||
| ebb0c003e3 | |||
|
|
3c071957ce | ||
|
|
fa10ef5116 | ||
|
|
7c33409624 | ||
|
|
f6176c945b | ||
|
|
b19a2ced6f | ||
|
|
db7885086f | ||
|
|
ba99900624 | ||
|
|
4b89b50006 | ||
|
|
b6583e07c8 | ||
|
|
6d1514b2d1 | ||
|
|
d280ad1656 | ||
|
|
c903990aaf | ||
|
|
405cd271db | ||
|
|
9529ea39c4 |
@@ -21,7 +21,8 @@ on:
|
||||
env:
|
||||
REGISTRY: code.littlelan.cn
|
||||
IMAGE_NAME: carrot_bbs/frontend-web
|
||||
OTA_PLATFORM: android
|
||||
OTA_PLATFORM_ANDROID: android
|
||||
OTA_PLATFORM_IOS: ios
|
||||
OTA_PUBLISH_URL: https://updates.littlelan.cn/admin/publish
|
||||
OTA_MANIFEST_URL: https://updates.littlelan.cn/api/manifest
|
||||
|
||||
@@ -39,7 +40,13 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '25.6.1'
|
||||
cache: 'npm'
|
||||
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
|
||||
@@ -76,7 +83,7 @@ jobs:
|
||||
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}" \
|
||||
curl -fSs -X POST "${OTA_PUBLISH_URL}?runtimeVersion=${{ steps.runtime.outputs.runtime_version }}&platform=${OTA_PLATFORM_ANDROID}" \
|
||||
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
||||
-H "Content-Type: application/zip" \
|
||||
--data-binary @"dist-android-update.zip"
|
||||
@@ -84,7 +91,7 @@ jobs:
|
||||
- name: Verify OTA manifest
|
||||
run: |
|
||||
REMOTE="$(curl -sS "${OTA_MANIFEST_URL}" \
|
||||
-H "expo-platform: ${OTA_PLATFORM}" \
|
||||
-H "expo-platform: ${OTA_PLATFORM_ANDROID}" \
|
||||
-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])")"
|
||||
@@ -93,6 +100,79 @@ jobs:
|
||||
echo "Local bundle: ${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:
|
||||
runs-on: android-builder
|
||||
container:
|
||||
@@ -121,7 +201,13 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '25.6.1'
|
||||
cache: 'npm'
|
||||
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: Cache Gradle
|
||||
uses: actions/cache@v4
|
||||
@@ -150,21 +236,37 @@ jobs:
|
||||
- name: Generate Android native project
|
||||
run: npx expo prebuild --platform android
|
||||
|
||||
- name: Configure Gradle with Aliyun Maven mirror
|
||||
- name: Switch Gradle distribution to Tencent 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: |
|
||||
cd android
|
||||
|
||||
# Update settings.gradle with Aliyun Maven mirror
|
||||
# Update settings.gradle with China Maven mirrors
|
||||
cat > settings.gradle << 'SETTINGS_EOF'
|
||||
pluginManagement {
|
||||
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()
|
||||
mavenCentral()
|
||||
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(
|
||||
providers.exec {
|
||||
@@ -187,6 +289,7 @@ jobs:
|
||||
plugins {
|
||||
id("com.facebook.react.settings")
|
||||
id("expo-autolinking-settings")
|
||||
id("org.gradle.toolchains.foojay-resolver-convention") version "0.5.0"
|
||||
}
|
||||
|
||||
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
|
||||
@@ -198,7 +301,7 @@ jobs:
|
||||
}
|
||||
expoAutolinking.useExpoModules()
|
||||
|
||||
rootProject.name = '萝卜社区'
|
||||
rootProject.name = '威友'
|
||||
|
||||
expoAutolinking.useExpoVersionCatalog()
|
||||
|
||||
@@ -206,18 +309,18 @@ jobs:
|
||||
includeBuild(expoAutolinking.reactNativeGradlePlugin)
|
||||
SETTINGS_EOF
|
||||
|
||||
# Update build.gradle with Aliyun Maven mirror
|
||||
# Update build.gradle with China Maven mirrors
|
||||
cat > build.gradle << 'BUILD_EOF'
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
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()
|
||||
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 {
|
||||
classpath('com.android.tools.build:gradle')
|
||||
@@ -228,12 +331,12 @@ jobs:
|
||||
|
||||
allprojects {
|
||||
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()
|
||||
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' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +363,16 @@ jobs:
|
||||
expo.useLegacyPackaging=false
|
||||
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)
|
||||
run: |
|
||||
cd android
|
||||
@@ -269,9 +382,45 @@ jobs:
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: carrot-bbs-android-release-apk
|
||||
name: withyou-android-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:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
10
.idea/.gitignore
generated
vendored
@@ -1,10 +0,0 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
# Screenshots
|
||||
screenshots/
|
||||
9
.idea/frontend.iml
generated
@@ -1,9 +0,0 @@
|
||||
<?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
@@ -1,448 +0,0 @@
|
||||
<?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
@@ -1,7 +0,0 @@
|
||||
<?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
@@ -1,8 +0,0 @@
|
||||
<?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
@@ -1,6 +0,0 @@
|
||||
<?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
Normal file
@@ -0,0 +1,376 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
# 架构修复方案文档
|
||||
|
||||
## 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,6 +10,7 @@ RUN npx expo export --platform web --output-dir dist-web
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist-web /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
const appJson = require('./app.json');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
const isDevVariant = process.env.APP_VARIANT === 'dev';
|
||||
const releaseApiBaseUrl = 'https://bbs.littlelan.cn/api/v1';
|
||||
const releaseApiBaseUrl = 'https://withyou.littlelan.cn/api/v1';
|
||||
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
|
||||
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) {
|
||||
const parsed = new URL(baseUrl);
|
||||
if (portOverride != null) {
|
||||
@@ -23,6 +34,29 @@ const devUpdatesUrl =
|
||||
|
||||
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 = {
|
||||
...expo,
|
||||
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
||||
@@ -35,15 +69,36 @@ module.exports = {
|
||||
checkAutomatically: 'ON_LOAD',
|
||||
fallbackToCacheTimeout: 0,
|
||||
},
|
||||
ios: expo.ios,
|
||||
ios: {
|
||||
...expo.ios,
|
||||
buildNumber: gitBuildSuffix,
|
||||
},
|
||||
android: {
|
||||
...expo.android,
|
||||
package: 'skin.carrot.bbs',
|
||||
package: 'cn.qczlit.withyou',
|
||||
versionCode: parseInt(gitBuildSuffix, 16),
|
||||
},
|
||||
// Web 端使用过滤后的插件
|
||||
plugins: filteredPlugins,
|
||||
extra: {
|
||||
...(expo.extra || {}),
|
||||
appVariant: isDevVariant ? 'dev' : 'release',
|
||||
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
|
||||
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
||||
},
|
||||
// Web 端配置
|
||||
...(isWeb && {
|
||||
web: {
|
||||
...expo.web,
|
||||
build: {
|
||||
...expo.web?.build,
|
||||
// 配置 Web 端别名,避免加载原生模块
|
||||
babel: {
|
||||
dangerouslyAddModulePathsToTranspile: [
|
||||
'react-native-webrtc',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
81
app.json
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "萝卜社区",
|
||||
"name": "威友",
|
||||
"slug": "qojo",
|
||||
"version": "1.0.11",
|
||||
"version": "0.0.2",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"scheme": "carrotbbs",
|
||||
"scheme": "withyou",
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
@@ -15,28 +15,32 @@
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"NSMicrophoneUsageDescription": "允许威友访问您的麦克风以进行语音通话",
|
||||
"UIBackgroundModes": [
|
||||
"fetch",
|
||||
"remote-notification",
|
||||
"fetch",
|
||||
"remote-notification"
|
||||
"remote-notification",
|
||||
"audio"
|
||||
],
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
},
|
||||
"bundleIdentifier": "skin.carrot.bbs"
|
||||
"bundleIdentifier": "cn.qczlit.weiyou"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"backgroundColor": "#0570F9",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "skin.carrot.bbs",
|
||||
"versionCode": 6,
|
||||
"jsEngine": "hermes",
|
||||
"package": "cn.qczlit.withyou",
|
||||
"versionCode": 7,
|
||||
"permissions": [
|
||||
"VIBRATE",
|
||||
"RECORD_AUDIO",
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
"WAKE_LOCK",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
@@ -52,13 +56,28 @@
|
||||
"android.permission.ACCESS_MEDIA_LOCATION",
|
||||
"android.permission.READ_MEDIA_IMAGES",
|
||||
"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": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"./plugins/withSigning",
|
||||
"./plugins/withMainActivityConfigChange",
|
||||
[
|
||||
"./plugins/withForegroundService",
|
||||
{
|
||||
"notificationTitle": "威友",
|
||||
"notificationBody": "正在后台同步消息",
|
||||
"notificationIcon": "ic_notification",
|
||||
"channelId": "withyou_sync_foreground",
|
||||
"channelName": "消息同步"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-router",
|
||||
{
|
||||
@@ -72,34 +91,28 @@
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": "允许萝卜社区访问您的相机以扫描二维码"
|
||||
"cameraPermission": "允许威友访问您的相机以扫描二维码"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-notifications",
|
||||
"expo-background-task",
|
||||
{
|
||||
"sounds": []
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-background-fetch",
|
||||
{
|
||||
"minimumInterval": 900
|
||||
"minimumInterval": 15
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
"photosPermission": "允许萝卜社区访问您的照片以发布内容",
|
||||
"savePhotosPermission": "允许萝卜社区保存照片到您的相册",
|
||||
"photosPermission": "允许威友访问您的照片以发布内容",
|
||||
"savePhotosPermission": "允许威友保存照片到您的相册",
|
||||
"isAccessMediaLocationEnabled": true
|
||||
}
|
||||
],
|
||||
[
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "允许萝卜社区访问您的照片以选择图片",
|
||||
"cameraPermission": "允许萝卜社区访问您的相机以拍摄图片",
|
||||
"photosPermission": "允许威友访问您的照片以选择图片",
|
||||
"cameraPermission": "允许威友访问您的相机以拍摄图片",
|
||||
"colors": {
|
||||
"cropToolbarColor": "#111827",
|
||||
"cropToolbarIconColor": "#ffffff",
|
||||
@@ -124,13 +137,29 @@
|
||||
"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": {
|
||||
"eas": {
|
||||
"projectId": "65540196-d37d-437b-8496-227df0317069"
|
||||
}
|
||||
},
|
||||
"jpushAppKey": "2cbe4da12e2c24aa3dca4610",
|
||||
"jpushChannel": "developer-default"
|
||||
},
|
||||
"owner": "qojo"
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Platform, useWindowDimensions } from 'react-native';
|
||||
import { Tabs, usePathname } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { useAppColors, shadows } from '../../../src/theme';
|
||||
import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
|
||||
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores';
|
||||
import { BREAKPOINTS } from '../../../src/hooks';
|
||||
import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores';
|
||||
|
||||
const TAB_BAR_HEIGHT = 56;
|
||||
const TAB_BAR_FLOATING_MARGIN = 12;
|
||||
@@ -19,10 +19,17 @@ export default function TabsLayout() {
|
||||
const pathname = usePathname();
|
||||
const unreadCount = useTotalUnreadCount();
|
||||
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
||||
const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress);
|
||||
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
|
||||
|
||||
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
|
||||
|
||||
const handleHomeTabPress = useCallback(() => {
|
||||
if (pathname === '/home') {
|
||||
triggerHomeTabPress();
|
||||
}
|
||||
}, [pathname, triggerHomeTabPress]);
|
||||
|
||||
const tabBarStyle = useMemo(() => {
|
||||
if (hideTabBar) {
|
||||
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
|
||||
@@ -81,6 +88,9 @@ export default function TabsLayout() {
|
||||
/>
|
||||
),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: handleHomeTabPress,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="apps"
|
||||
|
||||
11
app/(app)/(tabs)/apps/materials/_layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
5
app/(app)/(tabs)/apps/materials/detail.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MaterialDetailScreen } from '../../../../../src/screens/material';
|
||||
|
||||
export default function MaterialDetailRoute() {
|
||||
return <MaterialDetailScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/apps/materials/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MaterialsScreen } from '../../../../../src/screens/material';
|
||||
|
||||
export default function MaterialsRoute() {
|
||||
return <MaterialsScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/apps/materials/subject.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SubjectMaterialsScreen } from '../../../../../src/screens/material';
|
||||
|
||||
export default function SubjectMaterialsRoute() {
|
||||
return <SubjectMaterialsScreen />;
|
||||
}
|
||||
@@ -1,41 +1,28 @@
|
||||
import { useMemo } from 'react';
|
||||
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() {
|
||||
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 (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
...headerOptions,
|
||||
headerBackVisible: false,
|
||||
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="settings" options={{ headerShown: true, title: '设置' }} />
|
||||
<Stack.Screen name="edit-profile" options={{ title: '编辑资料' }} />
|
||||
<Stack.Screen name="account-security" options={{ title: '账号安全' }} />
|
||||
<Stack.Screen name="my-posts" options={{ title: '我的帖子' }} />
|
||||
<Stack.Screen name="bookmarks" options={{ title: '收藏' }} />
|
||||
<Stack.Screen name="notification-settings" options={{ title: '通知设置' }} />
|
||||
<Stack.Screen name="blocked-users" options={{ title: '黑名单' }} />
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="settings" />
|
||||
<Stack.Screen name="edit-profile" />
|
||||
<Stack.Screen name="account-security" />
|
||||
<Stack.Screen name="my-posts" />
|
||||
<Stack.Screen name="bookmarks" />
|
||||
<Stack.Screen name="notification-settings" />
|
||||
<Stack.Screen name="blocked-users" />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
5
app/(app)/(tabs)/profile/about.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AboutScreen } from '../../../../src/screens/profile/AboutScreen';
|
||||
|
||||
export default function AboutRoute() {
|
||||
return <AboutScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/account-deletion.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AccountDeletionScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function AccountDeletionRoute() {
|
||||
return <AccountDeletionScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/chat-settings.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ChatSettingsScreen } from '../../../../src/screens/profile/ChatSettingsScreen';
|
||||
|
||||
export default function ChatSettingsRoute() {
|
||||
return <ChatSettingsScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/data-storage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DataStorageScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function DataStorageRoute() {
|
||||
return <DataStorageScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/privacy-settings.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PrivacySettingsScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function PrivacySettingsRoute() {
|
||||
return <PrivacySettingsScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/privacy.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PrivacyPolicyScreen } from '../../../../src/screens/profile/PrivacyPolicyScreen';
|
||||
|
||||
export default function PrivacyPolicyRoute() {
|
||||
return <PrivacyPolicyScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/terms.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TermsOfServiceScreen } from '../../../../src/screens/profile/TermsOfServiceScreen';
|
||||
|
||||
export default function TermsOfServiceRoute() {
|
||||
return <TermsOfServiceScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/verification.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { VerificationSettingsScreen } from '@/screens/profile/VerificationSettingsScreen';
|
||||
|
||||
export default function VerificationSettingsPage() {
|
||||
return <VerificationSettingsScreen />;
|
||||
}
|
||||
@@ -1,19 +1,83 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { Redirect } from 'expo-router';
|
||||
|
||||
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
||||
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() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const userID = useAuthStore((s) => s.currentUser?.id);
|
||||
const deviceRegistered = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
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) {
|
||||
return <Redirect href="/login" />;
|
||||
}
|
||||
|
||||
return <AppRouteStack />;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { Redirect } from 'expo-router';
|
||||
|
||||
import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell';
|
||||
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
|
||||
import { BREAKPOINTS } from '../../src/hooks/useResponsive';
|
||||
import { BREAKPOINTS } from '../../src/hooks';
|
||||
import { messageManager, useAuthStore } from '../../src/stores';
|
||||
|
||||
export default function AppLayoutWeb() {
|
||||
|
||||
5
app/(auth)/verification-form.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { VerificationFormScreen } from '@/screens/auth/VerificationFormScreen';
|
||||
|
||||
export default function VerificationFormPage() {
|
||||
return <VerificationFormScreen />;
|
||||
}
|
||||
5
app/(auth)/verification-guide.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { VerificationGuideScreen } from '@/screens/auth/VerificationGuideScreen';
|
||||
|
||||
export default function VerificationGuidePage() {
|
||||
return <VerificationGuideScreen />;
|
||||
}
|
||||
177
app/_layout.tsx
@@ -6,21 +6,27 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { PaperProvider } from 'react-native-paper';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import * as SystemUI from 'expo-system-ui';
|
||||
import { useFonts } from 'expo-font';
|
||||
|
||||
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
|
||||
import { registerNotificationPresentationHandler } from '@/services/notification';
|
||||
import { api } from '@/services/core';
|
||||
import { wsService } from '@/services/core';
|
||||
import { EventSubscriber } from '../src/infrastructure/EventSubscriber';
|
||||
import {
|
||||
ThemeBootstrap,
|
||||
useAppColors,
|
||||
usePaperThemeFromStore,
|
||||
useResolvedColorScheme,
|
||||
} from '../src/theme';
|
||||
import { AppBackButton } from '../src/components/common';
|
||||
|
||||
import AppPromptBar from '../src/components/common/AppPromptBar';
|
||||
import AppDialogHost from '../src/components/common/AppDialogHost';
|
||||
import { installAlertOverride } from '../src/services/alertOverride';
|
||||
import { installAlertOverride } from '@/services/ui';
|
||||
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();
|
||||
|
||||
@@ -48,6 +54,34 @@ if (Platform.OS === 'web' && typeof document !== 'undefined') {
|
||||
}
|
||||
*:focus { 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);
|
||||
}
|
||||
@@ -94,15 +128,16 @@ function SessionGate({ children }: { children: React.ReactNode }) {
|
||||
|
||||
function NotificationBootstrap() {
|
||||
const appState = useRef<AppStateStatus>(AppState.currentState);
|
||||
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(null);
|
||||
const permissionRequested = useRef(false);
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const initNotifications = async () => {
|
||||
const { loadNotificationPreferences } = await import('../src/services/notificationPreferences');
|
||||
const { loadNotificationPreferences } = await import('@/services/notification');
|
||||
await loadNotificationPreferences();
|
||||
const { systemNotificationService } = await import('../src/services/systemNotificationService');
|
||||
const { systemNotificationService } = await import('@/services/notification');
|
||||
await systemNotificationService.initialize();
|
||||
const { initBackgroundService } = await import('../src/services/backgroundService');
|
||||
const { initBackgroundService } = await import('@/services/background');
|
||||
await initBackgroundService();
|
||||
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
@@ -112,26 +147,86 @@ function NotificationBootstrap() {
|
||||
appState.current = nextAppState;
|
||||
});
|
||||
|
||||
notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener(
|
||||
(response) => {
|
||||
void response.notification.request.content.data;
|
||||
}
|
||||
);
|
||||
// Listen for JPush notification taps — navigate to the relevant screen
|
||||
jpushService.onNotification((message) => {
|
||||
console.log('[NotificationBootstrap] JPush notification:', message.notificationEventType, message);
|
||||
if (message.notificationEventType !== 'notificationOpened') return;
|
||||
|
||||
const notificationReceivedSubscription = Notifications.addNotificationReceivedListener(
|
||||
(notification) => {
|
||||
void notification;
|
||||
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 {
|
||||
router.push('/messages/notifications');
|
||||
}
|
||||
} else {
|
||||
router.push('/messages/notifications');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
return () => {
|
||||
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();
|
||||
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;
|
||||
@@ -139,41 +234,42 @@ function NotificationBootstrap() {
|
||||
|
||||
function ThemedStack() {
|
||||
const router = useRouter();
|
||||
const colors = useAppColors();
|
||||
const resolved = useResolvedColorScheme();
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||||
<SystemChrome />
|
||||
<EventSubscriber />
|
||||
<SessionGate>
|
||||
<NotificationBootstrap />
|
||||
<APKUpdateBootstrap />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)" 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
|
||||
name="post/[postId]"
|
||||
options={{
|
||||
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()} />,
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="user/[userId]"
|
||||
options={{
|
||||
headerShown: true,
|
||||
title: '用户主页',
|
||||
headerStyle: { backgroundColor: colors.background.paper },
|
||||
headerTintColor: colors.text.primary,
|
||||
headerBackVisible: false,
|
||||
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
@@ -188,6 +284,16 @@ function ThemedProviders({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
export default function RootLayout() {
|
||||
const [fontsLoaded] = useFonts({});
|
||||
|
||||
if (!fontsLoaded) {
|
||||
return (
|
||||
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
|
||||
<ActivityIndicator />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
@@ -197,6 +303,9 @@ export default function RootLayout() {
|
||||
<ThemedStack />
|
||||
<AppPromptBar />
|
||||
<AppDialogHost />
|
||||
<IncomingCallModal />
|
||||
<CallScreen />
|
||||
<FloatingCallWindow />
|
||||
</QueryClientProvider>
|
||||
</ThemedProviders>
|
||||
</SafeAreaProvider>
|
||||
|
||||
@@ -7,5 +7,5 @@ export default function Index() {
|
||||
if (isAuthenticated) {
|
||||
return <Redirect href="/home" />;
|
||||
}
|
||||
return <Redirect href="/login" />;
|
||||
return <Redirect href="/welcome" />;
|
||||
}
|
||||
|
||||
5
app/privacy.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PrivacyPolicyScreen } from '../src/screens/profile/PrivacyPolicyScreen';
|
||||
|
||||
export default function PrivacyRoute() {
|
||||
return <PrivacyPolicyScreen />;
|
||||
}
|
||||
5
app/terms.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TermsOfServiceScreen } from '../src/screens/profile/TermsOfServiceScreen';
|
||||
|
||||
export default function TermsRoute() {
|
||||
return <TermsOfServiceScreen />;
|
||||
}
|
||||
7
app/trade/[tradeId].tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
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} />;
|
||||
}
|
||||
46
app/trade/edit/[tradeId].tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
app/welcome.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
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: 29 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.7 KiB |
BIN
assets/icon.png
|
Before Width: | Height: | Size: 895 KiB After Width: | Height: | Size: 385 KiB |
|
Before Width: | Height: | Size: 895 KiB After Width: | Height: | Size: 385 KiB |
3
eas.json
@@ -27,6 +27,9 @@
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
},
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
|
||||
@@ -1,11 +1,77 @@
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const path = require('path');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// Add wasm asset support
|
||||
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,
|
||||
// but there's currently no official alternative for custom dev server headers.
|
||||
// For production, configure COEP/COOP headers on your hosting platform.
|
||||
@@ -18,4 +84,4 @@ config.server.enhanceMiddleware = (middleware) => {
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
module.exports = config;
|
||||
19
nginx.conf
Normal file
@@ -0,0 +1,19 @@
|
||||
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": "carrot_bbs",
|
||||
"version": "1.0.1",
|
||||
"name": "with_you",
|
||||
"version": "0.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "carrot_bbs",
|
||||
"version": "1.0.1",
|
||||
"name": "with_you",
|
||||
"version": "0.0.2",
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
@@ -14,12 +14,11 @@
|
||||
"@react-navigation/material-top-tabs": "^7.4.16",
|
||||
"@react-navigation/native": "^7.1.31",
|
||||
"@react-navigation/native-stack": "^7.14.2",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@shopify/flash-list": "^2.3.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"axios": "^1.13.6",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~55.0.4",
|
||||
"expo-background-fetch": "~55.0.10",
|
||||
"expo-background-task": "~55.0.10",
|
||||
"expo-camera": "^55.0.10",
|
||||
"expo-constants": "~55.0.7",
|
||||
@@ -29,9 +28,10 @@
|
||||
"expo-haptics": "~55.0.8",
|
||||
"expo-image": "^55.0.5",
|
||||
"expo-image-picker": "^55.0.10",
|
||||
"expo-intent-launcher": "~55.0.9",
|
||||
"expo-linear-gradient": "^55.0.8",
|
||||
"expo-media-library": "~55.0.9",
|
||||
"expo-notifications": "^55.0.10",
|
||||
"expo-notifications": "~55.0.0",
|
||||
"expo-router": "^55.0.4",
|
||||
"expo-sqlite": "~55.0.10",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
@@ -39,6 +39,8 @@
|
||||
"expo-task-manager": "~55.0.9",
|
||||
"expo-updates": "^55.0.12",
|
||||
"expo-video": "^55.0.10",
|
||||
"jcore-react-native": "^2.3.5",
|
||||
"jpush-react-native": "^3.2.6",
|
||||
"katex": "^0.16.42",
|
||||
"markdown-it": "^14.1.1",
|
||||
"prismjs": "^1.30.0",
|
||||
@@ -51,8 +53,10 @@
|
||||
"react-native-reanimated": "^4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-share": "^12.2.6",
|
||||
"react-native-sse": "^1.2.1",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webrtc": "^124.0.7",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"zod": "^4.3.6",
|
||||
@@ -1675,17 +1679,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/image-utils": {
|
||||
"version": "0.8.12",
|
||||
"resolved": "https://registry.npmmirror.com/@expo/image-utils/-/image-utils-0.8.12.tgz",
|
||||
"integrity": "sha512-3KguH7kyKqq7pNwLb9j6BBdD/bjmNwXZG/HPWT6GWIXbwrvAJt2JNyYTP5agWJ8jbbuys1yuCzmkX+TU6rmI7A==",
|
||||
"version": "0.8.13",
|
||||
"resolved": "https://registry.npmmirror.com/@expo/image-utils/-/image-utils-0.8.13.tgz",
|
||||
"integrity": "sha512-1I//yBQeTY6p0u1ihqGNDAr35EbSG8uFEupFrIF0jd++h9EWH33521yZJU1yE+mwGlzCb61g3ehu78siMhXBlA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/require-utils": "^55.0.4",
|
||||
"@expo/spawn-async": "^1.7.2",
|
||||
"chalk": "^4.0.0",
|
||||
"getenv": "^2.0.0",
|
||||
"jimp-compact": "0.16.1",
|
||||
"parse-png": "^2.1.0",
|
||||
"resolve-from": "^5.0.0",
|
||||
"semver": "^7.6.0"
|
||||
}
|
||||
},
|
||||
@@ -2050,9 +2054,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/require-utils": {
|
||||
"version": "55.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/@expo/require-utils/-/require-utils-55.0.3.tgz",
|
||||
"integrity": "sha512-TS1m5tW45q4zoaTlt6DwmdYHxvFTIxoLrTHKOFrIirHIqIXnHCzpceg8wumiBi+ZXSaGY2gobTbfv+WVhJY6Fw==",
|
||||
"version": "55.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@expo/require-utils/-/require-utils-55.0.4.tgz",
|
||||
"integrity": "sha512-JAANvXqV7MOysWeVWgaiDzikoyDjJWOV/ulOW60Zb3kXJfrx2oZOtGtDXDFKD1mXuahQgoM5QOjuZhF7gFRNjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.20.0",
|
||||
@@ -3662,13 +3666,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@shopify/flash-list": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/@shopify/flash-list/-/flash-list-2.0.2.tgz",
|
||||
"integrity": "sha512-zhlrhA9eiuEzja4wxVvotgXHtqd3qsYbXkQ3rsBfOgbFA9BVeErpDE/yEwtlIviRGEqpuFj/oU5owD6ByaNX+w==",
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/@shopify/flash-list/-/flash-list-2.3.1.tgz",
|
||||
"integrity": "sha512-7oktg2NQR7KAODjFoDaWe8/OBzyYbdTE3zQTrUBMxjIbxHTHN7UXRX1hX3DHk8KvtkgQdRfZOV8Gjj2l4fGrXw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@babel/runtime": "*",
|
||||
"react": "*",
|
||||
@@ -5524,9 +5525,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-application": {
|
||||
"version": "55.0.10",
|
||||
"resolved": "https://registry.npmmirror.com/expo-application/-/expo-application-55.0.10.tgz",
|
||||
"integrity": "sha512-5ccf+S6hsQz+doi907TOJxKzV5AKgAgw004z4FoDWSoGhfab0LUPg6uyvOspuU4cbNvqw8EAy08hZbVO8nKc9Q==",
|
||||
"version": "55.0.14",
|
||||
"resolved": "https://registry.npmmirror.com/expo-application/-/expo-application-55.0.14.tgz",
|
||||
"integrity": "sha512-NgqDIt3eCf4aVLp1L6AcEanCYoyJeuBsGrgGSzOIvxAsOvp5X3SYKW3ROgpKUnLQEKMWlzwETpjsUGszcqkk8g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
@@ -5547,18 +5548,6 @@
|
||||
"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": {
|
||||
"version": "55.0.10",
|
||||
"resolved": "https://registry.npmmirror.com/expo-background-task/-/expo-background-task-55.0.10.tgz",
|
||||
@@ -5592,12 +5581,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-constants": {
|
||||
"version": "55.0.9",
|
||||
"resolved": "https://registry.npmmirror.com/expo-constants/-/expo-constants-55.0.9.tgz",
|
||||
"integrity": "sha512-iBiXjZeuU5S/8docQeNzsVvtDy4w0zlmXBpFEi1ypwugceEpdQQab65TVRbusXAcwpNVxCPMpNlDssYp0Pli2g==",
|
||||
"version": "55.0.15",
|
||||
"resolved": "https://registry.npmmirror.com/expo-constants/-/expo-constants-55.0.15.tgz",
|
||||
"integrity": "sha512-w394fcZLJjeKN+9ZnJzL/HiarE1nwZFDa+3S9frevh6Ur+MAAs9QDrcXhDrV8T3xqRzzYaqsP6Z8TFZ4efWN1A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/config": "~55.0.10",
|
||||
"@expo/env": "~2.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -5747,6 +5735,15 @@
|
||||
"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": {
|
||||
"version": "55.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz",
|
||||
@@ -5850,16 +5847,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/expo-notifications": {
|
||||
"version": "55.0.13",
|
||||
"resolved": "https://registry.npmmirror.com/expo-notifications/-/expo-notifications-55.0.13.tgz",
|
||||
"integrity": "sha512-vbtSBcMkYtNTO+6WKdeOzysOqvtmiq/sQrUKJpYcB75m9hBFcAfI2klpXdUiGg5kMr/ygBmFENSolQt1B9QY8A==",
|
||||
"version": "55.0.20",
|
||||
"resolved": "https://registry.npmmirror.com/expo-notifications/-/expo-notifications-55.0.20.tgz",
|
||||
"integrity": "sha512-ENwHZtr2ApR4VaqwwYluEi+ocip2rIkZfHQVi263fZXW3WWVuPa+VxWKtT0KLcvWYGld8lEqwAHWmFWPS6aG7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@expo/image-utils": "^0.8.12",
|
||||
"@expo/image-utils": "^0.8.13",
|
||||
"abort-controller": "^3.0.0",
|
||||
"badgin": "^1.1.5",
|
||||
"expo-application": "~55.0.10",
|
||||
"expo-constants": "~55.0.8"
|
||||
"expo-application": "~55.0.14",
|
||||
"expo-constants": "~55.0.15"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
@@ -7357,6 +7354,15 @@
|
||||
"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": {
|
||||
"version": "29.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz",
|
||||
@@ -7562,6 +7568,16 @@
|
||||
"@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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -9737,6 +9753,15 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-sse/-/react-native-sse-1.2.1.tgz",
|
||||
@@ -9789,6 +9814,55 @@
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"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": {
|
||||
"version": "13.16.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz",
|
||||
|
||||
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "carrot_bbs",
|
||||
"version": "1.0.1",
|
||||
"name": "with_you",
|
||||
"version": "0.0.2",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -20,12 +20,11 @@
|
||||
"@react-navigation/material-top-tabs": "^7.4.16",
|
||||
"@react-navigation/native": "^7.1.31",
|
||||
"@react-navigation/native-stack": "^7.14.2",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@shopify/flash-list": "^2.3.1",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"axios": "^1.13.6",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~55.0.4",
|
||||
"expo-background-fetch": "~55.0.10",
|
||||
"expo-background-task": "~55.0.10",
|
||||
"expo-camera": "^55.0.10",
|
||||
"expo-constants": "~55.0.7",
|
||||
@@ -35,9 +34,10 @@
|
||||
"expo-haptics": "~55.0.8",
|
||||
"expo-image": "^55.0.5",
|
||||
"expo-image-picker": "^55.0.10",
|
||||
"expo-intent-launcher": "~55.0.9",
|
||||
"expo-linear-gradient": "^55.0.8",
|
||||
"expo-media-library": "~55.0.9",
|
||||
"expo-notifications": "^55.0.10",
|
||||
"expo-notifications": "~55.0.0",
|
||||
"expo-router": "^55.0.4",
|
||||
"expo-sqlite": "~55.0.10",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
@@ -45,6 +45,8 @@
|
||||
"expo-task-manager": "~55.0.9",
|
||||
"expo-updates": "^55.0.12",
|
||||
"expo-video": "^55.0.10",
|
||||
"jcore-react-native": "^2.3.5",
|
||||
"jpush-react-native": "^3.2.6",
|
||||
"katex": "^0.16.42",
|
||||
"markdown-it": "^14.1.1",
|
||||
"prismjs": "^1.30.0",
|
||||
@@ -57,8 +59,10 @@
|
||||
"react-native-reanimated": "^4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-share": "^12.2.6",
|
||||
"react-native-sse": "^1.2.1",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webrtc": "^124.0.7",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"zod": "^4.3.6",
|
||||
|
||||
@@ -1,394 +0,0 @@
|
||||
# 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模块对齐。
|
||||
467
plugins/withForegroundService.js
Normal file
@@ -0,0 +1,467 @@
|
||||
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;
|
||||
283
plugins/withJPush.js
Normal file
@@ -0,0 +1,283 @@
|
||||
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;
|
||||
73
plugins/withMainActivityConfigChange.js
Normal file
@@ -0,0 +1,73 @@
|
||||
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;
|
||||
41
plugins/withSigning.js
Normal file
@@ -0,0 +1,41 @@
|
||||
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 {
|
||||
View,
|
||||
StyleSheet,
|
||||
@@ -187,7 +187,7 @@ export function AppDesktopShell() {
|
||||
>
|
||||
<View style={styles.sidebarHeader}>
|
||||
<MaterialCommunityIcons name="carrot" size={32} color={colors.primary.main} />
|
||||
{!isCollapsed && <Text style={styles.logoText}>胡萝卜BBS</Text>}
|
||||
{!isCollapsed && <Text style={styles.logoText}>威友</Text>}
|
||||
</View>
|
||||
<ScrollView style={styles.sidebarContent} showsVerticalScrollIndicator={false}>
|
||||
{NAV_ITEMS.map((item) => {
|
||||
|
||||
122
src/components/business/BlockEditor/BlockEditor.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
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;
|
||||
92
src/components/business/BlockEditor/ImageBlockView.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
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;
|
||||
276
src/components/business/BlockEditor/TextBlockInput.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
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;
|
||||
103
src/components/business/BlockEditor/blockEditorTypes.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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;
|
||||
}
|
||||
97
src/components/business/BlockEditor/blocksToSegments.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
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();
|
||||
}
|
||||
2
src/components/business/BlockEditor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './BlockEditor';
|
||||
export type { BlockEditorHandle } from './BlockEditor';
|
||||
315
src/components/business/BlockEditor/useBlockEditor.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
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 { View, TouchableOpacity, StyleSheet, Alert } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { formatChatTime } from '../../utils/formatTime';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { Comment, CommentImage } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
import { CompactImageGrid, ImageGridItem } from '../common';
|
||||
import PostContentRenderer from './PostContentRenderer';
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: Comment;
|
||||
onUserPress: () => void;
|
||||
onReply: () => void;
|
||||
onLike: () => void;
|
||||
onLike: (comment: Comment) => void; // 点赞回调,传入评论对象
|
||||
floorNumber?: number; // 楼层号
|
||||
isAuthor?: boolean; // 是否是楼主
|
||||
replyToUser?: string; // 回复给哪位用户
|
||||
@@ -29,6 +29,7 @@ interface CommentItemProps {
|
||||
onDelete?: (comment: Comment) => void; // 删除评论的回调
|
||||
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
|
||||
currentUserId?: string; // 当前用户ID,用于判断子评论作者
|
||||
onReport?: (comment: Comment) => void; // 举报评论的回调
|
||||
}
|
||||
|
||||
function createCommentItemStyles(colors: AppColors) {
|
||||
@@ -126,7 +127,7 @@ function createCommentItemStyles(colors: AppColors) {
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.lg,
|
||||
marginRight: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
paddingRight: spacing.xs,
|
||||
},
|
||||
@@ -141,7 +142,6 @@ function createCommentItemStyles(colors: AppColors) {
|
||||
paddingVertical: spacing.xs,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: colors.divider,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
subReplyItem: {
|
||||
flexDirection: 'row',
|
||||
@@ -152,8 +152,8 @@ function createCommentItemStyles(colors: AppColors) {
|
||||
marginBottom: 0,
|
||||
},
|
||||
subReplyBody: {
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: 20,
|
||||
fontSize: fontSizes.md,
|
||||
lineHeight: 22,
|
||||
marginTop: 2,
|
||||
},
|
||||
subReplyContent: {
|
||||
@@ -175,10 +175,10 @@ function createCommentItemStyles(colors: AppColors) {
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
replyToText: {
|
||||
fontSize: fontSizes.xs,
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
replyToName: {
|
||||
fontSize: fontSizes.xs,
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '500',
|
||||
},
|
||||
subReplyActions: {
|
||||
@@ -214,21 +214,11 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
onDelete,
|
||||
onImagePress,
|
||||
currentUserId,
|
||||
onReport,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
|
||||
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 => {
|
||||
@@ -477,7 +467,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
{targetNickname && (
|
||||
<>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.replyToText}>
|
||||
{' '}回复
|
||||
{' '}回复{' '}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.primary.main} style={styles.replyToName}>
|
||||
{targetNickname}
|
||||
@@ -487,7 +477,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
</View>
|
||||
{/* 显示回复内容(如果有文字) */}
|
||||
{reply.content ? (
|
||||
<Text variant="body" color={colors.text.primary} style={styles.subReplyBody}>
|
||||
<Text variant="body" color={colors.text.primary} selectable style={styles.subReplyBody}>
|
||||
{reply.content}
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -495,11 +485,30 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
{renderSubReplyImages(reply)}
|
||||
{/* 子评论操作按钮 */}
|
||||
<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
|
||||
style={styles.subActionButton}
|
||||
onPress={() => onReplyPress?.(reply)}
|
||||
>
|
||||
<MaterialCommunityIcons name="reply" size={12} color={colors.text.hint} />
|
||||
<MaterialCommunityIcons name="reply" size={14} color={colors.text.hint} />
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
||||
回复
|
||||
</Text>
|
||||
@@ -513,7 +522,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={isDeleting ? 'loading' : 'delete-outline'}
|
||||
size={12}
|
||||
size={14}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
||||
@@ -521,6 +530,17 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
</Text>
|
||||
</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>
|
||||
</TouchableOpacity>
|
||||
@@ -564,7 +584,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
{renderBadges()}
|
||||
<Text style={styles.metaDot}>·</Text>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
|
||||
{formatTime(comment.created_at || '')}
|
||||
{formatChatTime(comment.created_at || '')}
|
||||
</Text>
|
||||
</View>
|
||||
{renderFloorNumber()}
|
||||
@@ -581,9 +601,17 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
|
||||
{/* 评论文本 - 非气泡样式 */}
|
||||
<View style={styles.commentContent}>
|
||||
<Text variant="body" color={colors.text.primary} style={styles.text}>
|
||||
{comment.content}
|
||||
</Text>
|
||||
{comment.segments && comment.segments.length > 0 ? (
|
||||
<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}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 评论图片 */}
|
||||
@@ -592,7 +620,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
{/* 操作按钮 - 更紧凑 */}
|
||||
<View style={styles.actions}>
|
||||
{/* 点赞 */}
|
||||
<TouchableOpacity style={styles.actionButton} onPress={onLike}>
|
||||
<TouchableOpacity style={styles.actionButton} onPress={() => onLike(comment)}>
|
||||
<MaterialCommunityIcons
|
||||
name={comment.is_liked ? 'heart' : 'heart-outline'}
|
||||
size={14}
|
||||
@@ -615,6 +643,19 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
</Text>
|
||||
</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 && (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -16,13 +16,14 @@ import React, { useMemo, useState, memo, useEffect } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Alert, TextStyle } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { PostCardProps, PostCardAction } from './types';
|
||||
import { ImageGridItem, SmartImage } from '../../common';
|
||||
import { ImageGridItem, SmartImage, ImageGrid } from '../../common';
|
||||
import Text from '../../common/Text';
|
||||
import HighlightText from '../../common/HighlightText';
|
||||
import Avatar from '../../common/Avatar';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme';
|
||||
import { getPreviewImageUrl } from '../../../utils/imageHelper';
|
||||
import PostImages from './components/PostImages';
|
||||
import { useResponsive } from '../../../hooks/useResponsive';
|
||||
import PostContentRenderer from '../PostContentRenderer';
|
||||
import { useResponsive } from '../../../hooks';
|
||||
import { formatPostCreatedAtString } from '../../../core/entities/Post';
|
||||
|
||||
/** 列表相对时间:独立定时刷新,避免外层 PostCard.memo 挡住「分钟前」更新 */
|
||||
@@ -53,6 +54,13 @@ function imagesSignature(
|
||||
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 {
|
||||
if (!author) return '';
|
||||
return [author.id, author.nickname ?? '', author.avatar ?? ''].join('\u001f');
|
||||
@@ -84,6 +92,7 @@ function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolea
|
||||
if (prev.isAuthor !== next.isAuthor) return false;
|
||||
if (prev.style !== next.style) return false;
|
||||
if (featuresComparable(prev.features) !== featuresComparable(next.features)) return false;
|
||||
if (prev.highlightKeyword !== next.highlightKeyword) return false;
|
||||
|
||||
const a = prev.post;
|
||||
const b = next.post;
|
||||
@@ -107,6 +116,7 @@ function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolea
|
||||
if (!!a.is_favorited !== !!b.is_favorited) return false;
|
||||
if (authorSignature(a.author) !== authorSignature(b.author)) 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 (channelSignature(a.channel) !== channelSignature(b.channel)) return false;
|
||||
|
||||
@@ -206,6 +216,9 @@ function createPostCardStyles(colors: AppColors) {
|
||||
marginBottom: spacing.xs,
|
||||
lineHeight: fontSizes.md * 1.45,
|
||||
},
|
||||
contentRenderer: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
expandBtn: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
@@ -280,10 +293,10 @@ function createPostCardStyles(colors: AppColors) {
|
||||
marginLeft: 4,
|
||||
},
|
||||
activeActionText: {
|
||||
color: colors.error.main,
|
||||
color: colors.primary.main,
|
||||
},
|
||||
activeActionTextMerged: {
|
||||
color: colors.error.main,
|
||||
color: colors.primary.main,
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
@@ -374,6 +387,10 @@ function createPostCardStyles(colors: AppColors) {
|
||||
fontSize: fontSizes.sm,
|
||||
marginLeft: 4,
|
||||
},
|
||||
highlight: {
|
||||
backgroundColor: `${colors.warning.main}40`,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -391,6 +408,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
isAuthor = false,
|
||||
index,
|
||||
style,
|
||||
highlightKeyword,
|
||||
} = normalizedProps;
|
||||
|
||||
const colors = useAppColors();
|
||||
@@ -415,6 +433,22 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
const rawContent = post.content ?? '';
|
||||
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 handleUserPress = () => emit({ type: 'userPress' });
|
||||
const handleLike = () => emit({ type: post.is_liked ? 'unlike' : 'like' });
|
||||
@@ -444,17 +478,29 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const images: ImageGridItem[] = Array.isArray(post.images)
|
||||
? post.images.map((img) => ({
|
||||
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,
|
||||
}))
|
||||
: [];
|
||||
const segmentImages: ImageGridItem[] = (post.segments || [])
|
||||
.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) => ({
|
||||
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,
|
||||
}))
|
||||
: [],
|
||||
...segmentImages,
|
||||
];
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 10000) return `${(num / 10000).toFixed(1)}w`;
|
||||
@@ -463,6 +509,13 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
};
|
||||
|
||||
const getResponsiveMaxLength = () => {
|
||||
// 搜索高亮模式下展示更多内容
|
||||
if (highlightKeyword) {
|
||||
if (isWideScreen) return 500;
|
||||
if (isDesktop) return 400;
|
||||
if (isTablet) return 350;
|
||||
return 220;
|
||||
}
|
||||
if (isWideScreen) return 300;
|
||||
if (isDesktop) return 250;
|
||||
if (isTablet) return 200;
|
||||
@@ -470,22 +523,60 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
};
|
||||
|
||||
const contentNumberOfLines = useMemo(() => {
|
||||
if (highlightKeyword) {
|
||||
if (isWideScreen) return 12;
|
||||
if (isDesktop) return 10;
|
||||
if (isTablet) return 8;
|
||||
return 5;
|
||||
}
|
||||
if (isWideScreen) return 8;
|
||||
if (isDesktop) return 6;
|
||||
if (isTablet) return 5;
|
||||
return 3;
|
||||
}, [isWideScreen, isDesktop, isTablet]);
|
||||
}, [isWideScreen, isDesktop, isTablet, highlightKeyword]);
|
||||
|
||||
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 maxLength = getResponsiveMaxLength();
|
||||
if (value.length <= maxLength || isExpanded) return value;
|
||||
if (highlightKeyword) {
|
||||
return getSearchExcerpt(value, highlightKeyword);
|
||||
}
|
||||
return `${value.substring(0, maxLength)}...`;
|
||||
};
|
||||
|
||||
const shouldTruncate = !showGrid && !isCompact && rawContent.length > getResponsiveMaxLength();
|
||||
const shouldTruncate = !showGrid && !isCompact && displayContent.length > getResponsiveMaxLength();
|
||||
const content = useMemo(
|
||||
() => (showGrid || isCompact ? rawContent : getTruncatedContent(rawContent)),
|
||||
[rawContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
|
||||
() => (showGrid || isCompact ? displayContent : getTruncatedContent(displayContent)),
|
||||
[displayContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
|
||||
);
|
||||
|
||||
const renderImages = () => {
|
||||
@@ -507,10 +598,24 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
}
|
||||
|
||||
return (
|
||||
<PostImages
|
||||
images={post.images || []}
|
||||
displayMode="list"
|
||||
<ImageGrid
|
||||
images={images.map(img => ({
|
||||
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"
|
||||
usePreview={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -556,7 +661,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
) : (
|
||||
<View style={styles.gridNoImagePreview}>
|
||||
<Text style={styles.gridNoImageText} numberOfLines={6}>
|
||||
{contentPreview}
|
||||
{highlightKeyword ? <HighlightText text={contentPreview} keyword={highlightKeyword} style={styles.highlight} /> : contentPreview}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
@@ -570,7 +675,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
|
||||
{!!post.title && (
|
||||
<Text style={styles.gridTitleMain} numberOfLines={hasImage ? 2 : 3}>
|
||||
{post.title}
|
||||
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} style={styles.highlight} /> : post.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -589,8 +694,14 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
<Text style={styles.gridUsername} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.gridLikeArea}>
|
||||
<MaterialCommunityIcons name="heart-outline" size={14} color={colors.text.secondary} />
|
||||
<Text style={styles.gridLikeCount}>{formatNumber(post.likes_count || 0)}</Text>
|
||||
<MaterialCommunityIcons
|
||||
name={post.is_liked ? 'thumb-up' : 'thumb-up-outline'}
|
||||
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>
|
||||
</TouchableOpacity>
|
||||
@@ -636,7 +747,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
|
||||
{!!post.title && (
|
||||
<Text style={styles.title} numberOfLines={isCompact ? 2 : undefined}>
|
||||
{post.title}
|
||||
{highlightKeyword ? <HighlightText text={post.title} keyword={highlightKeyword} style={styles.highlight} /> : post.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -646,7 +757,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
style={styles.content}
|
||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||
>
|
||||
{content}
|
||||
{highlightKeyword ? <HighlightText text={content} keyword={highlightKeyword} style={styles.highlight} /> : content}
|
||||
</Text>
|
||||
{shouldTruncate && (
|
||||
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
|
||||
@@ -656,6 +767,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 仅当 segments 中不含 image 时才渲染独立的图片区域 */}
|
||||
{renderImages()}
|
||||
{renderTopComment()}
|
||||
|
||||
@@ -677,9 +789,9 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
<View style={styles.actionButtons}>
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleLike}>
|
||||
<MaterialCommunityIcons
|
||||
name={post.is_liked ? 'heart' : 'heart-outline'}
|
||||
name={post.is_liked ? 'thumb-up' : 'thumb-up-outline'}
|
||||
size={18}
|
||||
color={post.is_liked ? colors.error.main : colors.text.secondary}
|
||||
color={post.is_liked ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
<Text style={post.is_liked ? styles.activeActionTextMerged : styles.actionText}>
|
||||
{post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'}
|
||||
@@ -694,7 +806,7 @@ const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.actionBtn} onPress={handleBookmark}>
|
||||
<MaterialCommunityIcons
|
||||
name={post.is_favorited ? 'bookmark' : 'bookmark-outline'}
|
||||
name={post.is_favorited ? 'star' : 'star-outline'}
|
||||
size={18}
|
||||
color={post.is_favorited ? colors.warning.main : colors.text.secondary}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { spacing, borderRadius } from '../../../../theme';
|
||||
import { useResponsive } from '../../../../hooks/useResponsive';
|
||||
import { useResponsive } from '../../../../hooks';
|
||||
import { ImageGrid, ImageGridItem } from '../../../common';
|
||||
import { PostImagesProps } from '../types';
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ export type PostCardActionType =
|
||||
| 'unbookmark' // 取消收藏
|
||||
| 'share' // 分享
|
||||
| 'imagePress' // 点击图片
|
||||
| 'delete'; // 删除
|
||||
| 'delete' // 删除
|
||||
| 'report'; // 举报
|
||||
|
||||
/**
|
||||
* Action payload 类型
|
||||
@@ -117,6 +118,9 @@ export interface PostCardProps {
|
||||
/** 索引(用于虚拟化列表优化) */
|
||||
index?: number;
|
||||
|
||||
/** 搜索高亮关键词 */
|
||||
highlightKeyword?: string;
|
||||
|
||||
/** 自定义样式 */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
@@ -163,7 +167,7 @@ export interface PostContentProps {
|
||||
* PostImages 子组件 Props
|
||||
*/
|
||||
export interface PostImagesProps {
|
||||
images: PostImageDTO[];
|
||||
images: (PostImageDTO | { id?: string; url: string; thumbnail_url?: string; preview_url?: string; preview_url_large?: string; width?: number; height?: number })[];
|
||||
displayMode: 'list' | 'grid';
|
||||
onImagePress: (images: ImageGridItem[], index: number) => void;
|
||||
}
|
||||
|
||||
299
src/components/business/PostContentRenderer.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 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;
|
||||
419
src/components/business/PostMentionInput.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* 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;
|
||||
124
src/components/business/PostRefCard.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
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,12 +7,13 @@ import {
|
||||
Alert,
|
||||
Modal,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
@@ -133,22 +134,85 @@ function createQrScannerStyles(colors: AppColors) {
|
||||
fontSize: fontSizes.md,
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||
const router = useRouter();
|
||||
// Web 端不支持扫码组件
|
||||
const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||
const themeColors = useAppColors();
|
||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [scanned, setScanned] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setScanned(false);
|
||||
if (!permission?.granted) {
|
||||
requestPermission();
|
||||
}
|
||||
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 themeColors = useAppColors();
|
||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||
|
||||
// 动态导入 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);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && Platform.OS !== 'web') {
|
||||
import('expo-camera').then((module) => {
|
||||
setCameraModule(module);
|
||||
module.Camera.getCameraPermissionsAsync().then((result) => {
|
||||
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]);
|
||||
|
||||
@@ -157,7 +221,7 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
setScanned(true);
|
||||
onClose();
|
||||
|
||||
if (data.startsWith('carrotbbs://qrcode/login')) {
|
||||
if (data.startsWith('withyou://qrcode/login')) {
|
||||
const sessionId = extractSessionId(data);
|
||||
if (sessionId) {
|
||||
router.push(hrefs.hrefQrLoginConfirm(sessionId));
|
||||
@@ -178,7 +242,7 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
}
|
||||
};
|
||||
|
||||
if (!permission?.granted) {
|
||||
if (!cameraModule || permissionStatus !== 'granted') {
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
@@ -192,7 +256,16 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
<View style={styles.permissionContainer}>
|
||||
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
|
||||
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
||||
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
|
||||
<TouchableOpacity
|
||||
style={styles.permissionButton}
|
||||
onPress={() => {
|
||||
if (cameraModule) {
|
||||
cameraModule.Camera.requestCameraPermissionsAsync().then((result) => {
|
||||
setPermissionStatus(result.granted ? 'granted' : 'denied');
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={styles.permissionButtonText}>授予权限</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -201,6 +274,8 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
);
|
||||
}
|
||||
|
||||
const { CameraView } = cameraModule;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
@@ -243,4 +318,9 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
);
|
||||
};
|
||||
|
||||
// 根据平台导出不同组件
|
||||
export const QRCodeScanner: React.FC<QRCodeScannerProps> = Platform.OS === 'web'
|
||||
? QRCodeScannerWeb
|
||||
: QRCodeScannerNative;
|
||||
|
||||
export default QRCodeScanner;
|
||||
|
||||
493
src/components/business/ReportDialog/ReportDialog.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* 举报对话框组件
|
||||
* 支持举报帖子、评论、消息
|
||||
* 提供友好的用户界面和流畅的交互体验
|
||||
*/
|
||||
|
||||
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;
|
||||
2
src/components/business/ReportDialog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './ReportDialog';
|
||||
export type { ReportDialogProps } from './ReportDialog';
|
||||
@@ -16,59 +16,52 @@ interface SearchBarProps {
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
autoFocus?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function createSearchBarStyles(colors: AppColors) {
|
||||
function createSearchBarStyles(colors: AppColors, compact: boolean) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
backgroundColor: `${colors.primary.main}08`,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.xs,
|
||||
height: 46,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
paddingHorizontal: compact ? spacing.sm : spacing.md,
|
||||
height: compact ? 38 : 48,
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
containerFocused: {
|
||||
borderColor: colors.primary.main,
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
borderColor: `${colors.primary.main}50`,
|
||||
backgroundColor: `${colors.primary.main}10`,
|
||||
},
|
||||
searchIconWrap: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
marginLeft: spacing.xs,
|
||||
marginRight: spacing.xs,
|
||||
width: compact ? 22 : 32,
|
||||
height: compact ? 22 : 32,
|
||||
marginLeft: compact ? 2 : 0,
|
||||
marginRight: compact ? 6 : spacing.sm,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}12`,
|
||||
backgroundColor: 'transparent',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
searchIconWrapFocused: {
|
||||
backgroundColor: `${colors.primary.main}1A`,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
fontSize: compact ? fontSizes.md : fontSizes.lg,
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.sm + 1,
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: compact ? 0 : spacing.sm,
|
||||
paddingHorizontal: compact ? 2 : 0,
|
||||
fontWeight: '500',
|
||||
},
|
||||
clearButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
marginHorizontal: spacing.xs,
|
||||
width: 22,
|
||||
height: 22,
|
||||
marginLeft: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}14`,
|
||||
backgroundColor: `${colors.text.secondary}20`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@@ -83,9 +76,10 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
onFocus,
|
||||
onBlur,
|
||||
autoFocus = false,
|
||||
compact = false,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createSearchBarStyles(colors), [colors]);
|
||||
const styles = useMemo(() => createSearchBarStyles(colors, compact), [colors, compact]);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const handleFocus = () => {
|
||||
@@ -103,7 +97,7 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
<View style={[styles.searchIconWrap, isFocused && styles.searchIconWrapFocused]}>
|
||||
<MaterialCommunityIcons
|
||||
name="magnify"
|
||||
size={18}
|
||||
size={compact ? 16 : 18}
|
||||
color={isFocused ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
55
src/components/business/ShareSheet/ShareSheet.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
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;
|
||||
2
src/components/business/ShareSheet/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './ShareSheet';
|
||||
export type { ShareSheetProps } from './ShareSheet';
|
||||
@@ -1,15 +1,14 @@
|
||||
/**
|
||||
* SystemMessageItem 系统消息项组件 - 美化版
|
||||
* SystemMessageItem 系统消息项组件 - 扁平化风格
|
||||
* 根据系统消息类型显示不同图标和内容
|
||||
* 采用卡片式设计,符合胡萝卜BBS整体风格
|
||||
* 与登录、注册、设置页面风格保持一致
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { formatChatTime } from '../../utils/formatTime';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
@@ -124,17 +123,13 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: spacing.md,
|
||||
padding: 16,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginHorizontal: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
...shadows.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider || '#E5E5EA',
|
||||
},
|
||||
unreadContainer: {
|
||||
backgroundColor: colors.primary.light + '08',
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.primary.main,
|
||||
backgroundColor: colors.primary.main + '0A',
|
||||
},
|
||||
unreadIndicator: {
|
||||
position: 'absolute',
|
||||
@@ -143,19 +138,19 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
marginTop: -4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: borderRadius.full,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
iconContainer: {
|
||||
marginRight: spacing.md,
|
||||
marginRight: 14,
|
||||
},
|
||||
avatarWrapper: {
|
||||
position: 'relative',
|
||||
},
|
||||
iconWrapper: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: borderRadius.lg,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
@@ -163,9 +158,9 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: borderRadius.full,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
@@ -180,17 +175,17 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
marginBottom: 4,
|
||||
},
|
||||
titleLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
marginRight: 8,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '500',
|
||||
fontSize: fontSizes.md,
|
||||
fontSize: 15,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
unreadTitle: {
|
||||
@@ -200,10 +195,10 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
marginLeft: spacing.xs,
|
||||
borderRadius: 4,
|
||||
marginLeft: 6,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 10,
|
||||
@@ -211,55 +206,59 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
marginLeft: 2,
|
||||
},
|
||||
time: {
|
||||
fontSize: fontSizes.sm,
|
||||
fontSize: 13,
|
||||
flexShrink: 0,
|
||||
color: colors.text.hint,
|
||||
},
|
||||
messageContent: {
|
||||
lineHeight: 20,
|
||||
fontSize: fontSizes.sm,
|
||||
fontSize: 14,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
extraInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
backgroundColor: colors.primary.light + '12',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.md,
|
||||
marginTop: 8,
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 8,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
extraText: {
|
||||
marginLeft: spacing.xs,
|
||||
marginLeft: 6,
|
||||
flex: 1,
|
||||
fontWeight: '500',
|
||||
fontSize: 13,
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
marginTop: 12,
|
||||
gap: 10,
|
||||
},
|
||||
actionBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
gap: spacing.xs,
|
||||
gap: 4,
|
||||
},
|
||||
rejectBtn: {
|
||||
borderColor: `${colors.error.main}55`,
|
||||
backgroundColor: `${colors.error.main}18`,
|
||||
borderColor: colors.error.main + '40',
|
||||
backgroundColor: colors.error.light + '20',
|
||||
},
|
||||
approveBtn: {
|
||||
borderColor: `${colors.success.main}55`,
|
||||
backgroundColor: `${colors.success.main}18`,
|
||||
borderColor: colors.success.main + '40',
|
||||
backgroundColor: colors.success.light + '20',
|
||||
},
|
||||
actionBtnText: {
|
||||
fontWeight: '500',
|
||||
fontSize: 13,
|
||||
},
|
||||
arrowContainer: {
|
||||
marginLeft: spacing.sm,
|
||||
marginLeft: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 20,
|
||||
@@ -329,18 +328,6 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
const colors = useAppColors();
|
||||
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 title = getSystemMessageTitle(message);
|
||||
const { extra_data } = message;
|
||||
@@ -441,7 +428,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
{getStatusBadge()}
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.time}>
|
||||
{formatTime(message.created_at)}
|
||||
{formatChatTime(message.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -135,26 +135,21 @@ function createTabBarStyles(colors: AppColors) {
|
||||
modernContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.xl,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginVertical: spacing.md,
|
||||
padding: spacing.xs,
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
alignItems: 'center',
|
||||
paddingRight: spacing.xs,
|
||||
},
|
||||
modernTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingVertical: spacing.md,
|
||||
position: 'relative',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
modernTabActive: {
|
||||
backgroundColor: colors.primary.main + '15',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
modernTabContent: {
|
||||
flexDirection: 'row',
|
||||
@@ -169,15 +164,17 @@ function createTabBarStyles(colors: AppColors) {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
modernTabTextActive: {
|
||||
fontWeight: '700',
|
||||
fontWeight: '600',
|
||||
},
|
||||
modernTabIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
width: 20,
|
||||
bottom: 0,
|
||||
left: '25%',
|
||||
right: '25%',
|
||||
height: 3,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderRadius: borderRadius.full,
|
||||
borderTopLeftRadius: borderRadius.sm,
|
||||
borderTopRightRadius: borderRadius.sm,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -206,7 +203,7 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
key={index}
|
||||
style={[styles.modernTab, isActive && styles.modernTabActive]}
|
||||
onPress={() => onTabChange(index)}
|
||||
activeOpacity={0.85}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.modernTabContent}>
|
||||
{icon && (
|
||||
|
||||
382
src/components/business/TradeCard/TradeCard.tsx
Normal file
@@ -0,0 +1,382 @@
|
||||
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;
|
||||
@@ -22,3 +22,10 @@ export { default as TabBar } from './TabBar';
|
||||
export { default as VoteCard } from './VoteCard';
|
||||
export { default as VoteEditor } from './VoteEditor';
|
||||
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';
|
||||
|
||||
412
src/components/call/CallScreen.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
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;
|
||||
160
src/components/call/CallScreen.web.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
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;
|
||||
159
src/components/call/FloatingCallWindow.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
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;
|
||||
279
src/components/call/IncomingCallModal.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
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;
|
||||
3
src/components/call/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as CallScreen } from './CallScreen';
|
||||
export { default as IncomingCallModal } from './IncomingCallModal';
|
||||
export { default as FloatingCallWindow } from './FloatingCallWindow';
|
||||
@@ -15,12 +15,14 @@ import {
|
||||
Modal,
|
||||
Pressable,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import {
|
||||
useResponsive,
|
||||
useBreakpointGTE,
|
||||
FineBreakpointKey,
|
||||
} from '../../hooks/useResponsive';
|
||||
} from '../../hooks';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
export interface AdaptiveLayoutProps {
|
||||
/** 主内容区 */
|
||||
@@ -159,6 +161,9 @@ export function AdaptiveLayout({
|
||||
// 抽屉动画
|
||||
useEffect(() => {
|
||||
if (isDrawerOpen) {
|
||||
if (isDrawerOpen) {
|
||||
blurActiveElement();
|
||||
}
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 1,
|
||||
@@ -359,44 +364,4 @@ 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;
|
||||
|
||||
@@ -2,56 +2,55 @@ import React from 'react';
|
||||
import { TouchableOpacity, StyleProp, ViewStyle, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { borderRadius, spacing, useAppColors } from '../../theme';
|
||||
import { useAppColors } from '../../theme';
|
||||
|
||||
type AppBackButtonIcon = 'arrow-left' | 'close';
|
||||
type AppBackButtonIcon = 'chevron-left' | 'arrow-left' | 'close';
|
||||
|
||||
interface AppBackButtonProps {
|
||||
onPress: () => void;
|
||||
icon?: AppBackButtonIcon;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
iconColor?: string;
|
||||
backgroundColor?: string;
|
||||
hitSlop?: number;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
const AppBackButton: React.FC<AppBackButtonProps> = ({
|
||||
onPress,
|
||||
icon = 'arrow-left',
|
||||
icon = 'chevron-left',
|
||||
style,
|
||||
iconColor: iconColorProp,
|
||||
backgroundColor: backgroundColorProp,
|
||||
hitSlop = 8,
|
||||
testID,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const iconColor = iconColorProp ?? colors.text.primary;
|
||||
const backgroundColor = backgroundColorProp ?? colors.background.paper;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
style={[styles.button, { backgroundColor }, style]}
|
||||
activeOpacity={0.75}
|
||||
style={[styles.button, style]}
|
||||
activeOpacity={0.6}
|
||||
hitSlop={hitSlop}
|
||||
testID={testID}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={icon === 'close' ? '关闭' : '返回'}
|
||||
>
|
||||
<MaterialCommunityIcons name={icon} size={22} color={iconColor} />
|
||||
<MaterialCommunityIcons name={icon} size={36} color={iconColor} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
button: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.full,
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.xs,
|
||||
marginLeft: -12,
|
||||
marginRight: 0,
|
||||
padding: 0,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View, Platform } from 'react-native';
|
||||
import type { AlertButton } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
import { bindDialogListener, DialogPayload } from '../../services/dialogService';
|
||||
import { bindDialogListener, DialogPayload } from '@/services/ui';
|
||||
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
function createDialogHostStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
@@ -105,6 +106,12 @@ const AppDialogHost: React.FC = () => {
|
||||
return unbind;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (dialog) {
|
||||
blurActiveElement();
|
||||
}
|
||||
}, [dialog]);
|
||||
|
||||
const actions = useMemo<AlertButton[]>(() => {
|
||||
if (!dialog?.actions?.length) return [{ text: '确定' }];
|
||||
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 { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { bindPromptListener, PromptPayload, PromptType } from '../../services/promptService';
|
||||
import { bindPromptListener, PromptPayload, PromptType } from '@/services/ui';
|
||||
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
|
||||
|
||||
interface PromptState extends PromptPayload {
|
||||
|
||||
54
src/components/common/HighlightText.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
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,6 +14,7 @@ import {
|
||||
Text,
|
||||
StatusBar,
|
||||
Alert,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import {
|
||||
@@ -32,6 +33,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import { spacing, borderRadius, fontSizes } from '../../theme';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
@@ -126,6 +128,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
// 打开/关闭时重置状态
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
blurActiveElement();
|
||||
setCurrentIndex(initialIndex);
|
||||
setShowControls(true);
|
||||
setError(false);
|
||||
@@ -215,7 +218,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
const allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
const fileExt = allowedExts.includes(ext) ? ext : 'jpg';
|
||||
|
||||
const fileName = `carrot_${Date.now()}.${fileExt}`;
|
||||
const fileName = `withyou_${Date.now()}.${fileExt}`;
|
||||
const destination = new File(Paths.cache, fileName);
|
||||
|
||||
// File.downloadFileAsync 是新版 expo-file-system/next 的静态方法
|
||||
@@ -358,7 +361,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
onRequestClose={requestClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<GestureHandlerRootView style={styles.root} data-image-viewer="true">
|
||||
<View style={[styles.container, { backgroundColor: `rgba(0, 0, 0, ${backgroundOpacity})` }]}>
|
||||
{/* 顶部控制栏 */}
|
||||
{showControls && (
|
||||
|
||||
@@ -132,10 +132,10 @@ function createImageGridStyles(colors: AppColors) {
|
||||
aspectRatio: 1,
|
||||
},
|
||||
gridItem2: {
|
||||
width: '49%',
|
||||
width: '48.5%',
|
||||
},
|
||||
gridItem3: {
|
||||
width: '32.5%',
|
||||
width: '31.8%',
|
||||
},
|
||||
masonryContainer: {
|
||||
flexDirection: 'row',
|
||||
|
||||
31
src/components/common/PagerView.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
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;
|
||||
34
src/components/common/PagerView.web.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
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 { View, StyleProp, ViewStyle } from 'react-native';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
import { useResponsive } from '../../hooks';
|
||||
|
||||
export interface ResponsiveContainerProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
useColumnCount,
|
||||
useResponsiveSpacing,
|
||||
FineBreakpointKey,
|
||||
} from '../../hooks/useResponsive';
|
||||
} from '../../hooks';
|
||||
|
||||
export interface ResponsiveGridProps {
|
||||
/** 子元素 */
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
/**
|
||||
* 响应式堆叠布局组件
|
||||
* 移动端垂直堆叠,平板/桌面端水平排列
|
||||
*/
|
||||
|
||||
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;
|
||||
55
src/components/common/SimpleHeader.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
200
src/components/common/StepIndicator.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 步骤指示器组件 - 极简扁平设计
|
||||
* 与下方表单风格统一
|
||||
*/
|
||||
|
||||
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 { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native';
|
||||
import { useAppColors, fontSizes } from '../../theme';
|
||||
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle, Platform } from 'react-native';
|
||||
import { useAppColors, fontSizes, fontWeights } from '../../theme';
|
||||
|
||||
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
|
||||
|
||||
@@ -15,39 +15,46 @@ interface CustomTextProps extends Omit<TextProps, 'style'> {
|
||||
color?: string;
|
||||
numberOfLines?: number;
|
||||
onPress?: () => void;
|
||||
style?: TextStyle | TextStyle[];
|
||||
style?: TextStyle | TextStyle[] | (TextStyle | undefined)[];
|
||||
weight?: 'regular' | 'medium' | 'semibold' | 'bold';
|
||||
}
|
||||
|
||||
const variantStyles: Record<TextVariant, object> = {
|
||||
h1: {
|
||||
fontSize: fontSizes['4xl'],
|
||||
fontWeight: '700',
|
||||
lineHeight: fontSizes['4xl'] * 1.4,
|
||||
fontWeight: fontWeights.bold,
|
||||
lineHeight: fontSizes['4xl'] * 1.3,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
h2: {
|
||||
fontSize: fontSizes['3xl'],
|
||||
fontWeight: '600',
|
||||
lineHeight: fontSizes['3xl'] * 1.4,
|
||||
fontWeight: fontWeights.semibold,
|
||||
lineHeight: fontSizes['3xl'] * 1.3,
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
h3: {
|
||||
fontSize: fontSizes['2xl'],
|
||||
fontWeight: '600',
|
||||
fontWeight: fontWeights.semibold,
|
||||
lineHeight: fontSizes['2xl'] * 1.3,
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
body: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '400',
|
||||
lineHeight: fontSizes.md * 1.5,
|
||||
fontWeight: fontWeights.regular,
|
||||
lineHeight: fontSizes.md * 1.6,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
caption: {
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '400',
|
||||
lineHeight: fontSizes.sm * 1.4,
|
||||
fontWeight: fontWeights.regular,
|
||||
lineHeight: fontSizes.sm * 1.5,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
label: {
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '500',
|
||||
fontWeight: fontWeights.medium,
|
||||
lineHeight: fontSizes.xs * 1.4,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,6 +62,7 @@ const Text: React.FC<CustomTextProps> = ({
|
||||
children,
|
||||
variant = 'body',
|
||||
color,
|
||||
weight,
|
||||
numberOfLines,
|
||||
onPress,
|
||||
style,
|
||||
@@ -64,6 +72,7 @@ const Text: React.FC<CustomTextProps> = ({
|
||||
const textStyle = [
|
||||
styles.base,
|
||||
variantStyles[variant],
|
||||
weight ? { fontWeight: fontWeights[weight] } : null,
|
||||
color ? { color } : { color: colors.text.primary },
|
||||
style,
|
||||
];
|
||||
|
||||
266
src/components/common/VerificationCodeInput.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* 验证码输入组件
|
||||
* 参考设计: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;
|
||||