diff --git a/.github/workflows/app-expo-deploy.yml b/.github/workflows/app-expo-deploy.yml index b27b1a6..9c55039 100644 --- a/.github/workflows/app-expo-deploy.yml +++ b/.github/workflows/app-expo-deploy.yml @@ -1,6 +1,12 @@ # App Expo:CI 内生成 Android 签名 APK(expo prebuild + Gradle assembleRelease) # 使用 app-expo/plugins/withAndroidReleaseSigning:在 android/app 放置 keystore.properties + jks 后打 release 包。 # +# 产物说明: +# - 以下为「真机向」release APK;Google Play 上架更推荐 AAB(按设备 ABI 下发)。 +# - 构建步骤会将 reactNativeArchitectures 设为仅 arm64-v8a(更小;不含 x86/x86_64,多数模拟器无法安装)。 +# - 若需兼容旧 32 位 ARM 设备,可将该属性改为 armeabi-v7a,arm64-v8a(体积会增大)。 +# - 若需用 x86 模拟器验证此 APK,需改回含 x86 的 ABI 或另建 job。 +# # 环境映射(按触发源自动推断): # main → dev (开发 + 内部测试) # v*.*.* → prod (正式发布 + GitHub Release 附带 APK) @@ -41,6 +47,8 @@ concurrency: env: APP_NAME: app-expo + EXPO_NO_TELEMETRY: 1 + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: deploy: @@ -51,7 +59,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: ${{ startsWith(github.ref, 'refs/tags/') && '0' || '1' }} @@ -67,9 +75,9 @@ jobs: fi >> $GITHUB_OUTPUT - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: - node-version: 20 + node-version: 22.22.1 cache: npm cache-dependency-path: app-expo/package-lock.json @@ -116,18 +124,29 @@ jobs: echo "版本名: ${VERSION}, versionCode: ${VERSION_CODE}, tag: ${TAG_NAME}" - name: Set up JDK - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: java-version: '17' distribution: 'temurin' - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 - name: Expo prebuild (Android) working-directory: app-expo run: npx expo prebuild --platform android --clean + - name: Release APK — arm64 only (real devices; smaller) + working-directory: app-expo/android + run: | + set -euo pipefail + if grep -q '^reactNativeArchitectures=' gradle.properties; then + sed -i 's/^reactNativeArchitectures=.*/reactNativeArchitectures=arm64-v8a/' gradle.properties + else + echo 'reactNativeArchitectures=arm64-v8a' >> gradle.properties + fi + grep -E '^reactNativeArchitectures=' gradle.properties + - name: Decode keystore working-directory: app-expo/android/app run: | @@ -166,7 +185,7 @@ jobs: du -h "$FINAL_PATH" - name: Upload APK artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: ${{ steps.version.outputs.apk_name }} path: app-expo/android/${{ steps.apk.outputs.apk_path }} @@ -198,7 +217,7 @@ jobs: - name: Create GitHub Release (prod) if: steps.env.outputs.env == 'prod' - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v2.5.0 with: tag_name: ${{ steps.version.outputs.tag_name }} name: "${{ env.APP_NAME }} ${{ steps.version.outputs.tag_name }}" diff --git a/.github/workflows/docker-build-deploy.yml b/.github/workflows/docker-build-deploy.yml index e7703ed..4e7c63a 100644 --- a/.github/workflows/docker-build-deploy.yml +++ b/.github/workflows/docker-build-deploy.yml @@ -41,6 +41,7 @@ env: IMAGE_NAME: lifecho-api REGISTRY: crpi-u2903xccyzd6nqnc.cn-shanghai.personal.cr.aliyuncs.com REGISTRY_NAMESPACE: huaga + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: build-and-push: @@ -51,7 +52,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ github.event.inputs.branch || github.ref }} @@ -88,7 +89,7 @@ jobs: type=raw,value=latest,enable={{is_default_branch}} - name: Build and push Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: ./api file: ./api/Dockerfile @@ -106,7 +107,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: ref: ${{ github.event.inputs.branch || github.ref }} @@ -147,13 +148,13 @@ jobs: # 勿用 `prod && PROD_KEY || DEV_KEY`:PROD 为空时会错误回退到 DEV 密钥,导致连生产机报 Permission denied。 - name: Set up SSH (production) if: steps.deploy_target.outputs.target == 'prod' - uses: webfactory/ssh-agent@v0.9.0 + uses: webfactory/ssh-agent@v0.9.1 with: ssh-private-key: ${{ secrets.PROD_SSH_PRIVATE_KEY }} - name: Set up SSH (development) if: steps.deploy_target.outputs.target != 'prod' - uses: webfactory/ssh-agent@v0.9.0 + uses: webfactory/ssh-agent@v0.9.1 with: ssh-private-key: ${{ secrets.DEV_SSH_PRIVATE_KEY }} @@ -221,8 +222,9 @@ jobs: docker network inspect api_life-echo-network >/dev/null 2>&1 || docker network create api_life-echo-network " - echo "上传候选 compose 与环境变量..." + echo "上传候选 compose、Caddyfile 与环境变量..." scp -P "$SSH_PORT" ./api/docker-compose.yml "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/docker-compose.candidate.yml" + scp -P "$SSH_PORT" ./api/Caddyfile "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/Caddyfile.candidate" scp -P "$SSH_PORT" ./api/.env.production "$SSH_USER@$SSH_HOST:$COMPOSE_DIR/api/.env.production.candidate" ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" " @@ -247,10 +249,14 @@ jobs: if [ -f '$COMPOSE_FILE' ]; then cp '$COMPOSE_FILE' '${COMPOSE_FILE}.predeploy' fi + if [ -f 'Caddyfile' ]; then + cp 'Caddyfile' 'Caddyfile.predeploy' + fi if [ -f '.env.production' ]; then cp '.env.production' '.env.production.predeploy' fi mv 'docker-compose.candidate.yml' '$COMPOSE_FILE' + mv 'Caddyfile.candidate' 'Caddyfile' mv '.env.production.candidate' '.env.production' docker-compose -f '$COMPOSE_FILE' up -d --remove-orphans echo '等待服务启动...' @@ -290,5 +296,16 @@ jobs: exit 1 fi + CADDY_CID=\$(docker-compose ps -q caddy || true) + if [ -n \"\$CADDY_CID\" ]; then + CADDY_STATE=\$(docker inspect -f '{{.State.Status}}' \"\$CADDY_CID\") + echo \"caddy state: \$CADDY_STATE\" + if [ \"\$CADDY_STATE\" != 'running' ]; then + echo 'caddy 容器未处于 running 状态' + docker-compose logs --tail=80 caddy || true + exit 1 + fi + fi + docker-compose logs --tail=50 api " diff --git a/api/Caddyfile b/api/Caddyfile new file mode 100644 index 0000000..1083339 --- /dev/null +++ b/api/Caddyfile @@ -0,0 +1,4 @@ +{$CADDY_PRIMARY_DOMAIN:lifecho.worldsplats.com} { + encode zstd gzip + reverse_proxy api:8000 +} diff --git a/api/docker-compose.yml b/api/docker-compose.yml index 8410055..4c8d137 100644 --- a/api/docker-compose.yml +++ b/api/docker-compose.yml @@ -57,8 +57,8 @@ services: dockerfile: Dockerfile image: life-echo-api:latest container_name: life-echo-api-prod - ports: - - "8000:8000" + expose: + - "8000" env_file: - .env.production environment: @@ -109,7 +109,7 @@ services: api: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "celery -A app.tasks.celery_app inspect ping --timeout 10 2>/dev/null | grep -q pong || exit 1"] + test: ["CMD-SHELL", "uv run celery -A app.tasks.celery_app inspect ping --timeout 10 2>/dev/null | grep -q pong || exit 1"] interval: 30s timeout: 15s retries: 3 @@ -122,6 +122,28 @@ services: max-size: "10m" max-file: "3" + caddy: + image: caddy:2-alpine + container_name: life-echo-caddy + depends_on: + api: + condition: service_healthy + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + restart: always + networks: + - life-echo-network + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + # Celery Beat(定时任务调度,可选) # celery-beat: # build: @@ -175,3 +197,7 @@ volumes: driver: local redis_data: driver: local + caddy_data: + driver: local + caddy_config: + driver: local diff --git a/app-expo/app.config.ts b/app-expo/app.config.ts index 21fe6d2..6c06f1c 100644 --- a/app-expo/app.config.ts +++ b/app-expo/app.config.ts @@ -118,7 +118,7 @@ export default ({ config }: ConfigContext): ExpoConfig => { userInterfaceStyle: 'automatic', ios: { ...config?.ios, - icon: './assets/expo.icon', + icon: './assets/images/icon.png', bundleIdentifier: 'com.anonymous.app-expo', config: { usesNonExemptEncryption: false, @@ -142,7 +142,6 @@ export default ({ config }: ConfigContext): ExpoConfig => { adaptiveIcon: { backgroundColor: '#E6F4FE', foregroundImage: './assets/images/android-icon-foreground.png', - backgroundImage: './assets/images/android-icon-background.png', monochromeImage: './assets/images/android-icon-monochrome.png', }, predictiveBackGestureEnabled: false, @@ -168,11 +167,11 @@ export default ({ config }: ConfigContext): ExpoConfig => { [ 'expo-splash-screen', { - backgroundColor: '#208AEF', - android: { - image: './assets/images/splash-icon.png', - imageWidth: 76, - }, + // 与 android.adaptiveIcon.backgroundColor、品牌浅紫一致(见 scripts/generate-app-icon.sh) + backgroundColor: '#E6F4FE', + image: './assets/images/splash-icon.png', + resizeMode: 'contain', + imageWidth: 200, }, ], [ diff --git a/app-expo/assets/images/android-icon-foreground.png b/app-expo/assets/images/android-icon-foreground.png index 3a9e501..6f1ca32 100644 Binary files a/app-expo/assets/images/android-icon-foreground.png and b/app-expo/assets/images/android-icon-foreground.png differ diff --git a/app-expo/assets/images/android-icon-monochrome.png b/app-expo/assets/images/android-icon-monochrome.png index 77484eb..a1dd718 100644 Binary files a/app-expo/assets/images/android-icon-monochrome.png and b/app-expo/assets/images/android-icon-monochrome.png differ diff --git a/app-expo/assets/images/favicon.png b/app-expo/assets/images/favicon.png index 408bd74..6d2cab3 100644 Binary files a/app-expo/assets/images/favicon.png and b/app-expo/assets/images/favicon.png differ diff --git a/app-expo/assets/images/icon.png b/app-expo/assets/images/icon.png index 67c777a..5da1cfa 100644 Binary files a/app-expo/assets/images/icon.png and b/app-expo/assets/images/icon.png differ diff --git a/app-expo/assets/images/splash-icon.png b/app-expo/assets/images/splash-icon.png index 6b1642a..44a8862 100644 Binary files a/app-expo/assets/images/splash-icon.png and b/app-expo/assets/images/splash-icon.png differ diff --git a/app-expo/assets/life-echo-logo.jpg b/app-expo/assets/life-echo-logo.jpg new file mode 100644 index 0000000..f3dce3b Binary files /dev/null and b/app-expo/assets/life-echo-logo.jpg differ diff --git a/app-expo/scripts/generate-app-icon.sh b/app-expo/scripts/generate-app-icon.sh new file mode 100755 index 0000000..5bedbc4 --- /dev/null +++ b/app-expo/scripts/generate-app-icon.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# 一次性:用 ImageMagick 7+(magick)从 assets/life-echo-logo.jpg 生成各平台 PNG。 +# 依赖:brew install imagemagick +# 用法:在 app-expo 目录执行 ./scripts/generate-app-icon.sh + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SRC="$ROOT/assets/life-echo-logo.jpg" +OUT="$ROOT/assets/images" +# 与 Android adaptiveIcon.backgroundColor、开屏底色一致(新 logo 浅紫系) +BRAND_BG="#E6F4FE" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +if [[ ! -f "$SRC" ]]; then + echo "missing: $SRC" >&2 + exit 1 +fi +if ! command -v magick >/dev/null; then + echo "need: magick (ImageMagick 7)" >&2 + exit 1 +fi + +mkdir -p "$OUT" + +# 近白背景 → 透明(JPG 白底) +magick "$SRC" -fuzz 12% -transparent white PNG32:"$TMP/fg.png" + +# Android 自适应前景(透明底,1024,内容约 78% 安全区) +magick "$TMP/fg.png" -resize '800x800>' -background none -gravity center -extent 1024x1024 \ + PNG32:"$OUT/android-icon-foreground.png" + +# 通用 / iOS 主图标(实色底) +magick -size 1024x1024 "xc:${BRAND_BG}" \( "$TMP/fg.png" -resize '800x800>' \) -gravity center -compose over -composite \ + PNG32:"$OUT/icon.png" + +# Android 13+ 单色层(白/透明) +magick "$OUT/android-icon-foreground.png" -alpha extract -fill white -colorize 100 -background none -alpha shape \ + PNG32:"$OUT/android-icon-monochrome.png" + +# 开屏图:仅透明 logo(底色由 expo-splash-screen backgroundColor 填充) +magick "$SRC" -fuzz 12% -transparent white -resize '400x400>' PNG32:"$OUT/splash-icon.png" + +magick "$OUT/icon.png" -resize 48x48 PNG32:"$OUT/favicon.png" + +echo "OK → $OUT/icon.png, android-icon-foreground.png, android-icon-monochrome.png, splash-icon.png, favicon.png" diff --git a/app-expo/src/app/(main)/conversation/[id].tsx b/app-expo/src/app/(main)/conversation/[id].tsx index 10e80bc..c807c52 100644 --- a/app-expo/src/app/(main)/conversation/[id].tsx +++ b/app-expo/src/app/(main)/conversation/[id].tsx @@ -501,7 +501,6 @@ function ChatInputBar({ styles.iconButton, pressed && styles.iconButtonPressed, ]} - disabled={disabled && !isRecording} accessibilityLabel={ inputMode === 'text' ? switchToVoiceLabel : switchToTextLabel } @@ -510,11 +509,7 @@ function ChatInputBar({ {/* 中间:文字输入 或 语音录制按钮 */} @@ -531,7 +526,7 @@ function ChatInputBar({ scrollEnabled textAlignVertical="top" maxLength={2000} - editable={!disabled} + editable onContentSizeChange={onInputContentSizeChange} onSubmitEditing={onSend} returnKeyType="send" @@ -715,6 +710,15 @@ export default function ConversationScreen() { const handleSend = () => { const text = input.trim(); if (!text) return; + if (connectionState !== 'connected') { + Alert.alert( + t('chatUnavailableTitle'), + connectionState === 'connecting' + ? t('chatUnavailableConnecting') + : t('chatUnavailableDisconnected'), + ); + return; + } sendText(text); setInput(''); setInputResetKey((k) => k + 1); @@ -726,6 +730,12 @@ export default function ConversationScreen() { : connectionState === 'connecting' ? t('connectionConnecting') : t('connectionDisconnected'); + const showConnectionBadge = __DEV__; + const showConnectionNotice = connectionState !== 'connected'; + const connectionNoticeText = + connectionState === 'connecting' + ? t('chatUnavailableConnecting') + : t('chatUnavailableDisconnected'); /** iOS:用键盘高度直接顶起根布局,替代 KAV(避免与 safe area 叠出缝,见 RN #52626) */ const keyboardLift = @@ -744,22 +754,25 @@ export default function ConversationScreen() { title={ {tApp('name')} - - - {connectionLabel} - - + + {connectionLabel} + + + ) : null} } backAccessibilityLabel={t('chatTitle')} @@ -810,6 +823,16 @@ export default function ConversationScreen() { }, ]} > + {showConnectionNotice ? ( + + + {t('chatUnavailableTitle')} + + + {connectionNoticeText} + + + ) : null}