Compare commits
34 Commits
d98ec76dc0
...
0b8bbebf6a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b8bbebf6a | ||
|
|
1e1e49ea57 | ||
|
|
8e71503169 | ||
|
|
2b67a571bb | ||
|
|
8f84f25616 | ||
|
|
4c03fce720 | ||
|
|
c1c2c6197d | ||
| d742b398ef | |||
|
|
d525c6b939 | ||
|
|
c37bb2e2a5 | ||
|
|
4c450db9b2 | ||
| 8c02c5b9a0 | |||
|
|
90cbde1bac | ||
|
|
bb7a4c7361 | ||
|
|
17d19f9172 | ||
|
|
b35b71470d | ||
|
|
ccdc6350a1 | ||
|
|
7fea799087 | ||
|
|
e6b64b39f2 | ||
|
|
a457186f46 | ||
|
|
2d5b8b094f | ||
|
|
ef7c4e774d | ||
|
|
cc36301638 | ||
|
|
5dd632d4d3 | ||
|
|
4d70f16b69 | ||
|
|
2ddee6b8f8 | ||
|
|
aa530b0ce3 | ||
| cdd0ac32de | |||
| 3c35e14c3d | |||
| 915e995ab7 | |||
| 39e4bcab6c | |||
| ceaf459d97 | |||
|
|
996999453a | ||
|
|
3f91e734fa |
@@ -1,5 +1,69 @@
|
||||
请开始完成编码
|
||||
客户端请按照标准的RN架构目录写代码
|
||||
客户端API请求 统一使用utlis中封装的请求
|
||||
客户端的架构目录参考
|
||||
project-root
|
||||
├── android/ # Android 原生工程
|
||||
├── ios/ # iOS 原生工程
|
||||
├── src/ # 业务代码主目录 ⭐⭐⭐
|
||||
│ ├── app.tsx # App 入口(注册 Provider / Navigation)
|
||||
│ ├── navigation/ # 路由导航
|
||||
│ │ ├── index.tsx
|
||||
│ │ ├── RootNavigator.tsx
|
||||
│ │ └── types.ts
|
||||
│ ├── screens/ # 页面(Screen 级别)
|
||||
│ │ ├── Home/
|
||||
│ │ │ ├── index.tsx
|
||||
│ │ │ ├── styles.ts
|
||||
│ │ │ └── hooks.ts
|
||||
│ │ └── Profile/
|
||||
│ ├── components/ # 通用 UI 组件(无业务)
|
||||
│ │ ├── Button/
|
||||
│ │ │ ├── index.tsx
|
||||
│ │ │ └── styles.ts
|
||||
│ │ └── Empty/
|
||||
│ ├── modules/ # 业务模块(强烈推荐)
|
||||
│ │ ├── user/
|
||||
│ │ │ ├── api.ts
|
||||
│ │ │ ├── model.ts
|
||||
│ │ │ ├── store.ts
|
||||
│ │ │ └── index.ts
|
||||
│ │ └── emotion/
|
||||
│ ├── services/ # 跨模块服务(网络、存储等)
|
||||
│ │ ├── http.ts # axios/fetch 封装
|
||||
│ │ ├── storage.ts # AsyncStorage 封装
|
||||
│ │ └── logger.ts
|
||||
│ ├── store/ # 全局状态(Redux / Zustand / Jotai)
|
||||
│ │ ├── index.ts
|
||||
│ │ └── middleware.ts
|
||||
│ ├── hooks/ # 全局通用 hooks
|
||||
│ │ ├── useTheme.ts
|
||||
│ │ └── useDebounce.ts
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ ├── date.ts
|
||||
│ │ └── uuid.ts
|
||||
│ ├── constants/ # 常量
|
||||
│ │ ├── colors.ts
|
||||
│ │ ├── env.ts
|
||||
│ │ └── storageKeys.ts
|
||||
│ ├── assets/ # 静态资源
|
||||
│ │ ├── images/
|
||||
│ │ ├── icons/
|
||||
│ │ └── fonts/
|
||||
│ ├── theme/ # 主题系统
|
||||
│ │ ├── index.ts
|
||||
│ │ └── dark.ts
|
||||
│ └── types/ # 全局 TS 类型
|
||||
│ └── index.d.ts
|
||||
│
|
||||
├── __tests__/ # 测试
|
||||
├── .env # 环境变量
|
||||
├── babel.config.js
|
||||
├── metro.config.js
|
||||
├── tsconfig.json
|
||||
├── package.json
|
||||
└── index.js # RN 启动入口
|
||||
|
||||
后端请按照标准的python FastAPI 架构目录写代码
|
||||
现在多语言仅支持 EN / TC
|
||||
整个task.md执行完毕后需要在对应的overview.md标记,并且说明变更的文件名
|
||||
|
||||
@@ -72,33 +72,21 @@ jobs:
|
||||
- name: Set image variables
|
||||
shell: bash
|
||||
run: |
|
||||
# 自建镜像仓库地址(例如:docker.damer.fun 或 registry.example.com)
|
||||
# 如要推送 Docker Hub:可把 REGISTRY 留空并把 IMAGE_REPO 设置为 yourname/xxx
|
||||
REGISTRY="${DOCKER_REGISTRY:-docker.damer.fun}"
|
||||
# 镜像仓库路径(不含 registry)
|
||||
IMAGE_REPO="${DOCKER_IMAGE_REPO:-damer/mindfulness-server}"
|
||||
|
||||
if [[ -n "$REGISTRY" ]]; then
|
||||
IMAGE_NAME="$REGISTRY/$IMAGE_REPO"
|
||||
else
|
||||
IMAGE_NAME="$IMAGE_REPO"
|
||||
fi
|
||||
# 直接写死:推送到自建仓库
|
||||
IMAGE_NAME="docker.damer.fun/damer/mindfulness-server"
|
||||
|
||||
if [[ -n "${GITHUB_ENV:-}" ]]; then
|
||||
echo "REGISTRY=$REGISTRY" >> "$GITHUB_ENV"
|
||||
echo "IMAGE_REPO=$IMAGE_REPO" >> "$GITHUB_ENV"
|
||||
echo "IMAGE_NAME=$IMAGE_NAME" >> "$GITHUB_ENV"
|
||||
fi
|
||||
if [[ -n "${GITEA_ENV:-}" ]]; then
|
||||
echo "REGISTRY=$REGISTRY" >> "$GITEA_ENV"
|
||||
echo "IMAGE_REPO=$IMAGE_REPO" >> "$GITEA_ENV"
|
||||
echo "IMAGE_NAME=$IMAGE_NAME" >> "$GITEA_ENV"
|
||||
fi
|
||||
|
||||
# 4️⃣ 登录镜像仓库(自建 Registry)
|
||||
# 4️⃣ 登录镜像仓库(自建 Registry / Docker Hub)
|
||||
- name: Login to Docker Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
# 与 IMAGE_NAME 的 registry 保持一致
|
||||
registry: docker.damer.fun
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
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 姓名拼写
|
||||
# 生产密钥
|
||||
|
||||
ssh-keygen -t rsa -b 4096 -m PEM -N '' -f deploy_key_rsa
|
||||
# 目录结构
|
||||
|
||||
/mindfulness
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
EXPO_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
EXPO_PUBLIC_ENV=dev
|
||||
EXPO_PUBLIC_DEFAULT_LANGUAGE=auto
|
||||
#
|
||||
# Expo/EAS 项目 ID(UUID)。用于真机获取 Expo Push Token(expo-notifications)。
|
||||
# 获取方式:在 client 目录执行 `eas project:init` 或 `eas project:info` 查看。
|
||||
EXPO_PUBLIC_EAS_PROJECT_ID=c519f016-e5c8-426c-868f-5545dce8beef
|
||||
|
||||
1
client/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
30
client/app.config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { ConfigContext, ExpoConfig } from 'expo/config';
|
||||
|
||||
/**
|
||||
* 运行时获取 Push Token(expo-notifications)在真机/Dev Client 场景下通常需要 projectId。
|
||||
*
|
||||
* 这里把 projectId 注入到 `extra.eas.projectId`:
|
||||
* - 开发/本地:从 `.env.local`(EXPO_PUBLIC_EAS_PROJECT_ID)读取并写入配置
|
||||
* - CI/EAS:也可通过环境变量注入(EXPO_PUBLIC_EAS_PROJECT_ID 或 EAS_PROJECT_ID)
|
||||
*/
|
||||
export default ({ config }: ConfigContext): ExpoConfig => {
|
||||
const projectId =
|
||||
process.env.EXPO_PUBLIC_EAS_PROJECT_ID ||
|
||||
// 兼容部分 CI/EAS 注入的变量名
|
||||
process.env.EAS_PROJECT_ID ||
|
||||
undefined;
|
||||
|
||||
return {
|
||||
...config,
|
||||
extra: {
|
||||
...(config.extra ?? {}),
|
||||
eas: {
|
||||
// 保留已有配置,再覆盖 projectId
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...(((config.extra as any) ?? {}).eas ?? {}),
|
||||
projectId: projectId ?? (config.extra as any)?.eas?.projectId,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "client",
|
||||
"name": "Hey Mama",
|
||||
"slug": "client",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
@@ -9,9 +9,9 @@
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"image": "./assets/images/splashScreen.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
"backgroundColor": "#EAD2BA"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
@@ -23,7 +23,8 @@
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"edgeToEdgeEnabled": true,
|
||||
"predictiveBackGestureEnabled": false
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "com.damer.mindfulness"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
@@ -35,6 +36,13 @@
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "c519f016-e5c8-426c-868f-5545dce8beef"
|
||||
},
|
||||
"router": {}
|
||||
},
|
||||
"owner": "damersu"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Stack } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
|
||||
|
||||
export default function AppLayout() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(() => {
|
||||
// 小组件数据同步(仅 iOS 生效;内部会判断原生模块是否可用)
|
||||
// 目的:避免“仅 Onboarding 写入一次”导致老用户小组件一直显示兜底文案
|
||||
void (async () => {
|
||||
await syncWidgetConfig();
|
||||
await syncWidgetUserProfileFromStorage();
|
||||
await ensureDailyWidgetRecoUpToDate({ reason: 'app_start' });
|
||||
})();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
screenOptions={{
|
||||
@@ -13,6 +26,8 @@ export default function AppLayout() {
|
||||
<Stack.Screen
|
||||
name="home"
|
||||
options={{
|
||||
// Home 页不使用系统 Header,避免 iOS 原生导航栏自带的“毛玻璃/液玻璃”材质
|
||||
headerShown: false,
|
||||
// 卡片页标题按设计留空(右上角为 icon 按钮)
|
||||
title: '',
|
||||
headerShadowVisible: false,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigation, useFocusEffect } from 'expo-router';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import Animated, {
|
||||
Easing,
|
||||
runOnJS,
|
||||
@@ -14,20 +15,23 @@ import Animated, {
|
||||
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
||||
import {
|
||||
addFavorite,
|
||||
getRecoFeedCache,
|
||||
getRecoFeedHistory,
|
||||
getThemeMode,
|
||||
getUserProfile,
|
||||
getUserProfileScoring,
|
||||
recordRecoFeedServed,
|
||||
recordRecoFeedTouched,
|
||||
setRecoFeedCache,
|
||||
setReaction,
|
||||
setThemeMode,
|
||||
type RecoFeedCacheItem,
|
||||
getRecoFeedCache,
|
||||
setRecoFeedCache,
|
||||
getUserProfileScoring,
|
||||
getRecoFeedHistory,
|
||||
recordRecoFeedServed,
|
||||
type ThemeMode,
|
||||
getSuixinThemeState,
|
||||
setSuixinThemeState,
|
||||
type SuixinThemeStateV1,
|
||||
} from '@/src/storage/appStorage';
|
||||
|
||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
|
||||
|
||||
import ProfileModal from '@/components/home/ProfileModal';
|
||||
import ThemeModal from '@/components/home/ThemeModal';
|
||||
@@ -37,6 +41,9 @@ import MyIcon from '@/assets/images/home/my.svg';
|
||||
import LikeFilledIcon from '@/assets/images/home/like_filled.svg';
|
||||
import LikeIcon from '@/assets/images/icon/like_icon.svg';
|
||||
|
||||
import { getBootId } from '@/src/utils/bootSession';
|
||||
import { advanceSuixinState, buildInitialSuixinState, NEUTRAL_THEME_COLORS } from '@/src/features/suixinTheme';
|
||||
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
// 预定义风景图列表
|
||||
@@ -73,100 +80,191 @@ const THEME_COLORS = [
|
||||
'#CBD9F2',
|
||||
];
|
||||
|
||||
type FeedItem = { content_id: string; text: string };
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const isEnglish = i18n.language?.startsWith('en');
|
||||
const recoLang: 'en' | 'tc' = toBackendLocaleFromLanguageTag(i18n.language);
|
||||
const insets = useSafeAreaInsets();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [themeMode, setThemeModeState] = useState<ThemeMode>('scenery');
|
||||
const [suixinBgColor, setSuixinBgColor] = useState<string>(NEUTRAL_THEME_COLORS[1]);
|
||||
const [themeOpen, setThemeOpen] = useState(false);
|
||||
const [profileOpen, setProfileOpen] = useState(false);
|
||||
const [profileName, setProfileName] = useState<string | undefined>(undefined);
|
||||
const [busy, setBusy] = 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]);
|
||||
const currentContentId = typeof (item as any)?.content_id === 'number' ? Number((item as any).content_id) : null;
|
||||
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
|
||||
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
||||
const feedItemsRef = useRef<FeedItem[]>([]);
|
||||
const isFetchingRef = useRef(false);
|
||||
const themeModeRef = useRef<ThemeMode>('scenery');
|
||||
const suixinStateRef = useRef<SuixinThemeStateV1 | null>(null);
|
||||
useEffect(() => {
|
||||
feedItemsRef.current = feedItems;
|
||||
}, [feedItems]);
|
||||
useEffect(() => {
|
||||
isFetchingRef.current = isFetching;
|
||||
}, [isFetching]);
|
||||
useEffect(() => {
|
||||
themeModeRef.current = themeMode;
|
||||
}, [themeMode]);
|
||||
|
||||
const ensureSuixinReady = useCallback(async () => {
|
||||
const bootId = getBootId();
|
||||
const stored = await getSuixinThemeState();
|
||||
if (stored && stored.boot_id === bootId) {
|
||||
suixinStateRef.current = stored;
|
||||
setSuixinBgColor(stored.last_color || NEUTRAL_THEME_COLORS[1]);
|
||||
return stored;
|
||||
}
|
||||
|
||||
const profile = await getUserProfileScoring();
|
||||
const next = buildInitialSuixinState({ bootId, profile });
|
||||
suixinStateRef.current = next;
|
||||
setSuixinBgColor(next.last_color || NEUTRAL_THEME_COLORS[1]);
|
||||
await setSuixinThemeState(next);
|
||||
return next;
|
||||
}, []);
|
||||
|
||||
const advanceSuixinOnNextContent = useCallback(async () => {
|
||||
if (themeModeRef.current !== 'suixin') return;
|
||||
|
||||
const bootId = getBootId();
|
||||
let current = suixinStateRef.current;
|
||||
if (!current) {
|
||||
current = await getSuixinThemeState();
|
||||
}
|
||||
|
||||
// 冷启动后首次触发/或状态丢失:先初始化
|
||||
if (!current || current.boot_id !== bootId) {
|
||||
await ensureSuixinReady();
|
||||
return;
|
||||
}
|
||||
|
||||
const next = advanceSuixinState(current);
|
||||
suixinStateRef.current = next;
|
||||
setSuixinBgColor(next.last_color || NEUTRAL_THEME_COLORS[1]);
|
||||
await setSuixinThemeState(next);
|
||||
}, [ensureSuixinReady]);
|
||||
|
||||
// 动画相关 Shared Values
|
||||
const translateY = useSharedValue(0);
|
||||
const opacity = 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(
|
||||
useCallback(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const mode = await getThemeMode();
|
||||
const profile = await getUserProfile();
|
||||
const cache = await getRecoFeedCache();
|
||||
|
||||
if (cancelled) return;
|
||||
setThemeModeState(mode);
|
||||
setProfileName(profile.name);
|
||||
|
||||
// 随心:若当前主题为随心,进入 Home 时确保状态就绪(仅冷启动会话重算)
|
||||
if (mode === 'suixin') {
|
||||
ensureSuixinReady().catch(() => {
|
||||
// ignore:失败时回退默认中性底色
|
||||
setSuixinBgColor(NEUTRAL_THEME_COLORS[1]);
|
||||
});
|
||||
}
|
||||
|
||||
// 语言切换时:旧语言缓存不复用,触发重新拉取
|
||||
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 () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [])
|
||||
}, [fetchNewFeed, recoLang, ensureSuixinReady])
|
||||
);
|
||||
|
||||
// 首次进入:先读缓存,再拉后端 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(() => {
|
||||
if (themeMode === 'suixin') {
|
||||
return suixinBgColor;
|
||||
}
|
||||
if (themeMode === 'color') {
|
||||
const colorIndex = Math.floor(index / 10) % THEME_COLORS.length;
|
||||
return THEME_COLORS[colorIndex];
|
||||
}
|
||||
return '#F4D6C2'; // 风景模式下的默认底色(图片加载前显示)
|
||||
}, [themeMode, index]);
|
||||
}, [themeMode, suixinBgColor, index]);
|
||||
|
||||
// 计算当前应该显示的风景图索引(滑动 10 次切换一张)
|
||||
const natureImageIndex = useMemo(() => {
|
||||
@@ -175,30 +273,6 @@ export default function HomeScreen() {
|
||||
|
||||
const currentNatureImage = NATURE_IMAGES[natureImageIndex];
|
||||
|
||||
useLayoutEffect(() => {
|
||||
navigation.setOptions({
|
||||
headerShadowVisible: false,
|
||||
headerStyle: { backgroundColor: themeMode === 'scenery' ? 'transparent' : backgroundColor },
|
||||
headerTransparent: themeMode === 'scenery',
|
||||
headerRight: () => (
|
||||
<View style={styles.headerRight}>
|
||||
<CircleIconButton
|
||||
onPress={() => setThemeOpen(true)}
|
||||
accessibilityLabel={t('home.theme')}
|
||||
>
|
||||
<ThemeIcon width={18} height={18} />
|
||||
</CircleIconButton>
|
||||
<CircleIconButton
|
||||
onPress={() => setProfileOpen(true)}
|
||||
accessibilityLabel={t('home.profile')}
|
||||
>
|
||||
<MyIcon width={18} height={18} />
|
||||
</CircleIconButton>
|
||||
</View>
|
||||
),
|
||||
});
|
||||
}, [backgroundColor, themeMode, navigation, t]);
|
||||
|
||||
const textAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: translateY.value }],
|
||||
opacity: opacity.value,
|
||||
@@ -213,11 +287,6 @@ export default function HomeScreen() {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
|
||||
// 记录“看过/划过”的内容 id(用于下一次向后端请求时去重/频控)
|
||||
if (typeof currentContentId === 'number') {
|
||||
void recordRecoFeedTouched(currentContentId);
|
||||
}
|
||||
|
||||
// 1. 当前文案向上移动并消失
|
||||
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||
@@ -225,6 +294,12 @@ export default function HomeScreen() {
|
||||
// 2. 切换数据索引
|
||||
runOnJS(setIndex)(index + 1);
|
||||
runOnJS(setLikeFilled)(false);
|
||||
runOnJS(advanceSuixinOnNextContent)();
|
||||
|
||||
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条)
|
||||
if (index + 5 >= currentFeed.length && !isFetching) {
|
||||
runOnJS(fetchNewFeed)();
|
||||
}
|
||||
|
||||
// 3. 准备下一条文案:先瞬移到下方 40pt
|
||||
translateY.value = 40;
|
||||
@@ -238,7 +313,7 @@ export default function HomeScreen() {
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [busy, currentContentId, index, translateY, opacity]);
|
||||
}, [busy, index, currentFeed.length, isFetching, fetchNewFeed, translateY, opacity]);
|
||||
|
||||
const lastTapRef = useRef<number>(0);
|
||||
|
||||
@@ -288,20 +363,28 @@ export default function HomeScreen() {
|
||||
const dateStr = `${now.getFullYear()}.${String(now.getMonth() + 1).padStart(2, '0')}.${String(now.getDate()).padStart(2, '0')}`;
|
||||
|
||||
// 2. 保存到收藏夹,包含当前背景信息
|
||||
await addFavorite({
|
||||
const favItem = {
|
||||
favId: String(Date.now()), // 生成唯一 ID
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
date: dateStr,
|
||||
themeMode: themeMode,
|
||||
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(
|
||||
withTiming(0.8, { duration: 100 }),
|
||||
withTiming(1.2, { duration: 150 }),
|
||||
withTiming(1, { duration: 100 }, (finished) => {
|
||||
if (finished) {
|
||||
console.log('Home: Like animation finished, triggering next content');
|
||||
runOnJS(triggerNextContent)();
|
||||
}
|
||||
})
|
||||
@@ -312,6 +395,13 @@ export default function HomeScreen() {
|
||||
setThemeModeState(next);
|
||||
await setThemeMode(next);
|
||||
setThemeOpen(false);
|
||||
|
||||
// 切换到随心:不主动重算(除非冷启动会话变化/状态不存在),仅确保可用
|
||||
if (next === 'suixin') {
|
||||
await ensureSuixinReady().catch(() => {
|
||||
setSuixinBgColor(NEUTRAL_THEME_COLORS[1]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -323,8 +413,27 @@ export default function HomeScreen() {
|
||||
resizeMode="cover"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 自绘顶部按钮:不使用系统 Header,彻底避免 iOS 导航栏的毛玻璃/液玻璃材质 */}
|
||||
<View style={[styles.topRight, { top: insets.top + 8 }]}>
|
||||
<CircleIconButton
|
||||
onPress={() => setThemeOpen(true)}
|
||||
accessibilityLabel={t('home.theme')}
|
||||
>
|
||||
<ThemeIcon width={18} height={18} />
|
||||
</CircleIconButton>
|
||||
<CircleIconButton
|
||||
onPress={() => setProfileOpen(true)}
|
||||
accessibilityLabel={t('home.profile')}
|
||||
>
|
||||
<MyIcon width={18} height={18} />
|
||||
</CircleIconButton>
|
||||
</View>
|
||||
|
||||
<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>
|
||||
|
||||
<View style={styles.actions}>
|
||||
@@ -337,12 +446,12 @@ export default function HomeScreen() {
|
||||
style={styles.reactionInner}
|
||||
>
|
||||
{likeFilled ? (
|
||||
<LikeFilledIcon width={35} height={36} style={{ color: '#EA6969' }} />
|
||||
<LikeFilledIcon width={35} height={36} color="#EA6969" />
|
||||
) : (
|
||||
<LikeIcon
|
||||
width={35}
|
||||
height={36}
|
||||
style={{ color: themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28' }}
|
||||
color={themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28'}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -388,10 +497,12 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerRight: {
|
||||
topRight: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
flexDirection: 'row',
|
||||
gap: 10,
|
||||
paddingRight: 10,
|
||||
zIndex: 30,
|
||||
},
|
||||
circleBtn: {
|
||||
width: 34,
|
||||
@@ -419,6 +530,11 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
textEnglish: {
|
||||
fontFamily: 'STIXTwoText',
|
||||
// 英文字体观感更细一点,避免过粗
|
||||
fontWeight: '600',
|
||||
},
|
||||
sceneryCard: {
|
||||
// 风景模式下稍微收窄文案宽度,增加呼吸感
|
||||
paddingHorizontal: 50,
|
||||
|
||||
@@ -12,7 +12,6 @@ export default function OnboardingLayout() {
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="onboarding" options={{ title: t('onboarding.title') }} />
|
||||
<Stack.Screen name="push-prompt" options={{ title: t('push.title') }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert } from 'react-native';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import * as Device from 'expo-device';
|
||||
import { OnboardingLayout } from '@/components/onboarding/OnboardingLayout';
|
||||
import { NameInputStep } from '@/components/onboarding/NameInputStep';
|
||||
import { SelectionStep } from '@/components/onboarding/SelectionStep';
|
||||
import { ReminderStep } from '@/components/onboarding/ReminderStep';
|
||||
import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnaireAnswers } from '@/src/features/userProfileScoring';
|
||||
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco';
|
||||
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
|
||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
|
||||
import {
|
||||
recordRecoFeedServed,
|
||||
setOnboardingCompleted,
|
||||
@@ -14,71 +20,44 @@ import {
|
||||
setDailyReminderSettings,
|
||||
setUserProfileScoring,
|
||||
setRecoFeedCache,
|
||||
setPushPromptState,
|
||||
} from '@/src/storage/appStorage';
|
||||
|
||||
const STEPS = [
|
||||
{ id: 'name', type: 'name', title: '我可以怎么称呼你?' },
|
||||
{
|
||||
id: 'status',
|
||||
type: 'selection',
|
||||
title: '媽媽的狀態?',
|
||||
options: [
|
||||
{ id: 'pregnant', label: '懷孕中/準備成為媽媽' },
|
||||
{ id: 'has_kids', label: '已經有孩子' },
|
||||
{ id: 'no_fill', label: '不想填寫' },
|
||||
]
|
||||
},
|
||||
{
|
||||
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: '你需要每天几次提醒?' },
|
||||
type Step =
|
||||
| { id: 'name'; type: 'name' }
|
||||
| { id: 'status' | 'emotion' | 'influence' | 'support'; type: 'selection'; optionIds: string[] }
|
||||
| { id: 'reminder'; type: 'reminder' };
|
||||
|
||||
const STEPS: Step[] = [
|
||||
{ id: 'name', type: 'name' },
|
||||
{ id: 'status', type: 'selection', optionIds: ['pregnant', 'has_kids', 'no_fill'] },
|
||||
{ id: 'emotion', type: 'selection', optionIds: ['happy', 'calm', 'okay', 'tired', 'stressed', 'low'] },
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
export default function OnboardingScreen() {
|
||||
const router = useRouter();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [name, setName] = useState('');
|
||||
const [selections, setSelections] = useState<Record<string, string[]>>({});
|
||||
const [reminderTimes, setReminderTimes] = useState(3);
|
||||
|
||||
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() {
|
||||
// 请求推送权限
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
const pushEnabled = status === 'granted';
|
||||
// 用户选择每日次数 > 0:在此页直接触发系统通知权限(已移除单独的 push 引导页)。
|
||||
const wantsPush = reminderTimes > 0;
|
||||
|
||||
// 将 Onboarding 选择映射为标准问卷枚举(允许跳过)
|
||||
const answers = mapOnboardingSelectionsToQuestionnaireAnswers(selections);
|
||||
@@ -87,8 +66,16 @@ export default function OnboardingScreen() {
|
||||
const scoringProfile = buildUserProfileFromQuestionnaire(answers);
|
||||
await setUserProfileScoring(scoringProfile);
|
||||
|
||||
// 同步到 App Group:供 iOS Widget 拉取与展示
|
||||
await syncWidgetConfig();
|
||||
await syncWidgetUserProfileFromScoring(scoringProfile);
|
||||
|
||||
// 可选:前台辅助拉取一次“每日推荐”,提升小组件首次展示的成功率与一致性(失败不阻塞)
|
||||
await ensureDailyWidgetRecoUpToDate({ reason: 'onboarding_finish', scoringProfile });
|
||||
|
||||
// Onboarding 结束后预拉取一次 Feed 文案(失败不阻塞进入首页)
|
||||
try {
|
||||
const lang = toBackendLocaleFromLanguageTag(i18n.language);
|
||||
const { items, meta } = await fetchRecoFeed({
|
||||
k: 30,
|
||||
user_profile: {
|
||||
@@ -106,6 +93,7 @@ export default function OnboardingScreen() {
|
||||
|
||||
await setRecoFeedCache({
|
||||
saved_at: new Date().toISOString(),
|
||||
lang,
|
||||
items: items.map((x) => ({ content_id: x.content_id, text: x.text })),
|
||||
meta: meta as Record<string, unknown>,
|
||||
});
|
||||
@@ -120,10 +108,57 @@ export default function OnboardingScreen() {
|
||||
});
|
||||
await setDailyReminderSettings({
|
||||
timesPerDay: reminderTimes,
|
||||
pushEnabled: pushEnabled
|
||||
// 这里表示“用户意愿”,不代表系统权限一定已 granted
|
||||
pushEnabled: wantsPush,
|
||||
});
|
||||
await setOnboardingCompleted(true);
|
||||
router.replace('/(app)/home');
|
||||
|
||||
// 用户选择 0 次(关闭)或跳过:直接进入首页
|
||||
if (!wantsPush) {
|
||||
await setPushPromptState('skipped');
|
||||
router.replace('/(app)/home');
|
||||
return;
|
||||
}
|
||||
|
||||
// 用户想要 Push:请求系统权限并尽量完成 token/偏好上报(失败不阻塞进入首页)
|
||||
await setPushPromptState('unknown');
|
||||
try {
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
await setPushPromptState('skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
// iOS 模拟器通常无法获取 Expo Push Token(系统限制),此时不要提示“失败”,而是明确告知需要真机测试。
|
||||
if (Device.osName === 'iOS' && !Device.isDevice) {
|
||||
Alert.alert('提示', '当前为 iOS 模拟器,无法获取推送 Token。请使用真机测试推送功能。');
|
||||
await setPushPromptState('unknown');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) 获取 Expo Push Token(失败才认为“推送开启失败”)
|
||||
const expoPushToken = await getExpoPushTokenOrThrow();
|
||||
// 2) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
|
||||
await registerPushToken({ pushToken: expoPushToken });
|
||||
|
||||
// 3) 上报推送偏好(幂等)
|
||||
// 注意:这一步失败时,后端仍可能已成功接收 token。
|
||||
// 为避免出现“后端已接收 token 但前端弹窗提示失败”的错觉,这里改为:偏好同步失败不弹“开启失败”,仅记录并继续。
|
||||
try {
|
||||
await setPushPreferences({ enabled: wantsPush, timesPerDay: reminderTimes });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.warn('[PushPreferences] 同步失败(Onboarding,不阻塞)', msg);
|
||||
}
|
||||
|
||||
await setPushPromptState('enabled');
|
||||
} catch (e) {
|
||||
// 失败不阻塞进入首页;但这里给出更明确的文案(常见原因:模拟器/网络/后端异常)
|
||||
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
|
||||
await setPushPromptState('unknown');
|
||||
} finally {
|
||||
router.replace('/(app)/home');
|
||||
}
|
||||
}
|
||||
|
||||
const onNext = () => {
|
||||
@@ -145,16 +180,22 @@ export default function OnboardingScreen() {
|
||||
const scoringProfile = buildUserProfileFromQuestionnaire({});
|
||||
await setUserProfileScoring(scoringProfile);
|
||||
|
||||
// 同步到 App Group:供 iOS Widget 使用(失败不阻塞)
|
||||
await syncWidgetConfig();
|
||||
await syncWidgetUserProfileFromScoring(scoringProfile);
|
||||
|
||||
// 标记已完成,避免下次启动再次进入 Onboarding
|
||||
await setOnboardingCompleted(true);
|
||||
router.replace('/(app)/home');
|
||||
};
|
||||
|
||||
// 题目为单选:再次点击可取消;选择其他选项会替换为唯一选项
|
||||
// 题目为多选:点击切换选中状态
|
||||
const handleToggleSelection = (id: string) => {
|
||||
setSelections(prev => {
|
||||
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 };
|
||||
});
|
||||
};
|
||||
@@ -166,7 +207,7 @@ export default function OnboardingScreen() {
|
||||
|
||||
return (
|
||||
<OnboardingLayout
|
||||
title={currentStep.title}
|
||||
title={currentTitle}
|
||||
currentStep={stepIndex}
|
||||
totalSteps={STEPS.length - 1}
|
||||
onSkip={onSkip}
|
||||
@@ -183,11 +224,10 @@ export default function OnboardingScreen() {
|
||||
|
||||
{currentStep.type === 'selection' && (
|
||||
<SelectionStep
|
||||
options={currentStep.options!}
|
||||
options={currentOptions}
|
||||
selectedIds={selections[currentStep.id] || []}
|
||||
onToggle={handleToggleSelection}
|
||||
onNext={onNext}
|
||||
onSkip={handleSkipStep}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -196,6 +236,11 @@ export default function OnboardingScreen() {
|
||||
value={reminderTimes}
|
||||
onChange={setReminderTimes}
|
||||
onFinish={onFinish}
|
||||
onSkip={() => {
|
||||
// 跳过每日提醒:视为 0 次(关闭)
|
||||
setReminderTimes(0);
|
||||
onFinish();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</OnboardingLayout>
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
|
||||
import { setPushPromptState } from '@/src/storage/appStorage';
|
||||
|
||||
export default function PushPromptScreen() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function goHome() {
|
||||
router.replace('/(app)/home');
|
||||
}
|
||||
|
||||
async function onLater() {
|
||||
await setPushPromptState('skipped');
|
||||
await goHome();
|
||||
}
|
||||
|
||||
async function onEnableNow() {
|
||||
// 触发系统权限申请(可失败,但不阻塞进入主功能)
|
||||
setLoading(true);
|
||||
try {
|
||||
await Notifications.requestPermissionsAsync();
|
||||
await setPushPromptState('enabled');
|
||||
await goHome();
|
||||
} catch (e) {
|
||||
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
|
||||
await goHome();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>{t('push.cardTitle')}</Text>
|
||||
<Text style={styles.desc}>{t('push.cardDesc')}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable style={[styles.btn, styles.secondary]} onPress={onLater} disabled={loading}>
|
||||
<Text style={[styles.btnText, styles.secondaryText]}>{t('push.later')}</Text>
|
||||
</Pressable>
|
||||
<Pressable style={[styles.btn, styles.primary]} onPress={onEnableNow} disabled={loading}>
|
||||
<Text style={styles.btnText}>{loading ? t('push.loading') : t('push.enable')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: { flex: 1, padding: 20, justifyContent: 'center', gap: 16 },
|
||||
card: {
|
||||
borderRadius: 18,
|
||||
padding: 20,
|
||||
backgroundColor: '#111827',
|
||||
gap: 10
|
||||
},
|
||||
title: { color: 'white', fontSize: 20, fontWeight: '700' },
|
||||
desc: { color: '#E5E7EB', fontSize: 15, lineHeight: 21 },
|
||||
actions: { flexDirection: 'row', gap: 12 },
|
||||
btn: { flex: 1, paddingVertical: 14, borderRadius: 14, alignItems: 'center' },
|
||||
primary: { backgroundColor: '#16A34A' },
|
||||
secondary: { backgroundColor: '#F3F4F6' },
|
||||
btnText: { fontSize: 16, fontWeight: '600', color: '#FFFFFF' },
|
||||
secondaryText: { color: '#111827' }
|
||||
});
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Dimensions, Platform, Alert, Image } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { setConsentAccepted, getConsentAccepted } from '../../src/storage/appStorage';
|
||||
import { fetchLegalLinks } from '@/src/services/legalApi';
|
||||
import { getOnboardingCompleted } from '@/src/storage/appStorage';
|
||||
import { API_BASE_URL } from '@/src/constants/env';
|
||||
|
||||
// 导入 SVG 组件
|
||||
import FlowersBg from '../../assets/images/index/flowers_endbg.svg';
|
||||
@@ -16,16 +19,31 @@ export default function SplashScreen() {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [showConsent, setShowConsent] = useState(false);
|
||||
const [links, setLinks] = useState<{ privacy?: string; terms?: string }>({});
|
||||
const [linksLoading, setLinksLoading] = useState(false);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
checkConsent();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkConsent = async () => {
|
||||
const accepted = await getConsentAccepted();
|
||||
setShowConsent(!accepted);
|
||||
if (accepted) {
|
||||
router.replace('/');
|
||||
// 已同意协议则直接分发到目标页,避免先回到 /(index)再二次跳转导致“闪一下”
|
||||
const completed = await getOnboardingCompleted();
|
||||
if (completed) {
|
||||
router.replace('/(app)/home');
|
||||
} else {
|
||||
router.replace('/(onboarding)/onboarding');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -44,6 +62,50 @@ export default function SplashScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
async function refreshLegalLinks(): Promise<{ privacy?: string; terms?: string }> {
|
||||
if (mountedRef.current) setLinksLoading(true);
|
||||
try {
|
||||
const res = await fetchLegalLinks();
|
||||
const next = { privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl };
|
||||
if (mountedRef.current) setLinks(next);
|
||||
return next;
|
||||
} catch (e) {
|
||||
// 不阻塞主流程:失败时不崩溃,链接入口仍可点(会提示)
|
||||
if (__DEV__) console.log('[LegalLinks] 拉取失败(splash):', e);
|
||||
if (mountedRef.current) setLinks({});
|
||||
return {};
|
||||
} finally {
|
||||
if (mountedRef.current) setLinksLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOpenLegal(type: 'privacy' | 'terms') {
|
||||
const currentUrl = type === 'privacy' ? links.privacy : links.terms;
|
||||
if (currentUrl) {
|
||||
await openLink(currentUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// 链接还没拿到/拉取失败:点击时主动再拉一次,避免“点了没反应”
|
||||
const next = await refreshLegalLinks();
|
||||
const nextUrl = type === 'privacy' ? next.privacy : next.terms;
|
||||
if (nextUrl) {
|
||||
await openLink(nextUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const msg =
|
||||
typeof __DEV__ !== 'undefined' && __DEV__
|
||||
? t('consent.linkUnavailableDev', { baseUrl: API_BASE_URL })
|
||||
: t('consent.linkUnavailable');
|
||||
Alert.alert(t('common.notice'), msg);
|
||||
}
|
||||
|
||||
// 拉取协议链接(由后端按语言下发;默认 EN)
|
||||
useEffect(() => {
|
||||
void refreshLegalLinks();
|
||||
}, []);
|
||||
|
||||
const bgDecorationTop = 363;
|
||||
const bgDecorationHeight = height * 0.6;
|
||||
const contentTop = bgDecorationTop + (bgDecorationHeight * 0.25);
|
||||
@@ -67,28 +129,52 @@ export default function SplashScreen() {
|
||||
{/* 文案内容 */}
|
||||
<View style={[styles.contentContainer, { position: 'absolute', top: contentTop }]}>
|
||||
<Text style={styles.titleText}>
|
||||
You Are Perfect.{"\n"}
|
||||
Everything{"\n"}
|
||||
Will Be Better.
|
||||
{t('consent.title')}
|
||||
{'\n'}
|
||||
{t('consent.subtitle')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<SafeAreaView style={styles.bottomContainer} edges={['bottom']}>
|
||||
{showConsent && (
|
||||
<>
|
||||
<TouchableOpacity onPress={handleAgree} activeOpacity={0.8} style={styles.buttonWrapper}>
|
||||
<TouchableOpacity
|
||||
onPress={handleAgree}
|
||||
activeOpacity={0.8}
|
||||
style={styles.buttonWrapper}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('consent.agree')}
|
||||
>
|
||||
<WelcomeBtn width={87} height={57} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.linksContainer}>
|
||||
<TouchableOpacity onPress={() => openLink('https://example.com/privacy')}>
|
||||
<Text style={styles.linkText}>{t('consent.privacy')}</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.divider} />
|
||||
<TouchableOpacity onPress={() => openLink('https://example.com/terms')}>
|
||||
<Text style={styles.linkText}>{t('consent.terms')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={styles.noticeText}>
|
||||
<Trans
|
||||
i18nKey="consent.noticeRich"
|
||||
values={{
|
||||
privacyLabel: t('consent.privacy'),
|
||||
termsLabel: t('consent.terms'),
|
||||
privacySuffix: !links.privacy && linksLoading ? t('consent.linkLoadingSuffix') : '',
|
||||
termsSuffix: !links.terms && linksLoading ? t('consent.linkLoadingSuffix') : '',
|
||||
}}
|
||||
components={{
|
||||
privacy: (
|
||||
<Text
|
||||
style={[styles.noticeLinkText, !links.privacy && styles.noticeLinkTextDisabled]}
|
||||
onPress={() => void handleOpenLegal('privacy')}
|
||||
suppressHighlighting
|
||||
/>
|
||||
),
|
||||
terms: (
|
||||
<Text
|
||||
style={[styles.noticeLinkText, !links.terms && styles.noticeLinkTextDisabled]}
|
||||
onPress={() => void handleOpenLegal('terms')}
|
||||
suppressHighlighting
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
@@ -137,19 +223,22 @@ const styles = StyleSheet.create({
|
||||
buttonWrapper: {
|
||||
marginBottom: 40,
|
||||
},
|
||||
linksContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
linkText: {
|
||||
noticeText: {
|
||||
marginTop: 10,
|
||||
paddingHorizontal: 28,
|
||||
fontSize: 12,
|
||||
color: 'rgba(119, 47, 0, 0.5)', // 使用半透明的文字颜色
|
||||
textDecorationLine: 'underline',
|
||||
lineHeight: 16,
|
||||
textAlign: 'center',
|
||||
color: 'rgba(119, 47, 0, 0.45)',
|
||||
},
|
||||
divider: {
|
||||
width: 1,
|
||||
height: 12,
|
||||
backgroundColor: 'rgba(119, 47, 0, 0.2)',
|
||||
marginHorizontal: 15,
|
||||
noticeLinkText: {
|
||||
fontSize: 12,
|
||||
// 颜色区分:协议链接更醒目
|
||||
color: 'rgba(119, 47, 0, 0.75)',
|
||||
textDecorationLine: 'underline',
|
||||
fontWeight: '600',
|
||||
},
|
||||
noticeLinkTextDisabled: {
|
||||
opacity: 0.55,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -4,16 +4,22 @@ import { useFonts } from 'expo-font';
|
||||
import { Stack } from 'expo-router';
|
||||
import * as SplashScreen from 'expo-splash-screen';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import 'react-native-reanimated';
|
||||
import { Animated, AppState, Image, StyleSheet, View } from 'react-native';
|
||||
|
||||
import { useColorScheme } from '@/components/useColorScheme';
|
||||
import { initI18n } from '@/src/i18n';
|
||||
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
|
||||
import { getOrCreateClientUserId } from '@/src/storage/appStorage';
|
||||
|
||||
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
// 新版 expo-notifications 类型要求显式返回 banner/list 行为
|
||||
shouldShowBanner: true,
|
||||
shouldShowList: true,
|
||||
shouldPlaySound: false,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
@@ -26,7 +32,8 @@ export {
|
||||
|
||||
export const unstable_settings = {
|
||||
// Ensure that reloading on `/modal` keeps a back button present.
|
||||
initialRouteName: 'index',
|
||||
// 让首次启动(未同意协议)直接进入协议页,避免先渲染 index 再跳转导致“闪一下”
|
||||
initialRouteName: '(splash)/splash',
|
||||
};
|
||||
|
||||
// Prevent the splash screen from auto-hiding before asset loading is complete.
|
||||
@@ -34,10 +41,14 @@ SplashScreen.preventAutoHideAsync();
|
||||
|
||||
export default function RootLayout() {
|
||||
const [loaded, error] = useFonts({
|
||||
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
|
||||
STIXTwoText: require('../assets/fonts/STIXTwoText-VariableFont_wght.ttf'),
|
||||
...FontAwesome.font,
|
||||
});
|
||||
const [i18nReady, setI18nReady] = useState(false);
|
||||
const [appReady, setAppReady] = useState(false);
|
||||
const [splashOverlayVisible, setSplashOverlayVisible] = useState(true);
|
||||
const splashOpacity = useRef(new Animated.Value(1)).current;
|
||||
const hasHiddenNativeSplashRef = useRef(false);
|
||||
|
||||
// Expo Router uses Error Boundaries to catch errors in the navigation tree.
|
||||
useEffect(() => {
|
||||
@@ -48,31 +59,95 @@ export default function RootLayout() {
|
||||
initI18n()
|
||||
.catch((e) => {
|
||||
// i18n 初始化失败不应阻塞 App 启动,先打印错误再继续
|
||||
console.error('i18n 初始化失败', e);
|
||||
console.error('i18n init failed', e);
|
||||
})
|
||||
.finally(() => setI18nReady(true));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 等字体与 i18n 都准备好后再隐藏启动页,避免文案闪烁
|
||||
if (loaded && i18nReady) {
|
||||
SplashScreen.hideAsync();
|
||||
}
|
||||
// 尽早生成 client_user_id,便于后续任意时刻与后端建立关联(Push Token/偏好等)
|
||||
getOrCreateClientUserId()
|
||||
.then((id) => {
|
||||
if (__DEV__) console.log('[client_user_id]', id);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('[client_user_id] 生成失败(不阻塞启动)', e);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 字体与 i18n 都准备好后,允许渲染 App(原生 splash 的隐藏交给 onLayout,避免“硬切/闪白”)
|
||||
if (loaded && i18nReady) setAppReady(true);
|
||||
}, [loaded, i18nReady]);
|
||||
|
||||
const onLayoutRootView = useCallback(() => {
|
||||
if (!appReady) return;
|
||||
if (hasHiddenNativeSplashRef.current) return;
|
||||
hasHiddenNativeSplashRef.current = true;
|
||||
|
||||
// 先隐藏原生 splash,再把同款覆盖层淡出,视觉上实现平滑过渡
|
||||
void SplashScreen.hideAsync().finally(() => {
|
||||
Animated.timing(splashOpacity, {
|
||||
toValue: 0,
|
||||
duration: 380,
|
||||
useNativeDriver: true,
|
||||
}).start(({ finished }) => {
|
||||
if (finished) setSplashOverlayVisible(false);
|
||||
});
|
||||
});
|
||||
}, [appReady, splashOpacity]);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!appReady) return null;
|
||||
return <RootLayoutNav />;
|
||||
}, [appReady]);
|
||||
|
||||
if (!loaded || !i18nReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RootLayoutNav />;
|
||||
return (
|
||||
<View style={styles.root} onLayout={onLayoutRootView}>
|
||||
{content}
|
||||
{splashOverlayVisible && (
|
||||
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { opacity: splashOpacity }]}>
|
||||
<View style={styles.splashOverlay}>
|
||||
<Image
|
||||
source={require('../assets/images/splashScreen.png')}
|
||||
style={styles.splashImage}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function RootLayoutNav() {
|
||||
const colorScheme = useColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
// iOS 小组件:启动时把必要信息写入共享区,并尽力刷新一次“每日推荐”
|
||||
syncWidgetConfig().catch(() => {});
|
||||
syncWidgetUserProfileFromStorage().catch(() => {});
|
||||
ensureDailyWidgetRecoUpToDate({ reason: 'app_start' }).catch(() => {});
|
||||
|
||||
const sub = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
// App 回到前台时尝试刷新(失败不阻塞)
|
||||
ensureDailyWidgetRecoUpToDate({ reason: 'app_active' }).catch(() => {});
|
||||
}
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
{/* 协议页分组(首次启动优先进入) */}
|
||||
<Stack.Screen name="(splash)" />
|
||||
|
||||
{/* 启动分发页:根据 onboarding 状态跳转 */}
|
||||
<Stack.Screen name="index" />
|
||||
|
||||
@@ -89,3 +164,21 @@ function RootLayoutNav() {
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
splashOverlay: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
// 与 app.json 的 expo.splash.backgroundColor 保持一致
|
||||
backgroundColor: '#EAD2BA',
|
||||
},
|
||||
splashImage: {
|
||||
// 覆盖层图片尺寸需与系统原生 Splash 的视觉一致,避免出现“缩小一下”的错觉
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
BIN
client/assets/fonts/STIXTwoText-VariableFont_wght.ttf
Normal file
@@ -1,3 +1,3 @@
|
||||
<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>
|
||||
|
||||
|
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">
|
||||
<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>
|
||||
|
||||
|
||||
|
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">
|
||||
<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>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
BIN
client/assets/images/splashScreen.png
Normal file
|
After Width: | Height: | Size: 126 KiB |
@@ -39,13 +39,17 @@ export default function DailyReminderModal({ visible, onClose }: Props) {
|
||||
}, [visible]);
|
||||
|
||||
function clamp(next: number) {
|
||||
return Math.min(10, Math.max(1, next));
|
||||
// 需求:0~5(0 表示关闭)
|
||||
return Math.min(5, Math.max(0, next));
|
||||
}
|
||||
|
||||
async function onOk() {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
const next: DailyReminderSettings = { timesPerDay, pushEnabled };
|
||||
const next: DailyReminderSettings = {
|
||||
timesPerDay: Math.min(5, Math.max(0, Math.round(timesPerDay))),
|
||||
pushEnabled: Boolean(pushEnabled) && timesPerDay > 0,
|
||||
};
|
||||
await setDailyReminderSettings(next);
|
||||
setLoading(false);
|
||||
onClose();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SheetModal from '@/components/ui/SheetModal';
|
||||
import { MOCK_CONTENT } from '@/src/constants/mockContent';
|
||||
import { getFavorites } from '@/src/storage/appStorage';
|
||||
import { getFavorites, getRecoFeedCache, type FavoriteItem } from '@/src/storage/appStorage';
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
@@ -13,25 +13,50 @@ type Props = {
|
||||
|
||||
export default function FavoritesModal({ visible, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const [ids, setIds] = useState<string[]>([]);
|
||||
const [items, setItems] = useState<(FavoriteItem & { text: string })[]>([]);
|
||||
|
||||
// 每次打开时刷新一次,确保展示最新“喜欢”
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const list = await getFavorites();
|
||||
if (!cancelled) setIds(list);
|
||||
const [favList, cache] = await Promise.all([
|
||||
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 () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [visible]);
|
||||
|
||||
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]);
|
||||
}, [visible, t]);
|
||||
|
||||
return (
|
||||
<SheetModal visible={visible} title={t('profile.favorites')} onClose={onClose}>
|
||||
@@ -41,7 +66,7 @@ export default function FavoritesModal({ visible, onClose }: Props) {
|
||||
) : (
|
||||
<FlatList
|
||||
data={items}
|
||||
keyExtractor={(it) => it.id}
|
||||
keyExtractor={(it) => it.favId}
|
||||
contentContainerStyle={styles.list}
|
||||
renderItem={({ item }) => (
|
||||
<View style={styles.row}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Alert, FlatList, Image, Pressable, StyleSheet, Text, View, Platform, Di
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Switch } from 'react-native';
|
||||
import * as WebBrowser from 'expo-web-browser';
|
||||
import Animated, {
|
||||
Easing,
|
||||
FadeIn,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
getFavorites,
|
||||
setDailyReminderSettings,
|
||||
removeFavorite,
|
||||
getRecoFeedCache,
|
||||
getUserProfile,
|
||||
type DailyReminderSettings,
|
||||
type FavoriteItem,
|
||||
@@ -38,6 +40,8 @@ import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
|
||||
import QuestionIcon from '@/assets/images/home/Profile/widget/question_icon.svg';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { changeLanguage } from '@/src/i18n';
|
||||
import { fetchLegalLinks } from '@/src/services/legalApi';
|
||||
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
@@ -79,6 +83,7 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
const [page, setPage] = useState<Page>('root');
|
||||
const [navDirection, setNavDirection] = useState<NavDirection>('forward');
|
||||
const [currentName, setCurrentName] = useState(propName);
|
||||
const [legalLinks, setLegalLinks] = useState<{ privacy?: string; terms?: string }>({});
|
||||
const isRoot = page === 'root';
|
||||
|
||||
// 当弹窗打开时,尝试从存储中获取最新的昵称,确保与 onboarding 同步
|
||||
@@ -89,6 +94,16 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
setCurrentName(profile.name);
|
||||
}
|
||||
});
|
||||
|
||||
// 打开弹窗时拉取协议链接(由后端按语言下发;默认 EN)
|
||||
fetchLegalLinks()
|
||||
.then((res) => {
|
||||
setLegalLinks({ privacy: res.privacyPolicyUrl, terms: res.termsOfUseUrl });
|
||||
})
|
||||
.catch((e) => {
|
||||
if (__DEV__) console.log('[LegalLinks] 拉取失败(ProfileModal):', e);
|
||||
setLegalLinks({});
|
||||
});
|
||||
} else {
|
||||
setNavDirection('back');
|
||||
setPage('root');
|
||||
@@ -122,6 +137,21 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
handleClose();
|
||||
}
|
||||
|
||||
const openLink = useCallback(
|
||||
async (url?: string) => {
|
||||
if (!url) {
|
||||
Alert.alert(t('common.notice'), t('consent.linkUnavailable'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await WebBrowser.openBrowserAsync(url);
|
||||
} catch (error) {
|
||||
Alert.alert(t('common.error'), t('common.openLinkError'));
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (page === 'favorites') return t('profile.favorites');
|
||||
if (page === 'dailyReminder') return t('dailyReminder.title');
|
||||
@@ -135,23 +165,13 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
const duration = 220;
|
||||
const easing = Easing.out(Easing.cubic);
|
||||
|
||||
// 进入二级页:从右侧滑入;返回:从左侧滑入
|
||||
const entering =
|
||||
navDirection === 'forward'
|
||||
? SlideInRight.duration(duration).easing(easing)
|
||||
: SlideInLeft.duration(duration).easing(easing);
|
||||
|
||||
// 离开:进入二级页时旧页面向左滑出;返回时旧页面向右滑出
|
||||
const exiting =
|
||||
navDirection === 'forward'
|
||||
? SlideOutLeft.duration(duration).easing(easing)
|
||||
: SlideOutRight.duration(duration).easing(easing);
|
||||
// 需求:去掉左右滑动的切页动效,改为纯淡入淡出
|
||||
const entering = FadeIn.duration(duration).easing(easing);
|
||||
const exiting = FadeOut.duration(duration).easing(easing);
|
||||
|
||||
return {
|
||||
entering,
|
||||
exiting,
|
||||
fadeIn: FadeIn.duration(duration).easing(easing),
|
||||
fadeOut: FadeOut.duration(duration).easing(easing),
|
||||
};
|
||||
}, [navDirection]);
|
||||
|
||||
@@ -169,11 +189,6 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
exiting={transition.exiting}
|
||||
style={!isRoot ? { flex: 1 } : undefined}
|
||||
>
|
||||
<Animated.View
|
||||
entering={transition.fadeIn}
|
||||
exiting={transition.fadeOut}
|
||||
style={!isRoot ? { flex: 1 } : undefined}
|
||||
>
|
||||
{page === 'root' ? (
|
||||
<RootPage
|
||||
name={currentName}
|
||||
@@ -181,6 +196,8 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
onOpenWidget={() => go('widget', 'forward')}
|
||||
onOpenDailyReminder={() => go('dailyReminder', 'forward')}
|
||||
onOpenLanguage={() => go('language', 'forward')}
|
||||
onOpenPrivacy={() => openLink(legalLinks.privacy)}
|
||||
onOpenTerms={() => openLink(legalLinks.terms)}
|
||||
/>
|
||||
) : page === 'favorites' ? (
|
||||
<FavoritesPage visible={visible} page={page} />
|
||||
@@ -193,7 +210,6 @@ export default function ProfileModal({ visible, name: propName, onClose }: Props
|
||||
) : (
|
||||
<WidgetPage onOpenHowTo={() => go('widgetHowTo', 'forward')} />
|
||||
)}
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</SheetModal>
|
||||
@@ -210,12 +226,16 @@ function RootPage({
|
||||
onOpenWidget,
|
||||
onOpenDailyReminder,
|
||||
onOpenLanguage,
|
||||
onOpenPrivacy,
|
||||
onOpenTerms,
|
||||
}: {
|
||||
name?: string;
|
||||
onOpenFavorites: () => void;
|
||||
onOpenWidget: () => void;
|
||||
onOpenDailyReminder: () => void;
|
||||
onOpenLanguage: () => void;
|
||||
onOpenPrivacy: () => void;
|
||||
onOpenTerms: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -243,12 +263,12 @@ function RootPage({
|
||||
<ListItem
|
||||
icon={<PrivacyIcon width={22} height={22} />}
|
||||
title={t('profile.privacy')}
|
||||
onPress={() => toastTodo(t)}
|
||||
onPress={onOpenPrivacy}
|
||||
/>
|
||||
<ListItem
|
||||
icon={<TermsIcon width={22} height={22} />}
|
||||
title={t('profile.terms')}
|
||||
onPress={() => toastTodo(t)}
|
||||
onPress={onOpenTerms}
|
||||
/>
|
||||
<ListItem
|
||||
icon={<LanguageIcon width={22} height={22} />}
|
||||
@@ -271,15 +291,21 @@ function FavoritesPage({ visible, page }: { visible: boolean; page: Page }) {
|
||||
|
||||
async function refreshFavorites() {
|
||||
const storedFavs = await getFavorites();
|
||||
const map = new Map(MOCK_CONTENT.map((c) => [c.id, c.text]));
|
||||
|
||||
const list = storedFavs
|
||||
.map((fav) => ({
|
||||
...fav,
|
||||
text: map.get(fav.id) || ''
|
||||
}))
|
||||
.filter(item => item.text !== '');
|
||||
|
||||
const textMap = new Map<string, string>();
|
||||
|
||||
// 1) Mock 文案
|
||||
MOCK_CONTENT.forEach((c) => textMap.set(String(c.id), t(c.textKey)));
|
||||
|
||||
// 2) 后端推荐缓存文案(避免收藏后 cache 覆盖就丢文案)
|
||||
const cache = await getRecoFeedCache();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -366,13 +392,15 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
(async () => {
|
||||
// 1. 获取本地存储设置
|
||||
const s = await getDailyReminderSettings();
|
||||
|
||||
// 【测试模式】:强制模拟无权限状态
|
||||
const granted = false;
|
||||
|
||||
|
||||
// 2. 获取系统通知权限(用于 UI 展示/引导)
|
||||
const settings = await Notifications.getPermissionsAsync();
|
||||
const granted = settings.status === 'granted';
|
||||
|
||||
if (cancelled) return;
|
||||
setTimesPerDay(s.timesPerDay);
|
||||
setPushEnabled(granted);
|
||||
// pushEnabled 表示用户意愿;若系统未授权则强制展示为关闭
|
||||
setPushEnabled(Boolean(s.pushEnabled) && granted);
|
||||
setHasSystemPermission(granted);
|
||||
setLoading(false);
|
||||
})();
|
||||
@@ -390,7 +418,7 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
if (settings.status === 'denied') {
|
||||
Alert.alert(
|
||||
t('common.notice'),
|
||||
"系统权限已被拒绝,请前往手机设置开启通知。"
|
||||
t('permissions.notificationsDenied')
|
||||
);
|
||||
setPushEnabled(false);
|
||||
return;
|
||||
@@ -405,6 +433,23 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
if (status === 'granted') {
|
||||
setPushEnabled(true);
|
||||
setHasSystemPermission(true);
|
||||
|
||||
// 获取 token 并上报后端(幂等)
|
||||
try {
|
||||
const expoPushToken = await getExpoPushTokenOrThrow();
|
||||
await registerPushToken({ pushToken: expoPushToken });
|
||||
// 偏好同步失败不应被用户感知为“开启失败”
|
||||
// (常见现象:后端已接收 token,但偏好接口短暂失败/超时)
|
||||
try {
|
||||
await setPushPreferences({ enabled: true, timesPerDay });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.warn('[PushPreferences] 同步失败(ProfileModal,不阻塞)', msg);
|
||||
}
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
Alert.alert(t('common.notice'), msg);
|
||||
}
|
||||
} else {
|
||||
setPushEnabled(false);
|
||||
setHasSystemPermission(false);
|
||||
@@ -412,18 +457,35 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
}
|
||||
} else {
|
||||
setPushEnabled(false);
|
||||
// 关闭时尝试同步到后端(不阻塞)
|
||||
try {
|
||||
await setPushPreferences({ enabled: false, timesPerDay: 0 });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function clamp(next: number) {
|
||||
return Math.min(10, Math.max(1, next));
|
||||
// 需求:0~5(0 表示关闭)
|
||||
return Math.min(5, Math.max(0, next));
|
||||
}
|
||||
|
||||
async function onOk() {
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
const next: DailyReminderSettings = { timesPerDay, pushEnabled };
|
||||
const nextTimes = Math.min(5, Math.max(0, Math.round(timesPerDay)));
|
||||
const nextEnabled = Boolean(pushEnabled) && nextTimes > 0;
|
||||
const next: DailyReminderSettings = { timesPerDay: nextTimes, pushEnabled: nextEnabled };
|
||||
await setDailyReminderSettings(next);
|
||||
|
||||
// 同步后端偏好(幂等;失败不阻塞)
|
||||
try {
|
||||
await setPushPreferences({ enabled: nextEnabled, timesPerDay: nextTimes });
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
console.warn('[PushPreferences] 同步失败', msg);
|
||||
}
|
||||
setLoading(false);
|
||||
onDone();
|
||||
}
|
||||
@@ -457,24 +519,22 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{!hasSystemPermission && (
|
||||
<View style={styles.remindRow}>
|
||||
<View style={styles.rowLeft}>
|
||||
<View style={styles.rowIcon}>
|
||||
<RemindIcon width={18} height={18} />
|
||||
</View>
|
||||
<Text style={styles.rowText}>{t('dailyReminder.pushLabel')}</Text>
|
||||
</View>
|
||||
<View style={styles.rowRight}>
|
||||
<Switch
|
||||
value={pushEnabled}
|
||||
onValueChange={handleTogglePush}
|
||||
trackColor={{ false: '#D1D1D6', true: '#4CD964' }}
|
||||
ios_backgroundColor="#D1D1D6"
|
||||
/>
|
||||
<View style={styles.remindRow}>
|
||||
<View style={styles.rowLeft}>
|
||||
<View style={styles.rowIcon}>
|
||||
<RemindIcon width={18} height={18} />
|
||||
</View>
|
||||
<Text style={styles.rowText}>{t('dailyReminder.pushLabel')}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.rowRight}>
|
||||
<Switch
|
||||
value={pushEnabled}
|
||||
onValueChange={handleTogglePush}
|
||||
trackColor={{ false: '#D1D1D6', true: '#4CD964' }}
|
||||
ios_backgroundColor="#D1D1D6"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable onPress={onOk} disabled={loading} style={styles.okPressable}>
|
||||
<LinearGradient
|
||||
@@ -530,7 +590,8 @@ function WidgetHowToPage() {
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [isManual, setIsManual] = useState(false);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
// React Native 环境下 setInterval 返回值类型与 Node 不同,这里用 ReturnType 兼容
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const images = currentLang === 'en' ? [
|
||||
{ id: '1', src: require('@/assets/images/home/Profile/widget/Widget_description1_en.png'), desc: t('widget.howToDesc1') },
|
||||
@@ -607,12 +668,12 @@ function WidgetHowToPage() {
|
||||
}
|
||||
|
||||
function LanguagePage() {
|
||||
const { i18n } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const currentLang = i18n.language;
|
||||
|
||||
const languages = [
|
||||
{ id: 'zh-TW', label: '繁体' },
|
||||
{ id: 'en', label: 'English' },
|
||||
{ id: 'zh-TW', label: t('language.zhTW') },
|
||||
{ id: 'en', label: t('language.en') },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SheetModal from '@/components/ui/SheetModal';
|
||||
|
||||
export type ThemeMode = 'scenery' | 'color';
|
||||
import type { ThemeMode } from '@/src/storage/appStorage';
|
||||
|
||||
type Props = {
|
||||
visible: boolean;
|
||||
@@ -16,7 +16,7 @@ type Props = {
|
||||
export default function ThemeModal({ visible, mode, onSelect, onClose }: Props) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={350}>
|
||||
<SheetModal visible={visible} title={t('theme.title')} onClose={onClose} height={360}>
|
||||
<View style={styles.row}>
|
||||
<ThemeCard
|
||||
title={t('theme.scenery')}
|
||||
@@ -41,6 +41,19 @@ export default function ThemeModal({ visible, mode, onSelect, onClose }: Props)
|
||||
style={styles.previewImage}
|
||||
/>
|
||||
</ThemeCard>
|
||||
|
||||
<ThemeCard
|
||||
title={t('theme.suixin')}
|
||||
selected={mode === 'suixin'}
|
||||
onPress={() => onSelect('suixin')}
|
||||
>
|
||||
<Image
|
||||
// 占位:一期复用纯色预览图,后续可替换为专用资源
|
||||
source={require('../../assets/images/theme/theme_color.png')}
|
||||
resizeMode="cover"
|
||||
style={styles.previewImage}
|
||||
/>
|
||||
</ThemeCard>
|
||||
</View>
|
||||
</SheetModal>
|
||||
);
|
||||
@@ -68,7 +81,12 @@ function ThemeCard({
|
||||
{children}
|
||||
{/* 文案展示在图片中心 */}
|
||||
<View style={styles.textOverlay}>
|
||||
<Text style={[styles.overlayTitle, selected && styles.selectedOverlayTitle]}>
|
||||
<Text
|
||||
style={[styles.overlayTitle, selected && styles.selectedOverlayTitle]}
|
||||
numberOfLines={1}
|
||||
adjustsFontSizeToFit
|
||||
minimumFontScale={0.85}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -81,19 +99,21 @@ function ThemeCard({
|
||||
const styles = StyleSheet.create({
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
gap: 30,
|
||||
paddingHorizontal: 10,
|
||||
flexWrap: 'nowrap',
|
||||
gap: 12,
|
||||
paddingHorizontal: 4,
|
||||
paddingBottom: 50,
|
||||
paddingTop: 20,
|
||||
justifyContent: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
cardContainer: {
|
||||
alignItems: 'center',
|
||||
width: 143,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
alignItems: 'stretch',
|
||||
},
|
||||
previewWrapper: {
|
||||
width: 138,
|
||||
height: 203,
|
||||
width: '100%',
|
||||
aspectRatio: 110 / 178,
|
||||
borderRadius: 26,
|
||||
padding: 6.5,
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SerifText } from './SerifText';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
|
||||
export const INTENTS = [
|
||||
{ id: 'love', label: '爱情', icon: '❤️' },
|
||||
{ id: 'life', label: '生活', icon: '⛅' },
|
||||
{ id: 'travel', label: '旅游', icon: '🌴' },
|
||||
{ id: 'work', label: '职场', icon: '💼' },
|
||||
{ id: 'love', labelKey: 'intent.love', icon: '❤️' },
|
||||
{ id: 'life', labelKey: 'intent.life', icon: '⛅' },
|
||||
{ id: 'travel', labelKey: 'intent.travel', icon: '🌴' },
|
||||
{ id: 'work', labelKey: 'intent.work', icon: '💼' },
|
||||
];
|
||||
|
||||
interface IntentSelectionStepProps {
|
||||
@@ -16,9 +17,10 @@ interface IntentSelectionStepProps {
|
||||
}
|
||||
|
||||
export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionStepProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<SerifText style={styles.title}>你希望得到什么帮助?</SerifText>
|
||||
<SerifText style={styles.title}>{t('intent.title')}</SerifText>
|
||||
|
||||
<View style={styles.grid}>
|
||||
{INTENTS.map((intent) => {
|
||||
@@ -35,7 +37,7 @@ export function IntentSelectionStep({ selectedIds, onToggle }: IntentSelectionSt
|
||||
>
|
||||
<SerifText style={styles.icon}>{intent.icon}</SerifText>
|
||||
<SerifText style={[styles.label, isSelected && styles.labelSelected]}>
|
||||
{intent.label}
|
||||
{t(intent.labelKey)}
|
||||
</SerifText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Dimensions, Text } from 'react-native';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { View, StyleSheet, TextInput, Platform, Animated, TouchableOpacity, Text, Keyboard, Pressable } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||
import EnterLightIcon from '@/assets/images/icon/enter_Light_icon.svg';
|
||||
|
||||
const { height } = Dimensions.get('window');
|
||||
|
||||
interface NameInputStepProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
@@ -14,10 +14,30 @@ interface NameInputStepProps {
|
||||
}
|
||||
|
||||
export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [keyboardHeight, setKeyboardHeight] = useState(0);
|
||||
const blinkAnim = useRef(new Animated.Value(1)).current;
|
||||
const hasInput = value.trim().length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||
|
||||
const subShow = Keyboard.addListener(showEvent, (e) => {
|
||||
setKeyboardHeight(e.endCoordinates?.height ?? 0);
|
||||
});
|
||||
const subHide = Keyboard.addListener(hideEvent, () => {
|
||||
setKeyboardHeight(0);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subShow.remove();
|
||||
subHide.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const animation = Animated.loop(
|
||||
Animated.sequence([
|
||||
@@ -34,8 +54,14 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
||||
return () => animation.stop();
|
||||
}, [blinkAnim, isFocused]);
|
||||
|
||||
const footerBottom = useMemo(() => {
|
||||
// iOS 的 keyboard height 通常已包含底部安全区,避免重复叠加
|
||||
const keyboardOffset = Math.max(0, keyboardHeight - insets.bottom);
|
||||
return 16 + insets.bottom + keyboardOffset;
|
||||
}, [insets.bottom, keyboardHeight]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Pressable style={styles.container} onPress={Keyboard.dismiss} accessible={false}>
|
||||
<View style={styles.inputCard}>
|
||||
<View style={styles.inputWrapper}>
|
||||
{/* 显示层:文案 + 跟随的光标 */}
|
||||
@@ -46,7 +72,7 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
||||
(!isFocused && !hasInput) && { color: OnboardingColors.textSecondary }
|
||||
]}
|
||||
>
|
||||
{hasInput ? value : (isFocused ? "" : "Mama")}
|
||||
{hasInput ? value : isFocused ? '' : t('onboardingSurvey.steps.name.placeholder')}
|
||||
</Text>
|
||||
{isFocused && (
|
||||
<Animated.View style={[styles.cursorWrapper, { opacity: blinkAnim, marginLeft: 2 }]}>
|
||||
@@ -65,20 +91,30 @@ export function NameInputStep({ value, onChangeText, onNext }: NameInputStepProp
|
||||
caretHidden={true}
|
||||
autoCorrect={false}
|
||||
spellCheck={false}
|
||||
returnKeyType="done"
|
||||
blurOnSubmit={true}
|
||||
onSubmitEditing={() => {
|
||||
Keyboard.dismiss();
|
||||
// 有输入时,“完成”直接进入下一步,避免真机卡在键盘上
|
||||
if (value.trim().length > 0) onNext();
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<View style={[styles.footer, { bottom: footerBottom }]}>
|
||||
<TouchableOpacity
|
||||
onPress={onNext}
|
||||
onPress={() => {
|
||||
Keyboard.dismiss();
|
||||
onNext();
|
||||
}}
|
||||
disabled={!hasInput}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{hasInput ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -130,7 +166,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: height * 0.12,
|
||||
alignItems: 'center',
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, SafeAreaView, TouchableOpacity, StatusBar, Text, Image, Platform } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
|
||||
interface OnboardingLayoutProps {
|
||||
@@ -21,6 +22,7 @@ export function OnboardingLayout({
|
||||
onBack,
|
||||
showBackButton = false
|
||||
}: OnboardingLayoutProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="dark-content" />
|
||||
@@ -39,7 +41,7 @@ export function OnboardingLayout({
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={onSkip} style={styles.skipButton}>
|
||||
<Text style={styles.skipText}>skip</Text>
|
||||
<Text style={styles.skipText}>{t('onboarding.skipAll')}</Text>
|
||||
<Image
|
||||
source={require('@/assets/images/icon/skip_icon.png')}
|
||||
style={styles.skipIcon}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Text, Platform, Dimensions } from 'react-native';
|
||||
import { View, StyleSheet, TouchableOpacity, Text, Platform } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
import AddIcon from '@/assets/images/icon/add_icon.svg';
|
||||
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
|
||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||
|
||||
const { height } = Dimensions.get('window');
|
||||
|
||||
interface ReminderStepProps {
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
onFinish: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
||||
export function ReminderStep({ value, onChange, onFinish, onSkip }: ReminderStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const handleReduce = () => {
|
||||
if (value > 1) onChange(value - 1);
|
||||
// 允许 0~5;0 表示关闭每日提醒
|
||||
if (value > 0) onChange(value - 1);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
@@ -31,7 +36,7 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
||||
|
||||
<View style={styles.numberWrapper}>
|
||||
<Text style={styles.numberText}>{value}</Text>
|
||||
<Text style={styles.unitText}>次</Text>
|
||||
<Text style={styles.unitText}>{t('dailyReminder.timesUnit')}</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={handleAdd} activeOpacity={0.7}>
|
||||
@@ -39,10 +44,14 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
<View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
|
||||
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
|
||||
<BtnClicked width={87} height={57} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity onPress={onSkip} activeOpacity={0.8} style={styles.skipBtn}>
|
||||
<Text style={styles.skipText}>{t('onboarding.skip')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -83,7 +92,18 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: height * 0.12,
|
||||
alignItems: 'center',
|
||||
}
|
||||
,
|
||||
skipBtn: {
|
||||
marginTop: 14,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 18,
|
||||
},
|
||||
skipText: {
|
||||
color: OnboardingColors.textPrimary,
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
opacity: 0.85,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, ScrollView, Dimensions } from 'react-native';
|
||||
import { View, StyleSheet, TouchableOpacity, ScrollView } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||
import { SerifText } from './SerifText';
|
||||
import SelectedIcon from '@/assets/images/icon/selected_icon.svg';
|
||||
import BtnNotClicked from '@/assets/images/icon/btn_Notclicked.svg';
|
||||
import BtnClicked from '@/assets/images/icon/btn_clicked.svg';
|
||||
|
||||
const { height } = Dimensions.get('window');
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -23,10 +22,18 @@ interface SelectionStepProps {
|
||||
|
||||
export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }: SelectionStepProps) {
|
||||
const hasSelection = selectedIds.length > 0;
|
||||
const insets = useSafeAreaInsets();
|
||||
const footerBottom = insets.bottom + 16;
|
||||
const footerButtonHeight = 57;
|
||||
const footerPaddingBottom = footerBottom + footerButtonHeight + 24;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={styles.optionsList}>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={[styles.optionsList, { paddingBottom: footerPaddingBottom }]}
|
||||
>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedIds.includes(option.id);
|
||||
return (
|
||||
@@ -48,18 +55,10 @@ export function SelectionStep({ options, selectedIds, onToggle, onNext, onSkip }
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部按钮:距离底部 12% 高度 */}
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.footerRow}>
|
||||
{onSkip && (
|
||||
<TouchableOpacity onPress={onSkip} activeOpacity={0.8} style={styles.skipBtn}>
|
||||
<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 style={[styles.footer, { bottom: footerBottom }]}>
|
||||
<TouchableOpacity onPress={onNext} disabled={!hasSelection} activeOpacity={0.8}>
|
||||
{hasSelection ? <BtnClicked width={87} height={57} /> : <BtnNotClicked width={87} height={57} />}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@@ -70,8 +69,11 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
paddingTop: 20,
|
||||
},
|
||||
scroll: {
|
||||
flex: 1,
|
||||
},
|
||||
optionsList: {
|
||||
paddingBottom: 150, // 为底部按钮留出空间
|
||||
// paddingBottom 由安全区 + 按钮高度动态计算,避免选项被遮住
|
||||
},
|
||||
optionCard: {
|
||||
width: '100%',
|
||||
@@ -100,7 +102,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: height * 0.12,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import Animated, {
|
||||
Easing,
|
||||
@@ -27,6 +28,7 @@ type Props = {
|
||||
* - 高度固定:默认距离顶部固定间距,也支持传入指定高度
|
||||
*/
|
||||
export default function SheetModal({ visible, title, onClose, children, leftIcon, height: customHeight }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const progress = useSharedValue(0); // 0: 关闭, 1: 打开
|
||||
@@ -121,7 +123,7 @@ export default function SheetModal({ visible, title, onClose, children, leftIcon
|
||||
</Text>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={leftIcon ? "返回" : "关闭"}
|
||||
accessibilityLabel={leftIcon ? t('common.back') : t('common.close')}
|
||||
onPress={onClose}
|
||||
hitSlop={10}
|
||||
style={styles.close}
|
||||
|
||||
8
client/global.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* React Native 全局常量声明。
|
||||
*
|
||||
* 说明:`__DEV__` 在运行时由 RN 注入,用于区分开发/生产环境。
|
||||
* 这里补充 TypeScript 声明,避免在代码里使用时出现类型报错。
|
||||
*/
|
||||
declare const __DEV__: boolean;
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
struct Provider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> SimpleEntry {
|
||||
SimpleEntry(date: Date())
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) {
|
||||
completion(SimpleEntry(date: Date()))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<SimpleEntry>) -> ()) {
|
||||
// V1:写死内容,不做数据更新;给一个很长的刷新间隔(系统仍可能自行调度)
|
||||
let entry = SimpleEntry(date: Date())
|
||||
let nextUpdate = Calendar.current.date(byAdding: .day, value: 7, to: Date()) ?? Date().addingTimeInterval(60 * 60 * 24 * 7)
|
||||
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
|
||||
}
|
||||
}
|
||||
|
||||
struct SimpleEntry: TimelineEntry {
|
||||
let date: Date
|
||||
}
|
||||
|
||||
struct MindfulnessWidgetEntryView: View {
|
||||
var entry: Provider.Entry
|
||||
@Environment(\.widgetFamily) var family
|
||||
|
||||
private let title = "正念"
|
||||
private let text = "你已经很努力了,今天也值得被温柔对待。"
|
||||
private let deepLink = URL(string: "client:///(app)/home")
|
||||
|
||||
var body: some View {
|
||||
switch family {
|
||||
case .systemSmall:
|
||||
smallView()
|
||||
case .systemMedium:
|
||||
mediumView()
|
||||
case .systemLarge:
|
||||
largeView()
|
||||
default:
|
||||
smallView()
|
||||
}
|
||||
}
|
||||
|
||||
private func smallView() -> some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.07, green: 0.09, blue: 0.13), Color(red: 0.15, green: 0.18, blue: 0.26)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title).font(.headline).foregroundStyle(.white)
|
||||
Text(text)
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(Color.white.opacity(0.92))
|
||||
.lineLimit(4)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(14)
|
||||
}
|
||||
.widgetURL(deepLink)
|
||||
}
|
||||
|
||||
private func mediumView() -> some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.07, green: 0.09, blue: 0.13), Color(red: 0.10, green: 0.12, blue: 0.18)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(title).font(.headline).foregroundStyle(.white)
|
||||
Text(text)
|
||||
.font(.system(size: 16, weight: .semibold))
|
||||
.foregroundStyle(Color.white.opacity(0.92))
|
||||
.lineLimit(5)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
.widgetURL(deepLink)
|
||||
}
|
||||
|
||||
private func largeView() -> some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.07, green: 0.09, blue: 0.13), Color(red: 0.17, green: 0.22, blue: 0.32)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text(title)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.white)
|
||||
.bold()
|
||||
Text(text)
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
.foregroundStyle(Color.white.opacity(0.92))
|
||||
.lineLimit(7)
|
||||
Spacer(minLength: 0)
|
||||
Text("轻轻呼吸,回到当下")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(Color.white.opacity(0.7))
|
||||
}
|
||||
.padding(18)
|
||||
}
|
||||
.widgetURL(deepLink)
|
||||
}
|
||||
}
|
||||
|
||||
struct MindfulnessWidget: Widget {
|
||||
let kind: String = "MindfulnessWidget"
|
||||
|
||||
var body: some WidgetConfiguration {
|
||||
StaticConfiguration(kind: kind, provider: Provider()) { entry in
|
||||
MindfulnessWidgetEntryView(entry: entry)
|
||||
}
|
||||
.configurationDisplayName("正念")
|
||||
.description("一段温柔提醒,陪你回到当下。")
|
||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// 统一的 Widget Extension 入口(模块内只能有一个 @main)
|
||||
@main
|
||||
struct MindfulnessWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
MindfulnessWidget()
|
||||
EmotionWidget()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
# MindfulnessWidget(WidgetKit 扩展骨架)
|
||||
|
||||
本目录提供 iOS Widget(V1 写死文案)的 SwiftUI 代码骨架。
|
||||
|
||||
注意:**仅把文件放进仓库还不够**,你还需要在 Xcode 中创建 Widget Extension target,并把这些文件加入 target。
|
||||
|
||||
## 目标
|
||||
|
||||
- 支持 Small/Medium/Large 三种尺寸
|
||||
- 展示写死文案
|
||||
- 点击小组件跳转到 App 的 Home:`client:///(app)/home`
|
||||
|
||||
@@ -67,5 +67,84 @@ target 'client' do
|
||||
build_config.build_settings['DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT'] = 'YES'
|
||||
end
|
||||
end
|
||||
|
||||
# 修复:Xcode 编译阶段找不到 Expo 相关 modulemap
|
||||
# 现象:PrecompileSwiftBridgingHeader 报错 module map file '.../Build/Products/.../Expo/Expo.modulemap' not found
|
||||
# 原因:Pods-client 的 xcconfig 把 -fmodule-map-file 指向了 ${PODS_CONFIGURATION_BUILD_DIR},但该文件在构建早期并不存在。
|
||||
# 方案:将这些 modulemap 路径改为 Pods 内稳定存在的 Target Support Files 路径。
|
||||
def patch_pods_client_xcconfig!(path)
|
||||
return unless File.exist?(path)
|
||||
s = File.read(path)
|
||||
|
||||
# expo-dev-* 的 modulemap 文件名与 module 名不同,需要单独映射
|
||||
s = s.gsub('${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.modulemap',
|
||||
'${PODS_ROOT}/Target Support Files/expo-dev-launcher/expo-dev-launcher.modulemap')
|
||||
s = s.gsub('${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.modulemap',
|
||||
'${PODS_ROOT}/Target Support Files/expo-dev-menu/expo-dev-menu.modulemap')
|
||||
s = s.gsub('${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu-interface/EXDevMenuInterface.modulemap',
|
||||
'${PODS_ROOT}/Target Support Files/expo-dev-menu-interface/expo-dev-menu-interface.modulemap')
|
||||
|
||||
# 通用映射:${PODS_CONFIGURATION_BUILD_DIR}/<Pod>/<Pod>.modulemap -> ${PODS_ROOT}/Target Support Files/<Pod>/<Pod>.modulemap
|
||||
s = s.gsub(/\$\{PODS_CONFIGURATION_BUILD_DIR\}\/([^\/]+)\/\1\.modulemap/,
|
||||
'${PODS_ROOT}/Target Support Files/\1/\1.modulemap')
|
||||
|
||||
File.write(path, s)
|
||||
end
|
||||
|
||||
support_dir = File.join(__dir__, 'Pods', 'Target Support Files', 'Pods-client')
|
||||
patch_pods_client_xcconfig!(File.join(support_dir, 'Pods-client.debug.xcconfig'))
|
||||
patch_pods_client_xcconfig!(File.join(support_dir, 'Pods-client.release.xcconfig'))
|
||||
|
||||
# 修复:缺失 [CP] Copy XCFrameworks 阶段时,React/Expo 的 XCFramework 中间产物不会生成,
|
||||
# 导致 Swift 报 no such module 'React' 等。
|
||||
# 方案:在 [CP] Embed Pods Frameworks 脚本中,先执行各个 *-xcframeworks.sh 生成中间产物。
|
||||
def patch_pods_client_frameworks_sh!(path)
|
||||
return unless File.exist?(path)
|
||||
s = File.read(path)
|
||||
marker = "# [Mindfulness Fix] Prepare XCFramework intermediates\n"
|
||||
return if s.include?(marker)
|
||||
|
||||
insert = marker +
|
||||
"if [ -r \"${PODS_ROOT}/Target Support Files/React-Core-prebuilt/React-Core-prebuilt-xcframeworks.sh\" ]; then\n" \
|
||||
" /bin/sh \"${PODS_ROOT}/Target Support Files/React-Core-prebuilt/React-Core-prebuilt-xcframeworks.sh\"\n" \
|
||||
"fi\n" \
|
||||
"if [ -r \"${PODS_ROOT}/Target Support Files/ReactNativeDependencies/ReactNativeDependencies-xcframeworks.sh\" ]; then\n" \
|
||||
" /bin/sh \"${PODS_ROOT}/Target Support Files/ReactNativeDependencies/ReactNativeDependencies-xcframeworks.sh\"\n" \
|
||||
"fi\n" \
|
||||
"if [ -r \"${PODS_ROOT}/Target Support Files/hermes-engine/hermes-engine-xcframeworks.sh\" ]; then\n" \
|
||||
" /bin/sh \"${PODS_ROOT}/Target Support Files/hermes-engine/hermes-engine-xcframeworks.sh\"\n" \
|
||||
"fi\n\n"
|
||||
|
||||
s = s.sub(/^if \[\[ \"\$CONFIGURATION\" == \"Debug\" \]\]; then\n/, insert + "if [[ \"$CONFIGURATION\" == \"Debug\" ]]; then\n")
|
||||
File.write(path, s)
|
||||
end
|
||||
|
||||
patch_pods_client_frameworks_sh!(File.join(support_dir, 'Pods-client-frameworks.sh'))
|
||||
|
||||
# 让 React/ReactNativeDependencies/hermes 的 XCFramework 切片在编译 Swift 之前就准备好,
|
||||
# 否则会在 AppDelegate.swift 的 `import React` 阶段报 no such module。
|
||||
def patch_expo_configure_project_sh!(path)
|
||||
return unless File.exist?(path)
|
||||
s = File.read(path)
|
||||
marker = "# [Mindfulness Fix] Prepare XCFramework intermediates (before Swift compile)\n"
|
||||
return if s.include?(marker)
|
||||
|
||||
insert = marker +
|
||||
"if [ -r \"${PODS_ROOT}/Target Support Files/React-Core-prebuilt/React-Core-prebuilt-xcframeworks.sh\" ]; then\n" \
|
||||
" /bin/sh \"${PODS_ROOT}/Target Support Files/React-Core-prebuilt/React-Core-prebuilt-xcframeworks.sh\"\n" \
|
||||
"fi\n" \
|
||||
"if [ -r \"${PODS_ROOT}/Target Support Files/ReactNativeDependencies/ReactNativeDependencies-xcframeworks.sh\" ]; then\n" \
|
||||
" /bin/sh \"${PODS_ROOT}/Target Support Files/ReactNativeDependencies/ReactNativeDependencies-xcframeworks.sh\"\n" \
|
||||
"fi\n" \
|
||||
"if [ -r \"${PODS_ROOT}/Target Support Files/hermes-engine/hermes-engine-xcframeworks.sh\" ]; then\n" \
|
||||
" /bin/sh \"${PODS_ROOT}/Target Support Files/hermes-engine/hermes-engine-xcframeworks.sh\"\n" \
|
||||
"fi\n\n"
|
||||
|
||||
# 插在首次调用 with_node 之前即可(不能用 ^,因为 with_node 不在文件开头)
|
||||
s = s.sub("with_node \\\n", insert + "with_node \\\n")
|
||||
File.write(path, s)
|
||||
end
|
||||
|
||||
patch_expo_configure_project_sh!(File.join(support_dir, 'expo-configure-project.sh'))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,9 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- EXConstants (18.0.13):
|
||||
- ExpoModulesCore
|
||||
- EXJSONUtils (0.15.0)
|
||||
- EXManifests (1.0.10):
|
||||
- ExpoModulesCore
|
||||
- EXNotifications (0.32.16):
|
||||
- ExpoModulesCore
|
||||
- Expo (54.0.32):
|
||||
@@ -30,8 +33,183 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-client (6.0.20):
|
||||
- EXManifests
|
||||
- expo-dev-launcher
|
||||
- expo-dev-menu
|
||||
- expo-dev-menu-interface
|
||||
- EXUpdatesInterface
|
||||
- expo-dev-launcher (6.0.20):
|
||||
- EXManifests
|
||||
- expo-dev-launcher/Main (= 6.0.20)
|
||||
- expo-dev-menu
|
||||
- expo-dev-menu-interface
|
||||
- ExpoModulesCore
|
||||
- EXUpdatesInterface
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-jsinspector
|
||||
- React-NativeModulesApple
|
||||
- React-RCTAppDelegate
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactAppDependencyProvider
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-launcher/Main (6.0.20):
|
||||
- EXManifests
|
||||
- expo-dev-launcher/Unsafe
|
||||
- expo-dev-menu
|
||||
- expo-dev-menu-interface
|
||||
- ExpoModulesCore
|
||||
- EXUpdatesInterface
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-jsinspector
|
||||
- React-NativeModulesApple
|
||||
- React-RCTAppDelegate
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactAppDependencyProvider
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-launcher/Unsafe (6.0.20):
|
||||
- EXManifests
|
||||
- expo-dev-menu
|
||||
- expo-dev-menu-interface
|
||||
- ExpoModulesCore
|
||||
- EXUpdatesInterface
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-jsinspector
|
||||
- React-NativeModulesApple
|
||||
- React-RCTAppDelegate
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactAppDependencyProvider
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-menu (7.0.18):
|
||||
- expo-dev-menu/Main (= 7.0.18)
|
||||
- expo-dev-menu/ReactNativeCompatibles (= 7.0.18)
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-menu-interface (2.0.0)
|
||||
- expo-dev-menu/Main (7.0.18):
|
||||
- EXManifests
|
||||
- expo-dev-menu-interface
|
||||
- ExpoModulesCore
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-jsinspector
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- expo-dev-menu/ReactNativeCompatibles (7.0.18):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- ExpoAsset (12.0.12):
|
||||
- ExpoModulesCore
|
||||
- ExpoCrypto (15.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoDevice (8.0.10):
|
||||
- ExpoModulesCore
|
||||
- ExpoFileSystem (19.0.21):
|
||||
- ExpoModulesCore
|
||||
- ExpoFont (14.0.11):
|
||||
@@ -74,6 +252,8 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- ExpoWebBrowser (15.0.10):
|
||||
- ExpoModulesCore
|
||||
- EXUpdatesInterface (2.0.0):
|
||||
- ExpoModulesCore
|
||||
- FBLazyVector (0.81.5)
|
||||
- hermes-engine (0.81.5):
|
||||
- hermes-engine/Pre-built (= 0.81.5)
|
||||
@@ -1798,6 +1978,28 @@ PODS:
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- RNGestureHandler (2.30.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- RNReanimated (4.1.6):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
@@ -2040,12 +2242,20 @@ PODS:
|
||||
DEPENDENCIES:
|
||||
- "EXApplication (from `../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios`)"
|
||||
- "EXConstants (from `../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios`)"
|
||||
- "EXJSONUtils (from `../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios`)"
|
||||
- "EXManifests (from `../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios`)"
|
||||
- "EXNotifications (from `../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios`)"
|
||||
- "Expo (from `../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo`)"
|
||||
- "expo-dev-client (from `../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios`)"
|
||||
- "expo-dev-launcher (from `../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher`)"
|
||||
- "expo-dev-menu (from `../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu`)"
|
||||
- "expo-dev-menu-interface (from `../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios`)"
|
||||
- "ExpoAsset (from `../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios`)"
|
||||
- "ExpoCrypto (from `../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios`)"
|
||||
- "ExpoDevice (from `../node_modules/.pnpm/expo-device@8.0.10_expo@54.0.32/node_modules/expo-device/ios`)"
|
||||
- "ExpoFileSystem (from `../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios`)"
|
||||
- "ExpoFont (from `../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios`)"
|
||||
- "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_rjurfbyy5kjn57nkkfxix5iqea/node_modules/expo-router/ios`)"
|
||||
- "ExpoHead (from `../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_mxedi6ntnfsoyp6zijog4pvdsy/node_modules/expo-router/ios`)"
|
||||
- "ExpoKeepAwake (from `../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios`)"
|
||||
- "ExpoLinearGradient (from `../node_modules/.pnpm/expo-linear-gradient@15.0.8_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@_e6k2hjkd5k4lph2ersbp3gfshy/node_modules/expo-linear-gradient/ios`)"
|
||||
- "ExpoLinking (from `../node_modules/.pnpm/expo-linking@8.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-linking/ios`)"
|
||||
@@ -2053,6 +2263,7 @@ DEPENDENCIES:
|
||||
- "ExpoModulesCore (from `../node_modules/.pnpm/expo-modules-core@3.0.29_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-modules-core`)"
|
||||
- "ExpoSplashScreen (from `../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios`)"
|
||||
- "ExpoWebBrowser (from `../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios`)"
|
||||
- "EXUpdatesInterface (from `../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios`)"
|
||||
- "FBLazyVector (from `../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/Libraries/FBLazyVector`)"
|
||||
- "hermes-engine (from `../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/sdks/hermes-engine/hermes-engine.podspec`)"
|
||||
- "RCTDeprecation (from `../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/ReactApple/Libraries/RCTFoundation/RCTDeprecation`)"
|
||||
@@ -2122,6 +2333,7 @@ DEPENDENCIES:
|
||||
- "ReactCommon/turbomodule/core (from `../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/ReactCommon`)"
|
||||
- "ReactNativeDependencies (from `../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/third-party-podspecs/ReactNativeDependencies.podspec`)"
|
||||
- "RNCAsyncStorage (from `../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage`)"
|
||||
- "RNGestureHandler (from `../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1._tylda4qoo2jtxaj3472gn4luma/node_modules/react-native-gesture-handler`)"
|
||||
- "RNReanimated (from `../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated`)"
|
||||
- "RNScreens (from `../node_modules/.pnpm/react-native-screens@4.16.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-screens`)"
|
||||
- "RNSVG (from `../node_modules/.pnpm/react-native-svg@15.12.1_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/react-native-svg`)"
|
||||
@@ -2133,18 +2345,34 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/.pnpm/expo-application@7.0.8_expo@54.0.32/node_modules/expo-application/ios"
|
||||
EXConstants:
|
||||
:path: "../node_modules/.pnpm/expo-constants@18.0.13_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-constants/ios"
|
||||
EXJSONUtils:
|
||||
:path: "../node_modules/.pnpm/expo-json-utils@0.15.0/node_modules/expo-json-utils/ios"
|
||||
EXManifests:
|
||||
:path: "../node_modules/.pnpm/expo-manifests@1.0.10_expo@54.0.32/node_modules/expo-manifests/ios"
|
||||
EXNotifications:
|
||||
:path: "../node_modules/.pnpm/expo-notifications@0.32.16_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@1_nvlvke5tn7wk5pigfsu7j4ieeq/node_modules/expo-notifications/ios"
|
||||
Expo:
|
||||
:path: "../node_modules/.pnpm/expo@54.0.32_@babel+core@7.28.6_@expo+metro-runtime@6.1.2_expo-router@6.0.22_react-native@0.8_7rhpxisdkrzvrgzbu7ct455kta/node_modules/expo"
|
||||
expo-dev-client:
|
||||
:path: "../node_modules/.pnpm/expo-dev-client@6.0.20_expo@54.0.32/node_modules/expo-dev-client/ios"
|
||||
expo-dev-launcher:
|
||||
:path: "../node_modules/.pnpm/expo-dev-launcher@6.0.20_expo@54.0.32/node_modules/expo-dev-launcher"
|
||||
expo-dev-menu:
|
||||
:path: "../node_modules/.pnpm/expo-dev-menu@7.0.18_expo@54.0.32/node_modules/expo-dev-menu"
|
||||
expo-dev-menu-interface:
|
||||
:path: "../node_modules/.pnpm/expo-dev-menu-interface@2.0.0_expo@54.0.32/node_modules/expo-dev-menu-interface/ios"
|
||||
ExpoAsset:
|
||||
:path: "../node_modules/.pnpm/expo-asset@12.0.12_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-asset/ios"
|
||||
ExpoCrypto:
|
||||
:path: "../node_modules/.pnpm/expo-crypto@15.0.8_expo@54.0.32/node_modules/expo-crypto/ios"
|
||||
ExpoDevice:
|
||||
:path: "../node_modules/.pnpm/expo-device@8.0.10_expo@54.0.32/node_modules/expo-device/ios"
|
||||
ExpoFileSystem:
|
||||
:path: "../node_modules/.pnpm/expo-file-system@19.0.21_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-file-system/ios"
|
||||
ExpoFont:
|
||||
:path: "../node_modules/.pnpm/expo-font@14.0.11_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0__react@19.1.0/node_modules/expo-font/ios"
|
||||
ExpoHead:
|
||||
:path: "../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_rjurfbyy5kjn57nkkfxix5iqea/node_modules/expo-router/ios"
|
||||
:path: "../node_modules/.pnpm/expo-router@6.0.22_@expo+metro-runtime@6.1.2_@types+react@19.1.17_expo-constants@18.0.13_expo_mxedi6ntnfsoyp6zijog4pvdsy/node_modules/expo-router/ios"
|
||||
ExpoKeepAwake:
|
||||
:path: "../node_modules/.pnpm/expo-keep-awake@15.0.8_expo@54.0.32_react@19.1.0/node_modules/expo-keep-awake/ios"
|
||||
ExpoLinearGradient:
|
||||
@@ -2159,6 +2387,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../node_modules/.pnpm/expo-splash-screen@31.0.13_expo@54.0.32/node_modules/expo-splash-screen/ios"
|
||||
ExpoWebBrowser:
|
||||
:path: "../node_modules/.pnpm/expo-web-browser@15.0.10_expo@54.0.32_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1.17_react@19.1.0_/node_modules/expo-web-browser/ios"
|
||||
EXUpdatesInterface:
|
||||
:path: "../node_modules/.pnpm/expo-updates-interface@2.0.0_expo@54.0.32/node_modules/expo-updates-interface/ios"
|
||||
FBLazyVector:
|
||||
:path: "../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/Libraries/FBLazyVector"
|
||||
hermes-engine:
|
||||
@@ -2296,6 +2526,8 @@ EXTERNAL SOURCES:
|
||||
:podspec: "../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/third-party-podspecs/ReactNativeDependencies.podspec"
|
||||
RNCAsyncStorage:
|
||||
:path: "../node_modules/.pnpm/@react-native-async-storage+async-storage@2.2.0_react-native@0.81.5_@babel+core@7.28.6_@types_fp4qq3a7mejmut52v6jrlvxlzi/node_modules/@react-native-async-storage/async-storage"
|
||||
RNGestureHandler:
|
||||
:path: "../node_modules/.pnpm/react-native-gesture-handler@2.30.0_react-native@0.81.5_@babel+core@7.28.6_@types+react@19.1._tylda4qoo2jtxaj3472gn4luma/node_modules/react-native-gesture-handler"
|
||||
RNReanimated:
|
||||
:path: "../node_modules/.pnpm/react-native-reanimated@4.1.6_@babel+core@7.28.6_react-native-worklets@0.5.1_@babel+core@7.28_ky3sbxf6i7nkyacc2hzg3xcz4q/node_modules/react-native-reanimated"
|
||||
RNScreens:
|
||||
@@ -2310,9 +2542,17 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
EXApplication: 13420f8139864183f8a04fd6099077bdf8cfb186
|
||||
EXConstants: 3feb66fd1d94202fc1f0946d74e029d8b224b60e
|
||||
EXJSONUtils: 1d3e4590438c3ee593684186007028a14b3686cd
|
||||
EXManifests: 83ef0844fcf06d6099b12a7bdbd7d36fc0e1dd16
|
||||
EXNotifications: 2a3feb7af6194828d9aafda72f63a9a03866230a
|
||||
Expo: b8d64eb9a496ebe8c71e3dae7eeb7f394b146b80
|
||||
expo-dev-client: 12ef7d5b14d93e309922acea78dcd851db583a87
|
||||
expo-dev-launcher: 47994056008ffdc30a6a5e328a375b3e30a8db05
|
||||
expo-dev-menu: ea4fb803ace52e60d7cd8060c7cd379612a140b2
|
||||
expo-dev-menu-interface: 600df12ea01efecdd822daaf13cc0ac091775533
|
||||
ExpoAsset: d999f3bbd998a750f3b74cb913229848901b926b
|
||||
ExpoCrypto: 4d23a9ff67c25e2ed23ca792d81e58817a7ea1b9
|
||||
ExpoDevice: 0773c782b055558ca9b40b74aa4a8133a66cd0d2
|
||||
ExpoFileSystem: aefcd337b94b874f88752ebefc52813b84992fad
|
||||
ExpoFont: c625dbd97ed57e9089b172b2a7bb99003d074664
|
||||
ExpoHead: b691a2ed7ab02ed820b6c6468941832d34969c29
|
||||
@@ -2323,6 +2563,7 @@ SPEC CHECKSUMS:
|
||||
ExpoModulesCore: 77496909fd3c800f97f7f2007dd26aeac4bb3798
|
||||
ExpoSplashScreen: 72fbc6dd9d6404dd9d0725a56c9ac1383bc0b14f
|
||||
ExpoWebBrowser: 88b116cd378d9609c776c0903fe4070fca461588
|
||||
EXUpdatesInterface: 1436757deb0d574b84bba063bd024c315e0ec08b
|
||||
FBLazyVector: e95a291ad2dadb88e42b06e0c5fb8262de53ec12
|
||||
hermes-engine: 9f4dfe93326146a1c99eb535b1cb0b857a3cd172
|
||||
RCTDeprecation: 943572d4be82d480a48f4884f670135ae30bf990
|
||||
@@ -2391,12 +2632,13 @@ SPEC CHECKSUMS:
|
||||
ReactCommon: e6e232202a447d353e5531f2be82f50f47cbaa9a
|
||||
ReactNativeDependencies: 71ce9c28beb282aa720ea7b46980fff9669f428a
|
||||
RNCAsyncStorage: e85a99325df9eb0191a6ee2b2a842644c7eb29f4
|
||||
RNGestureHandler: 40c2d1c168e54715fe52e0fb16cb38c54611e4f3
|
||||
RNReanimated: 10415bc8396eaeac0d7b2c9a1538eae7e607ec9c
|
||||
RNScreens: dd61bc3a3e6f6901ad833efa411917d44827cf51
|
||||
RNSVG: 2825ee146e0f6a16221e852299943e4cceef4528
|
||||
RNWorklets: 9ccdc8112b17af6eee2c85a233891cb80db150ad
|
||||
Yoga: 5934998fbeaef7845dbf698f698518695ab4cd1a
|
||||
|
||||
PODFILE CHECKSUM: 4d5c52f9fa870c1d398cf59e37c149f66700c061
|
||||
PODFILE CHECKSUM: c2c3838f0b2a579fef2350bff2ecaa005e27145d
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objectVersion = 70;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@@ -11,14 +11,15 @@
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E3328F0E595C1F4A244DF238 /* libPods-client.a */; };
|
||||
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 */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
|
||||
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; };
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.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, ); }; };
|
||||
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 */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -47,12 +48,14 @@
|
||||
/* End PBXCopyFilesBuildPhase 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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppGroupStorage.swift; path = client/AppGroupStorage.swift; sourceTree = "<group>"; };
|
||||
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; 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>"; };
|
||||
C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||
@@ -60,8 +63,8 @@
|
||||
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "情绪小组件Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
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; };
|
||||
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>"; };
|
||||
EBEEC7562F31D82700C68C1A /* clientRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = clientRelease.entitlements; path = client/clientRelease.entitlements; sourceTree = "<group>"; };
|
||||
EBEEC7572F31D84B00C68C1A /* 情绪小组件ExtensionRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "情绪小组件ExtensionRelease.entitlements"; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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 +72,7 @@
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */ = {
|
||||
EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
EmotionWidget.swift,
|
||||
@@ -80,18 +83,7 @@
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
EB3DAF952F2A4B8F00450593 /* Exceptions for "情绪小组件" folder in "情绪小组件Extension" target */,
|
||||
);
|
||||
explicitFileTypes = {
|
||||
};
|
||||
explicitFolders = (
|
||||
);
|
||||
path = "情绪小组件";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (EB3DAF952F2A4B8F00450593 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = "情绪小组件"; sourceTree = "<group>"; };
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -100,6 +92,7 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
1A1DE01D4133812B2E2BA692 /* libPods-client.a in Frameworks */,
|
||||
A8C1D2E3F4A5B6C7D8E9F0A3 /* WidgetKit.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -118,8 +111,10 @@
|
||||
13B07FAE1A68108700A75B9A /* client */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EB3DAF9A2F2A4D0900450593 /* MindfulnessWidget.swift */,
|
||||
EBEEC7562F31D82700C68C1A /* clientRelease.entitlements */,
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
|
||||
A8C1D2E3F4A5B6C7D8E9F0A1 /* AppGroupStorage.swift */,
|
||||
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */,
|
||||
F11748442D0722820044C1D9 /* client-Bridging-Header.h */,
|
||||
BB2F792B24A3F905000567C9 /* Supporting */,
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
||||
@@ -159,7 +154,7 @@
|
||||
83CBB9F61A601CBA00E9B192 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EB630B2F2F30BE2700DC761A /* Assets.xcassets */,
|
||||
EBEEC7572F31D84B00C68C1A /* 情绪小组件ExtensionRelease.entitlements */,
|
||||
13B07FAE1A68108700A75B9A /* client */,
|
||||
832341AE1AAA6A7D00B99B32 /* Libraries */,
|
||||
EB3DAF842F2A4B8E00450593 /* 情绪小组件 */,
|
||||
@@ -177,7 +172,7 @@
|
||||
83CBBA001A601CBA00E9B192 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
13B07F961A680F5B00A75B9A /* client.app */,
|
||||
13B07F961A680F5B00A75B9A /* HeyMama.app */,
|
||||
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
@@ -204,7 +199,7 @@
|
||||
EB3DAFD42F2A5FC100450593 /* Recovered References */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A1B2C3D4E5F60718293A4B5B /* EmotionWidget.swift */,
|
||||
A1B2C3D4E5F60718293A4B5B /* 情绪小组件/EmotionWidget.swift */,
|
||||
);
|
||||
name = "Recovered References";
|
||||
sourceTree = "<group>";
|
||||
@@ -241,7 +236,7 @@
|
||||
);
|
||||
name = client;
|
||||
productName = client;
|
||||
productReference = 13B07F961A680F5B00A75B9A /* client.app */;
|
||||
productReference = 13B07F961A680F5B00A75B9A /* HeyMama.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
EB3DAF7E2F2A4B8D00450593 /* 情绪小组件Extension */ = {
|
||||
@@ -270,8 +265,12 @@
|
||||
83CBB9F71A601CBA00E9B192 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
KnownAssetTags = (
|
||||
New,
|
||||
);
|
||||
LastSwiftUpdateCheck = 2620;
|
||||
LastUpgradeCheck = 1130;
|
||||
LastUpgradeCheck = 2620;
|
||||
TargetAttributes = {
|
||||
13B07F861A680F5B00A75B9A = {
|
||||
LastSwiftMigration = 1250;
|
||||
@@ -309,7 +308,6 @@
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||
0BE245B56A79D95AB0A7B4BA /* PrivacyInfo.xcprivacy in Resources */,
|
||||
EB630B312F30BE2700DC761A /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -317,7 +315,6 @@
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
EB630B302F30BE2700DC761A /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -374,12 +371,15 @@
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoDevice/ExpoDevice_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoLocalization/ExpoLocalization_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/RNSVG/RNSVGFilters.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputPaths = (
|
||||
@@ -387,12 +387,15 @@
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoDevice_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoLocalization_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNSVGFilters.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
@@ -454,6 +457,8 @@
|
||||
files = (
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */,
|
||||
A8C1D2E3F4A5B6C7D8E9F0A2 /* AppGroupStorage.swift in Sources */,
|
||||
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -461,7 +466,7 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
A1B2C3D4E5F60718293A4B5C /* EmotionWidget.swift in Sources */,
|
||||
A1B2C3D4E5F60718293A4B5C /* 情绪小组件/EmotionWidget.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -484,9 +489,10 @@
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||
ENABLE_BITCODE = NO;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"$(inherited)",
|
||||
"FB_SONARKIT_ENABLED=1",
|
||||
@@ -497,7 +503,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -505,7 +511,8 @@
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
||||
PRODUCT_NAME = client;
|
||||
PRODUCT_NAME = HeyMama;
|
||||
SKIP_INSTALL = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
@@ -525,18 +532,19 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = client/client.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CODE_SIGN_ENTITLEMENTS = client/clientRelease.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||
DWARF_DSYM_FILE_SHOULD_ACCOMPANY_PRODUCT = YES;
|
||||
"EXCLUDED_ARCHS[sdk=iphonesimulator*]" = x86_64;
|
||||
INFOPLIST_FILE = client/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -544,7 +552,8 @@
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
||||
PRODUCT_NAME = client;
|
||||
PRODUCT_NAME = HeyMama;
|
||||
SKIP_INSTALL = NO;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = NO;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
@@ -579,6 +588,7 @@
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
@@ -610,9 +620,11 @@
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
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";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = NO;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
USE_HERMES = true;
|
||||
@@ -642,6 +654,7 @@
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
@@ -665,9 +678,13 @@
|
||||
"$(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";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = NO;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_ENABLE_EXPLICIT_MODULES = NO;
|
||||
USE_HERMES = true;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
@@ -687,8 +704,9 @@
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = "情绪小组件ExtensionRelease.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -704,7 +722,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
@@ -739,9 +757,10 @@
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CODE_SIGN_ENTITLEMENTS = "情绪小组件ExtensionRelease.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
CURRENT_PROJECT_VERSION = 4;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = WS92GPX9H2;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
@@ -757,7 +776,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MARKETING_VERSION = 1.0.0;
|
||||
MARKETING_VERSION = 1.0.1;
|
||||
MTL_FAST_MATH = YES;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness.emotionwidget;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1130"
|
||||
version = "1.3">
|
||||
LastUpgradeVersion = "2620"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
@@ -15,7 +15,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "client.app"
|
||||
BuildableName = "HeyMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -26,19 +26,8 @@
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES">
|
||||
<Testables>
|
||||
<TestableReference
|
||||
skipped = "NO">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "00E356ED1AD99517003FC87E"
|
||||
BuildableName = "clientTests.xctest"
|
||||
BlueprintName = "clientTests"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
</TestableReference>
|
||||
</Testables>
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
@@ -55,7 +44,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "client.app"
|
||||
BuildableName = "HeyMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -72,7 +61,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "client.app"
|
||||
BuildableName = "HeyMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
@@ -83,6 +72,26 @@
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
customArchiveName = "Hey Mama"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
<PostActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "修复归档头信息(避免 Generic Xcode Archive)"
|
||||
scriptText = "bash "${SRCROOT}/scripts/fix-xcarchive-header.sh" "${ARCHIVE_PATH}" "
|
||||
shellToInvoke = "/bin/sh">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||
BuildableName = "HeyMama.app"
|
||||
BlueprintName = "client"
|
||||
ReferencedContainer = "container:client.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PostActions>
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
53
client/ios/client/AppGroupStorage.swift
Normal file
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
import React
|
||||
import WidgetKit
|
||||
|
||||
/**
|
||||
* App Group 共享存储(RN Bridge)
|
||||
*
|
||||
* 约定:
|
||||
* - suiteName:group.com.damer.mindfulness(已在主 App 与 Widget Extension 的 entitlements 配置)
|
||||
* - 值统一使用字符串(通常是 JSON),由 JS 侧负责序列化与反序列化
|
||||
*/
|
||||
@objc(AppGroupStorage)
|
||||
final class AppGroupStorage: NSObject {
|
||||
// 通过 AppGroupStorageBridge.m 的 RCT_EXTERN_MODULE 导出到 RN
|
||||
// 这里不需要显式实现/遵循 RCTBridgeModule,避免某些 Archive 场景下找不到协议类型
|
||||
@objc static func requiresMainQueueSetup() -> Bool { false }
|
||||
|
||||
private let suiteName = "group.com.damer.mindfulness"
|
||||
|
||||
private func defaults() -> UserDefaults? {
|
||||
UserDefaults(suiteName: suiteName)
|
||||
}
|
||||
|
||||
@objc(setString:value:resolver:rejecter:)
|
||||
func setString(_ key: String, value: String, resolver resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
|
||||
guard let d = defaults() else {
|
||||
reject("E_APP_GROUP", "无法初始化 App Group UserDefaults(suiteName=\(suiteName))", nil)
|
||||
return
|
||||
}
|
||||
d.set(value, forKey: key)
|
||||
resolve(nil)
|
||||
}
|
||||
|
||||
@objc(getString:resolver:rejecter:)
|
||||
func getString(_ key: String, resolver resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
|
||||
guard let d = defaults() else {
|
||||
reject("E_APP_GROUP", "无法初始化 App Group UserDefaults(suiteName=\(suiteName))", nil)
|
||||
return
|
||||
}
|
||||
let v = d.string(forKey: key)
|
||||
resolve(v)
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发 Widget 刷新(系统仍可能延迟)
|
||||
*/
|
||||
@objc(reloadAllTimelines:rejecter:)
|
||||
func reloadAllTimelines(_ resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) {
|
||||
WidgetCenter.shared.reloadAllTimelines()
|
||||
resolve(nil)
|
||||
}
|
||||
}
|
||||
|
||||
26
client/ios/client/AppGroupStorageBridge.m
Normal file
@@ -0,0 +1,26 @@
|
||||
#import <React/RCTBridgeModule.h>
|
||||
|
||||
/**
|
||||
* Swift 模块桥接导出文件(必须)
|
||||
*
|
||||
* 说明:
|
||||
* - React Native 对 Swift 的方法导出通常需要通过 RCT_EXTERN_MODULE / RCT_EXTERN_METHOD
|
||||
* - 否则 JS 侧可能能拿到 NativeModules.AppGroupStorage,但方法为 undefined
|
||||
*/
|
||||
|
||||
@interface RCT_EXTERN_MODULE(AppGroupStorage, NSObject)
|
||||
|
||||
RCT_EXTERN_METHOD(setString:(NSString *)key
|
||||
value:(NSString *)value
|
||||
resolver:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject)
|
||||
|
||||
RCT_EXTERN_METHOD(getString:(NSString *)key
|
||||
resolver:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject)
|
||||
|
||||
RCT_EXTERN_METHOD(reloadAllTimelines:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject)
|
||||
|
||||
@end
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
"color": {
|
||||
"components": {
|
||||
"alpha": "1.000",
|
||||
"blue": "1.00000000000000",
|
||||
"green": "1.00000000000000",
|
||||
"red": "1.00000000000000"
|
||||
"blue": "0.729411764705882",
|
||||
"green": "0.823529411764706",
|
||||
"red": "0.917647058823529"
|
||||
},
|
||||
"color-space": "srgb"
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 123 KiB |
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>client</string>
|
||||
<string>Hey Mama</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -38,6 +38,8 @@
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>用于连接局域网服务以获取内容与同步数据(仅在需要访问内网地址时使用)。</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
@@ -52,7 +54,7 @@
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string></string>
|
||||
<string>SplashScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
<string>CA92.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategoryDiskSpace</string>
|
||||
@@ -31,14 +39,6 @@
|
||||
<string>85F4.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSPrivacyAccessedAPIType</key>
|
||||
<string>NSPrivacyAccessedAPICategorySystemBootTime</string>
|
||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||
<array>
|
||||
<string>35F9.1</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>NSPrivacyCollectedDataTypes</key>
|
||||
<array/>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||
</systemColor>
|
||||
<namedColor name="SplashScreenBackground">
|
||||
<color alpha="1.000" blue="1.00000000000000" green="1.00000000000000" red="1.00000000000000" customColorSpace="sRGB" colorSpace="custom"/>
|
||||
<color alpha="1.000" blue="0.729411764705882" green="0.823529411764706" red="0.917647058823529" customColorSpace="sRGB" colorSpace="custom"/>
|
||||
</namedColor>
|
||||
</resources>
|
||||
</document>
|
||||
@@ -1,3 +1,11 @@
|
||||
//
|
||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||
//
|
||||
|
||||
// 说明:
|
||||
// - 部分环境下仅 `import React` 可能无法在 Swift 中解析到 RCTBridge 等类型
|
||||
// - 通过 Bridging Header 显式引入需要的 React 头文件,保证 AppDelegate.swift 可编译
|
||||
#import <React/RCTBridge.h>
|
||||
#import <React/RCTBridgeModule.h>
|
||||
#import <React/RCTBundleURLProvider.h>
|
||||
#import <React/RCTLinkingManager.h>
|
||||
|
||||
@@ -4,5 +4,10 @@
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>production</string>
|
||||
<!-- iOS 小组件需要通过 App Group 与主 App 共享数据 -->
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.damer.mindfulness</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
12
client/ios/client/clientRelease.entitlements
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>production</string>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.damer.mindfulness</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
133
client/ios/scripts/fix-xcarchive-header.sh
Executable file
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# 修复 Xcode Organizer 显示 “Generic Xcode Archive” 的问题:
|
||||
# - 某些情况下 xcodebuild 生成的 .xcarchive/Info.plist 缺少 ApplicationProperties
|
||||
# - Organizer 无法识别归档中的主 App(即使 Products/Applications/*.app 存在)
|
||||
#
|
||||
# 说明:
|
||||
# - 该脚本的核心作用是让 Organizer 能识别归档里的主 App,从而出现“分发/上传 TestFlight”入口。
|
||||
# - 这类问题通常发生在命令行/CI 归档(xcodebuild archive)或某些自定义归档流程中,
|
||||
# 导致 .xcarchive/Info.plist 缺少/不完整。
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/fix-xcarchive-header.sh "/path/to/xxx.xcarchive"
|
||||
|
||||
ARCHIVE_PATH="${1:-}"
|
||||
if [[ -z "$ARCHIVE_PATH" ]]; then
|
||||
echo "用法: $0 \"/path/to/xxx.xcarchive\"" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ ! -d "$ARCHIVE_PATH" ]]; then
|
||||
echo "错误:找不到归档目录:$ARCHIVE_PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
ARCHIVE_INFO_PLIST="$ARCHIVE_PATH/Info.plist"
|
||||
if [[ ! -f "$ARCHIVE_INFO_PLIST" ]]; then
|
||||
echo "错误:找不到归档 Info.plist:$ARCHIVE_INFO_PLIST" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 归档名:尽量从路径推导,避免依赖 Xcode 环境变量
|
||||
archive_basename="$(/usr/bin/basename "$ARCHIVE_PATH")"
|
||||
archive_name="${archive_basename%.xcarchive}"
|
||||
|
||||
# 取第一个 App(归档里通常只有一个主 App)
|
||||
APP_PLIST="$(/usr/bin/find "$ARCHIVE_PATH/Products/Applications" -maxdepth 2 -name Info.plist -path "*.app/Info.plist" 2>/dev/null | /usr/bin/head -n 1 || true)"
|
||||
if [[ -z "$APP_PLIST" ]]; then
|
||||
echo "错误:归档中未找到 Products/Applications/*.app/Info.plist(请先确保归档产出包含 .app)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
APP_DIR="$(/usr/bin/dirname "$APP_PLIST")"
|
||||
APP_NAME="$(/usr/bin/basename "$APP_DIR")" # 例如 HeyMama.app
|
||||
APP_REL_PATH="Applications/$APP_NAME"
|
||||
|
||||
bundle_id="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||
short_version="$(/usr/bin/plutil -extract CFBundleShortVersionString raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||
build_version="$(/usr/bin/plutil -extract CFBundleVersion raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||
display_name="$(/usr/bin/plutil -extract CFBundleDisplayName raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||
bundle_name="$(/usr/bin/plutil -extract CFBundleName raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||
|
||||
# SchemeName 在 Organizer 中会用到,但在某些归档流程里会缺失
|
||||
scheme_name="${SCHEME_NAME:-}"
|
||||
if [[ -z "$scheme_name" ]]; then
|
||||
scheme_name="${archive_name:-}"
|
||||
fi
|
||||
|
||||
if [[ -z "$bundle_id" || -z "$short_version" || -z "$build_version" ]]; then
|
||||
echo "错误:无法从 App Info.plist 读取 bundle/version/build:$APP_PLIST" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 解析 embedded.mobileprovision(若存在)
|
||||
profile_name=""
|
||||
profile_uuid=""
|
||||
team_id=""
|
||||
provision_path="$APP_DIR/embedded.mobileprovision"
|
||||
if [[ -f "$provision_path" ]]; then
|
||||
decoded="$(/usr/bin/security cms -D -i "$provision_path" 2>/dev/null || true)"
|
||||
if [[ -n "$decoded" ]]; then
|
||||
# 使用 plutil 从 xml 中提取字段
|
||||
profile_name="$(printf "%s" "$decoded" | /usr/bin/plutil -extract Name raw -o - - 2>/dev/null || true)"
|
||||
profile_uuid="$(printf "%s" "$decoded" | /usr/bin/plutil -extract UUID raw -o - - 2>/dev/null || true)"
|
||||
team_id="$(printf "%s" "$decoded" | /usr/bin/plutil -extract TeamIdentifier.0 raw -o - - 2>/dev/null || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 备份一份,防止误操作
|
||||
cp -f "$ARCHIVE_INFO_PLIST" "$ARCHIVE_INFO_PLIST.bak"
|
||||
|
||||
# 修复归档根字段,避免 Organizer 仍然把它当 Generic Archive
|
||||
# 参考:标准 .xcarchive/Info.plist 通常包含 Name / SchemeName / ArchiveVersion / CreationDate 等。
|
||||
# 我们只在缺失时补齐,尽量不改动归档的其他内容。
|
||||
if ! /usr/bin/plutil -extract Name xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
||||
/usr/bin/plutil -insert Name -string "${archive_name:-${display_name:-${bundle_name:-}}}" "$ARCHIVE_INFO_PLIST" 2>/dev/null || true
|
||||
fi
|
||||
if ! /usr/bin/plutil -extract SchemeName xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
||||
/usr/bin/plutil -insert SchemeName -string "${scheme_name:-${archive_name:-}}" "$ARCHIVE_INFO_PLIST" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 如果已有 ApplicationProperties,直接更新关键字段即可
|
||||
if /usr/bin/plutil -extract ApplicationProperties xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
||||
/usr/bin/plutil -replace ApplicationProperties.ApplicationPath -string "$APP_REL_PATH" "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -replace ApplicationProperties.CFBundleIdentifier -string "$bundle_id" "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -replace ApplicationProperties.CFBundleShortVersionString -string "$short_version" "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -replace ApplicationProperties.CFBundleVersion -string "$build_version" "$ARCHIVE_INFO_PLIST"
|
||||
else
|
||||
# 新增 ApplicationProperties(注意:plutil 的空字典/数组类型是 -dictionary / -array)
|
||||
/usr/bin/plutil -insert ApplicationProperties -dictionary "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -insert ApplicationProperties.ApplicationPath -string "$APP_REL_PATH" "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -insert ApplicationProperties.CFBundleIdentifier -string "$bundle_id" "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -insert ApplicationProperties.CFBundleShortVersionString -string "$short_version" "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -insert ApplicationProperties.CFBundleVersion -string "$build_version" "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -insert ApplicationProperties.Architectures -array "$ARCHIVE_INFO_PLIST"
|
||||
/usr/bin/plutil -insert ApplicationProperties.Architectures.0 -string "arm64" "$ARCHIVE_INFO_PLIST"
|
||||
fi
|
||||
|
||||
# 可选字段:Provisioning Profile 信息(不保证一定存在)
|
||||
if [[ -n "$profile_name" ]]; then
|
||||
/usr/bin/plutil -replace ApplicationProperties.ProvisioningProfileName -string "$profile_name" "$ARCHIVE_INFO_PLIST" 2>/dev/null || \
|
||||
/usr/bin/plutil -insert ApplicationProperties.ProvisioningProfileName -string "$profile_name" "$ARCHIVE_INFO_PLIST"
|
||||
fi
|
||||
if [[ -n "$profile_uuid" ]]; then
|
||||
/usr/bin/plutil -replace ApplicationProperties.ProvisioningProfileUUID -string "$profile_uuid" "$ARCHIVE_INFO_PLIST" 2>/dev/null || \
|
||||
/usr/bin/plutil -insert ApplicationProperties.ProvisioningProfileUUID -string "$profile_uuid" "$ARCHIVE_INFO_PLIST"
|
||||
fi
|
||||
if [[ -n "$team_id" ]]; then
|
||||
/usr/bin/plutil -replace ApplicationProperties.Team -string "$team_id" "$ARCHIVE_INFO_PLIST" 2>/dev/null || \
|
||||
/usr/bin/plutil -insert ApplicationProperties.Team -string "$team_id" "$ARCHIVE_INFO_PLIST"
|
||||
fi
|
||||
|
||||
echo "已修复归档 header:$ARCHIVE_INFO_PLIST"
|
||||
echo "主 App:$APP_REL_PATH"
|
||||
echo "Bundle:$bundle_id"
|
||||
echo "Version/Build:$short_version/$build_version"
|
||||
echo "Name/SchemeName:${archive_name:-} / ${scheme_name:-}"
|
||||
|
||||
# 轻量自检:确保关键字段存在(不强制失败,避免中断归档)
|
||||
if ! /usr/bin/plutil -extract ApplicationProperties.ApplicationPath xml1 -o - "$ARCHIVE_INFO_PLIST" >/dev/null 2>&1; then
|
||||
echo "警告:归档 Info.plist 仍缺少 ApplicationProperties.ApplicationPath,Organizer 可能仍显示 Generic Archive" >&2
|
||||
fi
|
||||
@@ -1,190 +1,326 @@
|
||||
import Foundation
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
// V1:写死文案的小组件(Small/Medium/Large + 点击跳转 Home)
|
||||
// Daily Widget Reco:从 App Group 读取缓存;过期则请求后端 /v1/reco/widget;每日刷新(尽力而为)
|
||||
|
||||
private let appGroupSuiteName = "group.com.damer.mindfulness"
|
||||
private let keyWidgetConfig = "widget.config.v1"
|
||||
private let keyWidgetUserProfile = "widget.userProfile.v1_2"
|
||||
private let keyWidgetDailyReco = "widget.dailyReco.v1"
|
||||
|
||||
private let fallbackTextTC = "你已经很努力了,今天也值得被温柔对待。"
|
||||
private let fallbackTextEN = "You’ve been doing great — you deserve kindness today."
|
||||
|
||||
private func defaults() -> UserDefaults? {
|
||||
UserDefaults(suiteName: appGroupSuiteName)
|
||||
}
|
||||
|
||||
private func isoNow() -> String {
|
||||
ISO8601DateFormatter().string(from: Date())
|
||||
}
|
||||
|
||||
private func localDayKey(_ date: Date = Date()) -> String {
|
||||
let fmt = DateFormatter()
|
||||
fmt.calendar = Calendar.current
|
||||
fmt.timeZone = TimeZone.current
|
||||
fmt.dateFormat = "yyyy-MM-dd"
|
||||
return fmt.string(from: date)
|
||||
}
|
||||
|
||||
private func resolveLang() -> String {
|
||||
// 仅支持 en/tc
|
||||
let preferred = Locale.preferredLanguages.first?.lowercased() ?? "en"
|
||||
return preferred.hasPrefix("zh") ? "tc" : "en"
|
||||
}
|
||||
|
||||
private func resolveTitle(lang: String) -> String {
|
||||
lang == "en" ? "Mindfulness" : "正念"
|
||||
}
|
||||
|
||||
private func resolveFooterHint(lang: String) -> String {
|
||||
lang == "en" ? "Tap to open the app" : "点我回到 App"
|
||||
}
|
||||
|
||||
private func joinUrl(base: String, path: String) -> String {
|
||||
let b = base.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "/+$", with: "", options: .regularExpression)
|
||||
if path.hasPrefix("/") { return "\(b)\(path)" }
|
||||
return "\(b)/\(path)"
|
||||
}
|
||||
|
||||
private func nextDailyRefreshDate(from now: Date) -> Date {
|
||||
// 下一天 00:10~01:00 之间随机一个时间点(用户本地时区)
|
||||
var cal = Calendar.current
|
||||
cal.timeZone = TimeZone.current
|
||||
guard let tomorrow = cal.date(byAdding: .day, value: 1, to: now) else {
|
||||
return now.addingTimeInterval(60 * 60 * 6)
|
||||
}
|
||||
let start = cal.startOfDay(for: tomorrow)
|
||||
let minDate = cal.date(byAdding: .minute, value: 10, to: start) ?? start.addingTimeInterval(60 * 10)
|
||||
let maxDate = cal.date(byAdding: .hour, value: 1, to: start) ?? start.addingTimeInterval(60 * 60)
|
||||
let interval = max(0, maxDate.timeIntervalSince(minDate))
|
||||
let jitter = interval > 0 ? Double.random(in: 0..<interval) : 0
|
||||
return minDate.addingTimeInterval(jitter)
|
||||
}
|
||||
|
||||
private func readJsonDict(forKey key: String) -> [String: Any]? {
|
||||
guard let raw = defaults()?.string(forKey: key) else { return nil }
|
||||
guard let data = raw.data(using: .utf8) else { return nil }
|
||||
let obj = try? JSONSerialization.jsonObject(with: data, options: [])
|
||||
return obj as? [String: Any]
|
||||
}
|
||||
|
||||
private func writeJsonDict(_ dict: [String: Any], forKey key: String) {
|
||||
guard let data = try? JSONSerialization.data(withJSONObject: dict, options: []) else { return }
|
||||
guard let raw = String(data: data, encoding: .utf8) else { return }
|
||||
defaults()?.set(raw, forKey: key)
|
||||
}
|
||||
|
||||
private func readCachedText() -> (dayKey: String?, lang: String, text: String)? {
|
||||
guard let d = readJsonDict(forKey: keyWidgetDailyReco) else { return nil }
|
||||
let lang = (d["lang"] as? String) ?? resolveLang()
|
||||
let dayKey = d["day_key"] as? String
|
||||
if let item = d["item"] as? [String: Any], let text = item["text"] as? String, !text.isEmpty {
|
||||
return (dayKey: dayKey, lang: lang, text: text)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func readApiBaseUrl() -> String? {
|
||||
guard let d = readJsonDict(forKey: keyWidgetConfig) else { return nil }
|
||||
let base = d["apiBaseUrl"] as? String
|
||||
return base?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private func readUserProfileDict() -> [String: Any]? {
|
||||
guard let d = readJsonDict(forKey: keyWidgetUserProfile) else { return nil }
|
||||
return d["user_profile"] as? [String: Any]
|
||||
}
|
||||
|
||||
private func saveDailyReco(lang: String, dayKey: String, contentId: Int, text: String, meta: [String: Any]?) {
|
||||
var dict: [String: Any] = [
|
||||
"schema_version": 1,
|
||||
"saved_at": isoNow(),
|
||||
"day_key": dayKey,
|
||||
"lang": lang,
|
||||
"source": "widget",
|
||||
"item": [
|
||||
"content_id": contentId,
|
||||
"text": text
|
||||
]
|
||||
]
|
||||
if let meta = meta { dict["meta"] = meta }
|
||||
writeJsonDict(dict, forKey: keyWidgetDailyReco)
|
||||
}
|
||||
|
||||
private func fetchDailyRecoFromServer() async -> (lang: String, text: String, contentId: Int, meta: [String: Any]?)? {
|
||||
guard let baseUrl = readApiBaseUrl(), !baseUrl.isEmpty else { return nil }
|
||||
guard let userProfile = readUserProfileDict() else { return nil }
|
||||
|
||||
let lang = resolveLang()
|
||||
let urlStr = joinUrl(base: baseUrl, path: "/v1/reco/widget")
|
||||
guard let url = URL(string: urlStr) else { return nil }
|
||||
|
||||
var req = URLRequest(url: url)
|
||||
req.httpMethod = "POST"
|
||||
req.timeoutInterval = 12
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.setValue(lang, forHTTPHeaderField: "Accept-Language")
|
||||
|
||||
let body: [String: Any] = [
|
||||
"k": 1,
|
||||
"user_profile": userProfile,
|
||||
"already_recommended_ids": [],
|
||||
"touched_or_viewed_ids": []
|
||||
]
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: body, options: [])
|
||||
|
||||
do {
|
||||
let (data, res) = try await URLSession.shared.data(for: req)
|
||||
guard let httpRes = res as? HTTPURLResponse, (200..<300).contains(httpRes.statusCode) else { return nil }
|
||||
|
||||
let obj = try JSONSerialization.jsonObject(with: data, options: [])
|
||||
guard let root = obj as? [String: Any] else { return nil }
|
||||
guard let items = root["items"] as? [[String: Any]], let first = items.first else { return nil }
|
||||
guard let text = first["text"] as? String, !text.isEmpty else { return nil }
|
||||
let contentId = (first["content_id"] as? Int) ?? Int((first["content_id"] as? NSNumber)?.intValue ?? -1)
|
||||
if contentId < 0 { return nil }
|
||||
let meta = root["meta"] as? [String: Any]
|
||||
return (lang: lang, text: text, contentId: contentId, meta: meta)
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct EmotionProvider: TimelineProvider {
|
||||
func placeholder(in context: Context) -> EmotionEntry {
|
||||
EmotionEntry(date: Date())
|
||||
let lang = resolveLang()
|
||||
return EmotionEntry(
|
||||
date: Date(),
|
||||
lang: lang,
|
||||
title: resolveTitle(lang: lang),
|
||||
text: lang == "en" ? fallbackTextEN : fallbackTextTC,
|
||||
footerHint: resolveFooterHint(lang: lang)
|
||||
)
|
||||
}
|
||||
|
||||
func getSnapshot(in context: Context, completion: @escaping (EmotionEntry) -> ()) {
|
||||
completion(EmotionEntry(date: Date()))
|
||||
completion(placeholder(in: context))
|
||||
}
|
||||
|
||||
func getTimeline(in context: Context, completion: @escaping (Timeline<EmotionEntry>) -> ()) {
|
||||
// V1:内容写死,不做数据更新;给一个较长的刷新间隔(系统仍可能自行调度)
|
||||
let entry = EmotionEntry(date: Date())
|
||||
let nextUpdate = Calendar.current.date(byAdding: .day, value: 7, to: Date())
|
||||
?? Date().addingTimeInterval(60 * 60 * 24 * 7)
|
||||
completion(Timeline(entries: [entry], policy: .after(nextUpdate)))
|
||||
Task {
|
||||
let lang = resolveLang()
|
||||
let today = localDayKey(Date())
|
||||
|
||||
// 1) 今日缓存优先
|
||||
if let cached = readCachedText(), cached.dayKey == today {
|
||||
let entry = EmotionEntry(
|
||||
date: Date(),
|
||||
lang: cached.lang,
|
||||
title: resolveTitle(lang: cached.lang),
|
||||
text: cached.text,
|
||||
footerHint: resolveFooterHint(lang: cached.lang)
|
||||
)
|
||||
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
|
||||
return
|
||||
}
|
||||
|
||||
// 2) 过期/缺失:尝试拉取后端
|
||||
if let fetched = await fetchDailyRecoFromServer() {
|
||||
saveDailyReco(lang: fetched.lang, dayKey: today, contentId: fetched.contentId, text: fetched.text, meta: fetched.meta)
|
||||
let entry = EmotionEntry(
|
||||
date: Date(),
|
||||
lang: fetched.lang,
|
||||
title: resolveTitle(lang: fetched.lang),
|
||||
text: fetched.text,
|
||||
footerHint: resolveFooterHint(lang: fetched.lang)
|
||||
)
|
||||
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
|
||||
return
|
||||
}
|
||||
|
||||
// 3) 网络失败:用最近缓存或兜底
|
||||
if let cached = readCachedText() {
|
||||
let entry = EmotionEntry(
|
||||
date: Date(),
|
||||
lang: cached.lang,
|
||||
title: resolveTitle(lang: cached.lang),
|
||||
text: cached.text,
|
||||
footerHint: resolveFooterHint(lang: cached.lang)
|
||||
)
|
||||
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
|
||||
return
|
||||
}
|
||||
|
||||
let entry = EmotionEntry(
|
||||
date: Date(),
|
||||
lang: lang,
|
||||
title: resolveTitle(lang: lang),
|
||||
text: lang == "en" ? fallbackTextEN : fallbackTextTC,
|
||||
footerHint: resolveFooterHint(lang: lang)
|
||||
)
|
||||
completion(Timeline(entries: [entry], policy: .after(nextDailyRefreshDate(from: Date()))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EmotionEntry: TimelineEntry {
|
||||
let date: Date
|
||||
let lang: String
|
||||
let title: String
|
||||
let text: String
|
||||
let footerHint: String
|
||||
}
|
||||
|
||||
struct EmotionWidgetView: View {
|
||||
var entry: EmotionProvider.Entry
|
||||
@Environment(\.widgetFamily) var family
|
||||
|
||||
private let title = "正念"
|
||||
private let text = "你已经很努力了,今天也值得被温柔对待。"
|
||||
private let deepLink = URL(string: "client:///(app)/home")
|
||||
private let widgetBackgroundColor = Color(red: 1.0, green: 250.0 / 255.0, blue: 229.0 / 255.0) // #FFFAE5
|
||||
private let widgetTextColor = Color(red: 98.0 / 255.0, green: 59.0 / 255.0, blue: 59.0 / 255.0) // #623B3B
|
||||
|
||||
var body: some View {
|
||||
// 只显示一句话(不显示标题/提示/时间等装饰元素)
|
||||
Text(entry.text)
|
||||
.font(fontForFamily())
|
||||
.foregroundColor(widgetTextColor)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineSpacing(lineSpacingForFamily())
|
||||
.lineLimit(lineLimitForFamily())
|
||||
.minimumScaleFactor(0.78)
|
||||
.padding(paddingForFamily())
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
|
||||
.widgetSolidBackground(widgetBackgroundColor)
|
||||
.widgetURL(deepLink)
|
||||
}
|
||||
|
||||
private func fontForFamily() -> Font {
|
||||
switch family {
|
||||
case .systemSmall:
|
||||
smallView()
|
||||
return .system(size: 16, weight: .semibold)
|
||||
case .systemMedium:
|
||||
mediumView()
|
||||
return .system(size: 18, weight: .semibold)
|
||||
case .systemLarge:
|
||||
largeView()
|
||||
return .system(size: 22, weight: .semibold)
|
||||
default:
|
||||
smallView()
|
||||
return .system(size: 16, weight: .semibold)
|
||||
}
|
||||
}
|
||||
|
||||
// 统一的“卡片背景”风格(iOS 15 兼容)
|
||||
private func cardBackground(colors: [Color]) -> some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: colors,
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
// 轻微光斑,增加层次
|
||||
RadialGradient(
|
||||
gradient: Gradient(colors: [Color.white.opacity(0.16), Color.white.opacity(0.0)]),
|
||||
center: .topTrailing,
|
||||
startRadius: 10,
|
||||
endRadius: 180
|
||||
)
|
||||
private func lineSpacingForFamily() -> CGFloat {
|
||||
switch family {
|
||||
case .systemLarge:
|
||||
return 4
|
||||
default:
|
||||
return 3
|
||||
}
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.stroke(Color.white.opacity(0.14), lineWidth: 1)
|
||||
)
|
||||
.cornerRadius(18)
|
||||
}
|
||||
|
||||
private func chip(_ text: String) -> some View {
|
||||
Text(text)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(Color.white.opacity(0.9))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.white.opacity(0.14))
|
||||
.cornerRadius(999)
|
||||
private func lineLimitForFamily() -> Int {
|
||||
switch family {
|
||||
case .systemSmall:
|
||||
return 5
|
||||
case .systemMedium:
|
||||
return 6
|
||||
case .systemLarge:
|
||||
return 8
|
||||
default:
|
||||
return 5
|
||||
}
|
||||
}
|
||||
|
||||
private func smallView() -> some View {
|
||||
ZStack {
|
||||
cardBackground(colors: [
|
||||
Color(red: 0.06, green: 0.08, blue: 0.12),
|
||||
Color(red: 0.13, green: 0.16, blue: 0.22),
|
||||
])
|
||||
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack {
|
||||
chip(title)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
Text(text)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundColor(Color.white.opacity(0.92))
|
||||
.lineSpacing(2)
|
||||
.lineLimit(4)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Text("点我回到 App")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundColor(Color.white.opacity(0.65))
|
||||
}
|
||||
.padding(14)
|
||||
private func paddingForFamily() -> CGFloat {
|
||||
switch family {
|
||||
case .systemSmall:
|
||||
return 14
|
||||
case .systemMedium:
|
||||
return 16
|
||||
case .systemLarge:
|
||||
return 18
|
||||
default:
|
||||
return 14
|
||||
}
|
||||
.widgetURL(deepLink)
|
||||
}
|
||||
}
|
||||
|
||||
private func mediumView() -> some View {
|
||||
ZStack {
|
||||
cardBackground(colors: [
|
||||
Color(red: 0.06, green: 0.08, blue: 0.12),
|
||||
Color(red: 0.09, green: 0.11, blue: 0.17),
|
||||
])
|
||||
private struct WidgetSolidBackgroundModifier: ViewModifier {
|
||||
let color: Color
|
||||
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
chip(title)
|
||||
Text(text)
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
.foregroundColor(Color.white.opacity(0.92))
|
||||
.lineSpacing(3)
|
||||
.lineLimit(5)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Text("轻轻呼吸,回到当下")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.white.opacity(0.7))
|
||||
}
|
||||
|
||||
// 右侧装饰区:让版面更饱满
|
||||
VStack(alignment: .trailing, spacing: 8) {
|
||||
Text(entry.date, style: .time)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(Color.white.opacity(0.8))
|
||||
Spacer(minLength: 0)
|
||||
Text("今日")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundColor(Color.white.opacity(0.12))
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOSApplicationExtension 17.0, *) {
|
||||
content.containerBackground(for: .widget) { color }
|
||||
} else {
|
||||
content
|
||||
.background(color)
|
||||
.ignoresSafeArea()
|
||||
}
|
||||
.widgetURL(deepLink)
|
||||
}
|
||||
}
|
||||
|
||||
private func largeView() -> some View {
|
||||
ZStack {
|
||||
cardBackground(colors: [
|
||||
Color(red: 0.06, green: 0.08, blue: 0.12),
|
||||
Color(red: 0.14, green: 0.18, blue: 0.28),
|
||||
])
|
||||
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack {
|
||||
chip(title)
|
||||
Spacer(minLength: 0)
|
||||
Text(entry.date, style: .time)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundColor(Color.white.opacity(0.78))
|
||||
}
|
||||
|
||||
Text(text)
|
||||
.font(.system(size: 20, weight: .semibold))
|
||||
.foregroundColor(Color.white.opacity(0.92))
|
||||
.lineSpacing(4)
|
||||
.lineLimit(8)
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
HStack {
|
||||
Text("点我回到 Home")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(Color.white.opacity(0.7))
|
||||
Spacer(minLength: 0)
|
||||
Text("🌿")
|
||||
.font(.system(size: 18))
|
||||
.opacity(0.9)
|
||||
}
|
||||
}
|
||||
.padding(18)
|
||||
}
|
||||
.widgetURL(deepLink)
|
||||
private extension View {
|
||||
func widgetSolidBackground(_ color: Color) -> some View {
|
||||
modifier(WidgetSolidBackgroundModifier(color: color))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
10
client/ios/情绪小组件ExtensionRelease.entitlements
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.application-groups</key>
|
||||
<array>
|
||||
<string>group.com.damer.mindfulness</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"ios": "expo run:ios --scheme \"Hey Mama\"",
|
||||
"web": "expo start --web",
|
||||
"test": "vitest run"
|
||||
},
|
||||
@@ -15,7 +15,9 @@
|
||||
"@react-navigation/native": "^7.1.8",
|
||||
"expo": "~54.0.32",
|
||||
"expo-constants": "~18.0.13",
|
||||
"expo-crypto": "^15.0.8",
|
||||
"expo-dev-client": "^6.0.20",
|
||||
"expo-device": "^8.0.10",
|
||||
"expo-font": "~14.0.11",
|
||||
"expo-linear-gradient": "^15.0.8",
|
||||
"expo-linking": "~8.0.11",
|
||||
|
||||
32
client/pnpm-lock.yaml
generated
@@ -23,9 +23,15 @@ importers:
|
||||
expo-constants:
|
||||
specifier: ~18.0.13
|
||||
version: 18.0.13(expo@54.0.32)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))
|
||||
expo-crypto:
|
||||
specifier: ^15.0.8
|
||||
version: 15.0.8(expo@54.0.32)
|
||||
expo-dev-client:
|
||||
specifier: ^6.0.20
|
||||
version: 6.0.20(expo@54.0.32)
|
||||
expo-device:
|
||||
specifier: ^8.0.10
|
||||
version: 8.0.10(expo@54.0.32)
|
||||
expo-font:
|
||||
specifier: ~14.0.11
|
||||
version: 14.0.11(expo@54.0.32)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -2229,6 +2235,11 @@ packages:
|
||||
expo: '*'
|
||||
react-native: '*'
|
||||
|
||||
expo-crypto@15.0.8:
|
||||
resolution: {integrity: sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-dev-client@6.0.20:
|
||||
resolution: {integrity: sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA==}
|
||||
peerDependencies:
|
||||
@@ -2249,6 +2260,11 @@ packages:
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-device@8.0.10:
|
||||
resolution: {integrity: sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-file-system@19.0.21:
|
||||
resolution: {integrity: sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==}
|
||||
peerDependencies:
|
||||
@@ -3811,6 +3827,10 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
ua-parser-js@0.7.41:
|
||||
resolution: {integrity: sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==}
|
||||
hasBin: true
|
||||
|
||||
ua-parser-js@1.0.41:
|
||||
resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==}
|
||||
hasBin: true
|
||||
@@ -6537,6 +6557,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
expo-crypto@15.0.8(expo@54.0.32):
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
|
||||
expo-dev-client@6.0.20(expo@54.0.32):
|
||||
dependencies:
|
||||
expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -6566,6 +6591,11 @@ snapshots:
|
||||
expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
expo-dev-menu-interface: 2.0.0(expo@54.0.32)
|
||||
|
||||
expo-device@8.0.10(expo@54.0.32):
|
||||
dependencies:
|
||||
expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
ua-parser-js: 0.7.41
|
||||
|
||||
expo-file-system@19.0.21(expo@54.0.32)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0)):
|
||||
dependencies:
|
||||
expo: 54.0.32(@babel/core@7.28.6)(@expo/metro-runtime@6.1.2)(expo-router@6.0.22)(react-native@0.81.5(@babel/core@7.28.6)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -8282,6 +8312,8 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
ua-parser-js@0.7.41: {}
|
||||
|
||||
ua-parser-js@1.0.41: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
@@ -22,7 +22,14 @@ function getOptionalEnv(name: string, fallback: string): string {
|
||||
|
||||
export type AppRuntimeEnv = 'local' | 'dev' | 'prod';
|
||||
|
||||
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', 'local') as AppRuntimeEnv) ?? 'local';
|
||||
/**
|
||||
* Release/TestFlight 场景下如果未注入 EXPO_PUBLIC_ENV,
|
||||
* 默认回退到 prod(避免误打到 localhost 导致真机“无法发起网络请求”)。
|
||||
*/
|
||||
const DEFAULT_RUNTIME_ENV: AppRuntimeEnv =
|
||||
typeof __DEV__ !== 'undefined' && __DEV__ ? 'local' : 'prod';
|
||||
|
||||
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', DEFAULT_RUNTIME_ENV) as AppRuntimeEnv) ?? DEFAULT_RUNTIME_ENV;
|
||||
|
||||
function getApiBaseUrl(env: AppRuntimeEnv): string {
|
||||
// 向后兼容:若直接提供了 EXPO_PUBLIC_API_BASE_URL,则优先使用(不再强制要求 *_DEV/_PROD)
|
||||
@@ -34,13 +41,33 @@ function getApiBaseUrl(env: AppRuntimeEnv): string {
|
||||
return getOptionalEnv('EXPO_PUBLIC_API_BASE_URL_LOCAL', 'http://localhost:8000');
|
||||
}
|
||||
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);
|
||||
|
||||
/**
|
||||
* 调试:打印环境变量注入结果(仅开发环境)
|
||||
*
|
||||
* 用途:排查「为什么 API_BASE_URL 不是预期值」的问题(例如 .env.local/命令行注入/缓存导致)。
|
||||
*
|
||||
* 注意:在某些测试环境(如 vitest)里 `__DEV__` 可能不存在,需做兼容判断。
|
||||
*/
|
||||
if (typeof __DEV__ !== 'undefined' && __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
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
export type MockContentItem = {
|
||||
id: string;
|
||||
text: string;
|
||||
textKey: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 本地 mock 内容(后续接后端时可替换)
|
||||
*/
|
||||
export const MOCK_CONTENT: MockContentItem[] = [
|
||||
{ id: 'c1', text: '你已经很努力了,今天也值得被温柔对待。' },
|
||||
{ id: 'c2', text: '深呼吸三次,把注意力带回当下。' },
|
||||
{ id: 'c3', text: '允许自己慢一点,情绪会像云一样飘过。' },
|
||||
{ id: 'c4', text: '你不需要完美,你已经足够好。' },
|
||||
{ id: 'c5', text: '把手放在心口,对自己说一句:辛苦了。' }
|
||||
{ id: 'c1', textKey: 'mock.c1' },
|
||||
{ id: 'c2', textKey: 'mock.c2' },
|
||||
{ id: 'c3', textKey: 'mock.c3' },
|
||||
{ id: 'c4', textKey: 'mock.c4' },
|
||||
{ id: 'c5', textKey: 'mock.c5' }
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { UserProfileScoring } from '@/src/storage/appStorage';
|
||||
|
||||
import { advanceSuixinState, buildInitialSuixinState, computeSuixinSolidColor, pickSuixinBaseThemeId } from '../index';
|
||||
import { computeTFromStep } from '../progress';
|
||||
import { lerpHex } from '../colorMath';
|
||||
|
||||
function buildProfile(partial?: Partial<UserProfileScoring>): UserProfileScoring {
|
||||
const base: UserProfileScoring = {
|
||||
profile_version: 'v1.2',
|
||||
profile_source: 'questionnaire',
|
||||
profile_generated_at: '2026-01-30T00:00:00Z',
|
||||
profile_confidence: 1.0,
|
||||
profile_answered: { stage: true, emotion: true, context: true, need: true },
|
||||
stage: { expecting: 1, parenting: 0, unknown: 0 },
|
||||
emotion_score: 0.6,
|
||||
context: {},
|
||||
need: {},
|
||||
rule_hits: [],
|
||||
hard_rules: { forbidden_risk_flags: [], forbidden_content_predicates: [] },
|
||||
};
|
||||
return { ...base, ...(partial ?? {}) };
|
||||
}
|
||||
|
||||
describe('suixinTheme', () => {
|
||||
it('pickSuixinBaseThemeId: stage.unknown=1 → neutral', () => {
|
||||
const p = buildProfile({ stage: { unknown: 1 } as any, need: { rest_balance: 1 } });
|
||||
expect(pickSuixinBaseThemeId(p)).toBe('neutral');
|
||||
});
|
||||
|
||||
it('pickSuixinBaseThemeId: need 为空 → neutral', () => {
|
||||
const p = buildProfile({ need: {} });
|
||||
expect(pickSuixinBaseThemeId(p)).toBe('neutral');
|
||||
});
|
||||
|
||||
it('pickSuixinBaseThemeId: need 命中 → 对应 base theme', () => {
|
||||
const p = buildProfile({ need: { rest_balance: 1 } });
|
||||
expect(pickSuixinBaseThemeId(p)).toBe('rest_balance');
|
||||
});
|
||||
|
||||
it('computeTFromStep: 始终在 [0,1] 且往返不突跳', () => {
|
||||
const ts = Array.from({ length: 80 }).map((_, i) => computeTFromStep({ stepIndex: i, segments: 12, seed: 'boot' }));
|
||||
for (const t of ts) {
|
||||
expect(t).toBeGreaterThanOrEqual(0);
|
||||
expect(t).toBeLessThanOrEqual(1);
|
||||
}
|
||||
// 往返波形:起点与一个周期后的 t 相同
|
||||
expect(computeTFromStep({ stepIndex: 0, segments: 12, seed: 'boot' })).toBe(
|
||||
computeTFromStep({ stepIndex: 22, segments: 12, seed: 'boot' }) // 2*(N-1)=22
|
||||
);
|
||||
});
|
||||
|
||||
it('lerpHex: t=0/1 输出边界色', () => {
|
||||
expect(lerpHex('#000000', '#FFFFFF', 0)).toBe('#000000');
|
||||
expect(lerpHex('#000000', '#FFFFFF', 1)).toBe('#FFFFFF');
|
||||
});
|
||||
|
||||
it('buildInitialSuixinState: 生成可用状态并可推进', () => {
|
||||
const p = buildProfile({ need: { emotional_support: 1 } });
|
||||
const init = buildInitialSuixinState({ bootId: 'boot-1', profile: p, now: new Date('2026-02-05T00:00:00Z') });
|
||||
expect(init.schema_version).toBe(1);
|
||||
expect(init.base_theme_id).toBe('emotional_support');
|
||||
expect(init.boot_id).toBe('boot-1');
|
||||
expect(init.last_color).toMatch(/^#[0-9A-F]{6}$/);
|
||||
|
||||
const next = advanceSuixinState(init, new Date('2026-02-05T00:00:01Z'));
|
||||
expect(next.step_index).toBe(1);
|
||||
expect(next.base_theme_id).toBe(init.base_theme_id);
|
||||
expect(next.last_color).toMatch(/^#[0-9A-F]{6}$/);
|
||||
});
|
||||
|
||||
it('computeSuixinSolidColor: 输出为合法 hex', () => {
|
||||
const c = computeSuixinSolidColor({ baseThemeId: 'neutral', stepIndex: 3, seed: 'boot' });
|
||||
expect(c).toMatch(/^#[0-9A-F]{6}$/);
|
||||
});
|
||||
});
|
||||
|
||||
53
client/src/features/suixinTheme/colorMath.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
function clamp01(t: number): number {
|
||||
if (!Number.isFinite(t)) return 0;
|
||||
return Math.min(1, Math.max(0, t));
|
||||
}
|
||||
|
||||
type Rgb = { r: number; g: number; b: number };
|
||||
|
||||
function toByte(v: number): number {
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.min(255, Math.max(0, Math.round(v)));
|
||||
}
|
||||
|
||||
export function hexToRgb(hex: string): Rgb | null {
|
||||
const h = String(hex || '').trim();
|
||||
const m = /^#?([0-9a-fA-F]{6})$/.exec(h);
|
||||
if (!m) return null;
|
||||
const raw = m[1];
|
||||
const n = parseInt(raw, 16);
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const r = (n >> 16) & 0xff;
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const g = (n >> 8) & 0xff;
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const b = n & 0xff;
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
export function rgbToHex(rgb: Rgb): string {
|
||||
const r = toByte(rgb.r).toString(16).padStart(2, '0');
|
||||
const g = toByte(rgb.g).toString(16).padStart(2, '0');
|
||||
const b = toByte(rgb.b).toString(16).padStart(2, '0');
|
||||
return `#${r}${g}${b}`.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅允许线性插值(Hard Rule)
|
||||
*/
|
||||
export function lerpRgb(a: Rgb, b: Rgb, t: number): Rgb {
|
||||
const tt = clamp01(t);
|
||||
return {
|
||||
r: a.r + (b.r - a.r) * tt,
|
||||
g: a.g + (b.g - a.g) * tt,
|
||||
b: a.b + (b.b - a.b) * tt,
|
||||
};
|
||||
}
|
||||
|
||||
export function lerpHex(topHex: string, bottomHex: string, t: number, fallbackHex = '#E8F1EC'): string {
|
||||
const top = hexToRgb(topHex);
|
||||
const bottom = hexToRgb(bottomHex);
|
||||
if (!top || !bottom) return fallbackHex;
|
||||
return rgbToHex(lerpRgb(top, bottom, t));
|
||||
}
|
||||
|
||||
65
client/src/features/suixinTheme/index.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { SuixinBaseThemeId, SuixinThemeStateV1, UserProfileScoring } from '@/src/storage/appStorage';
|
||||
|
||||
import { getThemeTriplet, NEUTRAL_THEME_COLORS } from './palette';
|
||||
import { lerpHex } from './colorMath';
|
||||
import { computeTFromStep } from './progress';
|
||||
import { pickSuixinBaseThemeId } from './pickTheme';
|
||||
|
||||
export { NEUTRAL_THEME_COLORS } from './palette';
|
||||
export { pickSuixinBaseThemeId } from './pickTheme';
|
||||
|
||||
/**
|
||||
* 根据 base theme 与 step 计算当前背景纯色
|
||||
*/
|
||||
export function computeSuixinSolidColor(args: {
|
||||
baseThemeId: SuixinBaseThemeId;
|
||||
stepIndex: number;
|
||||
seed: string;
|
||||
}): string {
|
||||
const [top, _mid, bottom] = getThemeTriplet(args.baseThemeId);
|
||||
const t = computeTFromStep({ stepIndex: args.stepIndex, segments: 12, seed: args.seed });
|
||||
return lerpHex(top, bottom, t, NEUTRAL_THEME_COLORS[1]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化一份随心状态(在冷启动会话内锁定 base theme)
|
||||
*/
|
||||
export function buildInitialSuixinState(args: {
|
||||
bootId: string;
|
||||
profile: UserProfileScoring | null;
|
||||
now?: Date;
|
||||
}): SuixinThemeStateV1 {
|
||||
const now = args.now ?? new Date();
|
||||
const baseThemeId = pickSuixinBaseThemeId(args.profile);
|
||||
const seed = args.bootId;
|
||||
const step_index = 0;
|
||||
const last_color = computeSuixinSolidColor({ baseThemeId, stepIndex: step_index, seed });
|
||||
return {
|
||||
schema_version: 1,
|
||||
saved_at: now.toISOString(),
|
||||
boot_id: args.bootId,
|
||||
base_theme_id: baseThemeId,
|
||||
seed,
|
||||
step_index,
|
||||
last_color,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换文案时推进一步(不跨主题)
|
||||
*/
|
||||
export function advanceSuixinState(prev: SuixinThemeStateV1, now?: Date): SuixinThemeStateV1 {
|
||||
const nextStep = (Number.isFinite(prev.step_index) ? prev.step_index : 0) + 1;
|
||||
const last_color = computeSuixinSolidColor({
|
||||
baseThemeId: prev.base_theme_id,
|
||||
stepIndex: nextStep,
|
||||
seed: prev.seed,
|
||||
});
|
||||
return {
|
||||
...prev,
|
||||
saved_at: (now ?? new Date()).toISOString(),
|
||||
step_index: nextStep,
|
||||
last_color,
|
||||
};
|
||||
}
|
||||
|
||||
20
client/src/features/suixinTheme/palette.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { SuixinBaseThemeId } from '@/src/storage/appStorage';
|
||||
|
||||
/**
|
||||
* 随心主题色盘(与设计说明文档保持一致)
|
||||
*/
|
||||
export const BASE_THEME_COLORS: Record<Exclude<SuixinBaseThemeId, 'neutral'>, [string, string, string]> = {
|
||||
emotional_support: ['#F6DCE4', '#FFEFF4', '#FFF7FA'],
|
||||
parenting_pressure: ['#D6EAF5', '#EEF6FB', '#F8FCFF'],
|
||||
self_worth: ['#FFD8A8', '#FFE8C9', '#FFF6E5'],
|
||||
anxiety_relief: ['#DFF3EA', '#ECFBF6', '#F6FFFB'],
|
||||
rest_balance: ['#F2E6D8', '#FAF3EC', '#FFFDF9'],
|
||||
};
|
||||
|
||||
export const NEUTRAL_THEME_COLORS: [string, string, string] = ['#F4F7F2', '#E8F1EC', '#EDF4F8'];
|
||||
|
||||
export function getThemeTriplet(themeId: SuixinBaseThemeId): [string, string, string] {
|
||||
if (themeId === 'neutral') return NEUTRAL_THEME_COLORS;
|
||||
return BASE_THEME_COLORS[themeId];
|
||||
}
|
||||
|
||||
32
client/src/features/suixinTheme/pickTheme.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { SuixinBaseThemeId, UserProfileScoring } from '@/src/storage/appStorage';
|
||||
|
||||
const ALL_NEED_THEME_IDS: ReadonlySet<string> = new Set([
|
||||
'emotional_support',
|
||||
'parenting_pressure',
|
||||
'self_worth',
|
||||
'anxiety_relief',
|
||||
'rest_balance',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Base Theme 选择(Theme Picking)
|
||||
*
|
||||
* Hard Rules(对齐设计文档):
|
||||
* - mom_stage = unknown → 强制 Neutral
|
||||
* - need 跳过/缺失 → Neutral
|
||||
*/
|
||||
export function pickSuixinBaseThemeId(profile: UserProfileScoring | null | undefined): SuixinBaseThemeId {
|
||||
if (!profile) return 'neutral';
|
||||
|
||||
// Hard Rule:unknown → Neutral
|
||||
if (profile.stage?.unknown === 1) return 'neutral';
|
||||
|
||||
const needObj = profile.need ?? {};
|
||||
const keys = Object.keys(needObj);
|
||||
if (keys.length === 0) return 'neutral';
|
||||
|
||||
const needId = keys[0];
|
||||
if (!ALL_NEED_THEME_IDS.has(needId)) return 'neutral';
|
||||
return needId as Exclude<SuixinBaseThemeId, 'neutral'>;
|
||||
}
|
||||
|
||||
40
client/src/features/suixinTheme/progress.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
function clampInt(n: number, min: number, max: number): number {
|
||||
if (!Number.isFinite(n)) return min;
|
||||
return Math.min(max, Math.max(min, Math.floor(n)));
|
||||
}
|
||||
|
||||
function hashStringToInt32(input: string): number {
|
||||
// 简单可复现 hash:用于把 seed 映射为偏移量(不用于安全场景)
|
||||
let h = 0;
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
h = (h * 31 + input.charCodeAt(i)) | 0;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 t ∈ [0,1],用于 Color(t)=lerp(top,bottom,t)
|
||||
*
|
||||
* 说明:
|
||||
* - Home 没有 scroll,用“切换文案 step_index”模拟连续流动
|
||||
* - 采用往返波形,避免从 1 回到 0 的突跳
|
||||
*/
|
||||
export function computeTFromStep(args: {
|
||||
stepIndex: number;
|
||||
segments?: number; // N,默认 12
|
||||
seed?: string; // 允许用 seed 做初始相位偏移(同一次冷启动内稳定)
|
||||
}): number {
|
||||
const N = clampInt(args.segments ?? 12, 3, 60);
|
||||
const stepIndex = clampInt(args.stepIndex, 0, 1_000_000_000);
|
||||
const period = 2 * (N - 1);
|
||||
|
||||
const seed = String(args.seed ?? '');
|
||||
const offset = seed ? Math.abs(hashStringToInt32(seed)) % period : 0;
|
||||
|
||||
const phase = (stepIndex + offset) % period;
|
||||
const up = phase <= (N - 1);
|
||||
const pos = up ? phase : period - phase;
|
||||
return pos / (N - 1);
|
||||
}
|
||||
|
||||
@@ -33,9 +33,13 @@ function mapMomStage(raw: string | undefined): QuestionnaireAnswersV1_2['mom_sta
|
||||
|
||||
function mapEmotion(raw: string | undefined): QuestionnaireAnswersV1_2['emotion'] {
|
||||
if (!raw) return null;
|
||||
// UI 当前选项:happy/calm/stressed/low
|
||||
// UI 选项:
|
||||
// - happy/calm/stressed/low:历史选项(仍保留兼容)
|
||||
// - okay/tired:新增选项
|
||||
if (raw === 'happy') return 'joyful';
|
||||
if (raw === 'calm') return 'calm';
|
||||
if (raw === 'okay') return 'neutral';
|
||||
if (raw === 'tired') return 'tired';
|
||||
if (raw === 'stressed') return 'overwhelmed';
|
||||
if (raw === 'low') return 'low';
|
||||
return null;
|
||||
|
||||
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,11 @@ import * as Localization from 'expo-localization';
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import en from './locales/en.json';
|
||||
import zhTW from './locales/zh-TW.json';
|
||||
import { isTraditionalChineseLocaleTag } from './locale';
|
||||
|
||||
// 用 require 避免 TS 的 json module 配置差异导致无法编译
|
||||
// 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> };
|
||||
|
||||
/**
|
||||
* 语言码约定:
|
||||
@@ -28,13 +31,8 @@ function isSupportedLanguage(lang: string): lang is AppLanguage {
|
||||
function normalizeDeviceLanguageTagToAppLanguage(languageTag: string): AppLanguage {
|
||||
const tag = languageTag.toLowerCase();
|
||||
|
||||
// 中文:当前仅支持繁体中文(zh-TW)
|
||||
if (tag.startsWith('zh')) {
|
||||
return 'zh-TW';
|
||||
}
|
||||
|
||||
// 其他语言:按前缀匹配(当前仅支持英文)
|
||||
if (tag.startsWith('en')) return 'en';
|
||||
if (isTraditionalChineseLocaleTag(tag)) return 'zh-TW';
|
||||
|
||||
return DEFAULT_FALLBACK_LANGUAGE;
|
||||
}
|
||||
@@ -83,8 +81,8 @@ export async function initI18n(): Promise<void> {
|
||||
|
||||
await i18n.use(initReactI18next).init({
|
||||
resources: {
|
||||
'zh-TW': { translation: zhTW },
|
||||
en: { translation: en },
|
||||
'zh-TW': { translation: all['zh-TW'] as any },
|
||||
en: { translation: all.en as any },
|
||||
},
|
||||
lng: initialLang,
|
||||
fallbackLng: DEFAULT_FALLBACK_LANGUAGE,
|
||||
|
||||
35
client/src/i18n/locale.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type BackendLocale = 'en' | 'tc';
|
||||
|
||||
/**
|
||||
* 判断一个 BCP-47 language tag 是否应视为「繁体中文」(TC)。
|
||||
*
|
||||
* 规则(面向当前产品约束:只支持 EN/TC,默认 EN):
|
||||
* - 仅在语言为中文(zh)且脚本为 Hant 或地区为 TW/HK/MO 时,判定为 TC
|
||||
* - 兼容后端/历史写法:明确包含 tc 也视为 TC
|
||||
* - 其他情况一律视为 EN
|
||||
*/
|
||||
export function isTraditionalChineseLocaleTag(languageTag: string): boolean {
|
||||
const tag = (languageTag || '').trim().toLowerCase();
|
||||
if (!tag) return false;
|
||||
|
||||
// 兼容:有些链路可能直接传 tc
|
||||
const parts = tag.split(/[-_]/g).filter(Boolean);
|
||||
if (parts.includes('tc')) return true;
|
||||
|
||||
const lang = parts[0];
|
||||
if (lang !== 'zh') return false;
|
||||
|
||||
// 脚本:zh-Hant / zh-Hant-TW / zh-Hant-HK ...
|
||||
if (tag.includes('hant') || parts.includes('hant')) return true;
|
||||
|
||||
// 地区:zh-TW / zh-HK / zh-MO
|
||||
if (parts.includes('tw') || parts.includes('hk') || parts.includes('mo')) return true;
|
||||
|
||||
// 其他中文(如 zh / zh-CN / zh-Hans)不属于 TC → 回退 EN
|
||||
return false;
|
||||
}
|
||||
|
||||
export function toBackendLocaleFromLanguageTag(languageTag: string | null | undefined): BackendLocale {
|
||||
return isTraditionalChineseLocaleTag(languageTag ?? '') ? 'tc' : 'en';
|
||||
}
|
||||
|
||||
342
client/src/i18n/locales/all.json
Normal file
@@ -0,0 +1,342 @@
|
||||
{
|
||||
"en": {
|
||||
"common": {
|
||||
"ok": "OK",
|
||||
"cancel": "Cancel",
|
||||
"error": "Error",
|
||||
"notice": "Notice",
|
||||
"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 do you want to be called?", "placeholder": "Mama" },
|
||||
"status": {
|
||||
"title": "Which stage of motherhood are you in?",
|
||||
"options": {
|
||||
"pregnant": "Pregnant / Preparing",
|
||||
"has_kids": "Parenting",
|
||||
"no_fill": "Prefer not to say"
|
||||
}
|
||||
},
|
||||
"emotion": {
|
||||
"title": "How are you feeling right now?",
|
||||
"options": {
|
||||
"happy": "Joyful",
|
||||
"calm": "Calm",
|
||||
"okay": "Okay",
|
||||
"tired": "Tired",
|
||||
"stressed": "Overwhelmed",
|
||||
"low": "Low"
|
||||
}
|
||||
},
|
||||
"influence": {
|
||||
"title": "What’s been influencing how you feel?",
|
||||
"options": {
|
||||
"family": "Family",
|
||||
"work": "Work or study",
|
||||
"relationship": "Relationship",
|
||||
"friends": "Friends",
|
||||
"health": "Health"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
"title": "What kind of support do you need most right now?",
|
||||
"options": {
|
||||
"emotional": "Emotional support",
|
||||
"parenting": "Parenting pressure",
|
||||
"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",
|
||||
"suixin": "Ease"
|
||||
},
|
||||
"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",
|
||||
"howToTitle": "How to add the widget",
|
||||
"howToDesc1": "Long-press on the Home Screen until the apps jiggle, then tap “+” in the top-left corner.",
|
||||
"howToDesc2": "Search “Mindfulness”, choose a widget size you like, then tap “Add 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\nWill Be Better.",
|
||||
"agree": "Agree & Continue",
|
||||
"privacy": "Privacy Policy",
|
||||
"terms": "Terms of Use",
|
||||
"notice": "By continuing, you agree to the Privacy Policy and Terms of Use.",
|
||||
"noticeRich": "By continuing, you agree to the <privacy>{{privacyLabel}}{{privacySuffix}}</privacy> and <terms>{{termsLabel}}{{termsSuffix}}</terms>.",
|
||||
"linkUnavailable": "Failed to load the policy link. Please check your network and try again.",
|
||||
"linkUnavailableDev": "Failed to load the policy link. Please check your network or API_BASE_URL: {{baseUrl}}",
|
||||
"linkLoadingSuffix": " (loading…)"
|
||||
},
|
||||
"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": "返回",
|
||||
"error": "錯誤",
|
||||
"notice": "提示",
|
||||
"openLinkError": "無法打開鏈接",
|
||||
"close": "關閉"
|
||||
},
|
||||
"onboarding": {
|
||||
"title": "歡迎",
|
||||
"progress": "{{current}}/{{total}}",
|
||||
"next": "下一步",
|
||||
"skip": "跳過",
|
||||
"skipAll": "跳過整個引導",
|
||||
"q1Title": "你最近的感受更接近哪一種?",
|
||||
"q1Desc": "沒有對錯,你可以跳過,之後也能慢慢調整。",
|
||||
"q2Title": "你更希望獲得哪種支持?",
|
||||
"q2Desc": "例如:溫柔提醒、正念練習、情緒陪伴。",
|
||||
"q3Title": "你通常在什麼時候最需要被安慰?",
|
||||
"q3Desc": "例如:清晨、午后、深夜,或某些特定時刻。",
|
||||
"q4Title": "給自己一句溫柔的話",
|
||||
"q4Desc": "你可以直接跳過,我們會在之後繼續陪你。"
|
||||
},
|
||||
"onboardingSurvey": {
|
||||
"steps": {
|
||||
"name": { "title": "我可以怎麼稱呼你?", "placeholder": "媽媽" },
|
||||
"status": {
|
||||
"title": "媽媽的狀態?",
|
||||
"options": {
|
||||
"pregnant": "懷孕中/準備成為媽媽",
|
||||
"has_kids": "已經有孩子",
|
||||
"no_fill": "不想填寫"
|
||||
}
|
||||
},
|
||||
"emotion": {
|
||||
"title": "當下情緒狀態?",
|
||||
"options": {
|
||||
"happy": "愉悅、滿足",
|
||||
"calm": "平靜、安穩",
|
||||
"okay": "還可以、普通",
|
||||
"tired": "疲累、沒什麼力氣",
|
||||
"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": "顏色",
|
||||
"suixin": "隨心"
|
||||
},
|
||||
"profile": {
|
||||
"title": "我的",
|
||||
"favorites": "我的喜歡",
|
||||
"widget": "小工具",
|
||||
"dailyReminder": "每日提醒",
|
||||
"privacy": "隱私政策",
|
||||
"terms": "使用條款",
|
||||
"language": "語言",
|
||||
"todoTitle": "提示",
|
||||
"todoDesc": "此功能本期先占位,後續迭代補齊。"
|
||||
},
|
||||
"dailyReminder": {
|
||||
"title": "每日提醒",
|
||||
"timesUnit": "次",
|
||||
"pushLabel": "推送提醒",
|
||||
"ok": "確定",
|
||||
"minus": "減少次數",
|
||||
"plus": "增加次數"
|
||||
},
|
||||
"widget": {
|
||||
"lockScreen": "鎖屏小工具",
|
||||
"homeScreen": "桌面小工具",
|
||||
"howToTitle": "如何添加小工具",
|
||||
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
|
||||
"howToDesc2": "搜尋「正念」,選擇喜歡的尺寸,點「加入小工具」。",
|
||||
"previewDate": "1月29日週四 · 已至臘月十一",
|
||||
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
|
||||
},
|
||||
"favorites": {
|
||||
"title": "收藏夾",
|
||||
"empty": "這裡還沒有收藏內容。",
|
||||
"unknownText": "這條文案暫時無法顯示。"
|
||||
},
|
||||
"settings": {
|
||||
"title": "設定",
|
||||
"language": "語言",
|
||||
"version": "版本",
|
||||
"widgetTitle": "iOS 小工具",
|
||||
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
|
||||
},
|
||||
"consent": {
|
||||
"title": "你很完美。",
|
||||
"subtitle": "一切\n都會更好。",
|
||||
"agree": "同意並繼續",
|
||||
"privacy": "隱私協議",
|
||||
"terms": "用戶使用協議",
|
||||
"notice": "繼續使用即代表你同意《隱私協議》與《用戶使用協議》。",
|
||||
"noticeRich": "繼續使用即代表你同意<privacy>《{{privacyLabel}}》{{privacySuffix}}</privacy>與<terms>《{{termsLabel}}》{{termsSuffix}}</terms>。",
|
||||
"linkUnavailable": "協議鏈接載入失敗,請檢查網路後重試。",
|
||||
"linkUnavailableDev": "協議鏈接載入失敗,請檢查網路或 API_BASE_URL 設定:{{baseUrl}}",
|
||||
"linkLoadingSuffix": "(載入中…)"
|
||||
},
|
||||
"permissions": {
|
||||
"notificationsDenied": "系統權限已被拒絕,請前往手機設定開啟通知。"
|
||||
},
|
||||
"language": {
|
||||
"zhTW": "繁體中文",
|
||||
"en": "English"
|
||||
},
|
||||
"mock": {
|
||||
"c1": "你已經很努力了,今天也值得被溫柔對待。",
|
||||
"c2": "深呼吸三次,把注意力帶回當下。",
|
||||
"c3": "允許自己慢一點,情緒會像雲一樣飄過。",
|
||||
"c4": "你不需要完美,你已經足夠好。",
|
||||
"c5": "把手放在心口,對自己說一句:辛苦了。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"later": "稍后",
|
||||
"loading": "处理中…",
|
||||
"errorTitle": "提示",
|
||||
"errorDesc": "开启失败也没关系,你仍然可以继续使用应用。"
|
||||
"errorDesc": "开启失败,请稍后重试(模拟器可能无法获取推送 Token,建议用真机测试)。"
|
||||
},
|
||||
"home": {
|
||||
"title": "正念",
|
||||
|
||||
174
client/src/modules/dailyWidgetReco/index.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { API_BASE_URL } from '@/src/constants/env';
|
||||
import type { UserProfileV1_2, UserProfileV1_2_Extended } from '@/src/features/userProfileScoring/types';
|
||||
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
|
||||
import { fetchRecoWidget } from '@/src/services/recoApi';
|
||||
import i18n from 'i18next';
|
||||
import { getUserProfileScoring } from '@/src/storage/appStorage';
|
||||
import { getLocalDayKey } from '@/src/utils/date';
|
||||
|
||||
import {
|
||||
appGroupGetString,
|
||||
appGroupReloadAllTimelines,
|
||||
appGroupSetString,
|
||||
isAppGroupStorageAvailable,
|
||||
} from '@/src/services/appGroupStorage';
|
||||
|
||||
/**
|
||||
* App Group 共享 key(App ↔ Widget 共通)
|
||||
*/
|
||||
export const WIDGET_CONFIG_KEY = 'widget.config.v1';
|
||||
export const WIDGET_USER_PROFILE_KEY = 'widget.userProfile.v1_2';
|
||||
export const WIDGET_DAILY_RECO_KEY = 'widget.dailyReco.v1';
|
||||
|
||||
export type WidgetConfigV1 = {
|
||||
schema_version: 1;
|
||||
saved_at: string; // ISO8601
|
||||
apiBaseUrl: string;
|
||||
};
|
||||
|
||||
export type WidgetUserProfileV1_2 = {
|
||||
schema_version: 1;
|
||||
saved_at: string; // ISO8601
|
||||
user_profile: UserProfileV1_2;
|
||||
};
|
||||
|
||||
export type WidgetDailyRecoV1 = {
|
||||
schema_version: 1;
|
||||
saved_at: string; // ISO8601
|
||||
day_key: string; // YYYY-MM-DD(用户时区)
|
||||
lang: 'en' | 'tc';
|
||||
item: null | {
|
||||
content_id: number;
|
||||
text: string;
|
||||
final_score?: number;
|
||||
fallback_level_final?: number;
|
||||
};
|
||||
meta?: Record<string, unknown>;
|
||||
source?: 'app' | 'widget';
|
||||
};
|
||||
|
||||
function safeJsonParse<T>(raw: string | null): T | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pickUserProfileV1_2(scoringProfile: UserProfileV1_2_Extended): UserProfileV1_2 {
|
||||
return {
|
||||
profile_version: scoringProfile.profile_version,
|
||||
profile_source: scoringProfile.profile_source,
|
||||
profile_generated_at: scoringProfile.profile_generated_at,
|
||||
profile_confidence: scoringProfile.profile_confidence,
|
||||
profile_answered: scoringProfile.profile_answered,
|
||||
stage: scoringProfile.stage,
|
||||
emotion_score: scoringProfile.emotion_score,
|
||||
context: scoringProfile.context,
|
||||
need: scoringProfile.need,
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncWidgetConfig(): Promise<void> {
|
||||
if (!isAppGroupStorageAvailable()) return;
|
||||
const payload: WidgetConfigV1 = {
|
||||
schema_version: 1,
|
||||
saved_at: new Date().toISOString(),
|
||||
apiBaseUrl: API_BASE_URL,
|
||||
};
|
||||
await appGroupSetString(WIDGET_CONFIG_KEY, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export async function syncWidgetUserProfileFromScoring(scoringProfile: UserProfileV1_2_Extended): Promise<void> {
|
||||
if (!isAppGroupStorageAvailable()) return;
|
||||
const payload: WidgetUserProfileV1_2 = {
|
||||
schema_version: 1,
|
||||
saved_at: new Date().toISOString(),
|
||||
user_profile: pickUserProfileV1_2(scoringProfile),
|
||||
};
|
||||
await appGroupSetString(WIDGET_USER_PROFILE_KEY, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
export async function syncWidgetUserProfileFromStorage(): Promise<void> {
|
||||
const scoringProfile = await getUserProfileScoring();
|
||||
if (!scoringProfile) return;
|
||||
await syncWidgetUserProfileFromScoring(scoringProfile);
|
||||
}
|
||||
|
||||
export async function getWidgetDailyRecoCache(): Promise<WidgetDailyRecoV1 | null> {
|
||||
if (!isAppGroupStorageAvailable()) return null;
|
||||
const raw = await appGroupGetString(WIDGET_DAILY_RECO_KEY);
|
||||
return safeJsonParse<WidgetDailyRecoV1>(raw);
|
||||
}
|
||||
|
||||
export async function setWidgetDailyRecoCache(cache: WidgetDailyRecoV1): Promise<void> {
|
||||
if (!isAppGroupStorageAvailable()) return;
|
||||
await appGroupSetString(WIDGET_DAILY_RECO_KEY, JSON.stringify(cache));
|
||||
}
|
||||
|
||||
/**
|
||||
* App 前台辅助刷新(不保证准点,但能提升更新及时性与一致性)
|
||||
*
|
||||
* 规则:
|
||||
* - 若共享缓存 `day_key` 已是今天 → 不请求
|
||||
* - 若缺少用户画像 → 不请求(交给 Widget 走兜底/下次重试)
|
||||
* - 成功后写入共享缓存并触发 Widget reload(系统仍可能延迟)
|
||||
*/
|
||||
export async function ensureDailyWidgetRecoUpToDate(args?: {
|
||||
reason?: string;
|
||||
scoringProfile?: UserProfileV1_2_Extended | null;
|
||||
}): Promise<void> {
|
||||
if (!isAppGroupStorageAvailable()) return;
|
||||
|
||||
// 先确保 Widget 能拿到 baseURL(dev/pro 切换时很关键)
|
||||
await syncWidgetConfig();
|
||||
|
||||
const today = getLocalDayKey(new Date());
|
||||
const cached = await getWidgetDailyRecoCache();
|
||||
if (cached?.schema_version === 1 && cached.day_key === today && cached.item?.text) return;
|
||||
|
||||
const scoringProfile = args?.scoringProfile ?? (await getUserProfileScoring());
|
||||
if (!scoringProfile) return;
|
||||
|
||||
// 同步画像给 Widget(保证 Widget 独立拉取也有输入)
|
||||
await syncWidgetUserProfileFromScoring(scoringProfile);
|
||||
|
||||
try {
|
||||
const { items, meta } = await fetchRecoWidget({
|
||||
k: 1,
|
||||
user_profile: pickUserProfileV1_2(scoringProfile),
|
||||
already_recommended_ids: [],
|
||||
touched_or_viewed_ids: [],
|
||||
});
|
||||
|
||||
const top = items?.[0];
|
||||
if (!top?.text) return;
|
||||
|
||||
const lang = toBackendLocaleFromLanguageTag(i18n.language);
|
||||
await setWidgetDailyRecoCache({
|
||||
schema_version: 1,
|
||||
saved_at: new Date().toISOString(),
|
||||
day_key: today,
|
||||
// 语言策略:与后端 Accept-Language 保持一致(目前只区分 en/tc)
|
||||
lang,
|
||||
item: {
|
||||
content_id: top.content_id,
|
||||
text: top.text,
|
||||
final_score: top.final_score,
|
||||
fallback_level_final: top.fallback_level_final,
|
||||
},
|
||||
meta: meta as Record<string, unknown>,
|
||||
source: 'app',
|
||||
});
|
||||
|
||||
// 触发 Widget 刷新(系统仍可能延迟)
|
||||
await appGroupReloadAllTimelines();
|
||||
} catch (e) {
|
||||
// 失败不阻塞主流程;Widget 将使用缓存或兜底文案
|
||||
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
||||
console.log('[DailyWidgetReco] App 前台刷新失败:', args?.reason ?? 'unknown', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
44
client/src/services/__tests__/legalApi.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import i18n from 'i18next';
|
||||
|
||||
import { buildAcceptLanguage } from '../legalApi';
|
||||
|
||||
describe('legalApi.buildAcceptLanguage', () => {
|
||||
function setLang(lang: string) {
|
||||
Object.defineProperty(i18n, 'language', {
|
||||
value: lang,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
|
||||
it('非中文语言回退为 en', () => {
|
||||
setLang('en');
|
||||
expect(buildAcceptLanguage()).toBe('en');
|
||||
|
||||
setLang('es');
|
||||
expect(buildAcceptLanguage()).toBe('en');
|
||||
});
|
||||
|
||||
it('简中/其他中文不支持时回退为 en', () => {
|
||||
setLang('zh-CN');
|
||||
expect(buildAcceptLanguage()).toBe('en');
|
||||
|
||||
setLang('zh');
|
||||
expect(buildAcceptLanguage()).toBe('en');
|
||||
});
|
||||
|
||||
it('繁体中文归一为 tc', () => {
|
||||
setLang('zh-TW');
|
||||
expect(buildAcceptLanguage()).toBe('tc');
|
||||
});
|
||||
|
||||
it('显式 tc / hant 等归一为 tc', () => {
|
||||
setLang('tc');
|
||||
expect(buildAcceptLanguage()).toBe('tc');
|
||||
|
||||
setLang('zh-Hant');
|
||||
expect(buildAcceptLanguage()).toBe('tc');
|
||||
});
|
||||
});
|
||||
|
||||
53
client/src/services/appGroupStorage.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NativeModules, Platform } from 'react-native';
|
||||
|
||||
type AppGroupStorageNativeModule = {
|
||||
/**
|
||||
* 写入 App Group 的 UserDefaults(值为字符串,通常是 JSON)
|
||||
*/
|
||||
setString(key: string, value: string): Promise<void>;
|
||||
/**
|
||||
* 读取 App Group 的 UserDefaults(值为字符串,通常是 JSON)
|
||||
*/
|
||||
getString(key: string): Promise<string | null>;
|
||||
/**
|
||||
* 触发 Widget 刷新(iOS 系统仍可能延迟)
|
||||
*/
|
||||
reloadAllTimelines(): Promise<void>;
|
||||
};
|
||||
|
||||
function getNativeModule(): AppGroupStorageNativeModule | null {
|
||||
if (Platform.OS !== 'ios') return null;
|
||||
const raw = (NativeModules as Record<string, unknown>)?.AppGroupStorage as unknown;
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
|
||||
// 注意:若 iOS 原生模块没有正确导出方法(例如缺少 RCT_EXTERN_METHOD 桥接)
|
||||
// JS 侧可能能拿到模块对象,但方法会是 undefined,这里需要做运行时校验避免崩溃。
|
||||
const m = raw as Partial<AppGroupStorageNativeModule>;
|
||||
if (typeof m.setString !== 'function') return null;
|
||||
if (typeof m.getString !== 'function') return null;
|
||||
if (typeof m.reloadAllTimelines !== 'function') return null;
|
||||
return m as AppGroupStorageNativeModule;
|
||||
}
|
||||
|
||||
export function isAppGroupStorageAvailable(): boolean {
|
||||
return Boolean(getNativeModule());
|
||||
}
|
||||
|
||||
export async function appGroupSetString(key: string, value: string): Promise<void> {
|
||||
const m = getNativeModule();
|
||||
if (!m) return;
|
||||
await m.setString(key, value);
|
||||
}
|
||||
|
||||
export async function appGroupGetString(key: string): Promise<string | null> {
|
||||
const m = getNativeModule();
|
||||
if (!m) return null;
|
||||
return await m.getString(key);
|
||||
}
|
||||
|
||||
export async function appGroupReloadAllTimelines(): Promise<void> {
|
||||
const m = getNativeModule();
|
||||
if (!m) return;
|
||||
await m.reloadAllTimelines();
|
||||
}
|
||||
|
||||
29
client/src/services/legalApi.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import i18n from 'i18next';
|
||||
|
||||
import { httpJson } from '../utils/http';
|
||||
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
|
||||
|
||||
export type LegalLinks = {
|
||||
privacyPolicyUrl: string;
|
||||
termsOfUseUrl: string;
|
||||
resolvedLang: 'en' | 'tc';
|
||||
};
|
||||
|
||||
export function buildAcceptLanguage(): 'en' | 'tc' {
|
||||
// 当前多语言仅支持 EN / TC;其他语言统一回退到 en
|
||||
return toBackendLocaleFromLanguageTag(i18n.language);
|
||||
}
|
||||
|
||||
export async function fetchLegalLinks(): Promise<LegalLinks> {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept-Language': buildAcceptLanguage(),
|
||||
};
|
||||
return await httpJson<LegalLinks>({
|
||||
path: '/v1/legal/links',
|
||||
method: 'GET',
|
||||
headers,
|
||||
timeoutMs: 8_000,
|
||||
debugLabel: 'LegalLinks',
|
||||
});
|
||||
}
|
||||
|
||||
209
client/src/services/pushApi.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import i18n from 'i18next';
|
||||
import Constants from 'expo-constants';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
import { httpJson } from '../utils/http';
|
||||
import { APP_ENV } from '../constants/env';
|
||||
import { getDailyReminderSettings, getOrCreateClientUserId, getUserProfileScoring } from '../storage/appStorage';
|
||||
import type { UserProfileScoring } from '../storage/appStorage';
|
||||
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
|
||||
|
||||
export type PushEnv = 'dev' | 'prod';
|
||||
|
||||
export type PushPlatform = 'ios' | 'android';
|
||||
|
||||
export type PushDeviceMeta = {
|
||||
model?: string;
|
||||
os_version?: string;
|
||||
app_version?: string;
|
||||
locale?: string;
|
||||
timezone?: string;
|
||||
};
|
||||
|
||||
export type PushRegisterRequest = {
|
||||
client_user_id: string;
|
||||
platform: PushPlatform;
|
||||
push_token: string;
|
||||
app_id: string;
|
||||
env: PushEnv;
|
||||
device_meta?: PushDeviceMeta;
|
||||
};
|
||||
|
||||
export type PushPreferencesRequest = {
|
||||
client_user_id: string;
|
||||
enabled: boolean;
|
||||
times_per_day: number; // 0~5
|
||||
timezone?: string;
|
||||
locale?: string;
|
||||
// 可选:用户画像(用于后端 Push 场景推荐文案生成)
|
||||
user_profile?: UserProfileScoring;
|
||||
};
|
||||
|
||||
export type PushPreferencesResponse = PushPreferencesRequest & {
|
||||
updated_at?: string;
|
||||
};
|
||||
|
||||
export function buildAcceptLanguage(): 'en' | 'tc' {
|
||||
return toBackendLocaleFromLanguageTag(i18n.language);
|
||||
}
|
||||
|
||||
function toPushEnv(appEnv: typeof APP_ENV): PushEnv {
|
||||
// 客户端 APP_ENV: local/dev/prod → 后端推送 env: dev/prod
|
||||
if (appEnv === 'prod') return 'prod';
|
||||
return 'dev';
|
||||
}
|
||||
|
||||
function pickAppId(): string {
|
||||
// 优先按平台取 bundleId/package;拿不到则回退到 slug
|
||||
if (Platform.OS === 'ios') {
|
||||
return (
|
||||
Constants.expoConfig?.ios?.bundleIdentifier ||
|
||||
Constants.easConfig?.projectId ||
|
||||
Constants.expoConfig?.slug ||
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
if (Platform.OS === 'android') {
|
||||
return (
|
||||
Constants.expoConfig?.android?.package ||
|
||||
Constants.easConfig?.projectId ||
|
||||
Constants.expoConfig?.slug ||
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
return Constants.expoConfig?.slug || 'unknown';
|
||||
}
|
||||
|
||||
function pickTimezone(): string | undefined {
|
||||
try {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return tz && String(tz).trim() ? String(tz).trim() : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function pickLocale(): string | undefined {
|
||||
const lang = (i18n.language || '').trim();
|
||||
return lang ? lang : undefined;
|
||||
}
|
||||
|
||||
function pickPlatform(): PushPlatform {
|
||||
return Platform.OS === 'ios' ? 'ios' : 'android';
|
||||
}
|
||||
|
||||
function getExpoProjectId(): string | undefined {
|
||||
// Expo 官方推荐读取 projectId(EAS/Dev Client 下通常需要)
|
||||
return (
|
||||
Constants.easConfig?.projectId ||
|
||||
// 兼容 app.json / app.config.ts 的 extra.eas.projectId
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(Constants.expoConfig as any)?.extra?.eas?.projectId ||
|
||||
// 兜底:某些运行时环境仍可直接读到 EXPO_PUBLIC_ 注入
|
||||
process.env.EXPO_PUBLIC_EAS_PROJECT_ID ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
export async function getExpoPushTokenOrThrow(): Promise<string> {
|
||||
const projectId = getExpoProjectId();
|
||||
try {
|
||||
const res = projectId
|
||||
? await Notifications.getExpoPushTokenAsync({ projectId })
|
||||
: await Notifications.getExpoPushTokenAsync();
|
||||
return res.data;
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
const hint = projectId
|
||||
? ''
|
||||
: '(可能缺少 EAS projectId:请在 .env.local 配置 EXPO_PUBLIC_EAS_PROJECT_ID,或在 app.json/app.config.ts 的 extra.eas.projectId 写入后重试)';
|
||||
throw new Error(`获取 Expo Push Token 失败:${msg}${hint}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function registerPushToken(args: { pushToken: string }): Promise<void> {
|
||||
const clientUserId = await getOrCreateClientUserId();
|
||||
const headers: Record<string, string> = {
|
||||
'Accept-Language': buildAcceptLanguage(),
|
||||
};
|
||||
|
||||
const deviceMeta: PushDeviceMeta = {
|
||||
os_version: String(Platform.Version ?? ''),
|
||||
app_version: Constants.expoConfig?.version,
|
||||
locale: pickLocale(),
|
||||
timezone: pickTimezone(),
|
||||
};
|
||||
|
||||
const body: PushRegisterRequest = {
|
||||
client_user_id: clientUserId,
|
||||
platform: pickPlatform(),
|
||||
push_token: args.pushToken,
|
||||
app_id: pickAppId(),
|
||||
env: toPushEnv(APP_ENV),
|
||||
device_meta: deviceMeta,
|
||||
};
|
||||
|
||||
await httpJson<void>({
|
||||
path: '/v1/push/register',
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
timeoutMs: 10_000,
|
||||
debugLabel: 'PushRegister',
|
||||
});
|
||||
}
|
||||
|
||||
export async function setPushPreferences(args: { enabled: boolean; timesPerDay: number }): Promise<PushPreferencesResponse> {
|
||||
const clientUserId = await getOrCreateClientUserId();
|
||||
const tz = pickTimezone();
|
||||
const locale = pickLocale();
|
||||
const scoringProfile = await getUserProfileScoring().catch(() => null);
|
||||
const headers: Record<string, string> = {
|
||||
'Accept-Language': buildAcceptLanguage(),
|
||||
};
|
||||
|
||||
const body: PushPreferencesRequest = {
|
||||
client_user_id: clientUserId,
|
||||
enabled: Boolean(args.enabled) && args.timesPerDay > 0,
|
||||
times_per_day: Math.min(5, Math.max(0, Math.round(args.timesPerDay))),
|
||||
timezone: tz,
|
||||
locale,
|
||||
user_profile: scoringProfile ?? undefined,
|
||||
};
|
||||
|
||||
return await httpJson<PushPreferencesResponse>({
|
||||
path: '/v1/push/preferences',
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body,
|
||||
timeoutMs: 10_000,
|
||||
debugLabel: 'PushPreferences',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPushPreferences(): Promise<PushPreferencesResponse> {
|
||||
const clientUserId = await getOrCreateClientUserId();
|
||||
const headers: Record<string, string> = {
|
||||
'Accept-Language': buildAcceptLanguage(),
|
||||
};
|
||||
const qs = `client_user_id=${encodeURIComponent(clientUserId)}`;
|
||||
return await httpJson<PushPreferencesResponse>({
|
||||
path: `/v1/push/preferences?${qs}`,
|
||||
method: 'GET',
|
||||
headers,
|
||||
timeoutMs: 8_000,
|
||||
debugLabel: 'PushPreferencesGet',
|
||||
});
|
||||
}
|
||||
|
||||
// 预留:未来推送文案个性化需要上报用户画像时使用(本期先不强制依赖)
|
||||
export async function setPushProfileHint(_profile: UserProfileScoring | null): Promise<void> {
|
||||
// 本期不实现:后端通过现有推荐模块自行拉取/计算 push 文案
|
||||
}
|
||||
|
||||
export async function syncPushPreferencesFromLocal(): Promise<void> {
|
||||
const s = await getDailyReminderSettings();
|
||||
await setPushPreferences({ enabled: s.pushEnabled, timesPerDay: s.timesPerDay });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import i18n from 'i18next';
|
||||
|
||||
import { API_BASE_URL } from '@/src/constants/env';
|
||||
import type { UserProfileV1_2 } from '@/src/features/userProfileScoring';
|
||||
import type { UserProfileV1_2 } from '../features/userProfileScoring';
|
||||
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
|
||||
import { httpJson } from '../utils/http';
|
||||
|
||||
export type RecommendedItem = {
|
||||
content_id: number;
|
||||
@@ -26,38 +27,54 @@ export type RecoRequest = {
|
||||
now?: string; // ISO8601(可选)
|
||||
};
|
||||
|
||||
function withTimeout(ms: number): AbortController {
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => controller.abort(), ms);
|
||||
return controller;
|
||||
}
|
||||
|
||||
export async function fetchRecoFeed(req: RecoRequest): Promise<RecoEngineResult> {
|
||||
const controller = withTimeout(12_000);
|
||||
const url = `${API_BASE_URL}/v1/reco/feed`;
|
||||
const acceptLanguage = toBackendLocaleFromLanguageTag(i18n.language);
|
||||
|
||||
const res = await fetch(url, {
|
||||
const headers: Record<string, string> = {
|
||||
// 让后端做 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,
|
||||
};
|
||||
|
||||
return await httpJson<RecoEngineResult>({
|
||||
path: '/v1/reco/feed',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// 让后端做 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,
|
||||
headers,
|
||||
body: bodyObj,
|
||||
timeoutMs: 12_000,
|
||||
debugLabel: 'Feed API',
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchRecoWidget(req: RecoRequest): Promise<RecoEngineResult> {
|
||||
const acceptLanguage = toBackendLocaleFromLanguageTag(i18n.language);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
// 让后端做 locale 选择(目前后端只区分 en/tc)
|
||||
'Accept-Language': acceptLanguage,
|
||||
};
|
||||
|
||||
const bodyObj = {
|
||||
k: req.k ?? 1,
|
||||
user_profile: req.user_profile,
|
||||
already_recommended_ids: req.already_recommended_ids ?? [],
|
||||
touched_or_viewed_ids: req.touched_or_viewed_ids ?? [],
|
||||
now: req.now,
|
||||
};
|
||||
|
||||
return await httpJson<RecoEngineResult>({
|
||||
path: '/v1/reco/widget',
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: bodyObj,
|
||||
timeoutMs: 12_000,
|
||||
debugLabel: 'Widget API',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`推荐接口请求失败:${res.status} ${res.statusText} ${text}`.trim());
|
||||
}
|
||||
|
||||
return (await res.json()) as RecoEngineResult;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import * as Crypto from 'expo-crypto';
|
||||
import type { UserProfileV1_2_Extended } from '@/src/features/userProfileScoring';
|
||||
|
||||
/**
|
||||
* 本地存储 key 统一管理,避免 UI 里散落硬编码
|
||||
*/
|
||||
const KEY_CLIENT_USER_ID = 'client.userId';
|
||||
const KEY_ONBOARDING_COMPLETED = 'onboarding.completed';
|
||||
const KEY_PUSH_PROMPT_STATE = 'push.promptState';
|
||||
const KEY_CONTENT_REACTIONS = 'content.reactions';
|
||||
@@ -14,24 +16,56 @@ const KEY_USER_PROFILE_SCORING = 'user.profileScoring';
|
||||
const KEY_RECO_FEED_CACHE = 'reco.feedCache';
|
||||
const KEY_RECO_FEED_HISTORY = 'reco.feedHistory';
|
||||
const KEY_UI_THEME_MODE = 'ui.theme.mode';
|
||||
const KEY_UI_THEME_SUIXIN_STATE = 'ui.theme.suixin.state';
|
||||
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
|
||||
|
||||
export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
|
||||
export type Reaction = 'like' | 'dislike';
|
||||
export type ReactionsMap = Record<string, Reaction>;
|
||||
export type ThemeMode = 'scenery' | 'color';
|
||||
export type ThemeMode = 'scenery' | 'color' | 'suixin';
|
||||
export type UserProfile = {
|
||||
name?: string;
|
||||
intents?: string[];
|
||||
};
|
||||
|
||||
export type SuixinBaseThemeId =
|
||||
| 'neutral'
|
||||
| 'emotional_support'
|
||||
| 'parenting_pressure'
|
||||
| 'self_worth'
|
||||
| 'anxiety_relief'
|
||||
| 'rest_balance';
|
||||
|
||||
export type SuixinThemeStateV1 = {
|
||||
schema_version: 1;
|
||||
saved_at: string; // ISO8601
|
||||
/**
|
||||
* 冷启动会话标记(进程级)。
|
||||
* 用于确保:仅在冷启动时重置 base theme/seed。
|
||||
*/
|
||||
boot_id: string;
|
||||
base_theme_id: SuixinBaseThemeId;
|
||||
seed: string;
|
||||
step_index: number;
|
||||
last_color: string; // "#RRGGBB"
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户画像(问卷打分输出)
|
||||
* 说明:用于推荐/Push/Widget 统一复用;结构以 `src/features/userProfileScoring` 输出为准。
|
||||
*/
|
||||
export type UserProfileScoring = UserProfileV1_2_Extended;
|
||||
export type DailyReminderSettings = {
|
||||
/**
|
||||
* 每天推送次数:
|
||||
* - 0:关闭
|
||||
* - 1~5:每天推送 1~5 次
|
||||
*/
|
||||
timesPerDay: number;
|
||||
/**
|
||||
* 仅用于 UI 展示与交互(开关)。
|
||||
* 后端偏好建议使用:enabled = pushEnabled && timesPerDay > 0
|
||||
*/
|
||||
pushEnabled: boolean;
|
||||
};
|
||||
|
||||
@@ -42,6 +76,12 @@ export type RecoFeedCacheItem = {
|
||||
|
||||
export type RecoFeedCache = {
|
||||
saved_at: string; // ISO8601
|
||||
/**
|
||||
* 缓存文案的语言(后端目前只区分 en / tc)
|
||||
* - en: English
|
||||
* - tc: 繁体中文
|
||||
*/
|
||||
lang?: 'en' | 'tc';
|
||||
items: RecoFeedCacheItem[];
|
||||
meta?: Record<string, unknown>;
|
||||
};
|
||||
@@ -75,6 +115,49 @@ async function setJson<T>(key: string, value: T): Promise<void> {
|
||||
await AsyncStorage.setItem(key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
function looksLikeUuid(v: string): boolean {
|
||||
// 宽松校验:8-4-4-4-12(不强依赖大小写)
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
||||
}
|
||||
|
||||
function uuidV4FromBytes(bytes: Uint8Array): string {
|
||||
// RFC 4122 v4:设置 version 与 variant
|
||||
const b = new Uint8Array(bytes);
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
|
||||
const hex = Array.from(b)
|
||||
.map((x) => x.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
async function generateUuidV4(): Promise<string> {
|
||||
// 1) 优先使用运行时提供的 randomUUID(如可用)
|
||||
const maybeCrypto = (globalThis as unknown as { crypto?: { randomUUID?: () => string } }).crypto;
|
||||
if (maybeCrypto?.randomUUID) return maybeCrypto.randomUUID();
|
||||
|
||||
// 2) 使用 expo-crypto 生成安全随机数(推荐)
|
||||
const bytes = await Crypto.getRandomBytesAsync(16);
|
||||
return uuidV4FromBytes(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或生成客户端用户标识(UUID)。
|
||||
*
|
||||
* 说明:
|
||||
* - `client_user_id` 用于与后端关联 Push Token 与用户推送偏好
|
||||
* - 它是“安装实例标识”,不等同真实用户账号
|
||||
*/
|
||||
export async function getOrCreateClientUserId(): Promise<string> {
|
||||
const raw = await AsyncStorage.getItem(KEY_CLIENT_USER_ID);
|
||||
if (raw && looksLikeUuid(raw)) return raw;
|
||||
|
||||
const next = await generateUuidV4();
|
||||
await AsyncStorage.setItem(KEY_CLIENT_USER_ID, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function getOnboardingCompleted(): Promise<boolean> {
|
||||
const raw = await AsyncStorage.getItem(KEY_ONBOARDING_COMPLETED);
|
||||
return raw === 'true';
|
||||
@@ -107,6 +190,10 @@ export async function setReaction(contentId: string, reaction: Reaction): Promis
|
||||
export type FavoriteItem = {
|
||||
favId: string; // 唯一标识,支持重复点赞同一文案
|
||||
id: string;
|
||||
/**
|
||||
* 收藏时的文案快照(强烈建议写入,避免后续 cache 覆盖导致无法还原文案)
|
||||
*/
|
||||
text?: string;
|
||||
date: string;
|
||||
themeMode: ThemeMode;
|
||||
background: string; // 颜色值或图片路径
|
||||
@@ -120,7 +207,7 @@ export async function addFavorite(item: FavoriteItem): Promise<void> {
|
||||
const list = await getFavorites();
|
||||
// 允许重复点赞,不再根据 id 去重
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -141,7 +228,7 @@ export async function setConsentAccepted(accepted: boolean): Promise<void> {
|
||||
|
||||
export async function getThemeMode(): Promise<ThemeMode> {
|
||||
const raw = await AsyncStorage.getItem(KEY_UI_THEME_MODE);
|
||||
if (raw === 'scenery' || raw === 'color') return raw;
|
||||
if (raw === 'scenery' || raw === 'color' || raw === 'suixin') return raw;
|
||||
return 'scenery';
|
||||
}
|
||||
|
||||
@@ -149,6 +236,28 @@ export async function setThemeMode(mode: ThemeMode): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY_UI_THEME_MODE, mode);
|
||||
}
|
||||
|
||||
export async function getSuixinThemeState(): Promise<SuixinThemeStateV1 | null> {
|
||||
const raw = await AsyncStorage.getItem(KEY_UI_THEME_SUIXIN_STATE);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<SuixinThemeStateV1>;
|
||||
if (parsed.schema_version !== 1) return null;
|
||||
if (typeof parsed.boot_id !== 'string') return null;
|
||||
if (typeof parsed.base_theme_id !== 'string') return null;
|
||||
if (typeof parsed.seed !== 'string') return null;
|
||||
if (typeof parsed.step_index !== 'number') return null;
|
||||
if (typeof parsed.last_color !== 'string') return null;
|
||||
if (typeof parsed.saved_at !== 'string') return null;
|
||||
return parsed as SuixinThemeStateV1;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function setSuixinThemeState(state: SuixinThemeStateV1): Promise<void> {
|
||||
await setJson(KEY_UI_THEME_SUIXIN_STATE, state);
|
||||
}
|
||||
|
||||
export async function getUserProfile(): Promise<UserProfile> {
|
||||
return await getJson<UserProfile>(KEY_USER_PROFILE, {});
|
||||
}
|
||||
@@ -262,13 +371,15 @@ export async function getDailyReminderSettings(): Promise<DailyReminderSettings>
|
||||
});
|
||||
const timesPerDay = Number.isFinite(s.timesPerDay) ? s.timesPerDay : 3;
|
||||
return {
|
||||
timesPerDay: Math.min(10, Math.max(1, Math.round(timesPerDay))),
|
||||
// 需求:0~5(0 表示关闭)
|
||||
timesPerDay: Math.min(5, Math.max(0, Math.round(timesPerDay))),
|
||||
pushEnabled: Boolean(s.pushEnabled),
|
||||
};
|
||||
}
|
||||
|
||||
export async function setDailyReminderSettings(settings: DailyReminderSettings): Promise<void> {
|
||||
const timesPerDay = Math.min(10, Math.max(1, Math.round(settings.timesPerDay)));
|
||||
// 需求:0~5(0 表示关闭)
|
||||
const timesPerDay = Math.min(5, Math.max(0, Math.round(settings.timesPerDay)));
|
||||
await setJson(KEY_DAILY_REMINDER_SETTINGS, {
|
||||
timesPerDay,
|
||||
pushEnabled: Boolean(settings.pushEnabled),
|
||||
|
||||
16
client/src/utils/bootSession.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 冷启动会话标记(进程级、仅内存)。
|
||||
*
|
||||
* 目的:
|
||||
* - 在“随心”主题中实现:仅在冷启动时重置 base theme/seed
|
||||
* - 不落盘,避免污染 AsyncStorage
|
||||
*/
|
||||
let bootId: string | null = null;
|
||||
|
||||
export function getBootId(): string {
|
||||
if (bootId) return bootId;
|
||||
// 说明:无需加密强随机;只要在一次进程周期内稳定、不同冷启动尽量不同即可
|
||||
bootId = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
return bootId;
|
||||
}
|
||||
|
||||
14
client/src/utils/date.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 生成本地日维度 key(用户时区):YYYY-MM-DD
|
||||
*
|
||||
* 说明:
|
||||
* - 使用 JS Date 的本地时间字段(getFullYear/getMonth/getDate)
|
||||
* - 不依赖额外库,避免时区坑扩大化
|
||||
*/
|
||||
export function getLocalDayKey(date: Date = new Date()): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
163
client/src/utils/http.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { API_BASE_URL } from '../constants/env';
|
||||
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
|
||||
export class HttpError extends Error {
|
||||
readonly name = 'HttpError';
|
||||
readonly url: string;
|
||||
readonly status: number;
|
||||
readonly statusText: string;
|
||||
readonly responseText?: string;
|
||||
|
||||
constructor(args: { message: string; url: string; status: number; statusText: string; responseText?: string }) {
|
||||
super(args.message);
|
||||
this.url = args.url;
|
||||
this.status = args.status;
|
||||
this.statusText = args.statusText;
|
||||
this.responseText = args.responseText;
|
||||
}
|
||||
}
|
||||
|
||||
export type HttpJsonOptions = {
|
||||
/**
|
||||
* 支持传入完整 URL 或以 / 开头的 path(会自动拼到 API_BASE_URL)
|
||||
*/
|
||||
path: string;
|
||||
method?: HttpMethod;
|
||||
headers?: Record<string, string>;
|
||||
/**
|
||||
* 将对象自动 JSON.stringify;GET 请求请不要传 body
|
||||
*/
|
||||
body?: unknown;
|
||||
/**
|
||||
* 超时(毫秒),默认 10 秒
|
||||
*/
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* 外部 signal(例如上层取消请求)
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* 仅开发环境日志:便于联调排查
|
||||
*/
|
||||
debugLabel?: string;
|
||||
};
|
||||
|
||||
function isAbsoluteUrl(path: string): boolean {
|
||||
return /^https?:\/\//i.test(path);
|
||||
}
|
||||
|
||||
function joinUrl(baseUrl: string, path: string): string {
|
||||
const base = (baseUrl || '').replace(/\/+$/, '');
|
||||
const p = (path || '').trim();
|
||||
if (!p) return base;
|
||||
if (p.startsWith('/')) return `${base}${p}`;
|
||||
return `${base}/${p}`;
|
||||
}
|
||||
|
||||
function createTimeoutAbortSignal(timeoutMs: number, external?: AbortSignal): AbortController {
|
||||
const controller = new AbortController();
|
||||
|
||||
const t = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
// 避免 Node/Vitest 下悬挂定时器影响退出
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(t as any)?.unref?.();
|
||||
|
||||
if (external) {
|
||||
if (external.aborted) {
|
||||
controller.abort();
|
||||
} else {
|
||||
external.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
controller.abort();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
function isDev(): boolean {
|
||||
return typeof __DEV__ !== 'undefined' && __DEV__;
|
||||
}
|
||||
|
||||
export async function httpJson<T>(opts: HttpJsonOptions): Promise<T> {
|
||||
const method = opts.method ?? 'GET';
|
||||
const timeoutMs = opts.timeoutMs ?? 10_000;
|
||||
const url = isAbsoluteUrl(opts.path) ? opts.path : joinUrl(API_BASE_URL, opts.path);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...(opts.headers ?? {}),
|
||||
};
|
||||
|
||||
const hasBody = typeof opts.body !== 'undefined' && opts.body !== null;
|
||||
const body = hasBody ? JSON.stringify(opts.body) : undefined;
|
||||
|
||||
// 仅在有 body 时默认补齐 Content-Type,避免 GET 请求无意义携带
|
||||
if (hasBody && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
if (isDev() && opts.debugLabel) {
|
||||
console.log(`[${opts.debugLabel}] 请求地址:`, url);
|
||||
console.log(`[${opts.debugLabel}] 请求方法:`, method);
|
||||
console.log(`[${opts.debugLabel}] 请求头:`, headers);
|
||||
if (hasBody) console.log(`[${opts.debugLabel}] 请求体:`, opts.body);
|
||||
}
|
||||
|
||||
const controller = createTimeoutAbortSignal(timeoutMs, opts.signal);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (e) {
|
||||
// RN 下 AbortError 文案不完全一致,这里统一对外语义
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
// 带上 URL,便于在 TestFlight/Release 排查实际打到哪个地址(例如误打到 localhost)
|
||||
throw new Error(`网络请求失败:${msg}(${url})`);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new HttpError({
|
||||
message: `HTTP 请求失败:${res.status} ${res.statusText} ${text}`.trim(),
|
||||
url,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
responseText: text,
|
||||
});
|
||||
}
|
||||
|
||||
// 204/205 无内容时不要强行 parse
|
||||
if (res.status === 204 || res.status === 205) {
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
|
||||
// 某些后端/网关会返回 200 但 body 为空;此时 res.json() 会抛错,导致客户端误判“失败”。
|
||||
// 这里改为:先读 text,空则返回 undefined;非空再 parse JSON。
|
||||
const text = await res.text().catch(() => '');
|
||||
if (!text || !String(text).trim()) {
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new HttpError({
|
||||
message: `HTTP 响应不是合法 JSON:${res.status} ${res.statusText} ${text}`.trim(),
|
||||
url,
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
responseText: text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -20,8 +20,19 @@ RUN python -m pip install -U pip \
|
||||
COPY app /app/app
|
||||
COPY alembic /app/alembic
|
||||
COPY alembic.ini /app/alembic.ini
|
||||
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||
|
||||
RUN chmod +x /app/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# 注意:
|
||||
# - 镜像内不会打包 `.env.dev/.env.prod`(避免把敏感信息烘焙进镜像)
|
||||
# - 运行容器时请通过 `--env-file` 或 `-e` 注入 DATABASE_URL / REDIS_URL / CELERY_BROKER_URL
|
||||
# - 参考文档:server/README.md
|
||||
|
||||
# 生产镜像默认不开启 reload
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
# 默认行为:只启动 API(与之前一致)
|
||||
# 如需同时启动定时推送相关进程(Celery Worker/Beat),可在运行时注入:
|
||||
# -e START_ALL=1
|
||||
ENTRYPOINT ["/app/docker-entrypoint.sh"]
|
||||
|
||||
@@ -82,6 +82,11 @@ CELERY_BROKER_URL=redis://dev_user:devpassword@127.0.0.1:6379/0
|
||||
|
||||
`.env.prod` 同理,替换为生产环境地址与密钥即可。
|
||||
|
||||
补充:
|
||||
|
||||
- 仓库内提供了一个不包含真实值的模板文件 `server/env.example`,可复制为 `.env.dev/.env.prod` 后再填写。
|
||||
- **Docker 不会自动读取 `.env.*`**,容器运行时需要通过 `--env-file` 或 `-e` 注入环境变量(见下方 Docker 运行)。
|
||||
|
||||
### 1.1 MySQL 命名与 dev/pro 区分(约定)
|
||||
|
||||
- **生产库(prod)**:`mindfulness`
|
||||
@@ -146,6 +151,38 @@ uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
- OpenAPI 文档:`/docs`
|
||||
- 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:建议把迁移脚本放在 `server/alembic/`,并在 `alembic.ini` 中配置数据库连接(或从环境变量读取)。
|
||||
|
||||
123
server/alembic/versions/0002_init_push_tables.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""init push tables
|
||||
|
||||
Revision ID: 0002_init_push_tables
|
||||
Revises: 0001_init_content_tables
|
||||
Create Date: 2026-02-03
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "0002_init_push_tables"
|
||||
down_revision = "0001_init_content_tables"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# push_tokens
|
||||
op.create_table(
|
||||
"push_tokens",
|
||||
sa.Column(
|
||||
"id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
primary_key=True,
|
||||
autoincrement=True,
|
||||
comment="主键",
|
||||
),
|
||||
sa.Column("client_user_id", sa.String(length=64), nullable=False, comment="客户端用户标识(UUID)"),
|
||||
sa.Column(
|
||||
"platform",
|
||||
sa.Enum("ios", "android", name="push_platform"),
|
||||
nullable=False,
|
||||
comment="平台",
|
||||
),
|
||||
sa.Column("push_token", sa.String(length=255), nullable=False, comment="Expo Push Token"),
|
||||
sa.Column("app_id", sa.String(length=255), nullable=False, comment="bundle id / package name(用于隔离)"),
|
||||
sa.Column(
|
||||
"env",
|
||||
sa.Enum("dev", "prod", name="push_env"),
|
||||
nullable=False,
|
||||
comment="环境隔离",
|
||||
),
|
||||
sa.Column(
|
||||
"is_active",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("1"),
|
||||
nullable=False,
|
||||
comment="是否有效(发送失败且不可恢复时置为 false)",
|
||||
),
|
||||
sa.Column("last_seen_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="最后一次上报时间"),
|
||||
sa.UniqueConstraint("env", "app_id", "push_token", name="uniq_env_app_token"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
op.create_index("idx_push_tokens_client_user_id", "push_tokens", ["client_user_id"], unique=False)
|
||||
op.create_index("idx_push_tokens_is_active", "push_tokens", ["is_active"], unique=False)
|
||||
|
||||
# push_preferences
|
||||
op.create_table(
|
||||
"push_preferences",
|
||||
sa.Column("client_user_id", sa.String(length=64), primary_key=True, comment="客户端用户标识(UUID)"),
|
||||
sa.Column(
|
||||
"enabled",
|
||||
sa.Boolean(),
|
||||
server_default=sa.text("0"),
|
||||
nullable=False,
|
||||
comment="是否开启每日提醒(enabled=false 或 times_per_day=0 均视为关闭)",
|
||||
),
|
||||
sa.Column(
|
||||
"times_per_day",
|
||||
sa.SmallInteger(),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="每天推送次数(0~5)",
|
||||
),
|
||||
sa.Column("timezone", sa.String(length=64), nullable=True, comment="IANA 时区(例如 Asia/Shanghai),来自客户端上报"),
|
||||
sa.Column("locale", sa.String(length=32), nullable=True, comment="客户端语言(例如 zh-CN/en/zh-TW),用于文案语言选择"),
|
||||
sa.Column("user_profile_json", sa.JSON(), nullable=True, comment="用户画像(V1.2;可选)。用于 Push 场景推荐文案生成。"),
|
||||
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="更新时间"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
|
||||
# push_send_log
|
||||
op.create_table(
|
||||
"push_send_log",
|
||||
sa.Column(
|
||||
"id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
primary_key=True,
|
||||
autoincrement=True,
|
||||
comment="主键",
|
||||
),
|
||||
sa.Column("client_user_id", sa.String(length=64), nullable=False, comment="客户端用户标识(UUID)"),
|
||||
sa.Column("local_date", sa.Date(), nullable=False, comment="用户时区的本地日期(用于幂等)"),
|
||||
sa.Column("slot_index", sa.SmallInteger(), nullable=False, comment="当天第几条(1..times_per_day)"),
|
||||
sa.Column("scheduled_at", sa.DateTime(), nullable=False, comment="计划发送时间(UTC)"),
|
||||
sa.Column("sent_at", sa.DateTime(), nullable=True, comment="实际发送时间(UTC)"),
|
||||
sa.Column("status", sa.String(length=16), server_default="scheduled", nullable=False, comment="scheduled/sent/failed"),
|
||||
sa.Column("error", sa.Text(), nullable=True, comment="失败原因(可选)"),
|
||||
sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False, comment="创建时间"),
|
||||
sa.UniqueConstraint("client_user_id", "local_date", "slot_index", name="uniq_user_date_slot"),
|
||||
mysql_charset="utf8mb4",
|
||||
)
|
||||
op.create_index("idx_push_log_user_date", "push_send_log", ["client_user_id", "local_date"], unique=False)
|
||||
op.create_index("idx_push_log_status", "push_send_log", ["status"], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("idx_push_log_status", table_name="push_send_log")
|
||||
op.drop_index("idx_push_log_user_date", table_name="push_send_log")
|
||||
op.drop_table("push_send_log")
|
||||
|
||||
op.drop_table("push_preferences")
|
||||
|
||||
op.drop_index("idx_push_tokens_is_active", table_name="push_tokens")
|
||||
op.drop_index("idx_push_tokens_client_user_id", table_name="push_tokens")
|
||||
op.drop_table("push_tokens")
|
||||
|
||||
@@ -47,6 +47,7 @@ class FixedWindowRateLimiter:
|
||||
|
||||
|
||||
_reco_rate_limiter = FixedWindowRateLimiter(limit=10, window_seconds=60)
|
||||
_push_rate_limiter = FixedWindowRateLimiter(limit=30, window_seconds=60)
|
||||
|
||||
|
||||
async def rate_limit_reco_by_ip(request: Request) -> None:
|
||||
@@ -60,3 +61,19 @@ async def rate_limit_reco_by_ip(request: Request) -> None:
|
||||
|
||||
_reco_rate_limiter.allow(key=ip, now_ts=time.time())
|
||||
|
||||
|
||||
async def rate_limit_push_by_ip(request: Request) -> None:
|
||||
"""
|
||||
推送相关接口限流:按 IP,1 分钟 30 次。
|
||||
|
||||
说明:
|
||||
- register/preferences 等接口可能在客户端反复重试
|
||||
- 本期先用内存固定窗口限流做基础保护
|
||||
"""
|
||||
|
||||
ip = "unknown"
|
||||
if request.client and request.client.host:
|
||||
ip = str(request.client.host)
|
||||
|
||||
_push_rate_limiter.allow(key=ip, now_ts=time.time())
|
||||
|
||||
|
||||
131
server/app/api/v1/legal.py
Normal file
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel, HttpUrl
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.legal_docs import PRIVACY_POLICY_MD, TERMS_OF_USE_MD, choose_content_by_lang, render_as_simple_html
|
||||
|
||||
|
||||
router = APIRouter(prefix="/v1/legal", tags=["legal"])
|
||||
|
||||
INTERNAL_SENTINEL = "__internal__"
|
||||
|
||||
|
||||
# 当前多语言仅支持 EN / TC(繁体)
|
||||
ResolvedLang = Literal["en", "tc"]
|
||||
|
||||
|
||||
class LegalLinksResponse(BaseModel):
|
||||
privacyPolicyUrl: HttpUrl
|
||||
termsOfUseUrl: HttpUrl
|
||||
resolvedLang: ResolvedLang
|
||||
|
||||
|
||||
def _resolve_lang(accept_language: Optional[str]) -> ResolvedLang:
|
||||
"""
|
||||
从 Accept-Language 里做一个轻量语言解析。
|
||||
|
||||
说明:
|
||||
- 只关心:en / tc(繁体)
|
||||
- 解析失败或缺失:回退 en
|
||||
"""
|
||||
|
||||
if not accept_language:
|
||||
return "en"
|
||||
s = accept_language.lower()
|
||||
|
||||
# 目前只支持 EN / TC:只要是中文或显式 tc,都归到 tc
|
||||
if "tc" in s:
|
||||
return "tc"
|
||||
if "zh" in s or "hant" in s or "tw" in s or "hk" in s or "mo" in s:
|
||||
return "tc"
|
||||
|
||||
return "en"
|
||||
|
||||
|
||||
def _pick_urls(lang: ResolvedLang) -> tuple[str, str, ResolvedLang]:
|
||||
settings = get_settings()
|
||||
|
||||
if lang == "tc":
|
||||
privacy = settings.legal_privacy_url_tc or settings.legal_privacy_url_en
|
||||
terms = settings.legal_terms_url_tc or settings.legal_terms_url_en
|
||||
# 如果未配置 tc 链接:
|
||||
# - 若走内置页面(__internal__),可直接展示 tc 内容,因此 resolved=tc
|
||||
# - 否则回退到 en 链接,因此 resolved=en
|
||||
if settings.legal_privacy_url_tc or settings.legal_terms_url_tc:
|
||||
resolved: ResolvedLang = "tc"
|
||||
elif privacy == INTERNAL_SENTINEL or terms == INTERNAL_SENTINEL:
|
||||
resolved = "tc"
|
||||
else:
|
||||
resolved = "en"
|
||||
return privacy, terms, resolved
|
||||
|
||||
return settings.legal_privacy_url_en, settings.legal_terms_url_en, "en"
|
||||
|
||||
|
||||
def _join_base_url(base_url: str, path: str) -> str:
|
||||
base = (base_url or "").rstrip("/")
|
||||
p = (path or "").strip()
|
||||
if not p.startswith("/"):
|
||||
p = "/" + p
|
||||
return base + p
|
||||
|
||||
|
||||
def _normalize_internal_url(request: Request, url: str, internal_path: str) -> str:
|
||||
"""
|
||||
将内置哨兵值替换为当前服务的可访问绝对 URL。
|
||||
"""
|
||||
|
||||
if url != INTERNAL_SENTINEL:
|
||||
return url
|
||||
return _join_base_url(str(request.base_url), internal_path)
|
||||
|
||||
|
||||
@router.get("/links", response_model=LegalLinksResponse)
|
||||
async def get_legal_links(request: Request) -> LegalLinksResponse:
|
||||
"""
|
||||
获取协议链接(隐私协议 / 使用协议)。
|
||||
|
||||
- 语言来源:Accept-Language
|
||||
- 默认兜底:en
|
||||
"""
|
||||
|
||||
accept_language = request.headers.get("accept-language")
|
||||
lang = _resolve_lang(accept_language)
|
||||
privacy, terms, resolved = _pick_urls(lang)
|
||||
privacy = _normalize_internal_url(request, privacy, "/v1/legal/privacy")
|
||||
terms = _normalize_internal_url(request, terms, "/v1/legal/terms")
|
||||
return LegalLinksResponse(privacyPolicyUrl=privacy, termsOfUseUrl=terms, resolvedLang=resolved)
|
||||
|
||||
|
||||
@router.get("/privacy", response_class=HTMLResponse)
|
||||
async def get_privacy_policy(request: Request) -> HTMLResponse:
|
||||
"""
|
||||
内置隐私协议页面(用于未配置外部托管链接时的兜底)。
|
||||
"""
|
||||
|
||||
accept_language = request.headers.get("accept-language")
|
||||
lang = _resolve_lang(accept_language)
|
||||
content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang)
|
||||
title = "Hey Mama | Privacy Policy" if resolved == "en" else "Hey Mama|隱私權政策"
|
||||
page = render_as_simple_html(title=title, content=content)
|
||||
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
|
||||
|
||||
|
||||
@router.get("/terms", response_class=HTMLResponse)
|
||||
async def get_terms_of_use(request: Request) -> HTMLResponse:
|
||||
"""
|
||||
内置使用协议页面(用于未配置外部托管链接时的兜底)。
|
||||
"""
|
||||
|
||||
accept_language = request.headers.get("accept-language")
|
||||
lang = _resolve_lang(accept_language)
|
||||
content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang)
|
||||
title = "Hey Mama – Terms of Use" if resolved == "en" else "Hey Mama 使用條款"
|
||||
page = render_as_simple_html(title=title, content=content)
|
||||
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
|
||||
|
||||
262
server/app/api/v1/push.py
Normal file
@@ -0,0 +1,262 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.limits import rate_limit_push_by_ip
|
||||
from app.core.config import get_settings
|
||||
from app.db.models.push_preference import PushPreference
|
||||
from app.db.models.push_token import PushToken
|
||||
from app.db.session import get_db
|
||||
from app.features.user_profile_scoring.types import UserProfileV1_2
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/v1/push",
|
||||
tags=["push"],
|
||||
dependencies=[Depends(rate_limit_push_by_ip)],
|
||||
)
|
||||
|
||||
|
||||
PushEnv = Literal["dev", "prod"]
|
||||
PushPlatform = Literal["ios", "android"]
|
||||
|
||||
|
||||
class PushDeviceMeta(BaseModel):
|
||||
model: Optional[str] = None
|
||||
os_version: Optional[str] = None
|
||||
app_version: Optional[str] = None
|
||||
locale: Optional[str] = None
|
||||
timezone: Optional[str] = None
|
||||
|
||||
|
||||
class PushRegisterRequest(BaseModel):
|
||||
client_user_id: str = Field(min_length=8, max_length=64)
|
||||
platform: PushPlatform
|
||||
push_token: str = Field(min_length=8, max_length=255)
|
||||
app_id: str = Field(min_length=1, max_length=255)
|
||||
env: PushEnv
|
||||
device_meta: Optional[PushDeviceMeta] = None
|
||||
|
||||
|
||||
class PushPreferencesRequest(BaseModel):
|
||||
client_user_id: str = Field(min_length=8, max_length=64)
|
||||
enabled: bool
|
||||
times_per_day: int = Field(ge=0, le=5)
|
||||
timezone: Optional[str] = None
|
||||
locale: Optional[str] = None
|
||||
# 可选:用户画像(用于 Push 场景推荐文案生成)
|
||||
user_profile: Optional[UserProfileV1_2] = None
|
||||
|
||||
|
||||
class PushPreferencesResponse(BaseModel):
|
||||
client_user_id: str
|
||||
enabled: bool
|
||||
times_per_day: int
|
||||
timezone: Optional[str] = None
|
||||
locale: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
|
||||
|
||||
class PushTestRequest(BaseModel):
|
||||
client_user_id: str = Field(min_length=8, max_length=64)
|
||||
title: Optional[str] = None
|
||||
body: Optional[str] = None
|
||||
|
||||
|
||||
def _ensure_utc(dt: datetime) -> datetime:
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
async def _pick_active_token(db: AsyncSession, *, client_user_id: str) -> Optional[PushToken]:
|
||||
# 取最近一次上报的 active token
|
||||
q = (
|
||||
select(PushToken)
|
||||
.where(PushToken.client_user_id == client_user_id, PushToken.is_active == True) # noqa: E712
|
||||
.order_by(PushToken.last_seen_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
row = await db.execute(q)
|
||||
return row.scalar_one_or_none()
|
||||
|
||||
|
||||
async def _send_expo_push(*, to: str, title: str, body: str, data: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
"""
|
||||
发送 Expo Push。
|
||||
|
||||
说明:
|
||||
- V1:最小可用实现,满足 test 与后续定时任务调用
|
||||
- 失败处理与 token 停用在后续任务逻辑中完善
|
||||
"""
|
||||
|
||||
settings = get_settings()
|
||||
url = "https://exp.host/--/api/v2/push/send"
|
||||
|
||||
headers: dict[str, str] = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if settings.expo_access_token:
|
||||
headers["Authorization"] = f"Bearer {settings.expo_access_token}"
|
||||
|
||||
payload: dict[str, Any] = {"to": to, "title": title, "body": body}
|
||||
if data:
|
||||
payload["data"] = data
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
res = await client.post(url, headers=headers, json=payload)
|
||||
if res.status_code >= 400:
|
||||
raise HTTPException(status_code=502, detail=f"expo_push_failed:{res.status_code}")
|
||||
return res.json()
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(req: PushRegisterRequest, db: AsyncSession = Depends(get_db)) -> dict[str, str]:
|
||||
"""
|
||||
注册/更新 Push Token(幂等)。
|
||||
"""
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# 以 env+app_id+push_token 唯一:存在则更新归属与 last_seen
|
||||
q = select(PushToken).where(
|
||||
PushToken.env == req.env,
|
||||
PushToken.app_id == req.app_id,
|
||||
PushToken.push_token == req.push_token,
|
||||
)
|
||||
row = await db.execute(q)
|
||||
token = row.scalar_one_or_none()
|
||||
if token is None:
|
||||
token = PushToken(
|
||||
client_user_id=req.client_user_id,
|
||||
platform=req.platform,
|
||||
push_token=req.push_token,
|
||||
app_id=req.app_id,
|
||||
env=req.env,
|
||||
is_active=True,
|
||||
last_seen_at=_ensure_utc(now),
|
||||
)
|
||||
db.add(token)
|
||||
else:
|
||||
token.client_user_id = req.client_user_id
|
||||
token.platform = req.platform
|
||||
token.is_active = True
|
||||
token.last_seen_at = _ensure_utc(now)
|
||||
|
||||
await db.commit()
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.put("/preferences", response_model=PushPreferencesResponse)
|
||||
async def put_preferences(req: PushPreferencesRequest, db: AsyncSession = Depends(get_db)) -> PushPreferencesResponse:
|
||||
"""
|
||||
设置每日提醒偏好(幂等)。
|
||||
"""
|
||||
|
||||
# 规范化:enabled=false 或 times=0 均视为关闭
|
||||
times = int(req.times_per_day)
|
||||
enabled = bool(req.enabled) and times > 0
|
||||
|
||||
q = select(PushPreference).where(PushPreference.client_user_id == req.client_user_id)
|
||||
row = await db.execute(q)
|
||||
pref = row.scalar_one_or_none()
|
||||
|
||||
if pref is None:
|
||||
pref = PushPreference(
|
||||
client_user_id=req.client_user_id,
|
||||
enabled=enabled,
|
||||
times_per_day=times,
|
||||
timezone=req.timezone,
|
||||
locale=req.locale,
|
||||
# 注意:Pydantic 会把 ISO8601 字符串解析成 datetime;
|
||||
# SQLAlchemy JSON 列默认使用 json.dumps,无法序列化 datetime。
|
||||
# 这里用 mode="json" 保证写库内容都是可 JSON 序列化的基础类型(datetime → ISO 字符串)。
|
||||
user_profile_json=req.user_profile.model_dump(mode="json") if req.user_profile else None,
|
||||
)
|
||||
db.add(pref)
|
||||
else:
|
||||
pref.enabled = enabled
|
||||
pref.times_per_day = times
|
||||
pref.timezone = req.timezone
|
||||
pref.locale = req.locale
|
||||
if req.user_profile is not None:
|
||||
pref.user_profile_json = req.user_profile.model_dump(mode="json")
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 返回更新时间(从 ORM 读取到的可能不包含 server_onupdate,这里用 now 兜底)
|
||||
updated_at = getattr(pref, "updated_at", None)
|
||||
updated_at_iso = updated_at.isoformat() if isinstance(updated_at, datetime) else None
|
||||
|
||||
return PushPreferencesResponse(
|
||||
client_user_id=req.client_user_id,
|
||||
enabled=enabled,
|
||||
times_per_day=times,
|
||||
timezone=req.timezone,
|
||||
locale=req.locale,
|
||||
updated_at=updated_at_iso,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/preferences", response_model=PushPreferencesResponse)
|
||||
async def get_preferences(
|
||||
client_user_id: str = Query(min_length=8, max_length=64),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> PushPreferencesResponse:
|
||||
q = select(PushPreference).where(PushPreference.client_user_id == client_user_id)
|
||||
row = await db.execute(q)
|
||||
pref = row.scalar_one_or_none()
|
||||
if pref is None:
|
||||
return PushPreferencesResponse(client_user_id=client_user_id, enabled=False, times_per_day=0)
|
||||
updated_at_iso = pref.updated_at.isoformat() if pref.updated_at else None
|
||||
return PushPreferencesResponse(
|
||||
client_user_id=client_user_id,
|
||||
enabled=bool(pref.enabled) and int(pref.times_per_day) > 0,
|
||||
times_per_day=int(pref.times_per_day),
|
||||
timezone=pref.timezone,
|
||||
locale=pref.locale,
|
||||
updated_at=updated_at_iso,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/test")
|
||||
async def test_push(
|
||||
req: PushTestRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
accept_language: Optional[str] = Header(default=None, alias="Accept-Language"),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
立即测试推送(仅用于 dev 联调)。
|
||||
"""
|
||||
|
||||
settings = get_settings()
|
||||
if settings.app_env != "dev":
|
||||
raise HTTPException(status_code=403, detail="test_only_in_dev")
|
||||
|
||||
token = await _pick_active_token(db, client_user_id=req.client_user_id)
|
||||
if token is None:
|
||||
raise HTTPException(status_code=404, detail="no_active_token")
|
||||
|
||||
# 如果没有用户画像,也不影响 test 推送;文案按请求/默认文案发送。
|
||||
q = select(PushPreference).where(PushPreference.client_user_id == req.client_user_id)
|
||||
row = await db.execute(q)
|
||||
pref = row.scalar_one_or_none()
|
||||
|
||||
if pref and pref.user_profile_json:
|
||||
_ = UserProfileV1_2.model_validate(pref.user_profile_json)
|
||||
|
||||
# V1:先发固定测试文案;后续在定时任务中替换为推荐模块的 push 场景模板
|
||||
title = req.title or "Hey Mama"
|
||||
body = req.body or "这是一条测试推送(dev)。"
|
||||
|
||||
expo_res = await _send_expo_push(to=token.push_token, title=title, body=body, data={"client_user_id": req.client_user_id})
|
||||
_ = accept_language
|
||||
return {"status": "ok", "expo": expo_res}
|
||||
|
||||
@@ -4,6 +4,7 @@ from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import ValidationError
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -45,6 +46,24 @@ class Settings(BaseSettings):
|
||||
celery_broker_url: str
|
||||
celery_result_backend: Optional[str] = None
|
||||
|
||||
# 法务协议链接(由后端统一托管并下发给客户端)
|
||||
# 说明:
|
||||
# - 当前仅支持 EN / TC 两种语言(与客户端现状一致)
|
||||
# - 若未显式配置 LEGAL_*,后端会使用“内置协议页面”(/v1/legal/privacy、/v1/legal/terms)
|
||||
# - 线上建议配置为你们的官网/托管页/静态站点 HTTPS 链接,避免与 API 域名强绑定
|
||||
#
|
||||
# 这里用一个内部哨兵值表示“走内置页面”,避免默认值误导为可用的公网链接。
|
||||
legal_privacy_url_en: str = "__internal__"
|
||||
legal_terms_url_en: str = "__internal__"
|
||||
legal_privacy_url_tc: Optional[str] = None
|
||||
legal_terms_url_tc: Optional[str] = None
|
||||
|
||||
# Expo Push(可选)
|
||||
# 说明:
|
||||
# - 不填也能调用 Expo Push API,但会受更严格的速率限制
|
||||
# - 建议生产环境配置 EXPO_ACCESS_TOKEN,便于稳定性与配额
|
||||
expo_access_token: Optional[str] = None
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="",
|
||||
case_sensitive=False,
|
||||
@@ -67,5 +86,41 @@ def get_settings() -> Settings:
|
||||
env = os.getenv("APP_ENV", "dev").strip() or "dev"
|
||||
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,6 +9,9 @@
|
||||
from app.db.models.content import Content
|
||||
from app.db.models.content_profile import ContentProfile
|
||||
from app.db.models.content_risk_flag import ContentRiskFlag
|
||||
from app.db.models.push_preference import PushPreference
|
||||
from app.db.models.push_send_log import PushSendLog
|
||||
from app.db.models.push_token import PushToken
|
||||
|
||||
__all__ = ["Content", "ContentProfile", "ContentRiskFlag"]
|
||||
__all__ = ["Content", "ContentProfile", "ContentRiskFlag", "PushPreference", "PushSendLog", "PushToken"]
|
||||
|
||||
|
||||
63
server/app/db/models/push_preference.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, JSON, SmallInteger, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PushPreference(Base):
|
||||
"""
|
||||
用户推送偏好(以 client_user_id 为主键)。
|
||||
"""
|
||||
|
||||
__tablename__ = "push_preferences"
|
||||
|
||||
client_user_id: Mapped[str] = mapped_column(
|
||||
String(length=64),
|
||||
primary_key=True,
|
||||
comment="客户端用户标识(UUID)",
|
||||
)
|
||||
|
||||
enabled: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="是否开启每日提醒(enabled=false 或 times_per_day=0 均视为关闭)",
|
||||
)
|
||||
|
||||
times_per_day: Mapped[int] = mapped_column(
|
||||
SmallInteger,
|
||||
nullable=False,
|
||||
server_default="0",
|
||||
comment="每天推送次数(0~5)",
|
||||
)
|
||||
|
||||
timezone: Mapped[str | None] = mapped_column(
|
||||
String(length=64),
|
||||
nullable=True,
|
||||
comment="IANA 时区(例如 Asia/Shanghai),来自客户端上报",
|
||||
)
|
||||
|
||||
locale: Mapped[str | None] = mapped_column(
|
||||
String(length=32),
|
||||
nullable=True,
|
||||
comment="客户端语言(例如 zh-CN/en/zh-TW),用于文案语言选择",
|
||||
)
|
||||
|
||||
user_profile_json: Mapped[dict | None] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
comment="用户画像(V1.2;可选)。用于 Push 场景推荐文案生成。",
|
||||
)
|
||||
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
server_onupdate=func.now(),
|
||||
comment="更新时间",
|
||||
)
|
||||
|
||||
37
server/app/db/models/push_send_log.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, Index, SmallInteger, String, Text, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class PushSendLog(Base):
|
||||
"""
|
||||
推送发送日志(用于幂等防重复 + 可观测)。
|
||||
"""
|
||||
|
||||
__tablename__ = "push_send_log"
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("client_user_id", "local_date", "slot_index", name="uniq_user_date_slot"),
|
||||
Index("idx_push_log_user_date", "client_user_id", "local_date"),
|
||||
Index("idx_push_log_status", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, comment="主键")
|
||||
|
||||
client_user_id: Mapped[str] = mapped_column(String(length=64), nullable=False, comment="客户端用户标识(UUID)")
|
||||
local_date: Mapped[date] = mapped_column(Date, nullable=False, comment="用户时区的本地日期(用于幂等)")
|
||||
slot_index: Mapped[int] = mapped_column(SmallInteger, nullable=False, comment="当天第几条(1..times_per_day)")
|
||||
|
||||
scheduled_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, comment="计划发送时间(UTC)")
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, comment="实际发送时间(UTC)")
|
||||
|
||||
status: Mapped[str] = mapped_column(String(length=16), nullable=False, server_default="scheduled", comment="scheduled/sent/failed")
|
||||
error: Mapped[str | None] = mapped_column(Text, nullable=True, comment="失败原因(可选)")
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now(), comment="创建时间")
|
||||
|
||||
63
server/app/db/models/push_token.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Enum, Index, String, UniqueConstraint, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
PushEnv = Literal["dev", "prod"]
|
||||
PushPlatform = Literal["ios", "android"]
|
||||
|
||||
|
||||
class PushToken(Base):
|
||||
"""
|
||||
Push Token 绑定表(Expo Push Token)。
|
||||
|
||||
约束:
|
||||
- token 在同一 env + app_id 下必须唯一(避免重复推送)
|
||||
- 同一 client_user_id 可能会更新 token(重装/轮换/重新授权)
|
||||
"""
|
||||
|
||||
__tablename__ = "push_tokens"
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("env", "app_id", "push_token", name="uniq_env_app_token"),
|
||||
Index("idx_push_tokens_client_user_id", "client_user_id"),
|
||||
Index("idx_push_tokens_is_active", "is_active"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True, comment="主键")
|
||||
|
||||
client_user_id: Mapped[str] = mapped_column(String(length=64), nullable=False, comment="客户端用户标识(UUID)")
|
||||
platform: Mapped[PushPlatform] = mapped_column(
|
||||
Enum("ios", "android", name="push_platform"),
|
||||
nullable=False,
|
||||
comment="平台",
|
||||
)
|
||||
|
||||
push_token: Mapped[str] = mapped_column(String(length=255), nullable=False, comment="Expo Push Token")
|
||||
app_id: Mapped[str] = mapped_column(String(length=255), nullable=False, comment="bundle id / package name(用于隔离)")
|
||||
env: Mapped[PushEnv] = mapped_column(
|
||||
Enum("dev", "prod", name="push_env"),
|
||||
nullable=False,
|
||||
comment="环境隔离",
|
||||
)
|
||||
|
||||
is_active: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
comment="是否有效(发送失败且不可恢复时置为 false)",
|
||||
)
|
||||
|
||||
last_seen_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
server_onupdate=func.now(),
|
||||
comment="最后一次上报时间",
|
||||
)
|
||||
|
||||
305
server/app/legal_docs.py
Normal file
@@ -0,0 +1,305 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import re
|
||||
from typing import Literal
|
||||
|
||||
|
||||
ResolvedLang = Literal["en", "tc"]
|
||||
|
||||
|
||||
# 协议原文(直接来自仓库中的 Markdown 文档)。
|
||||
# 说明:为了保证“点击一定有内容”,这里在后端内置了一份可展示的协议文本。
|
||||
# 线上若你们有官网/静态站点托管页面,可通过环境变量 LEGAL_* 覆盖为外部链接。
|
||||
PRIVACY_POLICY_MD = """Hey Mama | Privacy Policy
|
||||
Last updated: February 2026
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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,即視為同意更新內容。
|
||||
"""
|
||||
|
||||
TERMS_OF_USE_MD = """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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
9. Governing Law
|
||||
These Terms shall be governed by and construed in accordance with the applicable laws of our operating jurisdiction.
|
||||
|
||||
---
|
||||
Hey Mama 使用條款
|
||||
最後更新日期:2026 年 2 月
|
||||
歡迎使用 Hey Mama(以下簡稱「本 App」、「我們」)。
|
||||
在下載、存取或使用本 App 前,請您仔細閱讀本使用條款。當您開始使用本 App,即表示您已閱讀、理解並同意遵守本條款。
|
||||
|
||||
1. 服務對象與使用資格
|
||||
Hey Mama 僅供成年人使用(intended for adults)。
|
||||
本 App 並非為兒童設計,使用者應確認自己具備依所在地法律使用本服務的完全行為能力。
|
||||
|
||||
2. 服務內容
|
||||
Hey Mama 提供以文字形式為主的內容與功能,包括但不限於:
|
||||
- 每日肯定語與正念文字內容
|
||||
- 使用者設定的提醒與推送通知
|
||||
- 桌面小組件顯示肯定語文字
|
||||
- 內容閱讀與收藏等個人化體驗(如適用)
|
||||
本 App 所提供之內容僅作為日常情緒支持與自我提醒用途,不構成任何形式的醫療、心理諮商或專業建議。
|
||||
|
||||
3. 使用方式與限制
|
||||
使用者同意:
|
||||
- 僅將本 App 用於個人、非商業用途
|
||||
- 不以任何方式複製、重製、散布、出售、反編譯或試圖取得本 App 之原始碼
|
||||
- 不進行任何可能影響 App 正常運作、穩定性或其他使用者體驗之行為
|
||||
如有違反,我們有權在不另行通知的情況下限制或終止使用權限。
|
||||
|
||||
4. 推送通知
|
||||
本 App 可能依使用者設定發送推送通知(例如每日肯定語提醒)。
|
||||
- 推送內容僅為一般文字資訊
|
||||
- 不包含個人化敏感資料
|
||||
- 您可隨時透過裝置系統設定關閉通知功能
|
||||
|
||||
5. 智慧財產權
|
||||
本 App 及其所有內容(包含但不限於文字、設計、介面、版面配置與視覺元素)之智慧財產權,均屬於我們或合法授權方所有。
|
||||
未經事前書面同意,任何形式之使用、修改、重製或散布皆屬禁止。
|
||||
|
||||
6. 免責聲明
|
||||
本 App 所提供之內容僅供一般參考與自我提醒之用。
|
||||
- 我們不保證內容能產生特定心理、情緒或行為結果
|
||||
- 本 App 不取代任何專業醫療、心理或法律建議
|
||||
- 使用者應自行判斷內容是否適合自身狀況
|
||||
|
||||
7. 服務中斷與變更
|
||||
我們可能因系統維護、功能調整、更新或其他不可抗力因素,暫時中斷或變更本 App 之全部或部分功能。
|
||||
對於因此可能造成的任何直接或間接損失,我們不負任何責任。
|
||||
|
||||
8. 條款修改
|
||||
我們可能不定期更新本使用條款。
|
||||
更新後的版本將公布於 App 內或相關頁面,您於條款更新後繼續使用本 App,即視為同意更新內容。
|
||||
|
||||
9. 準據法與管轄
|
||||
本使用條款之解釋與適用,悉依我們所在地之相關法律規定處理。
|
||||
"""
|
||||
|
||||
|
||||
def split_bilingual_markdown(text: str) -> tuple[str, str | None]:
|
||||
"""
|
||||
按文档中的 `---` 分隔符将内容拆成两段。
|
||||
|
||||
返回:
|
||||
- 第一段:默认视为英文
|
||||
- 第二段:默认视为繁体(可为空)
|
||||
"""
|
||||
|
||||
parts = re.split(r"^\s*---\s*$", text, maxsplit=1, flags=re.MULTILINE)
|
||||
if not parts:
|
||||
return "", None
|
||||
if len(parts) == 1:
|
||||
return parts[0].strip(), None
|
||||
return parts[0].strip(), parts[1].strip()
|
||||
|
||||
|
||||
def choose_content_by_lang(text: str, lang: ResolvedLang) -> tuple[str, ResolvedLang]:
|
||||
"""
|
||||
根据 lang 选择展示内容。若 tc 内容缺失则回退 en。
|
||||
"""
|
||||
|
||||
en, tc = split_bilingual_markdown(text)
|
||||
if lang == "tc" and tc:
|
||||
return tc, "tc"
|
||||
return en, "en"
|
||||
|
||||
|
||||
def render_as_simple_html(title: str, content: str) -> str:
|
||||
"""
|
||||
将文本以简单 HTML 的方式展示(使用 pre 保留换行并自动换行)。
|
||||
不做 Markdown 渲染,避免引入额外依赖,确保“最小可用、必有内容”。
|
||||
"""
|
||||
|
||||
safe_title = html.escape(title)
|
||||
safe_content = html.escape(content)
|
||||
return f"""<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{safe_title}</title>
|
||||
<style>
|
||||
:root {{
|
||||
color-scheme: light dark;
|
||||
}}
|
||||
body {{
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "PingFang TC", "PingFang SC", "Noto Sans CJK TC", "Noto Sans CJK SC", sans-serif;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
pre {{
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<pre>{safe_content}</pre>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
@@ -3,13 +3,15 @@ from fastapi import FastAPI
|
||||
from app.core.config import get_settings
|
||||
from app.api.v1.reco import router as reco_router
|
||||
from app.api.v1.user_profile_scoring import router as user_profile_router
|
||||
from app.api.v1.legal import router as legal_router
|
||||
from app.api.v1.push import router as push_router
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""
|
||||
创建 FastAPI 应用实例。
|
||||
|
||||
说明:目前仅提供最小可运行骨架(health check),后续逐步加入路由与中间件。
|
||||
说明:目前仅提供最小可运行骨架(健康检查),后续逐步加入路由与中间件。
|
||||
"""
|
||||
|
||||
settings = get_settings()
|
||||
@@ -19,6 +21,15 @@ def create_app() -> FastAPI:
|
||||
# 业务路由
|
||||
app.include_router(user_profile_router)
|
||||
app.include_router(reco_router)
|
||||
app.include_router(legal_router)
|
||||
app.include_router(push_router)
|
||||
|
||||
@app.get("/health")
|
||||
async def health() -> dict:
|
||||
"""
|
||||
部署健康检查(兼容常见探针路径)。
|
||||
"""
|
||||
return {"status": "ok", "env": settings.app_env}
|
||||
|
||||
@app.get("/healthz")
|
||||
async def healthz() -> dict:
|
||||
|
||||
@@ -2,3 +2,12 @@
|
||||
Celery 任务集合。
|
||||
"""
|
||||
|
||||
# 重要:
|
||||
# - 本项目的任务按文件拆分在 app/tasks/*.py 中
|
||||
# - Celery autodiscover 通常只会导入 app.tasks(即本包),不会自动递归导入子模块
|
||||
# - 因此这里需要显式导入各任务模块,确保 shared_task 被注册
|
||||
|
||||
from app.tasks import ping as _ping # noqa: F401
|
||||
from app.tasks import reco as _reco # noqa: F401
|
||||
from app.tasks import push as _push # noqa: F401
|
||||
|
||||
|
||||
312
server/app/tasks/push.py
Normal file
@@ -0,0 +1,312 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
from celery import current_app, shared_task
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.db.models.push_preference import PushPreference
|
||||
from app.db.models.push_send_log import PushSendLog
|
||||
from app.db.models.push_token import PushToken
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.features.personalized_reco.content_repository.types import normalize_locale
|
||||
from app.features.user_profile_scoring.scoring import build_user_profile_from_questionnaire
|
||||
from app.features.user_profile_scoring.types import QuestionnaireAnswersV1_2, UserProfileV1_2
|
||||
|
||||
|
||||
def _ensure_tz(tz_name: Optional[str]) -> ZoneInfo:
|
||||
raw = (tz_name or "").strip()
|
||||
if not raw:
|
||||
return ZoneInfo("UTC")
|
||||
try:
|
||||
return ZoneInfo(raw)
|
||||
except Exception:
|
||||
return ZoneInfo("UTC")
|
||||
|
||||
|
||||
def _pick_reco_locale(pref_locale: Optional[str]) -> str:
|
||||
"""
|
||||
将客户端 locale(如 zh-CN/en/zh-TW)映射到推荐系统 locale(en/tc)。
|
||||
"""
|
||||
|
||||
raw = (pref_locale or "").strip().lower()
|
||||
if raw.startswith("zh"):
|
||||
return "tc"
|
||||
# 其他语言在当前版本统一回退到 en(与现有 reco/ legal 链路一致)
|
||||
return "en"
|
||||
|
||||
|
||||
def _pick_title(locale: str) -> str:
|
||||
return "每日提醒" if str(locale) == "tc" else "Daily Reminder"
|
||||
|
||||
|
||||
async def _send_expo_push(*, to: str, title: str, body: str, data: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||||
settings = get_settings()
|
||||
url = "https://exp.host/--/api/v2/push/send"
|
||||
|
||||
headers: dict[str, str] = {"Content-Type": "application/json"}
|
||||
if settings.expo_access_token:
|
||||
headers["Authorization"] = f"Bearer {settings.expo_access_token}"
|
||||
|
||||
payload: dict[str, Any] = {"to": to, "title": title, "body": body}
|
||||
if data:
|
||||
payload["data"] = data
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
res = await client.post(url, headers=headers, json=payload)
|
||||
res.raise_for_status()
|
||||
return res.json()
|
||||
|
||||
|
||||
def _uniform_jitter_times(*, start: datetime, end: datetime, n: int) -> list[datetime]:
|
||||
"""
|
||||
将窗口均匀切分为 n 个区间,并在每段内随机取一个时间点(抖动)。
|
||||
"""
|
||||
|
||||
if n <= 0:
|
||||
return []
|
||||
total = (end - start).total_seconds()
|
||||
if total <= 0:
|
||||
return []
|
||||
|
||||
out: list[datetime] = []
|
||||
for i in range(n):
|
||||
seg_start = start + timedelta(seconds=total * i / n)
|
||||
seg_end = start + timedelta(seconds=total * (i + 1) / n)
|
||||
seg = (seg_end - seg_start).total_seconds()
|
||||
if seg <= 0:
|
||||
out.append(seg_start)
|
||||
continue
|
||||
jitter = random.random() * seg
|
||||
out.append(seg_start + timedelta(seconds=jitter))
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _ScheduleTarget:
|
||||
local_date: date
|
||||
start_local: datetime
|
||||
end_local: datetime
|
||||
|
||||
|
||||
def _pick_schedule_target(*, now_utc: datetime, tz: ZoneInfo) -> _ScheduleTarget:
|
||||
"""
|
||||
为某个用户选择“要生成计划的本地日期”:
|
||||
- 若当前本地时间 < 09:00:生成“今天”
|
||||
- 否则:生成“明天”
|
||||
|
||||
目的:确保生成的时间点尽量都在未来,避免任务 ETA 立刻触发导致体验异常。
|
||||
"""
|
||||
|
||||
now_local = now_utc.astimezone(tz)
|
||||
today = now_local.date()
|
||||
day = today if now_local.time() < time(9, 0) else (today + timedelta(days=1))
|
||||
start_local = datetime.combine(day, time(9, 0), tzinfo=tz)
|
||||
end_local = datetime.combine(day + timedelta(days=1), time(0, 0), tzinfo=tz) # 24:00
|
||||
return _ScheduleTarget(local_date=day, start_local=start_local, end_local=end_local)
|
||||
|
||||
|
||||
async def _generate_schedule_once(*, now_utc: datetime, max_users: int = 5000) -> dict[str, int]:
|
||||
"""
|
||||
为所有开启每日提醒的用户生成当天/明天的推送计划,并投递 ETA 发送任务。
|
||||
|
||||
幂等:
|
||||
- `push_send_log` 唯一键(client_user_id + local_date + slot_index)保证同一天不会重复排程
|
||||
- 即使重复运行,最多只会补齐缺失 slot
|
||||
"""
|
||||
|
||||
created = 0
|
||||
scheduled = 0
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
q = (
|
||||
select(PushPreference)
|
||||
.where(PushPreference.enabled == True, PushPreference.times_per_day > 0) # noqa: E712
|
||||
.limit(int(max_users))
|
||||
)
|
||||
rows = await session.execute(q)
|
||||
prefs = list(rows.scalars().all())
|
||||
|
||||
for pref in prefs:
|
||||
tz = _ensure_tz(pref.timezone)
|
||||
target = _pick_schedule_target(now_utc=now_utc, tz=tz)
|
||||
|
||||
n = int(pref.times_per_day or 0)
|
||||
n = max(0, min(5, n))
|
||||
if n <= 0:
|
||||
continue
|
||||
|
||||
times_local = _uniform_jitter_times(start=target.start_local, end=target.end_local, n=n)
|
||||
|
||||
for idx, dt_local in enumerate(times_local, start=1):
|
||||
dt_utc = dt_local.astimezone(timezone.utc)
|
||||
|
||||
# 先尝试插入日志(幂等)
|
||||
log = PushSendLog(
|
||||
client_user_id=pref.client_user_id,
|
||||
local_date=target.local_date,
|
||||
slot_index=int(idx),
|
||||
scheduled_at=dt_utc.replace(tzinfo=None), # DB 存 naive(约定为 UTC)
|
||||
status="scheduled",
|
||||
)
|
||||
session.add(log)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
# 可能已存在(唯一键冲突),跳过
|
||||
continue
|
||||
|
||||
created += 1
|
||||
|
||||
# 投递 ETA 发送任务
|
||||
current_app.send_task(
|
||||
"tasks.push.send_scheduled",
|
||||
kwargs={
|
||||
"client_user_id": pref.client_user_id,
|
||||
"local_date": target.local_date.isoformat(),
|
||||
"slot_index": int(idx),
|
||||
},
|
||||
eta=dt_utc,
|
||||
)
|
||||
scheduled += 1
|
||||
|
||||
return {"created": created, "scheduled": scheduled}
|
||||
|
||||
|
||||
@shared_task(name="tasks.push.generate_daily_schedule")
|
||||
def generate_daily_schedule(*, max_users: int = 5000) -> dict[str, int]:
|
||||
"""
|
||||
生成每日推送计划(入口任务)。
|
||||
|
||||
说明:
|
||||
- 建议由 celery beat 定时触发(见 app/worker.py)
|
||||
- 入参尽量小,避免 Redis 队列膨胀
|
||||
"""
|
||||
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
return asyncio.run(_generate_schedule_once(now_utc=now_utc, max_users=int(max_users)))
|
||||
|
||||
|
||||
async def _send_once_async(*, client_user_id: str, local_date: date, slot_index: int) -> dict[str, Any]:
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 1) 查 log,避免重复发送
|
||||
qlog = select(PushSendLog).where(
|
||||
PushSendLog.client_user_id == client_user_id,
|
||||
PushSendLog.local_date == local_date,
|
||||
PushSendLog.slot_index == int(slot_index),
|
||||
)
|
||||
rlog = await session.execute(qlog)
|
||||
log = rlog.scalar_one_or_none()
|
||||
if log is None:
|
||||
return {"status": "noop", "reason": "no_log"}
|
||||
if str(log.status) == "sent":
|
||||
return {"status": "noop", "reason": "already_sent"}
|
||||
|
||||
# 2) 当前偏好检查(用户可能中途关闭/改次数)
|
||||
qpref = select(PushPreference).where(PushPreference.client_user_id == client_user_id)
|
||||
rpref = await session.execute(qpref)
|
||||
pref = rpref.scalar_one_or_none()
|
||||
if pref is None or (not bool(pref.enabled)) or int(pref.times_per_day or 0) < int(slot_index):
|
||||
log.status = "skipped"
|
||||
log.error = "disabled_or_reduced"
|
||||
await session.commit()
|
||||
return {"status": "skipped"}
|
||||
|
||||
# 3) 找 token
|
||||
qtok = (
|
||||
select(PushToken)
|
||||
.where(PushToken.client_user_id == client_user_id, PushToken.is_active == True) # noqa: E712
|
||||
.order_by(PushToken.last_seen_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
rtok = await session.execute(qtok)
|
||||
token = rtok.scalar_one_or_none()
|
||||
if token is None:
|
||||
log.status = "failed"
|
||||
log.error = "no_active_token"
|
||||
await session.commit()
|
||||
return {"status": "failed", "reason": "no_active_token"}
|
||||
|
||||
# 4) 生成文案(复用推荐模块 push 场景)
|
||||
reco_locale = str(normalize_locale(_pick_reco_locale(pref.locale)))
|
||||
title = _pick_title(reco_locale)
|
||||
|
||||
if pref.user_profile_json:
|
||||
user_profile = UserProfileV1_2.model_validate(pref.user_profile_json)
|
||||
else:
|
||||
# 无画像:用“全跳过”的默认画像(降个性化/降风险)
|
||||
user_profile = UserProfileV1_2.model_validate(
|
||||
build_user_profile_from_questionnaire(QuestionnaireAnswersV1_2()).model_dump()
|
||||
)
|
||||
|
||||
# 直接复用 reco 的 Celery 任务实现(同步函数)
|
||||
from app.tasks.reco import generate as reco_generate
|
||||
|
||||
reco_payload = reco_generate(scene="push", user_profile=user_profile.model_dump(), k=1, locale=reco_locale)
|
||||
body = ""
|
||||
try:
|
||||
items = (reco_payload or {}).get("items") or []
|
||||
if items and isinstance(items, list):
|
||||
body = str(items[0].get("text") or "").strip()
|
||||
except Exception:
|
||||
body = ""
|
||||
|
||||
if not body:
|
||||
body = "给自己一句温柔的话。"
|
||||
|
||||
# 5) 发送
|
||||
try:
|
||||
expo_res = await _send_expo_push(
|
||||
to=str(token.push_token),
|
||||
title=title,
|
||||
body=body,
|
||||
data={"client_user_id": client_user_id, "scene": "push"},
|
||||
)
|
||||
except Exception as e:
|
||||
log.status = "failed"
|
||||
log.error = f"send_failed:{type(e).__name__}"
|
||||
await session.commit()
|
||||
return {"status": "failed", "error": str(e)}
|
||||
|
||||
# 6) 解析 Expo 回执,必要时停用 token
|
||||
try:
|
||||
data_list = (expo_res or {}).get("data") or []
|
||||
if data_list and isinstance(data_list, list):
|
||||
first = data_list[0] or {}
|
||||
if first.get("status") == "error":
|
||||
details = first.get("details") or {}
|
||||
err = str(details.get("error") or first.get("message") or "expo_error")
|
||||
log.status = "failed"
|
||||
log.error = err
|
||||
if "DeviceNotRegistered" in err:
|
||||
token.is_active = False
|
||||
await session.commit()
|
||||
return {"status": "failed", "expo": expo_res}
|
||||
except Exception:
|
||||
# 忽略解析异常,继续按成功处理
|
||||
pass
|
||||
|
||||
log.status = "sent"
|
||||
log.sent_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
log.error = None
|
||||
await session.commit()
|
||||
return {"status": "sent", "expo": expo_res}
|
||||
|
||||
|
||||
@shared_task(name="tasks.push.send_scheduled")
|
||||
def send_scheduled(*, client_user_id: str, local_date: str, slot_index: int) -> dict[str, Any]:
|
||||
"""
|
||||
ETA 发送任务:发送某用户某天第 slot 条推送。
|
||||
"""
|
||||
|
||||
d = date.fromisoformat(str(local_date))
|
||||
return asyncio.run(_send_once_async(client_user_id=str(client_user_id), local_date=d, slot_index=int(slot_index)))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
@@ -38,6 +39,20 @@ celery_app.conf.update(
|
||||
task_default_routing_key=f"{prefix}:celery",
|
||||
)
|
||||
|
||||
# 自动发现任务(app/tasks 下的 shared_task)
|
||||
celery_app.autodiscover_tasks(["app.tasks"])
|
||||
# 定时任务(Celery Beat)
|
||||
# 说明:
|
||||
# - 每天运行一次“生成推送计划”,为所有开启每日提醒的用户生成当天/明天的随机抖动时间点,并投递 ETA 发送任务
|
||||
# - 这里按 UTC 00:10 触发一次;具体时间可按运维习惯调整
|
||||
celery_app.conf.timezone = "UTC"
|
||||
celery_app.conf.beat_schedule = {
|
||||
"push-generate-daily-schedule": {
|
||||
"task": "tasks.push.generate_daily_schedule",
|
||||
"schedule": crontab(minute=10, hour=0),
|
||||
"kwargs": {"max_users": 5000},
|
||||
"options": {"queue": f"{prefix}:celery"},
|
||||
}
|
||||
}
|
||||
|
||||
# 自动发现任务(约定:导入 app.tasks 触发其内部对子模块的显式导入)
|
||||
celery_app.autodiscover_tasks(["app"])
|
||||
|
||||
|
||||
85
server/docker-entrypoint.sh
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# 容器入口:
|
||||
# - 默认只启动 API(与原 Dockerfile 行为一致)
|
||||
# - 如需测试“定时推送”,可额外启动 Celery Worker + Beat
|
||||
#
|
||||
# 环境变量:
|
||||
# - START_API=1|0(默认 1)
|
||||
# - START_WORKER=1|0(默认 0)
|
||||
# - START_BEAT=1|0(默认 0)
|
||||
# - START_ALL=1(等价于 START_WORKER=1 + START_BEAT=1)
|
||||
# - HOST / PORT(API 监听地址,默认 0.0.0.0:8000)
|
||||
|
||||
log() {
|
||||
echo "[entrypoint] $*"
|
||||
}
|
||||
|
||||
START_API="${START_API:-1}"
|
||||
START_WORKER="${START_WORKER:-0}"
|
||||
START_BEAT="${START_BEAT:-0}"
|
||||
|
||||
if [ "${START_ALL:-0}" = "1" ]; then
|
||||
START_WORKER="1"
|
||||
START_BEAT="1"
|
||||
fi
|
||||
|
||||
# Beat 只负责“投递任务到队列”,真正执行仍需要 Worker;避免误配。
|
||||
if [ "$START_BEAT" = "1" ] && [ "$START_WORKER" != "1" ]; then
|
||||
log "提示:已启用 START_BEAT=1,自动同时启用 START_WORKER=1(否则队列无人执行)。"
|
||||
START_WORKER="1"
|
||||
fi
|
||||
|
||||
PIDS=""
|
||||
|
||||
stop_children() {
|
||||
# 温和退出
|
||||
for pid in $PIDS; do
|
||||
kill -TERM "$pid" >/dev/null 2>&1 || true
|
||||
done
|
||||
}
|
||||
|
||||
on_term() {
|
||||
log "收到退出信号,正在停止子进程..."
|
||||
stop_children
|
||||
# 等待子进程退出,避免残留
|
||||
wait >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap on_term INT TERM
|
||||
|
||||
if [ "$START_WORKER" = "1" ]; then
|
||||
log "启动 Celery Worker:celery -A app.worker:celery_app worker -l info"
|
||||
celery -A app.worker:celery_app worker -l info &
|
||||
PIDS="$PIDS $!"
|
||||
fi
|
||||
|
||||
if [ "$START_BEAT" = "1" ]; then
|
||||
log "启动 Celery Beat:celery -A app.worker:celery_app beat -l info"
|
||||
celery -A app.worker:celery_app beat -l info &
|
||||
PIDS="$PIDS $!"
|
||||
fi
|
||||
|
||||
if [ "$START_API" = "1" ]; then
|
||||
HOST="${HOST:-0.0.0.0}"
|
||||
PORT="${PORT:-8000}"
|
||||
log "启动 API:uvicorn app.main:app --host $HOST --port $PORT"
|
||||
uvicorn app.main:app --host "$HOST" --port "$PORT" &
|
||||
API_PID="$!"
|
||||
PIDS="$PIDS $API_PID"
|
||||
|
||||
# 以 API 生命周期为准:API 退出则容器退出,并清理其他进程
|
||||
wait "$API_PID"
|
||||
CODE="$?"
|
||||
log "API 已退出(code=$CODE),正在停止其他进程..."
|
||||
stop_children
|
||||
wait >/dev/null 2>&1 || true
|
||||
exit "$CODE"
|
||||
fi
|
||||
|
||||
# 未启动 API:就阻塞等待其他进程(一般用于仅跑 worker/beat 的容器)
|
||||
log "未启动 API,等待后台进程..."
|
||||
wait
|
||||
|
||||
@@ -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
|
||||
APP_ENV=dev
|
||||
|
||||
@@ -18,3 +49,17 @@ CELERY_BROKER_URL=redis://<REDIS_USER>:<REDIS_PASSWORD>@<REDIS_HOST>:6379/0
|
||||
|
||||
# 推送(Expo)
|
||||
# EXPO_ACCESS_TOKEN=
|
||||
|
||||
# 法务协议链接(由后端统一托管并下发给客户端)
|
||||
# 说明:
|
||||
# - 当前仅支持 EN / TC 两种语言(与客户端现状一致)
|
||||
# - 不配置时:后端会自动使用内置协议页面(/v1/legal/privacy、/v1/legal/terms),保证点击有内容
|
||||
# - 线上建议配置为你们的官网/静态站点 HTTPS 链接,便于独立更新
|
||||
#
|
||||
# 可选:英文(EN)
|
||||
# LEGAL_PRIVACY_URL_EN=
|
||||
# LEGAL_TERMS_URL_EN=
|
||||
#
|
||||
# 可选:繁体(TC)
|
||||
# LEGAL_PRIVACY_URL_TC=
|
||||
# LEGAL_TERMS_URL_TC=
|
||||
|
||||
@@ -11,24 +11,28 @@ set -euo pipefail
|
||||
# ./run.sh --env prod # 使用 .env.prod(若存在且可被 source)
|
||||
# ./run.sh --port 9000 # 改端口
|
||||
# ./run.sh --no-reload # 关闭热更新
|
||||
# ./run.sh --with-worker --with-beat # 同时启动 Celery Worker + Beat(用于定时推送)
|
||||
# ./run.sh --install-only # 只安装依赖,不启动
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./run.sh [--env dev|prod] [--host 0.0.0.0] [--port 8000] [--no-reload] [--skip-install] [--install-only]
|
||||
./run.sh [--env dev|prod] [--host 0.0.0.0] [--port 8000] [--no-reload] [--with-worker] [--with-beat] [--skip-install] [--install-only]
|
||||
|
||||
参数:
|
||||
--env dev|prod 优先尝试加载 .env.dev 或 .env.prod(如果存在)。
|
||||
--host <host> uvicorn host(默认 0.0.0.0)
|
||||
--port <port> uvicorn port(默认 8000)
|
||||
--no-reload 关闭 uvicorn --reload
|
||||
--with-worker 同时启动 Celery Worker(处理异步/ETA 任务)
|
||||
--with-beat 同时启动 Celery Beat(定时调度,例如每日生成推送排程)
|
||||
--skip-install 跳过依赖安装(默认会安装/更新 requirements.txt)
|
||||
--install-only 只安装依赖,不启动服务
|
||||
-h, --help 显示帮助
|
||||
|
||||
说明:
|
||||
- 若你的 .env.* 不是 shell 可 source 的格式(例如包含空格/特殊字符未加引号),建议改成 KEY=value 形式。
|
||||
- 仅启动 API 并不会生成 `push_send_log`;要测试“定时推送”,需要 Beat 调度 `tasks.push.generate_daily_schedule`,并由 Worker 执行后续 ETA 任务。
|
||||
- 启动后访问:
|
||||
/healthz 健康检查
|
||||
/docs OpenAPI 文档
|
||||
@@ -45,6 +49,8 @@ PORT="8000"
|
||||
RELOAD="1"
|
||||
SKIP_INSTALL="0"
|
||||
INSTALL_ONLY="0"
|
||||
WITH_WORKER="0"
|
||||
WITH_BEAT="0"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
@@ -64,6 +70,14 @@ while [[ $# -gt 0 ]]; do
|
||||
RELOAD="0"
|
||||
shift 1
|
||||
;;
|
||||
--with-worker)
|
||||
WITH_WORKER="1"
|
||||
shift 1
|
||||
;;
|
||||
--with-beat)
|
||||
WITH_BEAT="1"
|
||||
shift 1
|
||||
;;
|
||||
--skip-install)
|
||||
SKIP_INSTALL="1"
|
||||
shift 1
|
||||
@@ -99,6 +113,15 @@ if [[ -f "$ENV_FILE" ]]; then
|
||||
set +a
|
||||
fi
|
||||
|
||||
# 让 API/Celery 统一使用同一个 APP_ENV(影响 Redis key 前缀、定时任务配置等)
|
||||
export APP_ENV="${APP_ENV:-$ENV_NAME}"
|
||||
|
||||
# Beat 只负责“投递任务到队列”,真正执行仍需要 Worker;这里自动补齐,避免误用。
|
||||
if [[ "$WITH_BEAT" == "1" && "$WITH_WORKER" == "0" ]]; then
|
||||
echo "提示:已启用 --with-beat,自动同时启用 --with-worker(否则队列无人执行)。"
|
||||
WITH_WORKER="1"
|
||||
fi
|
||||
|
||||
# 选择 python 命令(优先 python3)
|
||||
PY_BIN=""
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
@@ -140,6 +163,45 @@ if [[ "$RELOAD" == "1" ]]; then
|
||||
UVICORN_ARGS+=(--reload)
|
||||
fi
|
||||
|
||||
echo "启动服务:uvicorn ${UVICORN_ARGS[*]}"
|
||||
exec uvicorn "${UVICORN_ARGS[@]}"
|
||||
if [[ "$WITH_WORKER" == "0" && "$WITH_BEAT" == "0" ]]; then
|
||||
echo "启动服务:uvicorn ${UVICORN_ARGS[*]}"
|
||||
exec uvicorn "${UVICORN_ARGS[@]}"
|
||||
fi
|
||||
|
||||
PIDS=()
|
||||
|
||||
cleanup() {
|
||||
# 避免重复清理导致脚本退出码被覆盖
|
||||
set +e
|
||||
if [[ ${#PIDS[@]} -gt 0 ]]; then
|
||||
echo ""
|
||||
echo "正在停止后台进程..."
|
||||
# 先尝试温和退出
|
||||
for pid in "${PIDS[@]}"; do
|
||||
kill -TERM "$pid" >/dev/null 2>&1 || true
|
||||
done
|
||||
# 等待一点时间,再强制杀掉仍存活的(防止残留)
|
||||
sleep 1
|
||||
for pid in "${PIDS[@]}"; do
|
||||
kill -KILL "$pid" >/dev/null 2>&1 || true
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
if [[ "$WITH_WORKER" == "1" ]]; then
|
||||
echo "启动 Celery Worker:celery -A app.worker:celery_app worker -l info"
|
||||
celery -A app.worker:celery_app worker -l info &
|
||||
PIDS+=("$!")
|
||||
fi
|
||||
|
||||
if [[ "$WITH_BEAT" == "1" ]]; then
|
||||
echo "启动 Celery Beat:celery -A app.worker:celery_app beat -l info"
|
||||
celery -A app.worker:celery_app beat -l info &
|
||||
PIDS+=("$!")
|
||||
fi
|
||||
|
||||
echo "启动服务:uvicorn ${UVICORN_ARGS[*]}"
|
||||
uvicorn "${UVICORN_ARGS[@]}"
|
||||
|
||||
|
||||
72
server/tests/test_legal_links_unittest.py
Normal file
@@ -0,0 +1,72 @@
|
||||
import os
|
||||
import unittest
|
||||
|
||||
|
||||
class TestLegalLinksLangResolution(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
# 让 Settings 能顺利构造(避免因必填 env 缺失导致导入时报错)
|
||||
os.environ.setdefault("DATABASE_URL", "mysql+aiomysql://u:p@127.0.0.1:3306/mindfulness_dev?charset=utf8mb4")
|
||||
os.environ.setdefault("REDIS_URL", "redis://:p@127.0.0.1:6379/0")
|
||||
os.environ.setdefault("CELERY_BROKER_URL", "redis://:p@127.0.0.1:6379/0")
|
||||
|
||||
def setUp(self) -> None:
|
||||
# 每个用例都清理 settings 缓存,避免环境变量修改不生效
|
||||
from app.core.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
def test_resolve_lang_default_en(self) -> None:
|
||||
from app.api.v1.legal import _resolve_lang
|
||||
|
||||
self.assertEqual(_resolve_lang(None), "en")
|
||||
self.assertEqual(_resolve_lang(""), "en")
|
||||
|
||||
def test_resolve_lang_tc_for_zh(self) -> None:
|
||||
from app.api.v1.legal import _resolve_lang
|
||||
|
||||
self.assertEqual(_resolve_lang("zh"), "tc")
|
||||
self.assertEqual(_resolve_lang("zh-Hant"), "tc")
|
||||
self.assertEqual(_resolve_lang("zh-TW"), "tc")
|
||||
self.assertEqual(_resolve_lang("zh-HK"), "tc")
|
||||
self.assertEqual(_resolve_lang("tc"), "tc")
|
||||
|
||||
def test_pick_urls_fallback_to_en(self) -> None:
|
||||
# 不配置 tc 链接时,应回退到 en
|
||||
os.environ["LEGAL_PRIVACY_URL_EN"] = "https://example.com/en-privacy"
|
||||
os.environ["LEGAL_TERMS_URL_EN"] = "https://example.com/en-terms"
|
||||
os.environ.pop("LEGAL_PRIVACY_URL_TC", None)
|
||||
os.environ.pop("LEGAL_TERMS_URL_TC", None)
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
from app.api.v1.legal import _pick_urls
|
||||
|
||||
privacy, terms, resolved = _pick_urls("tc")
|
||||
self.assertEqual(privacy, "https://example.com/en-privacy")
|
||||
self.assertEqual(terms, "https://example.com/en-terms")
|
||||
self.assertEqual(resolved, "en")
|
||||
|
||||
def test_pick_urls_tc_when_configured(self) -> None:
|
||||
os.environ["LEGAL_PRIVACY_URL_EN"] = "https://example.com/en-privacy"
|
||||
os.environ["LEGAL_TERMS_URL_EN"] = "https://example.com/en-terms"
|
||||
os.environ["LEGAL_PRIVACY_URL_TC"] = "https://example.com/tc-privacy"
|
||||
os.environ["LEGAL_TERMS_URL_TC"] = "https://example.com/tc-terms"
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
get_settings.cache_clear()
|
||||
|
||||
from app.api.v1.legal import _pick_urls
|
||||
|
||||
privacy, terms, resolved = _pick_urls("tc")
|
||||
self.assertEqual(privacy, "https://example.com/tc-privacy")
|
||||
self.assertEqual(terms, "https://example.com/tc-terms")
|
||||
self.assertEqual(resolved, "tc")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||