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}