Compare commits
20 Commits
v1.0.8
...
d742b398ef
| Author | SHA1 | Date | |
|---|---|---|---|
| d742b398ef | |||
|
|
d525c6b939 | ||
|
|
c37bb2e2a5 | ||
|
|
4c450db9b2 | ||
| 8c02c5b9a0 | |||
|
|
90cbde1bac | ||
|
|
bb7a4c7361 | ||
|
|
17d19f9172 | ||
|
|
b35b71470d | ||
|
|
ccdc6350a1 | ||
|
|
7fea799087 | ||
|
|
e6b64b39f2 | ||
|
|
a457186f46 | ||
|
|
2d5b8b094f | ||
|
|
ef7c4e774d | ||
|
|
cc36301638 | ||
|
|
5dd632d4d3 | ||
|
|
4d70f16b69 | ||
|
|
996999453a | ||
|
|
3f91e734fa |
431
.gitea/workflows/server-deploy.yml
Normal file
431
.gitea/workflows/server-deploy.yml
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
name: Deploy Server (SSH + Nginx 蓝绿)
|
||||||
|
|
||||||
|
# 手动触发:选择部署环境(dev/pro)并输入要部署的镜像 tag
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
env:
|
||||||
|
description: '部署环境'
|
||||||
|
type: choice
|
||||||
|
required: true
|
||||||
|
options:
|
||||||
|
- dev
|
||||||
|
- pro
|
||||||
|
tag:
|
||||||
|
description: '要部署的镜像 Tag(例如:v1.2.7)'
|
||||||
|
required: true
|
||||||
|
|
||||||
|
# 同一环境同一时间只允许一个部署在跑,避免互相覆盖
|
||||||
|
concurrency:
|
||||||
|
group: deploy-server-${{ github.event.inputs.env }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
# 统一在 env 中注入变量,减少 runner 差异带来的兼容性问题
|
||||||
|
DEPLOY_ENV: ${{ github.event.inputs.env }}
|
||||||
|
DEPLOY_TAG: ${{ github.event.inputs.tag }}
|
||||||
|
|
||||||
|
# 镜像名(优先 vars.DOCKER_IMAGE;未配置则步骤里兜底)
|
||||||
|
DOCKER_IMAGE: ${{ vars.DOCKER_IMAGE }}
|
||||||
|
|
||||||
|
# 可选:明确 registry(私有仓库用)。未配置会从 DOCKER_IMAGE 推断
|
||||||
|
DOCKER_REGISTRY: ${{ vars.DOCKER_REGISTRY }}
|
||||||
|
|
||||||
|
# SSH:dev 用 secrets,pro 用 vars(按你现有用法)
|
||||||
|
SSH_HOST: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_HOST || vars.PRO_SSH_HOST }}
|
||||||
|
SSH_USER: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_USER || vars.PRO_SSH_USER }}
|
||||||
|
SSH_PORT: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_PORT || vars.PRO_SSH_PORT }}
|
||||||
|
SSH_KEY: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_KEY || secrets.PRO_SSH_KEY }}
|
||||||
|
# 推荐把私钥做成 base64(单行)存到 Secrets,避免多行变量丢换行
|
||||||
|
SSH_KEY_B64: ${{ github.event.inputs.env == 'dev' && secrets.DEV_SSH_KEY_B64 || secrets.PRO_SSH_KEY_B64 }}
|
||||||
|
|
||||||
|
# Nginx upstream 配置(建议放到 vars)
|
||||||
|
NGINX_UPSTREAM_FILE: ${{ vars.NGINX_UPSTREAM_FILE }}
|
||||||
|
NGINX_UPSTREAM_NAME: ${{ vars.NGINX_UPSTREAM_NAME }}
|
||||||
|
|
||||||
|
# 蓝绿端口(宿主机端口,建议放到 vars;未配置则脚本内有默认值)
|
||||||
|
BLUE_PORT: ${{ vars.BLUE_PORT }}
|
||||||
|
GREEN_PORT: ${{ vars.GREEN_PORT }}
|
||||||
|
|
||||||
|
# 容器内监听端口(FastAPI 常用 8000;未配置则默认 8000)
|
||||||
|
CONTAINER_PORT: ${{ vars.CONTAINER_PORT }}
|
||||||
|
|
||||||
|
# 健康检查路径(未配置则默认 /health;如果你没有 health 接口,可改为 /docs 或 /)
|
||||||
|
HEALTHCHECK_PATH: ${{ vars.HEALTHCHECK_PATH }}
|
||||||
|
|
||||||
|
# 可选:远端 env 文件路径(例如 /opt/mindfulness-server/.env.prod),存在则 docker run --env-file
|
||||||
|
REMOTE_ENV_FILE: ${{ vars.REMOTE_ENV_FILE }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 配置 SSH Key(密钥登陆)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 镜像名兜底(与 .gitea/workflows/server-build.yml 的默认一致)
|
||||||
|
if [[ -z "${DOCKER_IMAGE:-}" ]]; then
|
||||||
|
DOCKER_IMAGE="docker.damer.fun/damer/mindfulness-server"
|
||||||
|
fi
|
||||||
|
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||||||
|
echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
if [[ -n "${GITEA_ENV:-}" ]]; then
|
||||||
|
echo "DOCKER_IMAGE=${DOCKER_IMAGE}" >> "$GITEA_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
chmod 700 ~/.ssh
|
||||||
|
|
||||||
|
# 从 Secrets 写入私钥(推荐使用 *_SSH_KEY_B64)
|
||||||
|
SSH_KEY_PATH="${HOME}/.ssh/id_rsa"
|
||||||
|
if [[ -n "${SSH_KEY_B64:-}" ]]; then
|
||||||
|
# 兼容两种常见误配置:
|
||||||
|
# 1) *_SSH_KEY_B64 里其实粘贴的是“原始私钥”(包含 -----BEGIN ... PRIVATE KEY-----)
|
||||||
|
# 2) base64 值在粘贴/保存过程中混入空白或其他无关字符
|
||||||
|
if printf '%s' "${SSH_KEY_B64}" | grep -qE 'BEGIN[[:space:]].*PRIVATE[[:space:]]KEY'; then
|
||||||
|
echo "提示:检测到 *_SSH_KEY_B64 看起来是原始私钥内容,将按原始私钥写入(建议你改用真正的 base64 单行值)。"
|
||||||
|
echo "SSH_KEY_B64 字符数(原始):${#SSH_KEY_B64}"
|
||||||
|
printf '%s' "${SSH_KEY_B64}" | tr -d '\r' > "${SSH_KEY_PATH}"
|
||||||
|
else
|
||||||
|
CLEAN_B64="$(printf '%s' "${SSH_KEY_B64}" | tr -d '\r\n\t ')"
|
||||||
|
echo "SSH_KEY_B64 字符数(原始/清理后):${#SSH_KEY_B64}/${#CLEAN_B64}"
|
||||||
|
if ! printf '%s' "${CLEAN_B64}" | base64 -d -i | tr -d '\r' > "${SSH_KEY_PATH}"; then
|
||||||
|
echo "私钥 base64 解码失败:"
|
||||||
|
echo "- 请确认你在 Gitea Secrets 配置的 *_SSH_KEY_B64 是“单行 base64 字符串”,不要带引号/前后空格。"
|
||||||
|
echo "- 推荐用仓库里的脚本生成并复制:node scripts/ssh-key-to-b64.mjs ~/.ssh/你的私钥 --clipboard"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "使用 SSH_KEY(原始私钥)写入。SSH_KEY 字符数:${#SSH_KEY}"
|
||||||
|
printf '%s' "${SSH_KEY}" | tr -d '\r' > "${SSH_KEY_PATH}"
|
||||||
|
fi
|
||||||
|
chmod 600 "${SSH_KEY_PATH}"
|
||||||
|
|
||||||
|
# 自检(不输出私钥内容,仅输出元信息,方便定位是否写入了错误文件/被截断)
|
||||||
|
echo "SSH_KEY_PATH=${SSH_KEY_PATH}"
|
||||||
|
KEY_BYTES="$(wc -c < "${SSH_KEY_PATH}" | tr -d ' ')"
|
||||||
|
KEY_LINES="$(wc -l < "${SSH_KEY_PATH}" | tr -d ' ')"
|
||||||
|
echo "SSH key 字节数:${KEY_BYTES}"
|
||||||
|
echo "SSH key 行数:${KEY_LINES}"
|
||||||
|
echo "SSH key 首行:$(head -n 1 "${SSH_KEY_PATH}" | tr -d '\r')"
|
||||||
|
echo "SSH key 末行:$(tail -n 1 "${SSH_KEY_PATH}" | tr -d '\r')"
|
||||||
|
if command -v stat >/dev/null 2>&1; then
|
||||||
|
# Ubuntu runner: stat -c;不同系统做兼容
|
||||||
|
if stat -c '%a %U %G %n' "${SSH_KEY_PATH}" >/dev/null 2>&1; then
|
||||||
|
echo "SSH key 权限:$(stat -c '%a %U %G %n' "${SSH_KEY_PATH}")"
|
||||||
|
else
|
||||||
|
echo "SSH key 权限:$(stat -f '%Lp %Su %Sg %N' "${SSH_KEY_PATH}" 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if command -v file >/dev/null 2>&1; then
|
||||||
|
echo "file(1) 识别:$(file "${SSH_KEY_PATH}")"
|
||||||
|
fi
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
echo "SSH key sha256(前12位):$(sha256sum "${SSH_KEY_PATH}" | awk '{print substr($1,1,12)}')"
|
||||||
|
fi
|
||||||
|
echo "ssh 版本:$(ssh -V 2>&1 || true)"
|
||||||
|
echo "ssh-keygen 版本:$(ssh-keygen -V 2>&1 || true)"
|
||||||
|
echo "openssl 版本:$(openssl version 2>&1 || true)"
|
||||||
|
|
||||||
|
# 进一步校验:OpenSSH 私钥中间的 base64 块是否可解码(不输出内容)
|
||||||
|
if grep -q '^-----BEGIN OPENSSH PRIVATE KEY-----$' "${SSH_KEY_PATH}" && grep -q '^-----END OPENSSH PRIVATE KEY-----$' "${SSH_KEY_PATH}"; then
|
||||||
|
# 注意:不要用变量名 in(是 awk 关键字,部分实现会报语法错)
|
||||||
|
OPENSSH_B64_LEN="$(
|
||||||
|
awk '
|
||||||
|
BEGIN{in_block=0; n=0}
|
||||||
|
/^-----BEGIN OPENSSH PRIVATE KEY-----$/{in_block=1; next}
|
||||||
|
/^-----END OPENSSH PRIVATE KEY-----$/{in_block=0; exit}
|
||||||
|
in_block==1{gsub(/\r/,""); n+=length($0)}
|
||||||
|
END{print n}
|
||||||
|
' "${SSH_KEY_PATH}" 2>/dev/null || true
|
||||||
|
)"
|
||||||
|
if [[ -n "${OPENSSH_B64_LEN:-}" ]]; then
|
||||||
|
echo "OpenSSH base64 块字符数(合计):${OPENSSH_B64_LEN}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 该校验仅用于提示,不应阻断部署流程
|
||||||
|
if ! awk '
|
||||||
|
BEGIN{in_block=0}
|
||||||
|
/^-----BEGIN OPENSSH PRIVATE KEY-----$/{in_block=1; next}
|
||||||
|
/^-----END OPENSSH PRIVATE KEY-----$/{in_block=0; exit}
|
||||||
|
in_block==1{gsub(/\r/,""); print}
|
||||||
|
' "${SSH_KEY_PATH}" 2>/dev/null | tr -d '\n' | base64 -d >/dev/null 2>&1; then
|
||||||
|
echo "提示:OpenSSH 私钥的 base64 块无法解码(疑似内容被截断/损坏),将继续执行 ssh-keygen 校验定位。"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 额外信息:ssh-keygen -lf 的报错(不输出私钥内容)
|
||||||
|
if ! ssh-keygen -lf "${SSH_KEY_PATH}" >/dev/null 2>~/.ssh/ssh_key_fingerprint.err; then
|
||||||
|
echo "ssh-keygen -lf 报错:"
|
||||||
|
cat ~/.ssh/ssh_key_fingerprint.err || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 快速校验私钥是否可解析(不会输出私钥内容)
|
||||||
|
if ! ssh-keygen -y -f "${SSH_KEY_PATH}" >/dev/null 2>~/.ssh/ssh_key_check.err; then
|
||||||
|
echo "SSH 私钥无法解析。下面是 ssh-keygen 的报错(不包含私钥内容):"
|
||||||
|
cat ~/.ssh/ssh_key_check.err || true
|
||||||
|
echo
|
||||||
|
echo "常见原因:"
|
||||||
|
echo "- 你填的是公钥(.pub),不是私钥"
|
||||||
|
echo "- 私钥带口令(CI 无法交互输入 passphrase)"
|
||||||
|
echo "- 内容复制丢换行/被截断/不是正确的 base64"
|
||||||
|
echo
|
||||||
|
echo "推荐:生成一把“无口令”的部署专用私钥,并用 base64 存储到 Secrets(*_SSH_KEY_B64)。"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SSH_PORT="${SSH_PORT:-22}"
|
||||||
|
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||||||
|
echo "SSH_PORT=${SSH_PORT}" >> "$GITHUB_ENV"
|
||||||
|
fi
|
||||||
|
if [[ -n "${GITEA_ENV:-}" ]]; then
|
||||||
|
echo "SSH_PORT=${SSH_PORT}" >> "$GITEA_ENV"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 预写 known_hosts,避免交互
|
||||||
|
ssh-keyscan -p "${SSH_PORT}" -H "${SSH_HOST}" >> ~/.ssh/known_hosts 2>/dev/null
|
||||||
|
|
||||||
|
- name: 验证 SSH 连接(快速定位公钥/用户/端口问题)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
SSH_PORT="${SSH_PORT:-22}"
|
||||||
|
ssh -p "${SSH_PORT}" \
|
||||||
|
-i ~/.ssh/id_rsa \
|
||||||
|
-o StrictHostKeyChecking=yes \
|
||||||
|
-o BatchMode=yes \
|
||||||
|
-o IdentitiesOnly=yes \
|
||||||
|
"${SSH_USER}@${SSH_HOST}" 'echo "SSH_OK $(whoami)@$(hostname)"'
|
||||||
|
|
||||||
|
- name: 远程登录 Docker Registry(如需私有镜像)
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||||
|
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# 如果镜像是公开的,可不配 DOCKER_USERNAME/DOCKER_TOKEN;此步骤会自动跳过
|
||||||
|
if [[ -z "${DOCKER_USERNAME:-}" || -z "${DOCKER_TOKEN:-}" ]]; then
|
||||||
|
echo "未配置 DOCKER_USERNAME/DOCKER_TOKEN,跳过远程 docker login。"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SSH_PORT="${SSH_PORT:-22}"
|
||||||
|
|
||||||
|
# 推断 registry:优先使用 DOCKER_REGISTRY;否则从 DOCKER_IMAGE 的第一个段推断
|
||||||
|
REG="${DOCKER_REGISTRY:-}"
|
||||||
|
if [[ -z "${REG}" ]]; then
|
||||||
|
FIRST_SEG="${DOCKER_IMAGE%%/*}"
|
||||||
|
if [[ "${FIRST_SEG}" == *.* || "${FIRST_SEG}" == *:* || "${FIRST_SEG}" == "localhost" ]]; then
|
||||||
|
REG="${FIRST_SEG}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -z "${REG}" ]]; then
|
||||||
|
echo "无法推断 registry(看起来像 Docker Hub 公有镜像),跳过 docker login。"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ssh -p "${SSH_PORT}" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes \
|
||||||
|
"${SSH_USER}@${SSH_HOST}" bash -s -- "${DOCKER_TOKEN}" "${DOCKER_USERNAME}" "${REG}" <<'REMOTE'
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
TOKEN="$1"
|
||||||
|
USERNAME="$2"
|
||||||
|
REGISTRY="$3"
|
||||||
|
|
||||||
|
SUDO=""
|
||||||
|
if [[ "$(id -u)" -ne 0 ]] && command -v sudo >/dev/null 2>&1; then
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s' "$TOKEN" | ${SUDO} docker login "${REGISTRY}" -u "$USERNAME" --password-stdin
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
- name: SSH 部署(Nginx 蓝绿切换)
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SSH_PORT="${SSH_PORT:-22}"
|
||||||
|
NGINX_UPSTREAM_NAME="${NGINX_UPSTREAM_NAME:-mindfulness_backend}"
|
||||||
|
NGINX_UPSTREAM_FILE="${NGINX_UPSTREAM_FILE:-/etc/nginx/conf.d/api.damer.fun.conf}"
|
||||||
|
BLUE_PORT="${BLUE_PORT:-8001}"
|
||||||
|
GREEN_PORT="${GREEN_PORT:-8002}"
|
||||||
|
CONTAINER_PORT="${CONTAINER_PORT:-8000}"
|
||||||
|
HEALTHCHECK_PATH="${HEALTHCHECK_PATH:-/health}"
|
||||||
|
|
||||||
|
echo "准备部署:${DOCKER_IMAGE}:${DEPLOY_TAG} -> ${DEPLOY_ENV} (${SSH_USER}@${SSH_HOST}:${SSH_PORT})"
|
||||||
|
|
||||||
|
ssh -p "${SSH_PORT}" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes "${SSH_USER}@${SSH_HOST}" bash -s -- \
|
||||||
|
"${DOCKER_IMAGE}" "${DEPLOY_TAG}" "${NGINX_UPSTREAM_FILE}" "${NGINX_UPSTREAM_NAME}" "${BLUE_PORT}" "${GREEN_PORT}" "${CONTAINER_PORT}" "${HEALTHCHECK_PATH}" "${REMOTE_ENV_FILE:-}" <<'REMOTE'
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
IMAGE="$1"
|
||||||
|
TAG="$2"
|
||||||
|
UPSTREAM_FILE="$3"
|
||||||
|
UPSTREAM_NAME="$4"
|
||||||
|
BLUE_PORT="$5"
|
||||||
|
GREEN_PORT="$6"
|
||||||
|
CONTAINER_PORT="$7"
|
||||||
|
HEALTHCHECK_PATH="$8"
|
||||||
|
REMOTE_ENV_FILE="$9"
|
||||||
|
|
||||||
|
APP_DIR="/opt/mindfulness-server"
|
||||||
|
ACTIVE_FILE="${APP_DIR}/active_color"
|
||||||
|
mkdir -p "${APP_DIR}"
|
||||||
|
|
||||||
|
# 判断 sudo(如果非 root 且存在 sudo,则使用 sudo)
|
||||||
|
SUDO=""
|
||||||
|
if [[ "$(id -u)" -ne 0 ]] && command -v sudo >/dev/null 2>&1; then
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
|
||||||
|
ACTIVE_COLOR="blue"
|
||||||
|
if [[ -f "${ACTIVE_FILE}" ]]; then
|
||||||
|
ACTIVE_COLOR="$(cat "${ACTIVE_FILE}" || echo blue)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${ACTIVE_COLOR}" == "blue" ]]; then
|
||||||
|
NEW_COLOR="green"
|
||||||
|
NEW_PORT="${GREEN_PORT}"
|
||||||
|
OLD_COLOR="blue"
|
||||||
|
OLD_PORT="${BLUE_PORT}"
|
||||||
|
else
|
||||||
|
NEW_COLOR="blue"
|
||||||
|
NEW_PORT="${BLUE_PORT}"
|
||||||
|
OLD_COLOR="green"
|
||||||
|
OLD_PORT="${GREEN_PORT}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "当前在线:${ACTIVE_COLOR}(${OLD_PORT}),准备发布:${NEW_COLOR}(${NEW_PORT})"
|
||||||
|
|
||||||
|
# 拉取镜像
|
||||||
|
${SUDO} docker pull "${IMAGE}:${TAG}"
|
||||||
|
|
||||||
|
# 启动新颜色容器
|
||||||
|
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
ENV_FILE_ARGS=()
|
||||||
|
if [[ -n "${REMOTE_ENV_FILE}" && -f "${REMOTE_ENV_FILE}" ]]; then
|
||||||
|
ENV_FILE_ARGS=(--env-file "${REMOTE_ENV_FILE}")
|
||||||
|
echo "将使用远端 env 文件:${REMOTE_ENV_FILE}"
|
||||||
|
elif [[ -n "${REMOTE_ENV_FILE}" ]]; then
|
||||||
|
echo "提示:REMOTE_ENV_FILE 已配置但文件不存在:${REMOTE_ENV_FILE}(将忽略 env-file)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
${SUDO} docker run -d \
|
||||||
|
--name "mindfulness-server-${NEW_COLOR}" \
|
||||||
|
--restart=always \
|
||||||
|
-p "${NEW_PORT}:${CONTAINER_PORT}" \
|
||||||
|
"${ENV_FILE_ARGS[@]}" \
|
||||||
|
"${IMAGE}:${TAG}"
|
||||||
|
|
||||||
|
# 健康检查
|
||||||
|
if [[ "${HEALTHCHECK_PATH}" != /* ]]; then
|
||||||
|
HEALTHCHECK_PATH="/${HEALTHCHECK_PATH}"
|
||||||
|
fi
|
||||||
|
HEALTH_URL="http://127.0.0.1:${NEW_PORT}${HEALTHCHECK_PATH}"
|
||||||
|
echo "健康检查:${HEALTH_URL}"
|
||||||
|
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if command -v curl >/dev/null 2>&1; then
|
||||||
|
if curl -fsS "${HEALTH_URL}" >/dev/null; then
|
||||||
|
echo "健康检查通过"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
elif command -v wget >/dev/null 2>&1; then
|
||||||
|
if wget -q -O /dev/null "${HEALTH_URL}"; then
|
||||||
|
echo "健康检查通过"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "远端缺少 curl/wget,跳过 HTTP 健康检查"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$i" -eq 30 ]]; then
|
||||||
|
echo "健康检查失败:新版本未就绪,回滚并退出"
|
||||||
|
${SUDO} docker logs --tail 200 "mindfulness-server-${NEW_COLOR}" || true
|
||||||
|
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# 切换 Nginx upstream(在同一个 conf 文件中通过 backup 做主备切换)
|
||||||
|
if [[ ! -f "${UPSTREAM_FILE}" ]]; then
|
||||||
|
echo "未找到 Nginx upstream 配置文件:${UPSTREAM_FILE}"
|
||||||
|
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! ${SUDO} grep -qE "upstream[[:space:]]+${UPSTREAM_NAME}[[:space:]]*\\{" "${UPSTREAM_FILE}"; then
|
||||||
|
echo "在 ${UPSTREAM_FILE} 中未找到 upstream:${UPSTREAM_NAME}"
|
||||||
|
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${BLUE_PORT}" "${UPSTREAM_FILE}"; then
|
||||||
|
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${BLUE_PORT}(请先按参考配置写入 upstream)"
|
||||||
|
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${GREEN_PORT}" "${UPSTREAM_FILE}"; then
|
||||||
|
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${GREEN_PORT}(请先按参考配置写入 upstream)"
|
||||||
|
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 备份(回滚用)
|
||||||
|
BACKUP_FILE="${UPSTREAM_FILE}.bak.$(date +%s)"
|
||||||
|
${SUDO} cp -f "${UPSTREAM_FILE}" "${BACKUP_FILE}"
|
||||||
|
|
||||||
|
echo "切换 upstream:${UPSTREAM_NAME}(${OLD_PORT} -> ${NEW_PORT})通过 backup 切换"
|
||||||
|
|
||||||
|
TMP_FILE="$(mktemp)"
|
||||||
|
${SUDO} awk -v name="${UPSTREAM_NAME}" -v old="${OLD_PORT}" -v new="${NEW_PORT}" '
|
||||||
|
BEGIN { in_up = 0 }
|
||||||
|
$0 ~ ("^[[:space:]]*upstream[[:space:]]+" name "[[:space:]]*\\{[[:space:]]*$") { in_up = 1 }
|
||||||
|
in_up == 1 {
|
||||||
|
# 新端口:主(去掉 backup)
|
||||||
|
if ($0 ~ ("server[[:space:]]+127\\.0\\.0\\.1:" new)) {
|
||||||
|
gsub(/[[:space:]]+backup[[:space:]]*;/, ";")
|
||||||
|
}
|
||||||
|
# 旧端口:备(确保有 backup;)
|
||||||
|
if ($0 ~ ("server[[:space:]]+127\\.0\\.0\\.1:" old)) {
|
||||||
|
gsub(/[[:space:]]+backup[[:space:]]*;/, ";")
|
||||||
|
sub(/;[[:space:]]*$/, " backup;")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
in_up == 1 && $0 ~ /^[[:space:]]*\}[[:space:]]*$/ { in_up = 0 }
|
||||||
|
{ print }
|
||||||
|
' "${UPSTREAM_FILE}" > "${TMP_FILE}"
|
||||||
|
|
||||||
|
${SUDO} cp -f "${TMP_FILE}" "${UPSTREAM_FILE}"
|
||||||
|
rm -f "${TMP_FILE}"
|
||||||
|
|
||||||
|
# 校验并 reload nginx(失败则回滚并退出)
|
||||||
|
if ${SUDO} nginx -t; then
|
||||||
|
${SUDO} nginx -s reload
|
||||||
|
else
|
||||||
|
echo "Nginx 配置校验失败,回滚 upstream 配置并退出"
|
||||||
|
${SUDO} cp -f "${BACKUP_FILE}" "${UPSTREAM_FILE}" || true
|
||||||
|
${SUDO} nginx -t && ${SUDO} nginx -s reload || true
|
||||||
|
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 记录当前在线颜色
|
||||||
|
echo "${NEW_COLOR}" | ${SUDO} tee "${ACTIVE_FILE}" >/dev/null
|
||||||
|
|
||||||
|
# 下线旧容器(切流后再停旧的)
|
||||||
|
${SUDO} docker rm -f "mindfulness-server-${OLD_COLOR}" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
echo "部署完成:${NEW_COLOR} 已上线"
|
||||||
|
REMOTE
|
||||||
|
|
||||||
@@ -13,7 +13,9 @@ git pull
|
|||||||
|
|
||||||
# 创建自己的分支
|
# 创建自己的分支
|
||||||
git checkout -b 姓名拼写
|
git checkout -b 姓名拼写
|
||||||
|
# 生产密钥
|
||||||
|
|
||||||
|
ssh-keygen -t rsa -b 4096 -m PEM -N '' -f deploy_key_rsa
|
||||||
# 目录结构
|
# 目录结构
|
||||||
|
|
||||||
/mindfulness
|
/mindfulness
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "client",
|
"name": "Hey Mama",
|
||||||
"slug": "client",
|
"slug": "client",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
|
|||||||
@@ -14,19 +14,18 @@ import Animated, {
|
|||||||
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
||||||
import {
|
import {
|
||||||
addFavorite,
|
addFavorite,
|
||||||
getRecoFeedCache,
|
|
||||||
getRecoFeedHistory,
|
|
||||||
getThemeMode,
|
getThemeMode,
|
||||||
getUserProfile,
|
getUserProfile,
|
||||||
getUserProfileScoring,
|
|
||||||
recordRecoFeedServed,
|
|
||||||
recordRecoFeedTouched,
|
|
||||||
setRecoFeedCache,
|
|
||||||
setReaction,
|
setReaction,
|
||||||
setThemeMode,
|
setThemeMode,
|
||||||
type RecoFeedCacheItem,
|
getRecoFeedCache,
|
||||||
|
setRecoFeedCache,
|
||||||
|
getUserProfileScoring,
|
||||||
|
getRecoFeedHistory,
|
||||||
|
recordRecoFeedServed,
|
||||||
type ThemeMode,
|
type ThemeMode,
|
||||||
} from '@/src/storage/appStorage';
|
} from '@/src/storage/appStorage';
|
||||||
|
|
||||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||||
|
|
||||||
import ProfileModal from '@/components/home/ProfileModal';
|
import ProfileModal from '@/components/home/ProfileModal';
|
||||||
@@ -73,8 +72,12 @@ const THEME_COLORS = [
|
|||||||
'#CBD9F2',
|
'#CBD9F2',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type FeedItem = { content_id: string; text: string };
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const { t } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const isEnglish = i18n.language?.startsWith('en');
|
||||||
|
const recoLang: 'en' | 'tc' = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
|
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
|
||||||
@@ -83,83 +86,115 @@ export default function HomeScreen() {
|
|||||||
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [likeFilled, setLikeFilled] = useState(false);
|
const [likeFilled, setLikeFilled] = useState(false);
|
||||||
const [feedItems, setFeedItems] = useState<Array<{ content_id: number; text: string }>>([]);
|
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
|
||||||
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
|
|
||||||
const currentList = feedItems.length > 0 ? feedItems : MOCK_CONTENT;
|
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
|
||||||
const item = useMemo(() => currentList[index % currentList.length], [currentList, index]);
|
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
||||||
const currentContentId = typeof (item as any)?.content_id === 'number' ? Number((item as any).content_id) : null;
|
const feedItemsRef = useRef<FeedItem[]>([]);
|
||||||
|
const isFetchingRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
feedItemsRef.current = feedItems;
|
||||||
|
}, [feedItems]);
|
||||||
|
useEffect(() => {
|
||||||
|
isFetchingRef.current = isFetching;
|
||||||
|
}, [isFetching]);
|
||||||
|
|
||||||
// 动画相关 Shared Values
|
// 动画相关 Shared Values
|
||||||
const translateY = useSharedValue(0);
|
const translateY = useSharedValue(0);
|
||||||
const opacity = useSharedValue(1);
|
const opacity = useSharedValue(1);
|
||||||
const likeScale = useSharedValue(1);
|
const likeScale = useSharedValue(1);
|
||||||
|
|
||||||
// 每次进入页面或页面获得焦点时刷新个人信息
|
// 统一文案对象结构
|
||||||
|
const currentFeed = useMemo(() => {
|
||||||
|
if (feedItems.length > 0) {
|
||||||
|
return feedItems;
|
||||||
|
}
|
||||||
|
return MOCK_CONTENT.map(item => ({
|
||||||
|
content_id: item.id,
|
||||||
|
text: t(item.textKey)
|
||||||
|
}));
|
||||||
|
}, [feedItems, t]);
|
||||||
|
|
||||||
|
const item = useMemo(() => {
|
||||||
|
const data = currentFeed[index % currentFeed.length];
|
||||||
|
return {
|
||||||
|
id: String(data.content_id),
|
||||||
|
text: data.text
|
||||||
|
};
|
||||||
|
}, [currentFeed, index]);
|
||||||
|
|
||||||
|
// 异步拉取新文案
|
||||||
|
const fetchNewFeed = useCallback(async () => {
|
||||||
|
if (isFetchingRef.current) return;
|
||||||
|
isFetchingRef.current = true;
|
||||||
|
setIsFetching(true);
|
||||||
|
try {
|
||||||
|
const scoringProfile = await getUserProfileScoring();
|
||||||
|
if (!scoringProfile) return;
|
||||||
|
|
||||||
|
const history = await getRecoFeedHistory();
|
||||||
|
|
||||||
|
const { items, meta } = await fetchRecoFeed({
|
||||||
|
k: 30,
|
||||||
|
user_profile: scoringProfile,
|
||||||
|
already_recommended_ids: history.already_recommended_ids,
|
||||||
|
touched_or_viewed_ids: history.touched_or_viewed_ids,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length > 0) {
|
||||||
|
const wasEmpty = feedItemsRef.current.length === 0;
|
||||||
|
const newCache = {
|
||||||
|
saved_at: new Date().toISOString(),
|
||||||
|
lang: recoLang,
|
||||||
|
items: items.map((x) => ({ content_id: x.content_id, text: x.text })),
|
||||||
|
meta: meta as Record<string, unknown>,
|
||||||
|
};
|
||||||
|
await setRecoFeedCache(newCache);
|
||||||
|
await recordRecoFeedServed(items.map((x) => x.content_id));
|
||||||
|
setFeedItems(newCache.items.map((x) => ({ content_id: String(x.content_id), text: x.text })));
|
||||||
|
// 如果当前是 mock 数据,切换到新数据的第一条
|
||||||
|
if (wasEmpty) {
|
||||||
|
setIndex(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch new feed:', error);
|
||||||
|
} finally {
|
||||||
|
isFetchingRef.current = false;
|
||||||
|
setIsFetching(false);
|
||||||
|
}
|
||||||
|
}, [recoLang]);
|
||||||
|
|
||||||
|
// 每次进入页面或页面获得焦点时刷新个人信息和缓存文案
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const mode = await getThemeMode();
|
const mode = await getThemeMode();
|
||||||
const profile = await getUserProfile();
|
const profile = await getUserProfile();
|
||||||
|
const cache = await getRecoFeedCache();
|
||||||
|
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setThemeModeState(mode);
|
setThemeModeState(mode);
|
||||||
setProfileName(profile.name);
|
setProfileName(profile.name);
|
||||||
|
|
||||||
|
// 语言切换时:旧语言缓存不复用,触发重新拉取
|
||||||
|
if (cache && cache.items.length > 0 && (cache.lang ?? 'en') === recoLang) {
|
||||||
|
setFeedItems(cache.items.map((x) => ({ content_id: String(x.content_id), text: x.text })));
|
||||||
|
} else {
|
||||||
|
// 语言不匹配或没有缓存:先清空回落到本地 mock(会立即随语言切换),再拉取对应语言的推荐文案
|
||||||
|
setFeedItems([]);
|
||||||
|
setIndex(0);
|
||||||
|
fetchNewFeed();
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [])
|
}, [fetchNewFeed, recoLang])
|
||||||
);
|
);
|
||||||
|
|
||||||
// 首次进入:先读缓存,再拉后端 feed(失败则保持 mock/缓存)
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
(async () => {
|
|
||||||
const cache = await getRecoFeedCache();
|
|
||||||
if (!cancelled && cache?.items?.length) {
|
|
||||||
setFeedItems(cache.items.map((x: RecoFeedCacheItem) => ({ content_id: x.content_id, text: x.text })));
|
|
||||||
}
|
|
||||||
|
|
||||||
const scoring = await getUserProfileScoring();
|
|
||||||
if (!scoring) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const hist = await getRecoFeedHistory();
|
|
||||||
const out = await fetchRecoFeed({
|
|
||||||
k: 30,
|
|
||||||
user_profile: {
|
|
||||||
profile_version: scoring.profile_version,
|
|
||||||
profile_source: scoring.profile_source,
|
|
||||||
profile_generated_at: scoring.profile_generated_at,
|
|
||||||
profile_confidence: scoring.profile_confidence,
|
|
||||||
profile_answered: scoring.profile_answered,
|
|
||||||
stage: scoring.stage,
|
|
||||||
emotion_score: scoring.emotion_score,
|
|
||||||
context: scoring.context,
|
|
||||||
need: scoring.need,
|
|
||||||
},
|
|
||||||
already_recommended_ids: hist.already_recommended_ids,
|
|
||||||
touched_or_viewed_ids: hist.touched_or_viewed_ids,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!cancelled && out.items?.length) {
|
|
||||||
setFeedItems(out.items.map((x) => ({ content_id: x.content_id, text: x.text })));
|
|
||||||
await setRecoFeedCache({
|
|
||||||
saved_at: new Date().toISOString(),
|
|
||||||
items: out.items.map((x) => ({ content_id: x.content_id, text: x.text })),
|
|
||||||
meta: out.meta as Record<string, unknown>,
|
|
||||||
});
|
|
||||||
await recordRecoFeedServed(out.items.map((x) => x.content_id));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 忽略:保持缓存/本地 mock
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const backgroundColor = useMemo(() => {
|
const backgroundColor = useMemo(() => {
|
||||||
if (themeMode === 'color') {
|
if (themeMode === 'color') {
|
||||||
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
|
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
|
||||||
@@ -178,8 +213,10 @@ export default function HomeScreen() {
|
|||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerShadowVisible: false,
|
headerShadowVisible: false,
|
||||||
headerStyle: { backgroundColor: themeMode === 'scenery' ? 'transparent' : backgroundColor },
|
// 为了让风景/颜色两种主题下“文案的视觉居中位置”一致,统一使用透明 Header
|
||||||
headerTransparent: themeMode === 'scenery',
|
// 颜色主题下 Header 透明也不会影响观感(背景就是纯色)
|
||||||
|
headerStyle: { backgroundColor: 'transparent' },
|
||||||
|
headerTransparent: true,
|
||||||
headerRight: () => (
|
headerRight: () => (
|
||||||
<View style={styles.headerRight}>
|
<View style={styles.headerRight}>
|
||||||
<CircleIconButton
|
<CircleIconButton
|
||||||
@@ -213,11 +250,6 @@ export default function HomeScreen() {
|
|||||||
if (busy) return;
|
if (busy) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|
||||||
// 记录“看过/划过”的内容 id(用于下一次向后端请求时去重/频控)
|
|
||||||
if (typeof currentContentId === 'number') {
|
|
||||||
void recordRecoFeedTouched(currentContentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. 当前文案向上移动并消失
|
// 1. 当前文案向上移动并消失
|
||||||
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||||
@@ -226,6 +258,11 @@ export default function HomeScreen() {
|
|||||||
runOnJS(setIndex)(index + 1);
|
runOnJS(setIndex)(index + 1);
|
||||||
runOnJS(setLikeFilled)(false);
|
runOnJS(setLikeFilled)(false);
|
||||||
|
|
||||||
|
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条)
|
||||||
|
if (index + 5 >= currentFeed.length && !isFetching) {
|
||||||
|
runOnJS(fetchNewFeed)();
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 准备下一条文案:先瞬移到下方 40pt
|
// 3. 准备下一条文案:先瞬移到下方 40pt
|
||||||
translateY.value = 40;
|
translateY.value = 40;
|
||||||
|
|
||||||
@@ -238,7 +275,7 @@ export default function HomeScreen() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [busy, currentContentId, index, translateY, opacity]);
|
}, [busy, index, currentFeed.length, isFetching, fetchNewFeed, translateY, opacity]);
|
||||||
|
|
||||||
const lastTapRef = useRef<number>(0);
|
const lastTapRef = useRef<number>(0);
|
||||||
|
|
||||||
@@ -288,20 +325,28 @@ export default function HomeScreen() {
|
|||||||
const dateStr = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`;
|
const dateStr = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
// 2. 保存到收藏夹,包含当前背景信息
|
// 2. 保存到收藏夹,包含当前背景信息
|
||||||
await addFavorite({
|
const favItem = {
|
||||||
favId: String(Date.now()), // 生成唯一 ID
|
favId: String(Date.now()), // 生成唯一 ID
|
||||||
id: item.id,
|
id: item.id,
|
||||||
|
text: item.text,
|
||||||
date: dateStr,
|
date: dateStr,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
|
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
|
||||||
});
|
};
|
||||||
|
console.log('Home: Triggering addFavorite', JSON.stringify(favItem));
|
||||||
|
await addFavorite(favItem);
|
||||||
|
|
||||||
// 3. 爱心缩放动画
|
// 3. 记录到后端 Reaction(喜欢)
|
||||||
|
console.log('Home: Triggering setReaction', item.id);
|
||||||
|
await setReaction(item.id, 'like');
|
||||||
|
|
||||||
|
// 4. 爱心缩放动画
|
||||||
likeScale.value = withSequence(
|
likeScale.value = withSequence(
|
||||||
withTiming(0.8, { duration: 100 }),
|
withTiming(0.8, { duration: 100 }),
|
||||||
withTiming(1.2, { duration: 150 }),
|
withTiming(1.2, { duration: 150 }),
|
||||||
withTiming(1, { duration: 100 }, (finished) => {
|
withTiming(1, { duration: 100 }, (finished) => {
|
||||||
if (finished) {
|
if (finished) {
|
||||||
|
console.log('Home: Like animation finished, triggering next content');
|
||||||
runOnJS(triggerNextContent)();
|
runOnJS(triggerNextContent)();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -324,7 +369,9 @@ export default function HomeScreen() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
|
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
|
||||||
<Text style={[styles.text, themeMode === 'scenery' && styles.sceneryText]}>{item.text}</Text>
|
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
|
||||||
|
{item.text}
|
||||||
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
@@ -419,6 +466,11 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
textEnglish: {
|
||||||
|
fontFamily: 'STIXTwoText',
|
||||||
|
// 英文字体观感更细一点,避免过粗
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
sceneryCard: {
|
sceneryCard: {
|
||||||
// 风景模式下稍微收窄文案宽度,增加呼吸感
|
// 风景模式下稍微收窄文案宽度,增加呼吸感
|
||||||
paddingHorizontal: 50,
|
paddingHorizontal: 50,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
|
import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
|
||||||
import { NameInputStep } from '@/components/onboarding/NameInputStep';
|
import { NameInputStep } from '@/components/onboarding/NameInputStep';
|
||||||
import { SelectionStep } from '@/components/onboarding/SelectionStep';
|
import { SelectionStep } from '@/components/onboarding/SelectionStep';
|
||||||
@@ -16,64 +17,37 @@ import {
|
|||||||
setRecoFeedCache,
|
setRecoFeedCache,
|
||||||
} from '@/src/storage/appStorage';
|
} from '@/src/storage/appStorage';
|
||||||
|
|
||||||
const STEPS = [
|
type Step =
|
||||||
{ id: 'name', type: 'name', title: '我可以怎么称呼你?' },
|
| { id: 'name'; type: 'name' }
|
||||||
{
|
| { id: 'status' | 'emotion' | 'influence' | 'support'; type: 'selection'; optionIds: string[] }
|
||||||
id: 'status',
|
| { id: 'reminder'; type: 'reminder' };
|
||||||
type: 'selection',
|
|
||||||
title: '媽媽的狀態?',
|
const STEPS: Step[] = [
|
||||||
options: [
|
{ id: 'name', type: 'name' },
|
||||||
{ id: 'pregnant', label: '懷孕中/準備成為媽媽' },
|
{ id: 'status', type: 'selection', optionIds: ['pregnant', 'has_kids', 'no_fill'] },
|
||||||
{ id: 'has_kids', label: '已經有孩子' },
|
{ id: 'emotion', type: 'selection', optionIds: ['happy', 'calm', 'stressed', 'low'] },
|
||||||
{ id: 'no_fill', label: '不想填寫' },
|
{ id: 'influence', type: 'selection', optionIds: ['family', 'work', 'relationship', 'friends', 'health'] },
|
||||||
]
|
{ id: 'support', type: 'selection', optionIds: ['emotional', 'parenting', 'self_worth', 'anxiety', 'balance'] },
|
||||||
},
|
{ id: 'reminder', type: 'reminder' },
|
||||||
{
|
|
||||||
id: 'emotion',
|
|
||||||
type: 'selection',
|
|
||||||
title: '當下情緒狀態?',
|
|
||||||
options: [
|
|
||||||
{ id: 'happy', label: '愉悅、滿足' },
|
|
||||||
{ id: 'calm', label: '平靜、安穩' },
|
|
||||||
{ id: 'stressed', label: '被壓得有點喘不過氣' },
|
|
||||||
{ id: 'low', label: '情緒低落' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'influence',
|
|
||||||
type: 'selection',
|
|
||||||
title: '是什麼影響了你最近的感受?',
|
|
||||||
options: [
|
|
||||||
{ id: 'family', label: '家庭與孩子' },
|
|
||||||
{ id: 'work', label: '工作或學習' },
|
|
||||||
{ id: 'relationship', label: '親密關係' },
|
|
||||||
{ id: 'friends', label: '朋友與人際' },
|
|
||||||
{ id: 'health', label: '身心健康' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'support',
|
|
||||||
type: 'selection',
|
|
||||||
title: '最需要什麼支持?',
|
|
||||||
options: [
|
|
||||||
{ id: 'emotional', label: '情緒支持' },
|
|
||||||
{ id: 'parenting', label: '育兒壓力' },
|
|
||||||
{ id: 'self_worth', label: '自我價值' },
|
|
||||||
{ id: 'anxiety', label: '焦慮舒緩' },
|
|
||||||
{ id: 'balance', label: '休息與平衡' },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{ id: 'reminder', type: 'reminder', title: '你需要每天几次提醒?' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function OnboardingScreen() {
|
export default function OnboardingScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
const [stepIndex, setStepIndex] = useState(0);
|
const [stepIndex, setStepIndex] = useState(0);
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [selections, setSelections] = useState<Record<string, string[]>>({});
|
const [selections, setSelections] = useState<Record<string, string[]>>({});
|
||||||
const [reminderTimes, setReminderTimes] = useState(3);
|
const [reminderTimes, setReminderTimes] = useState(3);
|
||||||
|
|
||||||
const currentStep = STEPS[stepIndex];
|
const currentStep = STEPS[stepIndex];
|
||||||
|
const currentTitle = useMemo(() => t(`onboardingSurvey.steps.${currentStep.id}.title`), [t, currentStep.id]);
|
||||||
|
const currentOptions = useMemo(() => {
|
||||||
|
if (currentStep.type !== 'selection') return [];
|
||||||
|
return currentStep.optionIds.map((optId) => ({
|
||||||
|
id: optId,
|
||||||
|
label: t(`onboardingSurvey.steps.${currentStep.id}.options.${optId}`),
|
||||||
|
}));
|
||||||
|
}, [t, currentStep]);
|
||||||
|
|
||||||
async function onFinish() {
|
async function onFinish() {
|
||||||
// 请求推送权限
|
// 请求推送权限
|
||||||
@@ -89,6 +63,7 @@ export default function OnboardingScreen() {
|
|||||||
|
|
||||||
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
|
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
|
||||||
try {
|
try {
|
||||||
|
const lang = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||||
const { items, meta } = await fetchRecoFeed({
|
const { items, meta } = await fetchRecoFeed({
|
||||||
k: 30,
|
k: 30,
|
||||||
user_profile: {
|
user_profile: {
|
||||||
@@ -106,6 +81,7 @@ export default function OnboardingScreen() {
|
|||||||
|
|
||||||
await setRecoFeedCache({
|
await setRecoFeedCache({
|
||||||
saved_at: new Date().toISOString(),
|
saved_at: new Date().toISOString(),
|
||||||
|
lang,
|
||||||
items: items.map((x) => ({ content_id: x.content_id, text: x.text })),
|
items: items.map((x) => ({ content_id: x.content_id, text: x.text })),
|
||||||
meta: meta as Record<string, unknown>,
|
meta: meta as Record<string, unknown>,
|
||||||
});
|
});
|
||||||
@@ -150,11 +126,13 @@ export default function OnboardingScreen() {
|
|||||||
router.replace('/(app)/home');
|
router.replace('/(app)/home');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 题目为单选:再次点击可取消;选择其他选项会替换为唯一选项
|
// 题目为多选:点击切换选中状态
|
||||||
const handleToggleSelection = (id: string) => {
|
const handleToggleSelection = (id: string) => {
|
||||||
setSelections(prev => {
|
setSelections(prev => {
|
||||||
const currentIds = prev[currentStep.id] || [];
|
const currentIds = prev[currentStep.id] || [];
|
||||||
const nextIds = currentIds.includes(id) ? [] : [id];
|
const nextIds = currentIds.includes(id)
|
||||||
|
? currentIds.filter(i => i !== id)
|
||||||
|
: [...currentIds, id];
|
||||||
return { ...prev, [currentStep.id]: nextIds };
|
return { ...prev, [currentStep.id]: nextIds };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -166,7 +144,7 @@ export default function OnboardingScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<OnboardingLayout
|
<OnboardingLayout
|
||||||
title={currentStep.title}
|
title={currentTitle}
|
||||||
currentStep={stepIndex}
|
currentStep={stepIndex}
|
||||||
totalSteps={STEPS.length - 1}
|
totalSteps={STEPS.length - 1}
|
||||||
onSkip={onSkip}
|
onSkip={onSkip}
|
||||||
@@ -183,11 +161,10 @@ export default function OnboardingScreen() {
|
|||||||
|
|
||||||
{currentStep.type === 'selection' && (
|
{currentStep.type === 'selection' && (
|
||||||
<SelectionStep
|
<SelectionStep
|
||||||
options={currentStep.options!}
|
options={currentOptions}
|
||||||
selectedIds={selections[currentStep.id] || []}
|
selectedIds={selections[currentStep.id] || []}
|
||||||
onToggle={handleToggleSelection}
|
onToggle={handleToggleSelection}
|
||||||
onNext={onNext}
|
onNext={onNext}
|
||||||
onSkip={handleSkipStep}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ SplashScreen.preventAutoHideAsync();
|
|||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const [loaded, error] = useFonts({
|
const [loaded, error] = useFonts({
|
||||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
STIXTwoText: require('../assets/fonts/STIXTwoText-VariableFont_wght.ttf'),
|
||||||
...FontAwesome.font,
|
...FontAwesome.font,
|
||||||
});
|
});
|
||||||
const [i18nReady, setI18nReady] = useState(false);
|
const [i18nReady, setI18nReady] = useState(false);
|
||||||
@@ -48,7 +48,7 @@ export default function RootLayout() {
|
|||||||
initI18n()
|
initI18n()
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
// i18n 初始化失败不应阻塞 App 启动,先打印错误再继续
|
// i18n 初始化失败不应阻塞 App 启动,先打印错误再继续
|
||||||
console.error('i18n 初始化失败', e);
|
console.error('i18n init failed', e);
|
||||||
})
|
})
|
||||||
.finally(() => setI18nReady(true));
|
.finally(() => setI18nReady(true));
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
BIN
client/assets/fonts/STIXTwoText-VariableFont_wght.ttf
Normal file
BIN
client/assets/fonts/STIXTwoText-VariableFont_wght.ttf
Normal file
Binary file not shown.
@@ -1,3 +1,3 @@
|
|||||||
<svg width="32" height="27" viewBox="0 0 32 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="32" height="27" viewBox="0 0 32 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M23.0879 1C24.9887 1 26.7592 1.93575 28.0762 3.42188C29.3956 4.91097 30.2001 6.88999 30.2002 8.84082C30.2002 13.2337 27.1054 17.3696 23.5225 20.499C21.7556 22.0422 19.9254 23.2909 18.4199 24.1494C17.6667 24.5789 17.0062 24.904 16.4863 25.1182C16.2262 25.2253 16.0121 25.3001 15.8467 25.3467C15.6728 25.3956 15.5993 25.4002 15.5996 25.4004C15.5928 25.3997 15.5178 25.3932 15.3525 25.3467C15.1871 25.3001 14.973 25.2253 14.7129 25.1182C14.193 24.904 13.5333 24.5788 12.7803 24.1494C11.2748 23.2909 9.44467 22.0423 7.67773 20.499C4.0947 17.3696 1 13.2338 1 8.84082C1.00008 6.89007 1.80381 4.91108 3.12207 3.42188C4.43787 1.93557 6.20587 1.00033 8.10059 1C9.51294 1.00117 10.8927 1.41742 12.0635 2.19434C12.7589 2.65582 13.3618 3.2322 13.8486 3.89355C14.2951 4.50017 15.0032 4.73135 15.5996 4.73145C16.1961 4.73144 16.905 4.5003 17.3516 3.89355C17.8376 3.23336 18.4389 2.65737 19.1328 2.19629C20.3012 1.4199 21.6779 1.00339 23.0879 1Z" stroke="#5E2A28" stroke-width="2"/>
|
<path d="M23.0879 1C24.9887 1 26.7592 1.93575 28.0762 3.42188C29.3956 4.91097 30.2001 6.88999 30.2002 8.84082C30.2002 13.2337 27.1054 17.3696 23.5225 20.499C21.7556 22.0422 19.9254 23.2909 18.4199 24.1494C17.6667 24.5789 17.0062 24.904 16.4863 25.1182C16.2262 25.2253 16.0121 25.3001 15.8467 25.3467C15.6728 25.3956 15.5993 25.4002 15.5996 25.4004C15.5928 25.3997 15.5178 25.3932 15.3525 25.3467C15.1871 25.3001 14.973 25.2253 14.7129 25.1182C14.193 24.904 13.5333 24.5788 12.7803 24.1494C11.2748 23.2909 9.44467 22.0423 7.67773 20.499C4.0947 17.3696 1 13.2338 1 8.84082C1.00008 6.89007 1.80381 4.91108 3.12207 3.42188C4.43787 1.93557 6.20587 1.00033 8.10059 1C9.51294 1.00117 10.8927 1.41742 12.0635 2.19434C12.7589 2.65582 13.3618 3.2322 13.8486 3.89355C14.2951 4.50017 15.0032 4.73135 15.5996 4.73145C16.1961 4.73144 16.905 4.5003 17.3516 3.89355C17.8376 3.23336 18.4389 2.65737 19.1328 2.19629C20.3012 1.4199 21.6779 1.00339 23.0879 1Z" stroke="currentColor" stroke-width="2"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,4 +1,4 @@
|
|||||||
<svg width="32" height="27" viewBox="0 0 32 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="32" height="27" viewBox="0 0 32 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M23.0879 1C24.9887 1 26.7592 1.93575 28.0762 3.42188C29.3956 4.91097 30.2001 6.88999 30.2002 8.84082C30.2002 13.2337 27.1054 17.3696 23.5225 20.499C21.7556 22.0422 19.9254 23.2909 18.4199 24.1494C17.6667 24.5789 17.0062 24.904 16.4863 25.1182C16.2262 25.2253 16.0121 25.3001 15.8467 25.3467C15.6728 25.3956 15.5993 25.4002 15.5996 25.4004C15.5928 25.3997 15.5178 25.3932 15.3525 25.3467C15.1871 25.3001 14.973 25.2253 14.7129 25.1182C14.193 24.904 13.5333 24.5788 12.7803 24.1494C11.2748 23.2909 9.44467 22.0423 7.67773 20.499C4.0947 17.3696 1 13.2338 1 8.84082C1.00008 6.89007 1.80381 4.91108 3.12207 3.42188C4.43787 1.93557 6.20587 1.00033 8.10059 1C9.51294 1.00117 10.8927 1.41742 12.0635 2.19434C12.7589 2.65582 13.3618 3.2322 13.8486 3.89355C14.2951 4.50017 15.0032 4.73135 15.5996 4.73145C16.1961 4.73144 16.905 4.5003 17.3516 3.89355C17.8376 3.23336 18.4389 2.65737 19.1328 2.19629C20.3012 1.4199 21.6779 1.00339 23.0879 1Z" fill="#5E2A28" stroke="#5E2A28" stroke-width="2"/>
|
<path d="M23.0879 1C24.9887 1 26.7592 1.93575 28.0762 3.42188C29.3956 4.91097 30.2001 6.88999 30.2002 8.84082C30.2002 13.2337 27.1054 17.3696 23.5225 20.499C21.7556 22.0422 19.9254 23.2909 18.4199 24.1494C17.6667 24.5789 17.0062 24.904 16.4863 25.1182C16.2262 25.2253 16.0121 25.3001 15.8467 25.3467C15.6728 25.3956 15.5993 25.4002 15.5996 25.4004C15.5928 25.3997 15.5178 25.3932 15.3525 25.3467C15.1871 25.3001 14.973 25.2253 14.7129 25.1182C14.193 24.904 13.5333 24.5788 12.7803 24.1494C11.2748 23.2909 9.44467 22.0423 7.67773 20.499C4.0947 17.3696 1 13.2338 1 8.84082C1.00008 6.89007 1.80381 4.91108 3.12207 3.42188C4.43787 1.93557 6.20587 1.00033 8.10059 1C9.51294 1.00117 10.8927 1.41742 12.0635 2.19434C12.7589 2.65582 13.3618 3.2322 13.8486 3.89355C14.2951 4.50017 15.0032 4.73135 15.5996 4.73145C16.1961 4.73144 16.905 4.5003 17.3516 3.89355C17.8376 3.23336 18.4389 2.65737 19.1328 2.19629C20.3012 1.4199 21.6779 1.00339 23.0879 1Z" fill="currentColor" stroke="currentColor" stroke-width="2"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -1,3 +1,3 @@
|
|||||||
<svg width="35" height="36" viewBox="0 0 35 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="35" height="36" viewBox="0 0 35 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M25.0879 6C26.9887 6 28.7592 6.93575 30.0762 8.42188C31.3956 9.91097 32.2001 11.89 32.2002 13.8408C32.2002 18.2337 29.1054 22.3696 25.5225 25.499C23.7556 27.0422 21.9254 28.2909 20.4199 29.1494C19.6667 29.5789 19.0062 29.904 18.4863 30.1182C18.2262 30.2253 18.0121 30.3001 17.8467 30.3467C17.6728 30.3956 17.5993 30.4002 17.5996 30.4004C17.5928 30.3997 17.5178 30.3932 17.3525 30.3467C17.1871 30.3001 16.973 30.2253 16.7129 30.1182C16.193 29.904 15.5333 29.5788 14.7803 29.1494C13.2748 28.2909 11.4447 27.0423 9.67773 25.499C6.0947 22.3696 3 18.2338 3 13.8408C3.00008 11.8901 3.80381 9.91108 5.12207 8.42188C6.43787 6.93557 8.20587 6.00033 10.1006 6C11.5129 6.00117 12.8927 6.41742 14.0635 7.19434C14.7589 7.65582 15.3618 8.2322 15.8486 8.89355C16.2951 9.50017 17.0032 9.73135 17.5996 9.73145C18.1961 9.73144 18.905 9.5003 19.3516 8.89355C19.8376 8.23336 20.4389 7.65737 21.1328 7.19629C22.3012 6.4199 23.6779 6.00339 25.0879 6Z" stroke="#5E2A28" stroke-width="2"/>
|
<path d="M25.0879 6C26.9887 6 28.7592 6.93575 30.0762 8.42188C31.3956 9.91097 32.2001 11.89 32.2002 13.8408C32.2002 18.2337 29.1054 22.3696 25.5225 25.499C23.7556 27.0422 21.9254 28.2909 20.4199 29.1494C19.6667 29.5789 19.0062 29.904 18.4863 30.1182C18.2262 30.2253 18.0121 30.3001 17.8467 30.3467C17.6728 30.3956 17.5993 30.4002 17.5996 30.4004C17.5928 30.3997 17.5178 30.3932 17.3525 30.3467C17.1871 30.3001 16.973 30.2253 16.7129 30.1182C16.193 29.904 15.5333 29.5788 14.7803 29.1494C13.2748 28.2909 11.4447 27.0423 9.67773 25.499C6.0947 22.3696 3 18.2338 3 13.8408C3.00008 11.8901 3.80381 9.91108 5.12207 8.42188C6.43787 6.93557 8.20587 6.00033 10.1006 6C11.5129 6.00117 12.8927 6.41742 14.0635 7.19434C14.7589 7.65582 15.3618 8.2322 15.8486 8.89355C16.2951 9.50017 17.0032 9.73135 17.5996 9.73145C18.1961 9.73144 18.905 9.5003 19.3516 8.89355C19.8376 8.23336 20.4389 7.65737 21.1328 7.19629C22.3012 6.4199 23.6779 6.00339 25.0879 6Z" stroke="currentColor" stroke-width="2"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import SheetModal from '@/components/ui/SheetModal';
|
import SheetModal from '@/components/ui/SheetModal';
|
||||||
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
||||||
import { getFavorites } from '@/src/storage/appStorage';
|
import { getFavorites, getRecoFeedCache, type FavoriteItem } from '@/src/storage/appStorage';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -13,25 +13,50 @@ type Props = {
|
|||||||
|
|
||||||
export default function FavoritesModal({ visible, onClose }: Props) {
|
export default function FavoritesModal({ visible, onClose }: Props) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [ids, setIds] = useState<string[]>([]);
|
const [items, setItems] = useState<(FavoriteItem & { text: string })[]>([]);
|
||||||
|
|
||||||
// 每次打开时刷新一次,确保展示最新“喜欢”
|
// 每次打开时刷新一次,确保展示最新“喜欢”
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!visible) return;
|
if (!visible) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const list = await getFavorites();
|
const [favList, cache] = await Promise.all([
|
||||||
if (!cancelled) setIds(list);
|
getFavorites(),
|
||||||
|
getRecoFeedCache()
|
||||||
|
]);
|
||||||
|
console.log('FavoritesModal: Loaded favList', favList.length, 'items');
|
||||||
|
console.log('FavoritesModal: Loaded cache items', cache?.items?.length || 0);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// 建立文案查找表
|
||||||
|
const textMap = new Map<string, string>();
|
||||||
|
|
||||||
|
// 1. 放入 Mock 数据
|
||||||
|
MOCK_CONTENT.forEach(c => textMap.set(String(c.id), t(c.textKey)));
|
||||||
|
|
||||||
|
// 2. 放入缓存数据
|
||||||
|
if (cache?.items) {
|
||||||
|
cache.items.forEach(c => textMap.set(String(c.content_id), c.text));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 组装最终展示列表
|
||||||
|
const enriched = favList.map(fav => {
|
||||||
|
const favIdStr = String(fav.id);
|
||||||
|
const text = fav.text || textMap.get(favIdStr);
|
||||||
|
console.log(`FavoritesModal: Matching fav.id=${favIdStr}, found text=${!!text}, textValue=${text?.substring(0, 10)}...`);
|
||||||
|
return {
|
||||||
|
...fav,
|
||||||
|
text: text || t('favorites.unknownText')
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
setItems(enriched);
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [visible]);
|
}, [visible, t]);
|
||||||
|
|
||||||
const items = useMemo(() => {
|
|
||||||
const map = new Map(MOCK_CONTENT.map((c) => [c.id, c]));
|
|
||||||
return ids.map((id) => map.get(id)).filter(Boolean) as { id: string; text: string }[];
|
|
||||||
}, [ids]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SheetModal visible={visible} title={t('profile.favorites')} onClose={onClose}>
|
<SheetModal visible={visible} title={t('profile.favorites')} onClose={onClose}>
|
||||||
@@ -41,7 +66,7 @@ export default function FavoritesModal({ visible, onClose }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlatList
|
||||||
data={items}
|
data={items}
|
||||||
keyExtractor={(it) => it.id}
|
keyExtractor={(it) => it.favId}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
getFavorites,
|
getFavorites,
|
||||||
setDailyReminderSettings,
|
setDailyReminderSettings,
|
||||||
removeFavorite,
|
removeFavorite,
|
||||||
|
getRecoFeedCache,
|
||||||
getUserProfile,
|
getUserProfile,
|
||||||
type DailyReminderSettings,
|
type DailyReminderSettings,
|
||||||
type FavoriteItem,
|
type FavoriteItem,
|
||||||
@@ -271,15 +272,21 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
|||||||
|
|
||||||
async function refreshFavorites() {
|
async function refreshFavorites() {
|
||||||
const storedFavs = await getFavorites();
|
const storedFavs = await getFavorites();
|
||||||
const map = new Map(MOCK_CONTENT.map((c) => [c.id, c.text]));
|
const textMap = new Map<string, string>();
|
||||||
|
|
||||||
const list = storedFavs
|
// 1) Mock 文案
|
||||||
.map((fav) => ({
|
MOCK_CONTENT.forEach((c) => textMap.set(String(c.id), t(c.textKey)));
|
||||||
...fav,
|
|
||||||
text: map.get(fav.id) || ''
|
// 2) 后端推荐缓存文案(避免收藏后 cache 覆盖就丢文案)
|
||||||
}))
|
const cache = await getRecoFeedCache();
|
||||||
.filter(item => item.text !== '');
|
cache?.items?.forEach((c) => textMap.set(String(c.content_id), c.text));
|
||||||
|
|
||||||
|
// 3) 组装:优先使用收藏时写入的 text,其次从 map 回填
|
||||||
|
const list = storedFavs.map((fav) => ({
|
||||||
|
...fav,
|
||||||
|
text: fav.text || textMap.get(String(fav.id)) || t('favorites.unknownText'),
|
||||||
|
}));
|
||||||
|
|
||||||
setFavorites(list);
|
setFavorites(list);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +397,7 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
|||||||
if (settings.status === 'denied') {
|
if (settings.status === 'denied') {
|
||||||
Alert.alert(
|
Alert.alert(
|
||||||
t('common.notice'),
|
t('common.notice'),
|
||||||
"系统权限已被拒绝,请前往手机设置开启通知。"
|
t('permissions.notificationsDenied')
|
||||||
);
|
);
|
||||||
setPushEnabled(false);
|
setPushEnabled(false);
|
||||||
return;
|
return;
|
||||||
@@ -607,12 +614,12 @@ function WidgetHowToPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function LanguagePage() {
|
function LanguagePage() {
|
||||||
const { i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const currentLang = i18n.language;
|
const currentLang = i18n.language;
|
||||||
|
|
||||||
const languages = [
|
const languages = [
|
||||||
{ id: 'zh-TW', label: '繁体' },
|
{ id: 'zh-TW', label: t('language.zhTW') },
|
||||||
{ id: 'en', label: 'English' },
|
{ id: 'en', label: t('language.en') },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { SerifText } from './SerifText';
|
import { SerifText } from './SerifText';
|
||||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||||
|
|
||||||
export const INTENTS = [
|
export const INTENTS = [
|
||||||
{ id: 'love', label: '爱情', icon: '❤️' },
|
{ id: 'love', labelKey: 'intent.love', icon: '❤️' },
|
||||||
{ id: 'life', label: '生活', icon: '⛅' },
|
{ id: 'life', labelKey: 'intent.life', icon: '⛅' },
|
||||||
{ id: 'travel', label: '旅游', icon: '🌴' },
|
{ id: 'travel', labelKey: 'intent.travel', icon: '🌴' },
|
||||||
{ id: 'work', label: '职场', icon: '💼' },
|
{ id: 'work', labelKey: 'intent.work', icon: '💼' },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface IntentSelectionStepProps {
|
interface IntentSelectionStepProps {
|
||||||
@@ -16,9 +17,10 @@ interface IntentSelectionStepProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionStepProps) {
|
export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionStepProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<SerifText style={styles.title}>你希望得到什么帮助?</SerifText>
|
<SerifText style={styles.title}>{t('intent.title')}</SerifText>
|
||||||
|
|
||||||
<View style={styles.grid}>
|
<View style={styles.grid}>
|
||||||
{INTENTS.map((intent) => {
|
{INTENTS.map((intent) => {
|
||||||
@@ -35,7 +37,7 @@ export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionSt
|
|||||||
>
|
>
|
||||||
<SerifText style={styles.icon}>{intent.icon}</SerifText>
|
<SerifText style={styles.icon}>{intent.icon}</SerifText>
|
||||||
<SerifText style={[styles.label, isSelected && styles.labelSelected]}>
|
<SerifText style={[styles.label, isSelected && styles.labelSelected]}>
|
||||||
{intent.label}
|
{t(intent.labelKey)}
|
||||||
</SerifText>
|
</SerifText>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,17 +49,9 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }
|
|||||||
|
|
||||||
{/* 底部按钮:距离底部 12% 高度 */}
|
{/* 底部按钮:距离底部 12% 高度 */}
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<View style={styles.footerRow}>
|
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
|
||||||
{onSkip && (
|
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||||
<TouchableOpacity onPress={onSkip} activeOpacity={0.8} style={styles.skipBtn}>
|
</TouchableOpacity>
|
||||||
<SerifText style={styles.skipText}>跳过</SerifText>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
|
|
||||||
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
||||||
import { Modal, Pressable, StyleSheet, Text, View, PanResponder, Animated as RNAnimated, Dimensions, Image, ImageSourcePropType } from 'react-native';
|
import { Modal, Pressable, StyleSheet, Text, View, PanResponder, Animated as RNAnimated, Dimensions, Image, ImageSourcePropType } from 'react-native';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
Easing,
|
Easing,
|
||||||
@@ -27,6 +28,7 @@ type Props = {
|
|||||||
* - 高度固定:默认距离顶部固定间距,也支持传入指定高度
|
* - 高度固定:默认距离顶部固定间距,也支持传入指定高度
|
||||||
*/
|
*/
|
||||||
export default function SheetModal({ visible, title, onClose, children, leftIcon, height: customHeight }: Props) {
|
export default function SheetModal({ visible, title, onClose, children, leftIcon, height: customHeight }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const progress = useSharedValue(0); // 0: 关闭, 1: 打开
|
const progress = useSharedValue(0); // 0: 关闭, 1: 打开
|
||||||
@@ -121,7 +123,7 @@ export default function SheetModal({ visible, title, onClose, children, leftIcon
|
|||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={leftIcon ? "返回" : "关闭"}
|
accessibilityLabel={leftIcon ? t('common.back') : t('common.close')}
|
||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
hitSlop={10}
|
hitSlop={10}
|
||||||
style={styles.close}
|
style={styles.close}
|
||||||
|
|||||||
8
client/global.d.ts
vendored
Normal file
8
client/global.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* React Native 全局常量声明。
|
||||||
|
*
|
||||||
|
* 说明:`__DEV__` 在运行时由 RN 注入,用于区分开发/生产环境。
|
||||||
|
* 这里补充 TypeScript 声明,避免在代码里使用时出现类型报错。
|
||||||
|
*/
|
||||||
|
declare const __DEV__: boolean;
|
||||||
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 77;
|
objectVersion = 70;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
@@ -11,14 +11,12 @@
|
|||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||||
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
|
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||||
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */; };
|
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */; };
|
||||||
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; };
|
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; };
|
||||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||||
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
||||||
EB3DAF832F2A4B8E00450593 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */; };
|
EB3DAF832F2A4B8E00450593 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */; };
|
||||||
EB3DAF942F2A4B8F00450593 /* 情绪小组件Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
EB3DAF942F2A4B8F00450593 /* 情绪小组件Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
EB630B302F30BE2700DC761A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EB630B2F2F30BE2700DC761A /* Assets.xcassets */; };
|
|
||||||
EB630B312F30BE2700DC761A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EB630B2F2F30BE2700DC761A /* Assets.xcassets */; };
|
|
||||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
@@ -47,12 +45,12 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
13B07F961A680F5B00A75B9A /* client.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = client.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
13B07F961A680F5B00A75B9A /* HeyMama.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HeyMama.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = client/Images.xcassets; sourceTree = "<group>"; };
|
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = client/Images.xcassets; sourceTree = "<group>"; };
|
||||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; };
|
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; };
|
||||||
3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = "<group>"; };
|
3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = client/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
|
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "情绪小组件/EmotionWidget.swift"; sourceTree = "<group>"; };
|
||||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; };
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||||
C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||||
@@ -61,7 +59,6 @@
|
|||||||
EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
|
||||||
EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
|
||||||
EB3DAF9A2F2A4D0900450593 /* MindfulnessWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindfulnessWidget.swift; sourceTree = "<group>"; };
|
EB3DAF9A2F2A4D0900450593 /* MindfulnessWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MindfulnessWidget.swift; sourceTree = "<group>"; };
|
||||||
EB630B2F2F30BE2700DC761A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
|
||||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = client/AppDelegate.swift; sourceTree = "<group>"; };
|
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = client/AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
F11748442D0722820044C1D9 /* client-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "client-Bridging-Header.h"; path = "client/client-Bridging-Header.h"; sourceTree = "<group>"; };
|
F11748442D0722820044C1D9 /* client-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "client-Bridging-Header.h"; path = "client/client-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
@@ -69,7 +66,7 @@
|
|||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = {
|
EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||||
membershipExceptions = (
|
membershipExceptions = (
|
||||||
EmotionWidget.swift,
|
EmotionWidget.swift,
|
||||||
@@ -80,18 +77,7 @@
|
|||||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||||
|
|
||||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {
|
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = "<group>"; };
|
||||||
isa = PBXFileSystemSynchronizedRootGroup;
|
|
||||||
exceptions = (
|
|
||||||
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */,
|
|
||||||
);
|
|
||||||
explicitFileTypes = {
|
|
||||||
};
|
|
||||||
explicitFolders = (
|
|
||||||
);
|
|
||||||
path = "情绪小组件";
|
|
||||||
sourceTree = "<group>";
|
|
||||||
};
|
|
||||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -159,7 +145,6 @@
|
|||||||
83CBB9F61A601CBA00E9B192 = {
|
83CBB9F61A601CBA00E9B192 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
EB630B2F2F30BE2700DC761A /* Assets.xcassets */,
|
|
||||||
13B07FAE1A68108700A75B9A /* client */,
|
13B07FAE1A68108700A75B9A /* client */,
|
||||||
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */,
|
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */,
|
||||||
@@ -177,7 +162,7 @@
|
|||||||
83CBBA001A601CBA00E9B192 /* Products */ = {
|
83CBBA001A601CBA00E9B192 /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
13B07F961A680F5B00A75B9A /* client.app */,
|
13B07F961A680F5B00A75B9A /* HeyMama.app */,
|
||||||
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */,
|
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
@@ -204,7 +189,7 @@
|
|||||||
EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
|
EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */,
|
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */,
|
||||||
);
|
);
|
||||||
name = "Recovered References";
|
name = "Recovered References";
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -241,7 +226,7 @@
|
|||||||
);
|
);
|
||||||
name = client;
|
name = client;
|
||||||
productName = client;
|
productName = client;
|
||||||
productReference = 13B07F961A680F5B00A75B9A /* client.app */;
|
productReference = 13B07F961A680F5B00A75B9A /* HeyMama.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
EB3DAF7E2F2A4B8D00450593 /* 情绪小组件Extension */ = {
|
EB3DAF7E2F2A4B8D00450593 /* 情绪小组件Extension */ = {
|
||||||
@@ -270,8 +255,12 @@
|
|||||||
83CBB9F71A601CBA00E9B192 /* Project object */ = {
|
83CBB9F71A601CBA00E9B192 /* Project object */ = {
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
KnownAssetTags = (
|
||||||
|
New,
|
||||||
|
);
|
||||||
LastSwiftUpdateCheck = 2620;
|
LastSwiftUpdateCheck = 2620;
|
||||||
LastUpgradeCheck = 1130;
|
LastUpgradeCheck = 2620;
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
13B07F861A680F5B00A75B9A = {
|
13B07F861A680F5B00A75B9A = {
|
||||||
LastSwiftMigration = 1250;
|
LastSwiftMigration = 1250;
|
||||||
@@ -309,7 +298,6 @@
|
|||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||||
0BE245B56A79D95AB0A7B4BA /* PrivacyInfo.xcprivacy in Resources */,
|
0BE245B56A79D95AB0A7B4BA /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
EB630B312F30BE2700DC761A /* Assets.xcassets in Resources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -317,7 +305,6 @@
|
|||||||
isa = PBXResourcesBuildPhase;
|
isa = PBXResourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
EB630B302F30BE2700DC761A /* Assets.xcassets in Resources */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -461,7 +448,7 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */,
|
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -484,8 +471,7 @@
|
|||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = WS92GPX9H2;
|
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -497,7 +483,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -505,7 +491,8 @@
|
|||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
||||||
PRODUCT_NAME = client;
|
PRODUCT_NAME = HeyMama;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
@@ -526,7 +513,7 @@
|
|||||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = WS92GPX9H2;
|
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||||
DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT = YES;
|
DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT = YES;
|
||||||
@@ -536,7 +523,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -544,7 +531,8 @@
|
|||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
||||||
PRODUCT_NAME = client;
|
PRODUCT_NAME = HeyMama;
|
||||||
|
SKIP_INSTALL = YES;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||||
@@ -579,6 +567,7 @@
|
|||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
@@ -610,9 +599,11 @@
|
|||||||
);
|
);
|
||||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = NO;
|
||||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
|
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = NO;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||||
USE_HERMES = true;
|
USE_HERMES = true;
|
||||||
@@ -642,6 +633,7 @@
|
|||||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
@@ -665,9 +657,13 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
);
|
);
|
||||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
|
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/.pnpm/react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0/node_modules/react-native";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
SKIP_INSTALL = NO;
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||||
USE_HERMES = true;
|
USE_HERMES = true;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
@@ -688,9 +684,8 @@
|
|||||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
DEVELOPMENT_TEAM = WS92GPX9H2;
|
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -704,7 +699,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.1;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
@@ -741,7 +736,7 @@
|
|||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
COPY_PHASE_STRIP = NO;
|
COPY_PHASE_STRIP = NO;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
DEVELOPMENT_TEAM = WS92GPX9H2;
|
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -757,7 +752,7 @@
|
|||||||
"@executable_path/../../Frameworks",
|
"@executable_path/../../Frameworks",
|
||||||
);
|
);
|
||||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||||
MARKETING_VERSION = 1.0.0;
|
MARKETING_VERSION = 1.0.1;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness.emotionwidget;
|
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness.emotionwidget;
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2620"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
|
BuildableName = "HeyMama.app"
|
||||||
|
BlueprintName = "client"
|
||||||
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
|
BuildableName = "HeyMama.app"
|
||||||
|
BlueprintName = "client"
|
||||||
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
|
BuildableName = "HeyMama.app"
|
||||||
|
BlueprintName = "client"
|
||||||
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
customArchiveName = "Hey Mama"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme
|
||||||
|
LastUpgradeVersion = "2620"
|
||||||
|
version = "1.7">
|
||||||
|
<BuildAction
|
||||||
|
parallelizeBuildables = "YES"
|
||||||
|
buildImplicitDependencies = "YES"
|
||||||
|
buildArchitectures = "Automatic">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
|
BuildableName = "HeyMama.app"
|
||||||
|
BlueprintName = "client"
|
||||||
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
shouldAutocreateTestPlan = "YES">
|
||||||
|
</TestAction>
|
||||||
|
<LaunchAction
|
||||||
|
buildConfiguration = "Debug"
|
||||||
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
|
launchStyle = "0"
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
ignoresPersistentStateOnLaunch = "NO"
|
||||||
|
debugDocumentVersioning = "YES"
|
||||||
|
debugServiceExtension = "internal"
|
||||||
|
allowLocationSimulation = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
|
BuildableName = "HeyMama.app"
|
||||||
|
BlueprintName = "client"
|
||||||
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
|
savedToolIdentifier = ""
|
||||||
|
useCustomWorkingDirectory = "NO"
|
||||||
|
debugDocumentVersioning = "YES">
|
||||||
|
<BuildableProductRunnable
|
||||||
|
runnableDebuggingMode = "0">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
|
BuildableName = "HeyMama.app"
|
||||||
|
BlueprintName = "client"
|
||||||
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction
|
||||||
|
buildConfiguration = "Debug">
|
||||||
|
</AnalyzeAction>
|
||||||
|
<ArchiveAction
|
||||||
|
buildConfiguration = "Release"
|
||||||
|
revealArchiveInOrganizer = "YES">
|
||||||
|
</ArchiveAction>
|
||||||
|
</Scheme>
|
||||||
@@ -1,11 +1,20 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1130"
|
LastUpgradeVersion = "2620"
|
||||||
version = "1.3">
|
version = "2.2">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry
|
||||||
|
buildForTesting = "YES"
|
||||||
|
buildForRunning = "YES"
|
||||||
|
buildForProfiling = "YES"
|
||||||
|
buildForArchiving = "YES"
|
||||||
|
buildForAnalyzing = "YES">
|
||||||
|
<AutocreatedTestPlanReference>
|
||||||
|
</AutocreatedTestPlanReference>
|
||||||
|
</BuildActionEntry>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
buildForRunning = "YES"
|
buildForRunning = "YES"
|
||||||
@@ -15,7 +24,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
BuildableName = "client.app"
|
BuildableName = "HeyMama.app"
|
||||||
BlueprintName = "client"
|
BlueprintName = "client"
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
@@ -26,19 +35,8 @@
|
|||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||||
<Testables>
|
shouldAutocreateTestPlan = "YES">
|
||||||
<TestableReference
|
|
||||||
skipped = "NO">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
|
|
||||||
BuildableName = "clientTests.xctest"
|
|
||||||
BlueprintName = "clientTests"
|
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
</Testables>
|
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Debug"
|
||||||
@@ -55,7 +53,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
BuildableName = "client.app"
|
BuildableName = "HeyMama.app"
|
||||||
BlueprintName = "client"
|
BlueprintName = "client"
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
@@ -72,7 +70,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
BuildableName = "client.app"
|
BuildableName = "HeyMama.app"
|
||||||
BlueprintName = "client"
|
BlueprintName = "client"
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>client</string>
|
<string>Hey Mama</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<key>RCTNewArchEnabled</key>
|
<key>RCTNewArchEnabled</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>UILaunchStoryboardName</key>
|
<key>UILaunchStoryboardName</key>
|
||||||
<string></string>
|
<string>SplashScreen</string>
|
||||||
<key>UIRequiredDeviceCapabilities</key>
|
<key>UIRequiredDeviceCapabilities</key>
|
||||||
<array>
|
<array>
|
||||||
<string>arm64</string>
|
<string>arm64</string>
|
||||||
|
|||||||
@@ -34,13 +34,31 @@ function getApiBaseUrl(env: AppRuntimeEnv): string {
|
|||||||
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000');
|
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000');
|
||||||
}
|
}
|
||||||
if (env === 'dev') {
|
if (env === 'dev') {
|
||||||
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_DEV', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000'));
|
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_DEV', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'https://api.damer.fun'));
|
||||||
}
|
}
|
||||||
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_PROD', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000'));
|
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_PROD', getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'https://api.damer.fun'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const API_BASE_URL = getApiBaseUrl(APP_ENV);
|
export const API_BASE_URL = getApiBaseUrl(APP_ENV);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调试:打印环境变量注入结果(仅开发环境)
|
||||||
|
*
|
||||||
|
* 用途:排查「为什么 API_BASE_URL 不是预期值」的问题(例如 .env.local/命令行注入/缓存导致)。
|
||||||
|
*/
|
||||||
|
if (__DEV__) {
|
||||||
|
const injected = {
|
||||||
|
EXPO_PUBLIC_ENV: process.env.EXPO_PUBLIC_ENV,
|
||||||
|
EXPO_PUBLIC_API_BASE_URL: process.env.EXPO_PUBLIC_API_BASE_URL,
|
||||||
|
EXPO_PUBLIC_API_BASE_URL_LOCAL: process.env.EXPO_PUBLIC_API_BASE_URL_LOCAL,
|
||||||
|
EXPO_PUBLIC_API_BASE_URL_DEV: process.env.EXPO_PUBLIC_API_BASE_URL_DEV,
|
||||||
|
EXPO_PUBLIC_API_BASE_URL_PROD: process.env.EXPO_PUBLIC_API_BASE_URL_PROD,
|
||||||
|
};
|
||||||
|
console.log('[Env] 注入的 EXPO_PUBLIC_*(用于 API_BASE_URL 计算):', injected);
|
||||||
|
console.log('[Env] 解析得到 APP_ENV:', APP_ENV);
|
||||||
|
console.log('[Env] 解析得到 API_BASE_URL:', API_BASE_URL);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 默认语言策略:
|
* 默认语言策略:
|
||||||
* - auto:优先设备语言(支持列表内时),否则回退 en
|
* - auto:优先设备语言(支持列表内时),否则回退 en
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
export type MockContentItem = {
|
export type MockContentItem = {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
textKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 本地 mock 内容(后续接后端时可替换)
|
* 本地 mock 内容(后续接后端时可替换)
|
||||||
*/
|
*/
|
||||||
export const MOCK_CONTENT: MockContentItem[] = [
|
export const MOCK_CONTENT: MockContentItem[] = [
|
||||||
{ id: 'c1', text: '你已经很努力了,今天也值得被温柔对待。' },
|
{ id: 'c1', textKey: 'mock.c1' },
|
||||||
{ id: 'c2', text: '深呼吸三次,把注意力带回当下。' },
|
{ id: 'c2', textKey: 'mock.c2' },
|
||||||
{ id: 'c3', text: '允许自己慢一点,情绪会像云一样飘过。' },
|
{ id: 'c3', textKey: 'mock.c3' },
|
||||||
{ id: 'c4', text: '你不需要完美,你已经足够好。' },
|
{ id: 'c4', textKey: 'mock.c4' },
|
||||||
{ id: 'c5', text: '把手放在心口,对自己说一句:辛苦了。' }
|
{ id: 'c5', textKey: 'mock.c5' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
22
client/src/i18n/ALL_COPY.md
Normal file
22
client/src/i18n/ALL_COPY.md
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
## 应用文案总表(请在此文件对应的 JSON 中修改)
|
||||||
|
|
||||||
|
**单一文案源文件**:`client/src/i18n/locales/all.json`
|
||||||
|
|
||||||
|
- **English**:`all.json` 的 `en`
|
||||||
|
- **繁体中文**:`all.json` 的 `zh-TW`
|
||||||
|
|
||||||
|
> 说明:项目运行时只读取 `all.json`;请不要再改 `locales/en.json`、`locales/zh-TW.json`(它们已不再作为运行时数据源)。
|
||||||
|
|
||||||
|
### 快速索引(高频文案)
|
||||||
|
|
||||||
|
- **Home**:`home.*`
|
||||||
|
- **Push 提示**:`push.*`
|
||||||
|
- **主题**:`theme.*`
|
||||||
|
- **我的/Profile**:`profile.*`
|
||||||
|
- **收藏**:`favorites.*`
|
||||||
|
- **设置**:`settings.*`
|
||||||
|
- **Onboarding(问卷)**:`onboardingSurvey.steps.*`
|
||||||
|
- **Onboarding(兴趣)**:`intent.*`
|
||||||
|
- **Mock 文案**:`mock.*`
|
||||||
|
|
||||||
|
|
||||||
@@ -3,8 +3,9 @@ import * as Localization from 'expo-localization';
|
|||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
|
|
||||||
import en from './locales/en.json';
|
// 用 require 避免 TS 的 json module 配置差异导致无法编译
|
||||||
import zhTW from './locales/zh-TW.json';
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const all = require('./locales/all.json') as { en: Record<string, unknown>; 'zh-TW': Record<string, unknown> };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 语言码约定:
|
* 语言码约定:
|
||||||
@@ -83,8 +84,8 @@ export async function initI18n(): Promise<void> {
|
|||||||
|
|
||||||
await i18n.use(initReactI18next).init({
|
await i18n.use(initReactI18next).init({
|
||||||
resources: {
|
resources: {
|
||||||
'zh-TW': { translation: zhTW },
|
'zh-TW': { translation: all['zh-TW'] as any },
|
||||||
en: { translation: en },
|
en: { translation: all.en as any },
|
||||||
},
|
},
|
||||||
lng: initialLang,
|
lng: initialLang,
|
||||||
fallbackLng: DEFAULT_FALLBACK_LANGUAGE,
|
fallbackLng: DEFAULT_FALLBACK_LANGUAGE,
|
||||||
|
|||||||
314
client/src/i18n/locales/all.json
Normal file
314
client/src/i18n/locales/all.json
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
{
|
||||||
|
"en": {
|
||||||
|
"common": {
|
||||||
|
"ok": "OK",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"error": "Error",
|
||||||
|
"openLinkError": "Cannot open link",
|
||||||
|
"back": "Back",
|
||||||
|
"close": "Close"
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"title": "Welcome",
|
||||||
|
"progress": "{{current}}/{{total}}",
|
||||||
|
"next": "Next",
|
||||||
|
"skip": "Skip",
|
||||||
|
"skipAll": "Skip onboarding",
|
||||||
|
"q1Title": "How are you feeling lately?",
|
||||||
|
"q1Desc": "No right or wrong. You can skip and adjust later.",
|
||||||
|
"q2Title": "What kind of support do you want?",
|
||||||
|
"q2Desc": "For example: gentle reminders, mindfulness, emotional support.",
|
||||||
|
"q3Title": "When do you need comfort the most?",
|
||||||
|
"q3Desc": "Morning, afternoon, late night, or specific moments.",
|
||||||
|
"q4Title": "A gentle sentence for yourself",
|
||||||
|
"q4Desc": "You can skip. We’ll stay with you along the way."
|
||||||
|
},
|
||||||
|
"onboardingSurvey": {
|
||||||
|
"steps": {
|
||||||
|
"name": { "title": "What should I call you?" },
|
||||||
|
"status": {
|
||||||
|
"title": "Your current stage?",
|
||||||
|
"options": {
|
||||||
|
"pregnant": "Pregnant / preparing for motherhood",
|
||||||
|
"has_kids": "Already have kids",
|
||||||
|
"no_fill": "Prefer not to say"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"emotion": {
|
||||||
|
"title": "How are you feeling right now?",
|
||||||
|
"options": {
|
||||||
|
"happy": "Happy / satisfied",
|
||||||
|
"calm": "Calm / grounded",
|
||||||
|
"stressed": "Stressed / overwhelmed",
|
||||||
|
"low": "Down / low mood"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"influence": {
|
||||||
|
"title": "What has been affecting you lately?",
|
||||||
|
"options": {
|
||||||
|
"family": "Family & kids",
|
||||||
|
"work": "Work or study",
|
||||||
|
"relationship": "Intimate relationship",
|
||||||
|
"friends": "Friends & social life",
|
||||||
|
"health": "Mental & physical health"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"support": {
|
||||||
|
"title": "What support do you need most?",
|
||||||
|
"options": {
|
||||||
|
"emotional": "Emotional support",
|
||||||
|
"parenting": "Parenting stress",
|
||||||
|
"self_worth": "Self-worth",
|
||||||
|
"anxiety": "Anxiety relief",
|
||||||
|
"balance": "Rest & balance"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reminder": { "title": "How many reminders do you want per day?" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intent": {
|
||||||
|
"title": "What kind of help do you want?",
|
||||||
|
"love": "Love",
|
||||||
|
"life": "Life",
|
||||||
|
"travel": "Travel",
|
||||||
|
"work": "Career"
|
||||||
|
},
|
||||||
|
"push": {
|
||||||
|
"title": "Notifications",
|
||||||
|
"cardTitle": "Turn on gentle reminders",
|
||||||
|
"cardDesc": "We’ll send a short mindful phrase when you may need it. You can change this anytime in Settings.",
|
||||||
|
"enable": "Enable",
|
||||||
|
"later": "Later",
|
||||||
|
"loading": "Working…",
|
||||||
|
"errorTitle": "Notice",
|
||||||
|
"errorDesc": "It’s okay if enabling fails. You can keep using the app."
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "Mindfulness",
|
||||||
|
"like": "Like",
|
||||||
|
"dislike": "Dislike",
|
||||||
|
"favorites": "Favorites",
|
||||||
|
"settings": "Settings",
|
||||||
|
"theme": "Theme",
|
||||||
|
"profile": "Me"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"title": "Theme",
|
||||||
|
"scenery": "Scenery",
|
||||||
|
"color": "Color"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "Me",
|
||||||
|
"favorites": "My Likes",
|
||||||
|
"widget": "Widget",
|
||||||
|
"dailyReminder": "Daily Reminder",
|
||||||
|
"privacy": "Privacy Policy",
|
||||||
|
"terms": "Terms of Use",
|
||||||
|
"language": "Language",
|
||||||
|
"todoTitle": "Notice",
|
||||||
|
"todoDesc": "This feature is a placeholder for this iteration."
|
||||||
|
},
|
||||||
|
"dailyReminder": {
|
||||||
|
"title": "Daily Reminder",
|
||||||
|
"timesUnit": "times",
|
||||||
|
"pushLabel": "Push Reminder",
|
||||||
|
"ok": "Ok",
|
||||||
|
"minus": "Decrease",
|
||||||
|
"plus": "Increase"
|
||||||
|
},
|
||||||
|
"widget": {
|
||||||
|
"lockScreen": "Lock Screen Widget",
|
||||||
|
"homeScreen": "Home Screen Widget",
|
||||||
|
"previewDate": "Thu, Jan 29",
|
||||||
|
"previewQuote": "I’m proud of who I am, even while becoming who I want to be."
|
||||||
|
},
|
||||||
|
"favorites": {
|
||||||
|
"title": "Favorites",
|
||||||
|
"empty": "No favorites yet.",
|
||||||
|
"unknownText": "This quote is no longer available."
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"language": "Language",
|
||||||
|
"version": "Version",
|
||||||
|
"widgetTitle": "iOS Widget",
|
||||||
|
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like."
|
||||||
|
},
|
||||||
|
"consent": {
|
||||||
|
"title": "You Are Perfect.",
|
||||||
|
"subtitle": "Everything Will Be Better.",
|
||||||
|
"agree": "Agree & Continue",
|
||||||
|
"privacy": "Privacy Policy",
|
||||||
|
"terms": "Terms of Use"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"notificationsDenied": "Notifications are denied. Please enable them in Settings."
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"zhTW": "繁體中文",
|
||||||
|
"en": "English"
|
||||||
|
},
|
||||||
|
"mock": {
|
||||||
|
"c1": "You’ve been trying your best. You deserve kindness today.",
|
||||||
|
"c2": "Take three deep breaths and return to the present moment.",
|
||||||
|
"c3": "It’s okay to slow down. Emotions pass like clouds.",
|
||||||
|
"c4": "You don’t need to be perfect. You are enough.",
|
||||||
|
"c5": "Place a hand on your heart and say: You did well today."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-TW": {
|
||||||
|
"common": {
|
||||||
|
"ok": "確定",
|
||||||
|
"cancel": "取消",
|
||||||
|
"back": "返回",
|
||||||
|
"close": "關閉"
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"title": "歡迎",
|
||||||
|
"progress": "{{current}}/{{total}}",
|
||||||
|
"next": "下一步",
|
||||||
|
"skip": "跳過",
|
||||||
|
"skipAll": "跳過整個引導",
|
||||||
|
"q1Title": "你最近的感受更接近哪一種?",
|
||||||
|
"q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。",
|
||||||
|
"q2Title": "你更希望獲得哪種支持?",
|
||||||
|
"q2Desc": "例如:溫柔提醒、正念練習、情緒陪伴。",
|
||||||
|
"q3Title": "你通常在什麼時候最需要被安慰?",
|
||||||
|
"q3Desc": "例如:清晨、午后、深夜,或某些特定時刻。",
|
||||||
|
"q4Title": "給自己一句溫柔的話",
|
||||||
|
"q4Desc": "你可以直接跳過,我們會在之後繼續陪你。"
|
||||||
|
},
|
||||||
|
"onboardingSurvey": {
|
||||||
|
"steps": {
|
||||||
|
"name": { "title": "我可以怎麼稱呼你?" },
|
||||||
|
"status": {
|
||||||
|
"title": "媽媽的狀態?",
|
||||||
|
"options": {
|
||||||
|
"pregnant": "懷孕中/準備成為媽媽",
|
||||||
|
"has_kids": "已經有孩子",
|
||||||
|
"no_fill": "不想填寫"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"emotion": {
|
||||||
|
"title": "當下情緒狀態?",
|
||||||
|
"options": {
|
||||||
|
"happy": "愉悅、滿足",
|
||||||
|
"calm": "平靜、安穩",
|
||||||
|
"stressed": "被壓得有點喘不過氣",
|
||||||
|
"low": "情緒低落"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"influence": {
|
||||||
|
"title": "是什麼影響了你最近的感受?",
|
||||||
|
"options": {
|
||||||
|
"family": "家庭與孩子",
|
||||||
|
"work": "工作或學習",
|
||||||
|
"relationship": "親密關係",
|
||||||
|
"friends": "朋友與人際",
|
||||||
|
"health": "身心健康"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"support": {
|
||||||
|
"title": "最需要什麼支持?",
|
||||||
|
"options": {
|
||||||
|
"emotional": "情緒支持",
|
||||||
|
"parenting": "育兒壓力",
|
||||||
|
"self_worth": "自我價值",
|
||||||
|
"anxiety": "焦慮舒緩",
|
||||||
|
"balance": "休息與平衡"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"reminder": { "title": "你需要每天幾次提醒?" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"intent": {
|
||||||
|
"title": "你希望得到什麼幫助?",
|
||||||
|
"love": "愛情",
|
||||||
|
"life": "生活",
|
||||||
|
"travel": "旅遊",
|
||||||
|
"work": "職場"
|
||||||
|
},
|
||||||
|
"push": {
|
||||||
|
"title": "通知",
|
||||||
|
"cardTitle": "開啟溫柔提醒",
|
||||||
|
"cardDesc": "我們會在你需要的時候,送上一句正念短句或溫柔提醒(可隨時在設定中調整)。",
|
||||||
|
"enable": "立即開啟",
|
||||||
|
"later": "稍後",
|
||||||
|
"loading": "處理中…",
|
||||||
|
"errorTitle": "提示",
|
||||||
|
"errorDesc": "開啟失敗也沒關係,你仍然可以繼續使用應用。"
|
||||||
|
},
|
||||||
|
"home": {
|
||||||
|
"title": "正念",
|
||||||
|
"like": "喜歡",
|
||||||
|
"dislike": "不喜歡",
|
||||||
|
"favorites": "收藏",
|
||||||
|
"settings": "設定",
|
||||||
|
"theme": "主題",
|
||||||
|
"profile": "我的"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"title": "主題",
|
||||||
|
"scenery": "風景",
|
||||||
|
"color": "顏色"
|
||||||
|
},
|
||||||
|
"profile": {
|
||||||
|
"title": "我的",
|
||||||
|
"favorites": "我的喜歡",
|
||||||
|
"widget": "小工具",
|
||||||
|
"dailyReminder": "每日提醒",
|
||||||
|
"privacy": "隱私政策",
|
||||||
|
"terms": "使用條款",
|
||||||
|
"language": "語言",
|
||||||
|
"todoTitle": "提示",
|
||||||
|
"todoDesc": "此功能本期先占位,後續迭代補齊。"
|
||||||
|
},
|
||||||
|
"dailyReminder": {
|
||||||
|
"title": "每日提醒",
|
||||||
|
"timesUnit": "次",
|
||||||
|
"pushLabel": "推送提醒",
|
||||||
|
"ok": "確定",
|
||||||
|
"minus": "減少次數",
|
||||||
|
"plus": "增加次數"
|
||||||
|
},
|
||||||
|
"widget": {
|
||||||
|
"lockScreen": "鎖屏小工具",
|
||||||
|
"homeScreen": "桌面小工具",
|
||||||
|
"previewDate": "1月29日週四 · 已至臘月十一",
|
||||||
|
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
|
||||||
|
},
|
||||||
|
"favorites": {
|
||||||
|
"title": "收藏夾",
|
||||||
|
"empty": "這裡還沒有收藏內容。",
|
||||||
|
"unknownText": "這條文案暫時無法顯示。"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "設定",
|
||||||
|
"language": "語言",
|
||||||
|
"version": "版本",
|
||||||
|
"widgetTitle": "iOS 小工具",
|
||||||
|
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
|
||||||
|
},
|
||||||
|
"consent": {
|
||||||
|
"agree": "同意並繼續",
|
||||||
|
"privacy": "隱私協議",
|
||||||
|
"terms": "用戶使用協議"
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"zhTW": "繁體中文",
|
||||||
|
"en": "English"
|
||||||
|
},
|
||||||
|
"mock": {
|
||||||
|
"c1": "你已經很努力了,今天也值得被溫柔對待。",
|
||||||
|
"c2": "深呼吸三次,把注意力帶回當下。",
|
||||||
|
"c3": "允許自己慢一點,情緒會像雲一樣飄過。",
|
||||||
|
"c4": "你不需要完美,你已經足夠好。",
|
||||||
|
"c5": "把手放在心口,對自己說一句:辛苦了。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -35,21 +35,32 @@ function withTimeout(ms: number): AbortController {
|
|||||||
export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult> {
|
export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult> {
|
||||||
const controller = withTimeout(12_000);
|
const controller = withTimeout(12_000);
|
||||||
const url = `${API_BASE_URL}/v1/reco/feed`;
|
const url = `${API_BASE_URL}/v1/reco/feed`;
|
||||||
|
const acceptLanguage = i18n.language?.toLowerCase().startsWith('zh') ? 'tc' : 'en';
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
// 让后端做 locale 选择(目前后端只区分 en/tc)
|
||||||
|
'Accept-Language': acceptLanguage,
|
||||||
|
};
|
||||||
|
const bodyObj = {
|
||||||
|
k: req.k,
|
||||||
|
user_profile: req.user_profile,
|
||||||
|
already_recommended_ids: req.already_recommended_ids ?? [],
|
||||||
|
touched_or_viewed_ids: req.touched_or_viewed_ids ?? [],
|
||||||
|
now: req.now,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 仅在开发环境打印,避免生产环境日志泄露敏感信息
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('[Feed API] 请求地址:', url);
|
||||||
|
console.log('[Feed API] Feed的API请求头:', headers);
|
||||||
|
console.log('[Feed API] Feed的API请求体:', bodyObj);
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers,
|
||||||
'Content-Type': 'application/json',
|
body: JSON.stringify(bodyObj),
|
||||||
// 让后端做 locale 选择(目前后端只区分 en/tc)
|
|
||||||
'Accept-Language': i18n.language || 'en',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
k: req.k,
|
|
||||||
user_profile: req.user_profile,
|
|
||||||
already_recommended_ids: req.already_recommended_ids ?? [],
|
|
||||||
touched_or_viewed_ids: req.touched_or_viewed_ids ?? [],
|
|
||||||
now: req.now,
|
|
||||||
}),
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export type RecoFeedCacheItem = {
|
|||||||
|
|
||||||
export type RecoFeedCache = {
|
export type RecoFeedCache = {
|
||||||
saved_at: string; // ISO8601
|
saved_at: string; // ISO8601
|
||||||
|
/**
|
||||||
|
* 缓存文案的语言(后端目前只区分 en / tc)
|
||||||
|
* - en: English
|
||||||
|
* - tc: 繁体中文
|
||||||
|
*/
|
||||||
|
lang?: 'en' | 'tc';
|
||||||
items: RecoFeedCacheItem[];
|
items: RecoFeedCacheItem[];
|
||||||
meta?: Record<string, unknown>;
|
meta?: Record<string, unknown>;
|
||||||
};
|
};
|
||||||
@@ -107,6 +113,10 @@ export async function setReaction(contentId: string, reaction: Reaction): Promis
|
|||||||
export type FavoriteItem = {
|
export type FavoriteItem = {
|
||||||
favId: string; // 唯一标识,支持重复点赞同一文案
|
favId: string; // 唯一标识,支持重复点赞同一文案
|
||||||
id: string;
|
id: string;
|
||||||
|
/**
|
||||||
|
* 收藏时的文案快照(强烈建议写入,避免后续 cache 覆盖导致无法还原文案)
|
||||||
|
*/
|
||||||
|
text?: string;
|
||||||
date: string;
|
date: string;
|
||||||
themeMode: ThemeMode;
|
themeMode: ThemeMode;
|
||||||
background: string; // 颜色值或图片路径
|
background: string; // 颜色值或图片路径
|
||||||
@@ -120,7 +130,7 @@ export async function addFavorite(item: FavoriteItem): Promise<void> {
|
|||||||
const list = await getFavorites();
|
const list = await getFavorites();
|
||||||
// 允许重复点赞,不再根据 id 去重
|
// 允许重复点赞,不再根据 id 去重
|
||||||
const newList = [item, ...list];
|
const newList = [item, ...list];
|
||||||
console.log('Adding to favorites, new list size:', newList.length);
|
console.log('Adding to favorites:', JSON.stringify(item));
|
||||||
await setJson(KEY_FAVORITES_ITEMS, newList);
|
await setJson(KEY_FAVORITES_ITEMS, newList);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
89
scripts/ssh-key-to-b64.mjs
Normal file
89
scripts/ssh-key-to-b64.mjs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 把 SSH 私钥转换为“单行 base64”,用于放入 Gitea Secrets(例如 DEV_SSH_KEY_B64)。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* 1) 从文件读取:
|
||||||
|
* node scripts/ssh-key-to-b64.mjs ~/.ssh/deploy_key
|
||||||
|
*
|
||||||
|
* 2) 从 stdin 读取(直接粘贴私钥内容,结束后按 Ctrl+D):
|
||||||
|
* node scripts/ssh-key-to-b64.mjs -
|
||||||
|
*
|
||||||
|
* 3) 输出同时复制到剪贴板(仅 macOS,需系统自带 pbcopy):
|
||||||
|
* node scripts/ssh-key-to-b64.mjs ~/.ssh/deploy_key --clipboard
|
||||||
|
*
|
||||||
|
* 注意:
|
||||||
|
* - 请不要把输出写入仓库或提交到 git。
|
||||||
|
* - 工作流会优先使用 *_SSH_KEY_B64(更稳,避免多行换行丢失)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
|
||||||
|
function printHelp() {
|
||||||
|
console.log(`用法:
|
||||||
|
node scripts/ssh-key-to-b64.mjs <私钥文件路径> [--clipboard] [--stdout]
|
||||||
|
node scripts/ssh-key-to-b64.mjs - [--clipboard] [--stdout]
|
||||||
|
|
||||||
|
示例:
|
||||||
|
node scripts/ssh-key-to-b64.mjs ~/.ssh/deploy_key
|
||||||
|
node scripts/ssh-key-to-b64.mjs - --clipboard
|
||||||
|
node scripts/ssh-key-to-b64.mjs ~/.ssh/deploy_key --clipboard --stdout
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const clipboard = args.includes('--clipboard') || args.includes('-c');
|
||||||
|
const forceStdout = args.includes('--stdout');
|
||||||
|
const help = args.includes('--help') || args.includes('-h');
|
||||||
|
const input = args.find((a) => !a.startsWith('-'));
|
||||||
|
|
||||||
|
if (help || !input) {
|
||||||
|
printHelp();
|
||||||
|
process.exit(help ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAllStdin() {
|
||||||
|
return fs.readFileSync(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
let buf;
|
||||||
|
try {
|
||||||
|
if (input === '-') {
|
||||||
|
buf = readAllStdin();
|
||||||
|
} else {
|
||||||
|
buf = fs.readFileSync(input);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`读取失败:${String(e?.message || e)}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重要:按“原始字节”做 base64,避免任何编码/换行处理导致私钥内容变化
|
||||||
|
// 这样工作流解码后能 100% 还原原文件内容
|
||||||
|
const asText = buf.toString('utf8');
|
||||||
|
if (!asText.includes('BEGIN') || !asText.includes('PRIVATE KEY')) {
|
||||||
|
console.error('提示:输入内容看起来不像 SSH 私钥(未检测到 PRIVATE KEY 头部)。仍会继续转换,但请确认输入正确。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const b64 = buf.toString('base64');
|
||||||
|
|
||||||
|
if (clipboard) {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
console.error('当前不是 macOS,无法使用 --clipboard(需要 pbcopy)。将仅输出到 stdout。');
|
||||||
|
} else {
|
||||||
|
const r = spawnSync('pbcopy', [], { input: b64, encoding: 'utf8' });
|
||||||
|
if (r.status !== 0) {
|
||||||
|
console.error(`复制到剪贴板失败:pbcopy 退出码=${r.status}`);
|
||||||
|
} else {
|
||||||
|
console.error('已复制到剪贴板:请粘贴到 Gitea Secrets(例如 DEV_SSH_KEY_B64)。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认:如果使用了 --clipboard,就不再把超长 base64 打到终端(避免影响后续命令输出)。
|
||||||
|
// 如需同时打印,请加 --stdout。
|
||||||
|
if (!clipboard || forceStdout || process.platform !== 'darwin') {
|
||||||
|
process.stdout.write(b64 + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
@@ -23,5 +23,10 @@ COPY alembic.ini /app/alembic.ini
|
|||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# 注意:
|
||||||
|
# - 镜像内不会打包 `.env.dev/.env.prod`(避免把敏感信息烘焙进镜像)
|
||||||
|
# - 运行容器时请通过 `--env-file` 或 `-e` 注入 DATABASE_URL / REDIS_URL / CELERY_BROKER_URL
|
||||||
|
# - 参考文档:server/README.md
|
||||||
|
|
||||||
# 生产镜像默认不开启 reload
|
# 生产镜像默认不开启 reload
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@@ -82,6 +82,11 @@ CELERY_BROKER_URL=redis://dev_user:devpassword@127.0.0.1:6379/0
|
|||||||
|
|
||||||
`.env.prod` 同理,替换为生产环境地址与密钥即可。
|
`.env.prod` 同理,替换为生产环境地址与密钥即可。
|
||||||
|
|
||||||
|
补充:
|
||||||
|
|
||||||
|
- 仓库内提供了一个不包含真实值的模板文件 `server/env.example`,可复制为 `.env.dev/.env.prod` 后再填写。
|
||||||
|
- **Docker 不会自动读取 `.env.*`**,容器运行时需要通过 `--env-file` 或 `-e` 注入环境变量(见下方 Docker 运行)。
|
||||||
|
|
||||||
### 1.1 MySQL 命名与 dev/pro 区分(约定)
|
### 1.1 MySQL 命名与 dev/pro 区分(约定)
|
||||||
|
|
||||||
- **生产库(prod)**:`mindfulness`
|
- **生产库(prod)**:`mindfulness`
|
||||||
@@ -146,6 +151,38 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
|||||||
- OpenAPI 文档:`/docs`
|
- OpenAPI 文档:`/docs`
|
||||||
- ReDoc:`/redoc`
|
- ReDoc:`/redoc`
|
||||||
|
|
||||||
|
## Docker 运行
|
||||||
|
|
||||||
|
后端启动至少需要以下 3 个环境变量:
|
||||||
|
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- `REDIS_URL`
|
||||||
|
- `CELERY_BROKER_URL`
|
||||||
|
|
||||||
|
### 方式 A:使用 env 文件(推荐)
|
||||||
|
|
||||||
|
1) 在宿主机准备 `server/.env.prod`(或 `.env.dev`),内容为 `KEY=value`:
|
||||||
|
|
||||||
|
- 可从 `server/env.example` 复制后填写
|
||||||
|
|
||||||
|
2) 运行容器时通过 `--env-file` 注入:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8000:8000 \
|
||||||
|
--env-file server/.env.prod \
|
||||||
|
mindfulness-server:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式 B:直接用 -e 注入
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm -p 8000:8000 \
|
||||||
|
-e DATABASE_URL="mysql+aiomysql://用户名:密码@mysql:3306/mindfulness?charset=utf8mb4" \
|
||||||
|
-e REDIS_URL="redis://:密码@redis:6379/0" \
|
||||||
|
-e CELERY_BROKER_URL="redis://:密码@redis:6379/0" \
|
||||||
|
mindfulness-server:latest
|
||||||
|
```
|
||||||
|
|
||||||
## 数据库迁移(Alembic)
|
## 数据库迁移(Alembic)
|
||||||
|
|
||||||
> 若你采用 Alembic:建议把迁移脚本放在 `server/alembic/`,并在 `alembic.ini` 中配置数据库连接(或从环境变量读取)。
|
> 若你采用 Alembic:建议把迁移脚本放在 `server/alembic/`,并在 `alembic.ini` 中配置数据库连接(或从环境变量读取)。
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from functools import lru_cache
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal, Optional
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import ValidationError
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -67,5 +68,41 @@ def get_settings() -> Settings:
|
|||||||
env = os.getenv("APP_ENV", "dev").strip() or "dev"
|
env = os.getenv("APP_ENV", "dev").strip() or "dev"
|
||||||
env_file = _guess_env_file(env)
|
env_file = _guess_env_file(env)
|
||||||
|
|
||||||
return Settings(_env_file=env_file)
|
try:
|
||||||
|
return Settings(_env_file=env_file)
|
||||||
|
except ValidationError as e:
|
||||||
|
# 给出更可执行的错误信息,避免只看到一串校验栈。
|
||||||
|
missing_fields: list[str] = []
|
||||||
|
for err in e.errors():
|
||||||
|
if err.get("type") == "missing":
|
||||||
|
loc = err.get("loc") or ()
|
||||||
|
if loc:
|
||||||
|
missing_fields.append(str(loc[0]))
|
||||||
|
|
||||||
|
# 将字段名映射为常见的环境变量名(默认规则:字段名大写)
|
||||||
|
missing_env_keys = [f.upper() for f in missing_fields] if missing_fields else []
|
||||||
|
|
||||||
|
env_file_hint = env_file or f".env.{env}(未找到,已回退到系统环境变量)"
|
||||||
|
required_hint = (
|
||||||
|
"、".join(missing_env_keys)
|
||||||
|
if missing_env_keys
|
||||||
|
else "DATABASE_URL、REDIS_URL、CELERY_BROKER_URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
"应用启动失败:缺少必填配置。\n\n"
|
||||||
|
f"- 当前 APP_ENV:{env}\n"
|
||||||
|
f"- 期望读取的 env 文件:{env_file_hint}\n"
|
||||||
|
f"- 缺少的环境变量:{required_hint}\n\n"
|
||||||
|
"修复方式(任选其一):\n"
|
||||||
|
"1) 直接注入环境变量(推荐):\n"
|
||||||
|
" - DATABASE_URL=...\n"
|
||||||
|
" - REDIS_URL=...\n"
|
||||||
|
" - CELERY_BROKER_URL=...\n"
|
||||||
|
"2) 使用 env 文件:在 `server/` 下准备 `.env.dev` 或 `.env.prod`(KEY=value 格式),\n"
|
||||||
|
" 本地可通过 `server/run.sh --env dev|prod` 自动加载;Docker 运行可用 `--env-file` 传入。\n"
|
||||||
|
)
|
||||||
|
# 不附带原始 ValidationError 的异常上下文,减少日志噪音;
|
||||||
|
# msg 已包含缺失项与修复方式,足够定位问题。
|
||||||
|
raise RuntimeError(msg) from None
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ def create_app() -> FastAPI:
|
|||||||
"""
|
"""
|
||||||
创建 FastAPI 应用实例。
|
创建 FastAPI 应用实例。
|
||||||
|
|
||||||
说明:目前仅提供最小可运行骨架(health check),后续逐步加入路由与中间件。
|
说明:目前仅提供最小可运行骨架(健康检查),后续逐步加入路由与中间件。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -20,6 +20,13 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(user_profile_router)
|
app.include_router(user_profile_router)
|
||||||
app.include_router(reco_router)
|
app.include_router(reco_router)
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health() -> dict:
|
||||||
|
"""
|
||||||
|
部署健康检查(兼容常见探针路径)。
|
||||||
|
"""
|
||||||
|
return {"status": "ok", "env": settings.app_env}
|
||||||
|
|
||||||
@app.get("/healthz")
|
@app.get("/healthz")
|
||||||
async def healthz() -> dict:
|
async def healthz() -> dict:
|
||||||
return {"status": "ok", "env": settings.app_env}
|
return {"status": "ok", "env": settings.app_env}
|
||||||
|
|||||||
@@ -1,3 +1,34 @@
|
|||||||
|
# 说明:
|
||||||
|
# - 这是后端环境变量模板,请复制为 `.env.dev` 或 `.env.prod` 再填写真实值
|
||||||
|
# - 请勿把包含真实账号密码/Token 的 `.env.*` 提交到仓库
|
||||||
|
#
|
||||||
|
# 用法示例:
|
||||||
|
# - 本地:`./run.sh --env dev`
|
||||||
|
# - Docker:`docker run --env-file server/.env.prod ...`
|
||||||
|
|
||||||
|
# 运行环境
|
||||||
|
APP_ENV=dev
|
||||||
|
APP_NAME=mindfulness-server
|
||||||
|
APP_HOST=0.0.0.0
|
||||||
|
APP_PORT=8000
|
||||||
|
|
||||||
|
# 数据库(SQLAlchemy 异步连接串)
|
||||||
|
# MySQL 示例(aiomysql):
|
||||||
|
# DATABASE_URL=mysql+aiomysql://用户名:密码@127.0.0.1:3306/mindfulness_dev?charset=utf8mb4
|
||||||
|
DATABASE_URL=
|
||||||
|
|
||||||
|
# Redis(缓存/任务队列)
|
||||||
|
# 示例:
|
||||||
|
# REDIS_URL=redis://:密码@127.0.0.1:6379/0
|
||||||
|
REDIS_URL=
|
||||||
|
|
||||||
|
# Celery(建议先只配 broker;如需结果存储可另配 CELERY_RESULT_BACKEND)
|
||||||
|
# 示例:
|
||||||
|
# CELERY_BROKER_URL=redis://:密码@127.0.0.1:6379/0
|
||||||
|
CELERY_BROKER_URL=
|
||||||
|
|
||||||
|
# 可选:Celery 结果存储
|
||||||
|
# CELERY_RESULT_BACKEND=redis://:密码@127.0.0.1:6379/0
|
||||||
# 运行环境:dev 或 prod
|
# 运行环境:dev 或 prod
|
||||||
APP_ENV=dev
|
APP_ENV=dev
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
- **已完成编码(阶段性)**:
|
- **已完成编码(阶段性)**:
|
||||||
- Expo 工程已在 `client/` 初始化,并完成 `pnpm install`
|
- Expo 工程已在 `client/` 初始化,并完成 `pnpm install`
|
||||||
- i18n 基座已接入:5 份语言资源 + 设备语言优先/设置可切换/持久化 + 入口初始化
|
- i18n 基座已接入:5 份语言资源 + 设备语言优先/设置可切换/持久化 + 入口初始化
|
||||||
|
- 推荐 Feed 请求链路增加调试日志(仅开发环境):打印 Feed API 请求头与请求体,便于联调排查
|
||||||
|
- 环境变量注入增加调试日志(仅开发环境):打印参与 `API_BASE_URL` 计算的 `EXPO_PUBLIC_*` 与最终解析结果,便于定位“打到哪个后端”
|
||||||
|
|
||||||
## Client User Identity
|
## Client User Identity
|
||||||
|
|
||||||
@@ -39,6 +41,8 @@
|
|||||||
- **近期变更**:
|
- **近期变更**:
|
||||||
- 新增后端镜像构建基础:`server/Dockerfile`、`server/.dockerignore`
|
- 新增后端镜像构建基础:`server/Dockerfile`、`server/.dockerignore`
|
||||||
- 新增后端镜像打包与推送工作流:`.gitea/workflows/server-build.yml`(自动递增 semver tag 并推送到 Docker Hub)
|
- 新增后端镜像打包与推送工作流:`.gitea/workflows/server-build.yml`(自动递增 semver tag 并推送到 Docker Hub)
|
||||||
|
- 新增后端蓝绿部署工作流:`.gitea/workflows/server-deploy.yml`(SSH 上机 + Docker 蓝绿启动 + Nginx upstream 切流 + 健康检查与失败回滚)
|
||||||
|
- 后端配置缺失时报错增强:`app/core/config.py` 将缺失的必填环境变量用中文提示并给出注入方式;补充 `server/env.example` 与 Docker 运行说明
|
||||||
|
|
||||||
## Onboarding App Shell
|
## Onboarding App Shell
|
||||||
|
|
||||||
@@ -59,6 +63,7 @@
|
|||||||
- `spec_kit/iOS Widget/tasks.md`
|
- `spec_kit/iOS Widget/tasks.md`
|
||||||
- **近期变更**:
|
- **近期变更**:
|
||||||
- iOS 主 App Bundle ID 已统一为 `com.damer.mindfulness`,Widget Extension 为 `com.damer.mindfulness.emotionwidget`
|
- iOS 主 App Bundle ID 已统一为 `com.damer.mindfulness`,Widget Extension 为 `com.damer.mindfulness.emotionwidget`
|
||||||
|
- 修复 iOS 签名 Team 配置不一致:为主 App(Debug)与 Widget Extension(Debug/Release)显式补齐 `DEVELOPMENT_TEAM=WS92GPX9H2`,避免打包/上传过程中回退到默认 Team 导致显示 “Other Team”
|
||||||
- iOS 构建号已提升到 `2`,并将 `client/ios/client/Info.plist` 改为自动跟随 `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION`
|
- iOS 构建号已提升到 `2`,并将 `client/ios/client/Info.plist` 改为自动跟随 `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION`
|
||||||
- 推送 entitlements 的 `aps-environment` 已切到 `production`(用于 TestFlight/线上包)
|
- 推送 entitlements 的 `aps-environment` 已切到 `production`(用于 TestFlight/线上包)
|
||||||
|
|
||||||
@@ -89,6 +94,7 @@
|
|||||||
- 新增 `SheetModal`、`ThemeModal`、`ProfileModal` 并在 Home 中接入
|
- 新增 `SheetModal`、`ThemeModal`、`ProfileModal` 并在 Home 中接入
|
||||||
- 新增 `DailyReminderModal` 并从个人主页弹窗打开
|
- 新增 `DailyReminderModal` 并从个人主页弹窗打开
|
||||||
- Home:右上角 icon 按钮、主题切换背景、喜欢/讨厌 icon + 动效
|
- Home:右上角 icon 按钮、主题切换背景、喜欢/讨厌 icon + 动效
|
||||||
|
- **修复**:收藏(“我的喜欢”)不展示后端推荐文案的问题——收藏时持久化写入 `text` 快照,并在 `ProfileModal` 的 Favorites 页面同时兼容 Mock 与推荐缓存内容回填
|
||||||
|
|
||||||
## User Profile Scoring
|
## User Profile Scoring
|
||||||
|
|
||||||
|
|||||||
103
设计说明文档/用户使用协议.md
103
设计说明文档/用户使用协议.md
@@ -1,29 +1,102 @@
|
|||||||
欢迎使用本App(情绪推送应用)。请仔细阅读以下协议内容:
|
Hey Mama – Terms of Use
|
||||||
|
Last updated: February 2026
|
||||||
|
Welcome to Hey Mama (“the App,” “we,” or “us”).
|
||||||
|
Please read these Terms of Use carefully before downloading, accessing, or using the App. By using the App, you agree to be bound by these Terms.
|
||||||
|
|
||||||
适用范围与接受:本协议适用于您下载、安装、登录或使用本App时的全部行为。您在使用本App前,应仔细阅读并充分理解本协议的各项条款。一旦您下载、安装或使用本App,即表示您已阅读并同意接受本协议的全部内容;如果您不同意,请立即停止使用。
|
1. Intended Audience
|
||||||
|
Hey Mama is intended for adults only.
|
||||||
|
The App is not designed for children, and users must ensure they have the legal capacity to use the App under applicable laws.
|
||||||
|
|
||||||
服务内容:本App致力于为宝妈群体提供情绪关怀和正能量内容,包括通过定时推送名人励志语句、个性化推荐情绪正向内容等功能。您无需注册或登录即可使用本App,可选择提供昵称改善使用体验。通过参与问卷调查和点击操作,您可帮助我们了解您的内容偏好,以获得更符合您需求的推荐内容。
|
2. Services Provided
|
||||||
|
Hey Mama provides text-based content and features, including but not limited to:
|
||||||
|
- Daily affirmations and mindfulness text
|
||||||
|
- User-configured reminders and push notifications
|
||||||
|
- Home screen widgets displaying affirmation text
|
||||||
|
- Personalized reading or saving experiences (where applicable)
|
||||||
|
All content is provided for general emotional support and self-reflection purposes only and does not constitute medical, psychological, or professional advice.
|
||||||
|
|
||||||
使用规范:
|
3. Acceptable Use
|
||||||
|
You agree to:
|
||||||
|
- Use the App for personal, non-commercial purposes only
|
||||||
|
- Not copy, reproduce, distribute, sell, modify, reverse engineer, or attempt to extract the source code of the App
|
||||||
|
- Not engage in any activity that may interfere with the App’s functionality, stability, or user experience
|
||||||
|
We reserve the right to restrict or terminate access if these Terms are violated.
|
||||||
|
|
||||||
您承诺遵守中华人民共和国相关法律法规,不得利用本App从事任何违法犯罪或侵权行为。
|
4. Push Notifications
|
||||||
|
The App may send push notifications based on your settings (e.g. daily affirmation reminders).
|
||||||
|
- Notifications contain general text only
|
||||||
|
- No sensitive personal data is included
|
||||||
|
- You may disable notifications at any time through your device settings
|
||||||
|
|
||||||
您不得干扰本App的正常运行,不得攻击、破坏应用系统或试图绕过使用限制。
|
5. Intellectual Property
|
||||||
|
All content, design elements, interfaces, and materials within the App are owned by us or our licensors and are protected by applicable intellectual property laws.
|
||||||
|
Unauthorized use, reproduction, or distribution is strictly prohibited.
|
||||||
|
|
||||||
您不得未经授权破解、反编译、反向工程、篡改本App软件,也不得删除或篡改本App内的任何版权、商标或所有权声明。
|
6. Disclaimer
|
||||||
|
The App and its content are provided for informational and self-support purposes only.
|
||||||
|
- We do not guarantee specific emotional or psychological outcomes
|
||||||
|
- The App does not replace professional medical, mental health, or legal advice
|
||||||
|
- You are solely responsible for how you use the content
|
||||||
|
|
||||||
本App及其中的素材、内容(包括但不限于名人句子、图文等)版权归开发者或原作者所有,仅供个人学习交流使用。您不得擅自复制、传播、演绎或用于商业目的。若您使用内容时涉及版权问题,请自行妥善处理或联系我们协助。
|
7. Service Availability
|
||||||
|
We may modify, suspend, or discontinue any part of the App at any time due to maintenance, updates, or circumstances beyond our control.
|
||||||
|
We are not liable for any loss or damage resulting from such interruptions or changes.
|
||||||
|
|
||||||
隐私保护:本App不要求注册登录,也不采集敏感个人信息。您自愿提供的昵称和偏好数据仅用于在本地设备上为您推荐内容,不会上传或共享给任何第三方。您可以选择不提供昵称,我们的核心功能不会因此受影响。
|
8. Changes to These Terms
|
||||||
|
We may update these Terms of Use from time to time.
|
||||||
|
Updated versions will be made available within the App or related pages. Continued use of the App after changes indicates acceptance of the revised Terms.
|
||||||
|
|
||||||
版权声明:本App内所有内容(包括界面设计、程序代码、文字、图片、音视频等)及软件著作权均归开发者或相关权利人所有,受著作权法等法律保护。未经许可,任何个人或组织不得以任何形式复制、发行、展示、播发、修改、链接、转载或建立镜像。您仅可在个人使用的前提下使用本App及其内容。
|
9. Governing Law
|
||||||
|
These Terms shall be governed by and construed in accordance with the applicable laws of our operating jurisdiction.
|
||||||
|
|
||||||
免责声明:本App按“现状”和“可用”原则向您提供服务,对服务不作任何形式的担保(包括但不限于准确性、可靠性或持续可用性)。对于因网络故障、通信线路等客观原因导致的应用功能异常或信息延迟,我们不承担责任。您使用本App过程中应自行承担风险;在法律允许的范围内,对于您因使用本App而可能产生的任何直接或间接损失,我们不承担责任。
|
---
|
||||||
|
Hey Mama 使用條款
|
||||||
|
最後更新日期:2026 年 2 月
|
||||||
|
歡迎使用 Hey Mama(以下簡稱「本 App」、「我們」)。
|
||||||
|
在下載、存取或使用本 App 前,請您仔細閱讀本使用條款。當您開始使用本 App,即表示您已閱讀、理解並同意遵守本條款。
|
||||||
|
|
||||||
协议的变更和终止:我们保留随时修改、更新本协议条款和/或终止本App运营的权利。如协议条款发生变更,我们将在应用更新或官网渠道公布最新协议内容,并提示用户注意更新。修改后的协议一经公布即生效,您继续使用本App即视为接受修改后的协议。
|
1. 服務對象與使用資格
|
||||||
|
Hey Mama 僅供成年人使用(intended for adults)。
|
||||||
|
本 App 並非為兒童設計,使用者應確認自己具備依所在地法律使用本服務的完全行為能力。
|
||||||
|
|
||||||
法律适用和争议解决:本协议的订立、生效、解释及争议解决均适用中华人民共和国法律。因本协议或使用本App引起的任何争议,双方应友好协商解决;协商不成时,任何一方均可向开发者所在地有管辖权的法院提起诉讼。
|
2. 服務內容
|
||||||
|
Hey Mama 提供以文字形式為主的內容與功能,包括但不限於:
|
||||||
|
- 每日肯定語與正念文字內容
|
||||||
|
- 使用者設定的提醒與推送通知
|
||||||
|
- 桌面小組件顯示肯定語文字
|
||||||
|
- 內容閱讀與收藏等個人化體驗(如適用)
|
||||||
|
本 App 所提供之內容僅作為日常情緒支持與自我提醒用途,不構成任何形式的醫療、心理諮商或專業建議。
|
||||||
|
|
||||||
其他:本协议与本隐私政策共同构成本App服务的完整规则。若本协议条款与隐私政策存在不一致之处,以隐私政策为准。本协议条款标题仅为阅读方便而设,不影响条款含义的解释。本协议最终解释权归开发者所有。如您对本协议内容有任何疑问或建议,请通过应用商店提供的联系方式与我们联系。
|
3. 使用方式與限制
|
||||||
|
使用者同意:
|
||||||
|
- 僅將本 App 用於個人、非商業用途
|
||||||
|
- 不以任何方式複製、重製、散布、出售、反編譯或試圖取得本 App 之原始碼
|
||||||
|
- 不進行任何可能影響 App 正常運作、穩定性或其他使用者體驗之行為
|
||||||
|
如有違反,我們有權在不另行通知的情況下限制或終止使用權限。
|
||||||
|
|
||||||
感谢您使用本App!我们将持续优化服务体验,努力为您提供温暖和支持。
|
4. 推送通知
|
||||||
|
本 App 可能依使用者設定發送推送通知(例如每日肯定語提醒)。
|
||||||
|
- 推送內容僅為一般文字資訊
|
||||||
|
- 不包含個人化敏感資料
|
||||||
|
- 您可隨時透過裝置系統設定關閉通知功能
|
||||||
|
|
||||||
|
5. 智慧財產權
|
||||||
|
本 App 及其所有內容(包含但不限於文字、設計、介面、版面配置與視覺元素)之智慧財產權,均屬於我們或合法授權方所有。
|
||||||
|
未經事前書面同意,任何形式之使用、修改、重製或散布皆屬禁止。
|
||||||
|
|
||||||
|
6. 免責聲明
|
||||||
|
本 App 所提供之內容僅供一般參考與自我提醒之用。
|
||||||
|
- 我們不保證內容能產生特定心理、情緒或行為結果
|
||||||
|
- 本 App 不取代任何專業醫療、心理或法律建議
|
||||||
|
- 使用者應自行判斷內容是否適合自身狀況
|
||||||
|
|
||||||
|
7. 服務中斷與變更
|
||||||
|
我們可能因系統維護、功能調整、更新或其他不可抗力因素,暫時中斷或變更本 App 之全部或部分功能。
|
||||||
|
對於因此可能造成的任何直接或間接損失,我們不負任何責任。
|
||||||
|
|
||||||
|
8. 條款修改
|
||||||
|
我們可能不定期更新本使用條款。
|
||||||
|
更新後的版本將公布於 App 內或相關頁面,您於條款更新後繼續使用本 App,即視為同意更新內容。
|
||||||
|
|
||||||
|
9. 準據法與管轄
|
||||||
|
本使用條款之解釋與適用,悉依我們所在地之相關法律規定處理。
|
||||||
118
设计说明文档/隐私协议.md
118
设计说明文档/隐私协议.md
@@ -1,21 +1,119 @@
|
|||||||
隐私政策
|
Hey Mama | Privacy Policy
|
||||||
|
Last updated: February 2026
|
||||||
|
|
||||||
我们尊重并保护您的隐私。根据《个人信息保护法》《网络安全法》等法律法规的规定,以及 Apple App Store 审核指南要求,我们制定本隐私政策,帮助您了解我们如何收集、使用和保护您的信息:
|
1. Introduction
|
||||||
|
Welcome to Hey Mama (“the App,” “we,” “us”).
|
||||||
|
We respect your privacy and are committed to protecting your personal information. This Privacy Policy explains how we collect, use, store, and protect information when you use the App.
|
||||||
|
By downloading, accessing, or using the App, you acknowledge that you have read, understood, and agreed to this Privacy Policy.
|
||||||
|
|
||||||
信息收集:本应用无需注册或登录,也不使用任何第三方授权。用户可自愿提供昵称,仅用于界面展示和个性化服务标识;通过问卷或点击行为记录您的内容偏好(如“喜欢/不喜欢”标签)。我们不会收集您的身份信息、地理位置、通讯录等敏感个人信息。
|
2. Data Controller and Scope
|
||||||
|
The App is operated and maintained by the Hey Mama team.
|
||||||
|
This Privacy Policy applies to information processing activities related to your use of the App.
|
||||||
|
|
||||||
信息使用:我们仅使用上述收集的信息进行个性化推荐和名人句子定时推送,不用于其他任何目的,不进行广告或营销投放。所有个性化功能均基于您在应用中的操作和偏好生成。
|
3. Information We Collect
|
||||||
|
3.1 Information You Provide
|
||||||
|
Hey Mama does not require account registration and does not require you to provide personally identifiable information.
|
||||||
|
During your use of the App, you may optionally provide or generate the following information:
|
||||||
|
- Reminder settings (e.g., reminder frequency)
|
||||||
|
- Text content you view, save as favorites, or create within the App (if available)
|
||||||
|
This information is used only to operate core App features and provide a personalized experience.
|
||||||
|
|
||||||
数据存储和安全:所有数据均存储在您设备本地,不上传至服务器;本应用不接入任何第三方 SDK。我们采用系统加密和权限隔离等安全机制保护本地数据,防止未经授权的访问。您可以随时通过清除应用数据或卸载应用来删除您的个人信息。
|
3.2 Information Collected Automatically
|
||||||
|
When you use the App, we may automatically collect certain non-identifiable technical information, including but not limited to:
|
||||||
|
- Device type and operating system version
|
||||||
|
- App version
|
||||||
|
- Device language settings
|
||||||
|
- Basic usage status (e.g., whether the App is opened, whether reminders are enabled)
|
||||||
|
This information does not directly identify you and is primarily used to maintain App stability, troubleshoot issues, and improve user experience.
|
||||||
|
The App does not collect precise location information for the purposes described in this policy, does not engage in cross-app tracking, and does not use your data for third-party advertising.
|
||||||
|
|
||||||
信息共享:我们不会向任何无关第三方提供、出售、出租或分享您的个人信息。未经您明确同意,我们绝不公开您的任何信息,除非法律法规要求或为维护您合法权益所必需。
|
4. Push Notifications
|
||||||
|
With your permission, the App may send you reminder notifications, such as daily affirmations.
|
||||||
|
- Notifications contain general text information only
|
||||||
|
- Notifications do not include sensitive personal data
|
||||||
|
- You can disable notifications at any time in your device settings
|
||||||
|
|
||||||
用户权利与选择:您有权自主决定是否提供个人信息,并可对已提供的信息进行查询、修改或删除。您可以随时在应用中清除偏好设置,或卸载应用以删除所有本地数据。根据法律法规,您还可以通过我们的客服途径要求访问、更正或删除您的信息。
|
5. Home Screen Widgets
|
||||||
|
If you choose to use home screen widgets, the displayed content comes from the App’s text-based affirmations. Widgets do not collect or transmit additional personal data.
|
||||||
|
|
||||||
未成年人保护:本应用主要面向成人用户设计,不针对未成年人提供特殊服务。如 18 岁以下用户使用,请在监护人陪同下进行。我们不会在未征得监护人同意的情况下收集或使用未成年人的个人信息。
|
6. How We Use Information
|
||||||
|
We use collected information only for the following purposes:
|
||||||
|
- To provide and maintain core App functionality
|
||||||
|
- To improve content presentation and user experience
|
||||||
|
- To fix bugs and enhance system stability
|
||||||
|
We do not:
|
||||||
|
- Sell, rent, or trade your personal data
|
||||||
|
- Use your data for third-party advertising purposes
|
||||||
|
|
||||||
隐私政策更新:我们可能会根据产品功能变化或法律法规要求更新本隐私政策,并在应用内或相关页面公布最新版本。您继续使用本应用即视为接受更新后的隐私政策。如有重大变更,我们会适当提示您。
|
7. Third-Party Services
|
||||||
|
Hey Mama currently does not integrate third-party advertising or marketing services.
|
||||||
|
The App may rely on necessary operating system and app store services to provide functionality (for example, push notification delivery mechanisms).
|
||||||
|
If we later integrate third-party analytics or technical services, we will update this Privacy Policy accordingly.
|
||||||
|
|
||||||
我们承诺在此过程中严格遵守相关法律法规和行业标准,切实保护您的个人信息安全。本隐私政策与用户使用协议共同构成本应用合法合规运营的基础。
|
8. Data Retention and Security
|
||||||
|
We retain information only for as long as necessary to achieve the purposes described above. We implement reasonable technical and organizational measures to protect information against unauthorized access, disclosure, alteration, or loss.
|
||||||
|
|
||||||
|
9. Minors
|
||||||
|
Hey Mama is not designed for children, and we do not knowingly collect personal information from users under the age of 13.
|
||||||
|
If you are a minor, please use the App with the consent and supervision of a parent or guardian.
|
||||||
|
|
||||||
|
10. Changes to This Privacy Policy
|
||||||
|
We may update this Privacy Policy from time to time. The updated version will be made available within the App or through related pages. If you continue to use the App after updates take effect, you are deemed to have accepted the updated policy.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
Hey Mama|隱私權政策
|
||||||
|
最後更新日期:2026 年 2 月
|
||||||
|
|
||||||
|
一、前言
|
||||||
|
歡迎使用 Hey Mama(以下簡稱「本 App」、「我們」)。
|
||||||
|
我們重視您的隱私,並致力於保護您的個人資料安全。本隱私權政策說明您在使用 Hey Mama 時,我們如何收集、使用、保存與保護相關資訊。
|
||||||
|
當您下載、存取或使用本 App,即表示您已閱讀、理解並同意本隱私權政策之內容。
|
||||||
|
|
||||||
|
二、我們收集的資訊
|
||||||
|
1. 使用者主動提供的資訊
|
||||||
|
Hey Mama 不要求建立帳號,亦不強制使用者提供可識別個人身分的資料。
|
||||||
|
在使用過程中,您可能會選擇性提供或產生以下資訊:
|
||||||
|
- 提醒設定(例如提醒頻率)
|
||||||
|
- 使用者在 App 內閱讀、收藏或建立的文字內容(如有)
|
||||||
|
上述資訊僅用於 App 功能運作與個人化體驗。
|
||||||
|
2. 自動收集的資訊
|
||||||
|
當您使用本 App 時,我們可能會自動收集部分非識別性技術資訊,包括但不限於:
|
||||||
|
- 裝置類型與作業系統版本
|
||||||
|
- App 版本
|
||||||
|
- 裝置語言設定
|
||||||
|
- 基本使用行為(例如是否開啟 App、是否啟用提醒)
|
||||||
|
這些資訊無法直接識別您的身分,僅用於維持 App 穩定性與改善使用體驗。
|
||||||
|
|
||||||
|
三、推送通知
|
||||||
|
在取得您同意後,Hey Mama 可能會向您發送提醒推送,例如每日肯定語提示。
|
||||||
|
- 推送內容僅包含一般文字資訊
|
||||||
|
- 不包含任何敏感個人資料
|
||||||
|
- 您可隨時於裝置系統設定中關閉通知功能
|
||||||
|
|
||||||
|
四、桌面小組件
|
||||||
|
若您選擇使用桌面小組件,其顯示內容僅來自 App 內的文字肯定語,不會額外收集或傳送新的個人資料。
|
||||||
|
|
||||||
|
五、資訊使用方式
|
||||||
|
我們僅於下列目的範圍內使用所收集的資訊:
|
||||||
|
- 提供與維護 App 的基本功能
|
||||||
|
- 改善內容呈現與使用體驗
|
||||||
|
- 修復錯誤與提升系統穩定性
|
||||||
|
我們不會:
|
||||||
|
- 出售、出租或交換您的個人資料
|
||||||
|
- 將資料用於第三方廣告投放
|
||||||
|
|
||||||
|
六、第三方服務
|
||||||
|
目前 Hey Mama 未整合第三方廣告或行銷服務。
|
||||||
|
如未來整合第三方分析或技術服務,我們將於本政策中另行說明並更新。
|
||||||
|
|
||||||
|
七、資料保存與安全
|
||||||
|
我們僅在達成上述目的所需期間內保存相關資訊,並採取合理的技術與管理措施,以防止資料遭未經授權存取、洩漏、竄改或遺失。
|
||||||
|
|
||||||
|
八、未成年人說明
|
||||||
|
Hey Mama 並非專為兒童設計,亦不刻意收集未滿 13 歲使用者的個人資料。
|
||||||
|
若您為未成年人,請在監護人同意與陪同下使用本 App。
|
||||||
|
|
||||||
|
九、隱私權政策的變更
|
||||||
|
我們可能會不定期更新本隱私權政策。
|
||||||
|
更新後的版本將公布於 App 內或相關頁面,您於政策更新後繼續使用本 App,即視為同意更新內容。
|
||||||
Reference in New Issue
Block a user