Merge remote-tracking branch 'origin/development'

This commit is contained in:
Kevin
2026-03-23 10:36:42 +08:00
16 changed files with 209 additions and 42 deletions

View File

@@ -1,6 +1,12 @@
# App ExpoCI 内生成 Android 签名 APKexpo prebuild + Gradle assembleRelease
# 使用 app-expo/plugins/withAndroidReleaseSigning在 android/app 放置 keystore.properties + jks 后打 release 包。
#
# 产物说明:
# - 以下为「真机向」release APKGoogle 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 }}"

View File

@@ -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
"

4
api/Caddyfile Normal file
View File

@@ -0,0 +1,4 @@
{$CADDY_PRIMARY_DOMAIN:lifecho.worldsplats.com} {
encode zstd gzip
reverse_proxy api:8000
}

View File

@@ -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

View File

@@ -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,
},
],
[

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 780 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@@ -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"

View File

@@ -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({
<Icon
as={inputMode === 'text' ? Mic : Type}
size={22}
color={
disabled && !isRecording
? 'rgba(141, 140, 144, 0.56)'
: CHAT_COLORS.onSurfaceVariant
}
color={CHAT_COLORS.onSurfaceVariant}
/>
</Pressable>
{/* 中间:文字输入 或 语音录制按钮 */}
@@ -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={
<View style={styles.headerTitleBlock}>
<Text style={styles.headerTitle}>{tApp('name')}</Text>
<View
style={[
styles.statusBadge,
connectionState === 'connected' && styles.statusBadgeConnected,
]}
>
<Text
{showConnectionBadge ? (
<View
style={[
styles.statusBadgeText,
styles.statusBadge,
connectionState === 'connected' &&
styles.statusBadgeTextConnected,
styles.statusBadgeConnected,
]}
>
{connectionLabel}
</Text>
</View>
<Text
style={[
styles.statusBadgeText,
connectionState === 'connected' &&
styles.statusBadgeTextConnected,
]}
>
{connectionLabel}
</Text>
</View>
) : null}
</View>
}
backAccessibilityLabel={t('chatTitle')}
@@ -810,6 +823,16 @@ export default function ConversationScreen() {
},
]}
>
{showConnectionNotice ? (
<View style={styles.connectionNotice}>
<Text style={styles.connectionNoticeTitle}>
{t('chatUnavailableTitle')}
</Text>
<Text style={styles.connectionNoticeText}>
{connectionNoticeText}
</Text>
</View>
) : null}
<ChatInputBar
value={input}
onChangeText={setInput}
@@ -1028,6 +1051,28 @@ const styles = StyleSheet.create({
shadowRadius: 12,
elevation: 8,
},
connectionNotice: {
marginHorizontal: 14,
marginTop: 14,
marginBottom: 4,
paddingHorizontal: 12,
paddingVertical: 10,
borderRadius: 14,
backgroundColor: 'rgba(186, 26, 26, 0.08)',
borderWidth: 1,
borderColor: 'rgba(186, 26, 26, 0.14)',
},
connectionNoticeTitle: {
fontSize: 13,
fontWeight: '700',
color: CHAT_COLORS.errorRed,
marginBottom: 2,
},
connectionNoticeText: {
fontSize: 13,
lineHeight: 18,
color: CHAT_COLORS.onSurface,
},
inputBar: {
flexDirection: 'row',
alignItems: 'center',

View File

@@ -66,6 +66,9 @@ interface Resources {
cancel: 'Cancel';
cancelRecording: 'Cancel recording';
chatTitle: 'Conversation';
chatUnavailableConnecting: 'Reconnecting now. You can keep typing and send once the connection is back.';
chatUnavailableDisconnected: 'Connection lost. You can keep typing and send after reconnecting.';
chatUnavailableTitle: 'Chat unavailable';
confirm: 'OK';
confirmDeleteConversation: 'Are you sure you want to delete this conversation? It cannot be recovered.';
connectionConnected: 'Connected';

View File

@@ -1,6 +1,9 @@
{
"confirmDeleteConversation": "Are you sure you want to delete this conversation? It cannot be recovered.",
"createError": "Unable to create conversation. Please check your network and try again.",
"chatUnavailableTitle": "Chat unavailable",
"chatUnavailableConnecting": "Reconnecting now. You can keep typing and send once the connection is back.",
"chatUnavailableDisconnected": "Connection lost. You can keep typing and send after reconnecting.",
"confirm": "OK",
"cancel": "Cancel",
"delete": "Delete",

View File

@@ -1,6 +1,9 @@
{
"confirmDeleteConversation": "确定要删除此对话吗?删除后无法恢复。",
"createError": "无法创建对话,请检查网络连接或稍后重试",
"chatUnavailableTitle": "聊天暂不可用",
"chatUnavailableConnecting": "正在重新连接。你仍可继续输入,恢复后再发送。",
"chatUnavailableDisconnected": "当前连接已断开。你仍可先输入,连接恢复后再发送。",
"confirm": "知道了",
"cancel": "取消",
"delete": "删除",