feat: 实现减脂体重管理App完整功能
- 实现拍照识别食物功能(集成大语言模型视觉能力) - 实现智能对话功能(集成大语言模型流式输出) - 实现食物记录和卡路里管理功能 - 实现体重记录和统计功能 - 实现健康数据管理页面 - 配置数据库表结构(用户、食物记录、体重记录) - 实现Express后端API路由 - 配置Tab导航和前端页面 - 采用健康运动配色方案
12
.coze
Normal file
@@ -0,0 +1,12 @@
|
||||
[project]
|
||||
entrypoint = "server.js"
|
||||
requires = ["nodejs-24"]
|
||||
|
||||
[dev]
|
||||
build = ["bash", ".cozeproj/scripts/dev_build.sh"]
|
||||
run = ["bash", ".cozeproj/scripts/dev_run.sh"]
|
||||
|
||||
[deploy]
|
||||
build = ["bash", ".cozeproj/scripts/prod_build.sh"]
|
||||
run = ["bash", ".cozeproj/scripts/prod_run.sh"]
|
||||
build_app_dir = "./client"
|
||||
46
.cozeproj/scripts/dev_build.sh
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/bin/bash
|
||||
if [ -z "${BASH_VERSION:-}" ]; then exec /usr/bin/env bash "$0" "$@"; fi
|
||||
set -euo pipefail
|
||||
ROOT_DIR="$(pwd)"
|
||||
PREVIEW_DIR="${COZE_PREVIEW_DIR:-/source/preview}"
|
||||
|
||||
# ==================== 配置项 ====================
|
||||
SERVER_DIR="app"
|
||||
EXPO_DIR="expo"
|
||||
CHECK_HASH_SCRIPT="$ROOT_DIR/check_hash.py"
|
||||
|
||||
check_command() {
|
||||
if ! command -v "$1" &> /dev/null; then
|
||||
echo "error:命令 $1 未找到,请先安装"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "==================== 开始构建 ===================="
|
||||
|
||||
echo "检查根目录 pre_install.py"
|
||||
if [ -f "$PREVIEW_DIR/pre_install.py" ]; then
|
||||
echo "执行:python $PREVIEW_DIR/pre_install.py"
|
||||
python "$PREVIEW_DIR/pre_install.py" || echo "pre_install.py 执行失败"
|
||||
fi
|
||||
|
||||
echo "开始执行构建脚本(build_dev.sh)..."
|
||||
echo "正在检查依赖命令是否存在..."
|
||||
# 检查核心命令
|
||||
check_command "pnpm"
|
||||
check_command "npm"
|
||||
|
||||
echo "==================== 安装项目依赖 ===================="
|
||||
if [ ! -f "package.json" ]; then
|
||||
echo "项目目录下无 package.json,不是合法的 Node.js 项目"
|
||||
fi
|
||||
# 步骤 2.1/2.2:安装项目依赖
|
||||
pnpm install --registry=https://registry.npmmirror.com || echo "Expo 项目依赖安装失败(pnpm 执行出错)"
|
||||
|
||||
echo "检查根目录 post_install.py"
|
||||
if [ -f "$PREVIEW_DIR/post_install.py" ]; then
|
||||
echo "执行:python $PREVIEW_DIR/post_install.py"
|
||||
python "$PREVIEW_DIR/post_install.py" || echo "post_install.py 执行失败"
|
||||
fi
|
||||
|
||||
echo "==================== 依赖安装完成!====================\n"
|
||||
echo "下一步:执行 ./deploy_run.sh 启动服务"
|
||||
228
.cozeproj/scripts/dev_run.sh
Normal file
@@ -0,0 +1,228 @@
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
PREVIEW_DIR="${COZE_PREVIEW_DIR:-/source/preview}"
|
||||
LOG_DIR="${COZE_LOG_DIR:-$ROOT_DIR/logs}"
|
||||
LOG_FILE="$LOG_DIR/app.log"
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
# ==================== 配置项 ====================
|
||||
# Server 服务配置
|
||||
SERVER_HOST="0.0.0.0"
|
||||
SERVER_PORT="9091"
|
||||
# Expo 项目配置
|
||||
EXPO_HOST="0.0.0.0"
|
||||
EXPO_DIR="expo"
|
||||
EXPO_PORT="5000"
|
||||
WEB_URL="${COZE_PROJECT_DOMAIN_DEFAULT:-http://127.0.0.1:${SERVER_PORT}}"
|
||||
ASSUME_YES="1"
|
||||
EXPO_PUBLIC_BACKEND_BASE_URL="${EXPO_PUBLIC_BACKEND_BASE_URL:-$WEB_URL}"
|
||||
EXPO_PUBLIC_COZE_PROJECT_ID="${COZE_PROJECT_ID:-}"
|
||||
|
||||
EXPO_PACKAGER_PROXY_URL="${EXPO_PUBLIC_BACKEND_BASE_URL}"
|
||||
export EXPO_PUBLIC_BACKEND_BASE_URL EXPO_PACKAGER_PROXY_URL EXPO_PUBLIC_COZE_PROJECT_ID
|
||||
# 运行时变量(为避免 set -u 的未绑定错误,预置为空)
|
||||
SERVER_PID=""
|
||||
EXPO_PID=""
|
||||
|
||||
# ==================== 工具函数 ====================
|
||||
check_command() {
|
||||
if ! command -v "$1" &> /dev/null; then
|
||||
echo "error:命令 $1 未找到,请先安装"
|
||||
fi
|
||||
}
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-y|--yes)
|
||||
ASSUME_YES="1"
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
is_port_free() {
|
||||
! lsof -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1
|
||||
}
|
||||
choose_next_free_port() {
|
||||
local start=$1
|
||||
local p=$start
|
||||
while ! is_port_free "$p"; do
|
||||
p=$((p+1))
|
||||
done
|
||||
echo "$p"
|
||||
}
|
||||
ensure_port() {
|
||||
local var_name=$1
|
||||
local port_val=$2
|
||||
if is_port_free "$port_val"; then
|
||||
echo "端口未占用:$port_val"
|
||||
eval "$var_name=$port_val"
|
||||
else
|
||||
echo "端口已占用:$port_val"
|
||||
local choice
|
||||
if [ "$ASSUME_YES" = "1" ]; then choice="Y"; else read -r -p "是否关闭该端口的进程?[Y/n] " choice || choice="Y"; fi
|
||||
if [ -z "$choice" ] || [ "$choice" = "y" ] || [ "$choice" = "Y" ]; then
|
||||
if command -v lsof &> /dev/null; then
|
||||
local pids
|
||||
pids=$(lsof -t -i tcp:"$port_val" -sTCP:LISTEN 2>/dev/null || true)
|
||||
if [ -n "$pids" ]; then
|
||||
echo "正在关闭进程:$pids"
|
||||
kill -9 $pids 2>/dev/null || echo "关闭进程失败:$pids"
|
||||
eval "$var_name=$port_val"
|
||||
else
|
||||
echo "未获取到占用该端口的进程"
|
||||
eval "$var_name=$port_val"
|
||||
fi
|
||||
else
|
||||
echo "缺少 lsof,无法自动关闭进程"
|
||||
eval "$var_name=$port_val"
|
||||
fi
|
||||
else
|
||||
local new_port
|
||||
new_port=$(choose_next_free_port "$port_val")
|
||||
info "使用新的端口:$new_port"
|
||||
eval "$var_name=$new_port"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
pipe_to_log() {
|
||||
local source="${1:-CLIENT}"
|
||||
local raw_log="${2:-}"
|
||||
local line timestamp ts msg record
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
if [ -n "$raw_log" ]; then
|
||||
echo "$line" >> "$raw_log"
|
||||
fi
|
||||
line=$(echo "[$source] $line" | sed 's/\x1b\[[0-9;]*[a-zA-Z]//g; s/\x1b\[[0-9;]*m//g')
|
||||
msg="${line}"
|
||||
echo "$msg"
|
||||
done
|
||||
}
|
||||
|
||||
wait_port_connectable() {
|
||||
local host=$1 port=$2 retries=${3:-10}
|
||||
for _ in $(seq 1 "$retries"); do
|
||||
nc -z -w 1 "$host" "$port" >/dev/null 2>&1 && return 0
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
start_expo() {
|
||||
local offline="${1:-0}"
|
||||
|
||||
pushd "$ROOT_DIR/client"
|
||||
|
||||
if [ "$offline" = "1" ]; then
|
||||
( EXPO_OFFLINE=1 EXPO_NO_DEPENDENCY_VALIDATION=1 EXPO_PUBLIC_BACKEND_BASE_URL="$EXPO_PUBLIC_BACKEND_BASE_URL" EXPO_PACKAGER_PROXY_URL="$EXPO_PACKAGER_PROXY_URL" EXPO_PUBLIC_COZE_PROJECT_ID="$EXPO_PUBLIC_COZE_PROJECT_ID" \
|
||||
nohup npx expo start --clear --port "$EXPO_PORT" 2>&1 | pipe_to_log "CLIENT" "$ROOT_DIR/logs/client.log" ) &
|
||||
else
|
||||
( EXPO_NO_DEPENDENCY_VALIDATION=1 EXPO_PUBLIC_BACKEND_BASE_URL="$EXPO_PUBLIC_BACKEND_BASE_URL" EXPO_PACKAGER_PROXY_URL="$EXPO_PACKAGER_PROXY_URL" EXPO_PUBLIC_COZE_PROJECT_ID="$EXPO_PUBLIC_COZE_PROJECT_ID" \
|
||||
nohup npx expo start --clear --port "$EXPO_PORT" 2>&1 | pipe_to_log "CLIENT" "$ROOT_DIR/logs/client.log" ) &
|
||||
fi
|
||||
EXPO_PID=$!
|
||||
disown $EXPO_PID 2>/dev/null || true
|
||||
|
||||
popd
|
||||
}
|
||||
|
||||
detect_expo_fetch_failed() {
|
||||
local timeout="${1:-8}"
|
||||
local waited=0
|
||||
local log_file="$ROOT_DIR/logs/client.log"
|
||||
while [ "$waited" -lt "$timeout" ]; do
|
||||
if [ -f "$log_file" ] && grep -q "TypeError: fetch failed" "$log_file" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
waited=$((waited+1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# ==================== 前置检查 ====================
|
||||
# 关掉nginx进程
|
||||
ps -ef | grep nginx | grep -v grep | awk '{print $2}' | xargs -r kill -9
|
||||
|
||||
echo "检查根目录 pre_install.py"
|
||||
if [ -f "$PREVIEW_DIR/pre_install.py" ]; then
|
||||
echo "执行:python $PREVIEW_DIR/pre_install.py"
|
||||
python "$PREVIEW_DIR/pre_install.py" || echo "pre_install.py 执行失败"
|
||||
fi
|
||||
|
||||
echo "检查根目录 post_install.py"
|
||||
if [ -f "$PREVIEW_DIR/post_install.py" ]; then
|
||||
echo "执行:python $PREVIEW_DIR/post_install.py"
|
||||
python "$PREVIEW_DIR/post_install.py" || echo "post_install.py 执行失败"
|
||||
fi
|
||||
|
||||
echo "==================== 开始启动 ===================="
|
||||
echo "开始执行服务启动脚本(start_dev.sh)..."
|
||||
echo "正在检查依赖命令和目录是否存在..."
|
||||
# 检查核心命令
|
||||
check_command "npm"
|
||||
check_command "pnpm"
|
||||
check_command "lsof"
|
||||
check_command "bash"
|
||||
|
||||
echo "准备日志目录:$ROOT_DIR/logs"
|
||||
mkdir -p "$ROOT_DIR/logs"
|
||||
# 端口占用预检查与处理
|
||||
ensure_port SERVER_PORT "$SERVER_PORT"
|
||||
ensure_port EXPO_PORT "$EXPO_PORT"
|
||||
|
||||
echo "==================== 启动 server 服务 ===================="
|
||||
echo "正在执行:pnpm run dev (server)"
|
||||
( pushd "$ROOT_DIR/server" > /dev/null && SERVER_PORT="$SERVER_PORT" nohup pnpm run dev; popd > /dev/null ) &
|
||||
SERVER_PID=$!
|
||||
disown $SERVER_PID 2>/dev/null || true
|
||||
if [ -z "${SERVER_PID}" ]; then
|
||||
echo "无法获取 server 后台进程 PID"
|
||||
fi
|
||||
echo "server 服务已启动,进程 ID:${SERVER_PID:-unknown}"
|
||||
|
||||
echo "==================== 启动 Expo 项目 ===================="
|
||||
echo "开始启动 Expo 服务,端口 ${EXPO_PORT}"
|
||||
start_expo 0
|
||||
if detect_expo_fetch_failed 8; then
|
||||
echo "Expo 启动检测到网络错误:TypeError: fetch failed,启用离线模式重试"
|
||||
if [ -n "${EXPO_PID}" ]; then kill -9 "$EXPO_PID" 2>/dev/null || true; fi
|
||||
: > "$ROOT_DIR/logs/client.log"
|
||||
start_expo 1
|
||||
fi
|
||||
# 输出以下环境变量,确保 Expo 项目能正确连接到 Server 服务
|
||||
echo "Expo 环境变量配置:"
|
||||
echo "EXPO_PUBLIC_BACKEND_BASE_URL=${EXPO_PUBLIC_BACKEND_BASE_URL}"
|
||||
echo "EXPO_PACKAGER_PROXY_URL=${EXPO_PACKAGER_PROXY_URL}"
|
||||
echo "EXPO_PUBLIC_COZE_PROJECT_ID=${EXPO_PUBLIC_COZE_PROJECT_ID}"
|
||||
if [ -z "${EXPO_PID}" ]; then
|
||||
echo "无法获取 Expo 后台进程 PID"
|
||||
fi
|
||||
|
||||
echo "所有服务已启动。Server PID: ${SERVER_PID}, Expo PID: ${EXPO_PID}"
|
||||
|
||||
echo "检查 Server 服务端口:$SERVER_HOST:$SERVER_PORT"
|
||||
if wait_port_connectable "$SERVER_HOST" "$SERVER_PORT" 10 2; then
|
||||
echo "端口可连接:$SERVER_HOST:$SERVER_PORT"
|
||||
else
|
||||
echo "端口不可连接:$SERVER_HOST:$SERVER_PORT 10 次)"
|
||||
fi
|
||||
|
||||
echo "检查 Expo 服务端口:$EXPO_HOST:$EXPO_PORT"
|
||||
if wait_port_connectable "$EXPO_HOST" "$EXPO_PORT" 10 2; then
|
||||
echo "端口可连接:$EXPO_HOST:$EXPO_PORT"
|
||||
else
|
||||
echo "端口不可连接:$EXPO_HOST:$EXPO_PORT(已尝试 10 次)"
|
||||
fi
|
||||
|
||||
echo "服务端口检查完成"
|
||||
|
||||
echo "检查根目录 post_run.py"
|
||||
if [ -f "$ROOT_DIR/post_run.py" ]; then
|
||||
echo "启动检查中"
|
||||
python "$ROOT_DIR/post_run.py" --port "$EXPO_PORT" || echo "post_run.py 执行失败"
|
||||
echo "启动检查结束"
|
||||
fi
|
||||
|
||||
echo "==================== 服务启动完成 ===================="
|
||||
47
.cozeproj/scripts/prod_build.sh
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/bin/bash
|
||||
if [ -z "${BASH_VERSION:-}" ]; then exec /usr/bin/env bash "$0" "$@"; fi
|
||||
set -euo pipefail
|
||||
ROOT_DIR="$(pwd)"
|
||||
|
||||
# ==================== 工具函数 ====================
|
||||
info() {
|
||||
echo "[INFO] $1"
|
||||
}
|
||||
warn() {
|
||||
echo "[WARN] $1"
|
||||
}
|
||||
error() {
|
||||
echo "[ERROR] $1"
|
||||
exit 1
|
||||
}
|
||||
check_command() {
|
||||
if ! command -v "$1" &> /dev/null; then
|
||||
error "命令 $1 未找到,请先安装"
|
||||
fi
|
||||
}
|
||||
|
||||
info "==================== 开始构建 ===================="
|
||||
info "开始执行构建脚本(build_prod.sh)..."
|
||||
info "正在检查依赖命令是否存在..."
|
||||
# 检查核心命令
|
||||
check_command "pnpm"
|
||||
check_command "npm"
|
||||
|
||||
# ==================== 安装 Node 依赖 ====================
|
||||
info "==================== 安装 Node 依赖 ===================="
|
||||
info "开始安装 Node 依赖"
|
||||
if [ -f "$ROOT_DIR/package.json" ]; then
|
||||
info "进入目录:$ROOT_DIR"
|
||||
info "正在执行:pnpm install"
|
||||
(cd "$ROOT_DIR" && pnpm install --registry=https://registry.npmmirror.com) || error "Node 依赖安装失败"
|
||||
else
|
||||
warn "未找到 $ROOT_DIR/package.json 文件,请检查路径是否正确"
|
||||
fi
|
||||
info "==================== 依赖安装完成!====================\n"
|
||||
|
||||
info "==================== dist打包 ===================="
|
||||
info "开始执行:pnpm run build (server)"
|
||||
(pushd "$ROOT_DIR/server" > /dev/null && pnpm run build; popd > /dev/null) || error "dist打包失败"
|
||||
info "==================== dist打包完成!====================\n"
|
||||
|
||||
info "下一步:执行 ./prod_run.sh 启动服务"
|
||||
34
.cozeproj/scripts/prod_run.sh
Normal file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# 产物部署使用
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(pwd)"
|
||||
|
||||
HOST="${HOST:-0.0.0.0}"
|
||||
PORT="${PORT:-5000}"
|
||||
|
||||
# ==================== 工具函数 ====================
|
||||
info() {
|
||||
echo "[INFO] $1"
|
||||
}
|
||||
warn() {
|
||||
echo "[WARN] $1"
|
||||
}
|
||||
error() {
|
||||
echo "[ERROR] $1"
|
||||
exit 1
|
||||
}
|
||||
check_command() {
|
||||
if ! command -v "$1" &> /dev/null; then
|
||||
error "命令 $1 未找到,请先安装"
|
||||
fi
|
||||
}
|
||||
|
||||
# ============== 启动服务 ======================
|
||||
# 检查核心命令
|
||||
check_command "pnpm"
|
||||
check_command "npm"
|
||||
|
||||
info "开始执行:pnpm run start (server)"
|
||||
(pushd "$ROOT_DIR/server" > /dev/null && PORT="$PORT" pnpm run start; popd > /dev/null) || error "服务启动失败"
|
||||
info "服务启动完成!\n"
|
||||
45
.cozeproj/scripts/server_dev_run.sh
Normal file
@@ -0,0 +1,45 @@
|
||||
#!/bin/bash
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
SERVER_DIR="$ROOT_DIR/server"
|
||||
LOG_DIR="$ROOT_DIR/logs"
|
||||
LOG_FILE="$LOG_DIR/server.log"
|
||||
SERVER_PORT="${SERVER_PORT:-9091}"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
pipe_to_log() {
|
||||
local source="${1:-SERVER}"
|
||||
local raw_log="${2:-}"
|
||||
local line
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
if [ -n "$raw_log" ]; then
|
||||
echo "$line" >> "$raw_log"
|
||||
fi
|
||||
line=$(echo "[$source] $line" | sed 's/\x1b\[[0-9;]*[a-zA-Z]//g; s/\x1b\[[0-9;]*m//g')
|
||||
echo "$line"
|
||||
done
|
||||
}
|
||||
|
||||
kill_old_server() {
|
||||
if command -v lsof &> /dev/null; then
|
||||
local pids
|
||||
pids=$(lsof -t -i tcp:"$SERVER_PORT" -sTCP:LISTEN 2>/dev/null || true)
|
||||
if [ -n "$pids" ]; then
|
||||
echo "正在关闭旧的 server 进程:$pids"
|
||||
kill -9 $pids 2>/dev/null || echo "关闭进程失败:$pids"
|
||||
sleep 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
echo "==================== Server Dev Run ===================="
|
||||
echo "Server 目录:$SERVER_DIR"
|
||||
echo "Server 端口:$SERVER_PORT"
|
||||
echo "日志文件:$LOG_FILE"
|
||||
|
||||
kill_old_server
|
||||
|
||||
echo "启动 server 服务..."
|
||||
cd "$SERVER_DIR"
|
||||
NODE_ENV=development PORT="$SERVER_PORT" npx tsx ./src/index.ts 2>&1 | pipe_to_log "SERVER" "$LOG_FILE"
|
||||
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.expo/
|
||||
.expo-shared/
|
||||
.eslintcache
|
||||
expo/expo-env.d.ts
|
||||
expo-env.d.ts
|
||||
.DS_Store
|
||||
logs/
|
||||
*.tsbuildinfo
|
||||
20
.npmrc
Normal file
@@ -0,0 +1,20 @@
|
||||
registry=https://registry.npmmirror.com
|
||||
|
||||
strictStorePkgContentCheck=false
|
||||
verifyStoreIntegrity=false
|
||||
|
||||
# 网络优化
|
||||
network-concurrency=16
|
||||
fetch-retries=3
|
||||
fetch-timeout=60000
|
||||
|
||||
# peerDependencies
|
||||
strict-peer-dependencies=false
|
||||
auto-install-peers=true
|
||||
|
||||
# lockfile 配置
|
||||
lockfile=true
|
||||
prefer-frozen-lockfile=true
|
||||
|
||||
# semver 选择最高版本
|
||||
resolution-mode=highest
|
||||
74
README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# Expo App + Express.js
|
||||
|
||||
## 目录结构规范(严格遵循)
|
||||
|
||||
当前仓库是一个 monorepo(基于 pnpm 的 workspace)
|
||||
|
||||
- Expo 代码在 client 目录,Express.js 代码在 server 目录
|
||||
- 本模板默认无 Tab Bar,可按需改造
|
||||
|
||||
目录结构说明
|
||||
|
||||
├── server/ # 服务端代码根目录 (Express.js)
|
||||
| ├── src/
|
||||
│ │ └── index.ts # Express 入口文件
|
||||
| └── package.json # 服务端 package.json
|
||||
├── client/ # React Native 前端代码
|
||||
│ ├── app/ # Expo Router 路由目录(仅路由配置)
|
||||
│ │ ├── _layout.tsx # 根布局文件(必需,务必阅读)
|
||||
│ │ ├── home.tsx # 首页
|
||||
│ │ └── index.tsx # re-export home.tsx
|
||||
│ ├── screens/ # 页面实现目录(与 app/ 路由对应)
|
||||
│ │ └── demo/ # demo 示例页面
|
||||
│ │ ├── index.tsx # 页面组件实现
|
||||
│ │ └── styles.ts # 页面样式
|
||||
│ ├── components/ # 可复用组件
|
||||
│ │ └── Screen.tsx # 页面容器组件(必用)
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ ├── contexts/ # React Context 代码
|
||||
│ ├── constants/ # 常量定义(如主题配置)
|
||||
│ ├── utils/ # 工具函数
|
||||
│ ├── assets/ # 静态资源
|
||||
| └── package.json # Expo 应用 package.json
|
||||
├── package.json
|
||||
├── .cozeproj # 预置脚手架脚本(禁止修改)
|
||||
└── .coze # 配置文件(禁止修改)
|
||||
|
||||
## 安装依赖
|
||||
|
||||
### 命令
|
||||
|
||||
```bash
|
||||
pnpm i
|
||||
```
|
||||
|
||||
### 新增依赖约束
|
||||
|
||||
如果需要新增依赖,需在 client 和 server 各自的目录添加(原因:隔离前后端的依赖),禁止在根目录直接安装依赖
|
||||
|
||||
### 新增依赖标准流程
|
||||
|
||||
- 编辑 `client/package.json` 或 `server/package.json`
|
||||
- 在根目录执行 `pnpm i`
|
||||
|
||||
## Expo 开发规范
|
||||
|
||||
### 路径别名
|
||||
|
||||
Expo 配置了 `@/` 路径别名指向 `client/` 目录:
|
||||
|
||||
```tsx
|
||||
// 正确
|
||||
import { Screen } from '@/components/Screen';
|
||||
|
||||
// 避免相对路径
|
||||
import { Screen } from '../../../components/Screen';
|
||||
```
|
||||
|
||||
## 本地开发
|
||||
|
||||
运行 coze dev 可以同时启动前端和后端服务,如果端口已占用,该命令会先杀掉占用端口的进程再启动,也可以用来重启前端和后端服务
|
||||
|
||||
```bash
|
||||
coze dev
|
||||
```
|
||||
6
client/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
|
||||
# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb
|
||||
# The following patterns were generated by expo-cli
|
||||
|
||||
expo-env.d.ts
|
||||
# @end expo-cli
|
||||
76
client/app.config.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { ExpoConfig, ConfigContext } from 'expo/config';
|
||||
|
||||
const appName = process.env.COZE_PROJECT_NAME || process.env.EXPO_PUBLIC_COZE_PROJECT_NAME || '应用';
|
||||
const projectId = process.env.COZE_PROJECT_ID || process.env.EXPO_PUBLIC_COZE_PROJECT_ID;
|
||||
const slugAppName = projectId ? `app${projectId}` : 'myapp';
|
||||
|
||||
export default ({ config }: ConfigContext): ExpoConfig => {
|
||||
return {
|
||||
...config,
|
||||
"name": appName,
|
||||
"slug": slugAppName,
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
"scheme": "myapp",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"ios": {
|
||||
"supportsTablet": true
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/images/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"package": `com.anonymous.x${projectId || '0'}`
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "single",
|
||||
"favicon": "./assets/images/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
process.env.EXPO_PUBLIC_BACKEND_BASE_URL ? [
|
||||
"expo-router",
|
||||
{
|
||||
"origin": process.env.EXPO_PUBLIC_BACKEND_BASE_URL
|
||||
}
|
||||
] : 'expo-router',
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
"image": "./assets/images/splash-icon.png",
|
||||
"imageWidth": 200,
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": `允许FoodWeight智能体App访问您的相册,以便您上传或保存图片。`,
|
||||
"cameraPermission": `允许FoodWeight智能体App使用您的相机,以便您直接拍摄照片上传。`,
|
||||
"microphonePermission": `允许FoodWeight智能体App访问您的麦克风,以便您拍摄带有声音的视频。`
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-location",
|
||||
{
|
||||
"locationWhenInUsePermission": `FoodWeight智能体App需要访问您的位置以提供周边服务及导航功能。`
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-camera",
|
||||
{
|
||||
"cameraPermission": `FoodWeight智能体App需要访问相机以拍摄照片和视频。`,
|
||||
"microphonePermission": `FoodWeight智能体App需要访问麦克风以录制视频声音。`,
|
||||
"recordAudioAndroid": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
}
|
||||
}
|
||||
}
|
||||
58
client/app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Platform } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { FontAwesome6 } from '@expo/vector-icons';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
export default function TabLayout() {
|
||||
const { theme, isDark } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.backgroundDefault,
|
||||
borderTopColor: theme.border,
|
||||
height: Platform.OS === 'web' ? 60 : 50 + insets.bottom,
|
||||
paddingBottom: Platform.OS === 'web' ? 0 : insets.bottom,
|
||||
},
|
||||
tabBarActiveTintColor: theme.primary,
|
||||
tabBarInactiveTintColor: theme.textMuted,
|
||||
tabBarItemStyle: {
|
||||
height: Platform.OS === 'web' ? 60 : undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: '首页',
|
||||
tabBarIcon: ({ color }) => <FontAwesome6 name="house" size={20} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="record"
|
||||
options={{
|
||||
title: '记录',
|
||||
tabBarIcon: ({ color }) => <FontAwesome6 name="camera" size={20} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="stats"
|
||||
options={{
|
||||
title: '统计',
|
||||
tabBarIcon: ({ color }) => <FontAwesome6 name="chart-line" size={20} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="chat"
|
||||
options={{
|
||||
title: 'AI助手',
|
||||
tabBarIcon: ({ color }) => <FontAwesome6 name="robot" size={20} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
1
client/app/(tabs)/chat.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@/screens/chat";
|
||||
1
client/app/(tabs)/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@/screens/home";
|
||||
1
client/app/(tabs)/record.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@/screens/record";
|
||||
1
client/app/(tabs)/stats.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from "@/screens/stats";
|
||||
30
client/app/+not-found.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { Link } from 'expo-router';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { Spacing } from '@/constants/theme';
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
const { theme } = useTheme();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: theme.backgroundRoot }]}>
|
||||
<Text>
|
||||
页面不存在
|
||||
</Text>
|
||||
<Link href="/" style={[styles.gohome]}>
|
||||
返回首页
|
||||
</Link>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
gohome: {
|
||||
marginTop: Spacing['2xl'],
|
||||
},
|
||||
});
|
||||
9
client/app/_layout.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="(tabs)" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
BIN
client/assets/fonts/SpaceMono-Regular.ttf
Executable file
BIN
client/assets/images/adaptive-icon.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
client/assets/images/default-avatar.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
client/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
client/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
client/assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
client/assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
client/assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
client/assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
client/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
314
client/components/Screen.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Platform,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
View,
|
||||
TouchableWithoutFeedback,
|
||||
Keyboard,
|
||||
ViewStyle,
|
||||
FlatList,
|
||||
SectionList,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets, Edge } from 'react-native-safe-area-context';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
// 引入 KeyboardAware 系列组件
|
||||
import {
|
||||
KeyboardAwareScrollView,
|
||||
KeyboardAwareFlatList,
|
||||
KeyboardAwareSectionList
|
||||
} from 'react-native-keyboard-aware-scroll-view';
|
||||
|
||||
/**
|
||||
* # Screen 组件使用指南
|
||||
*
|
||||
* 核心原则:统一使用手动安全区管理 (padding),支持沉浸式布局,解决 iOS/Android 状态栏一致性问题。
|
||||
*
|
||||
* ## 1. 普通页面 (默认)
|
||||
* - 场景:标准的白底或纯色背景页面,Header 在安全区下方。
|
||||
* - 用法:`<Screen>{children}</Screen>`
|
||||
* - 行为:自动处理上下左右安全区,状态栏文字黑色。
|
||||
*
|
||||
* ## 2. 沉浸式 Header (推荐)
|
||||
* - 场景:Header 背景色/图片需要延伸到状态栏 (如首页、个人中心)。
|
||||
* - 用法:`<Screen safeAreaEdges={['left', 'right', 'bottom']}>` (❌ 去掉 'top')
|
||||
* - 配合:页面内部 Header 组件必须手动添加 paddingTop:
|
||||
* ```tsx
|
||||
* const insets = useSafeAreaInsets();
|
||||
* <View style={{ paddingTop: insets.top + 12, backgroundColor: '...' }}>
|
||||
* ```
|
||||
*
|
||||
* ## 3. 底部有 TabBar 或 悬浮按钮
|
||||
* - 场景:页面底部有固定导航栏,或者需要精细控制底部留白。
|
||||
* - 用法:`<Screen safeAreaEdges={['top', 'left', 'right']}>` (❌ 去掉 'bottom')
|
||||
* - 配合:
|
||||
* - 若是滚动页:`<ScrollView contentContainerStyle={{ paddingBottom: insets.bottom + 80 }}>`
|
||||
* - 若是固定页:`<View style={{ paddingBottom: insets.bottom + 60 }}>`
|
||||
*
|
||||
* ## 4. 滚动列表/表单
|
||||
* - 场景:长内容,需要键盘避让。
|
||||
* - 用法:`<Screen>{children}</Screen>`
|
||||
* - 行为:若子树不包含 ScrollView/FlatList/SectionList,则外层自动使用 ScrollView,
|
||||
* 自动处理键盘遮挡,底部安全区会自动加在内容末尾。
|
||||
*/
|
||||
interface ScreenProps {
|
||||
children: React.ReactNode;
|
||||
/** 背景色,默认 #fff */
|
||||
backgroundColor?: string;
|
||||
/**
|
||||
* 状态栏样式
|
||||
* - 'dark': 黑色文字 (默认)
|
||||
* - 'light': 白色文字 (深色背景时用)
|
||||
*/
|
||||
statusBarStyle?: 'auto' | 'inverted' | 'light' | 'dark';
|
||||
/**
|
||||
* 状态栏背景色
|
||||
* - 默认 'transparent' 以支持沉浸式
|
||||
* - Android 下如果需要不透明,可传入具体颜色
|
||||
*/
|
||||
statusBarColor?: string;
|
||||
/**
|
||||
* 安全区控制 (关键属性)
|
||||
* - 默认: ['top', 'left', 'right', 'bottom'] (全避让)
|
||||
* - 沉浸式 Header: 去掉 'top'
|
||||
* - 自定义底部: 去掉 'bottom'
|
||||
*/
|
||||
safeAreaEdges?: Edge[];
|
||||
/** 自定义容器样式 */
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
type KeyboardAwareProps = {
|
||||
element: React.ReactElement<any, any>;
|
||||
extraPadding: number;
|
||||
contentInsetBehaviorIOS: 'automatic' | 'never';
|
||||
};
|
||||
|
||||
const KeyboardAwareScrollable = ({
|
||||
element,
|
||||
extraPadding,
|
||||
contentInsetBehaviorIOS,
|
||||
}: KeyboardAwareProps) => {
|
||||
// 获取原始组件的 props
|
||||
const childAttrs: any = (element as any).props || {};
|
||||
const originStyle = childAttrs['contentContainerStyle'];
|
||||
const styleArray = Array.isArray(originStyle) ? originStyle : originStyle ? [originStyle] : [];
|
||||
const merged = Object.assign({}, ...styleArray);
|
||||
const currentPB = typeof merged.paddingBottom === 'number' ? merged.paddingBottom : 0;
|
||||
|
||||
// 合并 paddingBottom (安全区 + 额外留白)
|
||||
const enhancedContentStyle = [{ ...merged, paddingBottom: currentPB + extraPadding }];
|
||||
|
||||
// 基础配置 props,用于传递给 KeyboardAware 组件
|
||||
const commonProps = {
|
||||
...childAttrs,
|
||||
contentContainerStyle: enhancedContentStyle,
|
||||
keyboardShouldPersistTaps: childAttrs['keyboardShouldPersistTaps'] ?? 'handled',
|
||||
keyboardDismissMode: childAttrs['keyboardDismissMode'] ?? 'on-drag',
|
||||
enableOnAndroid: true,
|
||||
// 类似于原代码中的 setTimeout/scrollToEnd 逻辑,这里设置额外的滚动高度确保输入框可见
|
||||
extraHeight: 100,
|
||||
// 禁用自带的 ScrollView 自动 inset,由外部 padding 控制
|
||||
enableAutomaticScroll: true,
|
||||
...(Platform.OS === 'ios'
|
||||
? { contentInsetAdjustmentBehavior: childAttrs['contentInsetAdjustmentBehavior'] ?? contentInsetBehaviorIOS }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const t = (element as any).type;
|
||||
|
||||
// 根据组件类型返回对应的 KeyboardAware 版本
|
||||
// 注意:不再使用 KeyboardAvoidingView,直接替换为增强版 ScrollView
|
||||
if (t === ScrollView) {
|
||||
return <KeyboardAwareScrollView {...commonProps} />;
|
||||
}
|
||||
|
||||
if (t === FlatList) {
|
||||
return <KeyboardAwareFlatList {...commonProps} />;
|
||||
}
|
||||
|
||||
if (t === SectionList) {
|
||||
return <KeyboardAwareSectionList {...commonProps} />;
|
||||
}
|
||||
|
||||
// 理论上不应运行到这里,如果是非标准组件则原样返回,仅修改样式
|
||||
return React.cloneElement(element, {
|
||||
contentContainerStyle: enhancedContentStyle,
|
||||
keyboardShouldPersistTaps: childAttrs['keyboardShouldPersistTaps'] ?? 'handled',
|
||||
keyboardDismissMode: childAttrs['keyboardDismissMode'] ?? 'on-drag',
|
||||
});
|
||||
};
|
||||
|
||||
export const Screen = ({
|
||||
children,
|
||||
backgroundColor = '#fff',
|
||||
statusBarStyle = 'dark',
|
||||
statusBarColor = 'transparent',
|
||||
safeAreaEdges = ['top', 'left', 'right', 'bottom'],
|
||||
style,
|
||||
}: ScreenProps) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [keyboardShown, setKeyboardShown] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
||||
const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
||||
const s1 = Keyboard.addListener(showEvent, () => setKeyboardShown(true));
|
||||
const s2 = Keyboard.addListener(hideEvent, () => setKeyboardShown(false));
|
||||
return () => { s1.remove(); s2.remove(); };
|
||||
}, []);
|
||||
|
||||
// 自动检测:若子树中包含 ScrollView/FlatList/SectionList,则认为页面自身处理滚动
|
||||
const isNodeScrollable = (node: React.ReactNode): boolean => {
|
||||
const isScrollableElement = (el: unknown): boolean => {
|
||||
if (!React.isValidElement(el)) return false;
|
||||
const element = el as React.ReactElement<any, any>;
|
||||
const t = element.type;
|
||||
// 不递归检查 Modal 内容,避免将弹窗内的 ScrollView 误判为页面已具备垂直滚动
|
||||
if (t === Modal) return false;
|
||||
const props = element.props as Record<string, unknown> | undefined;
|
||||
// 仅识别“垂直”滚动容器;横向滚动不视为页面已处理垂直滚动
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const isHorizontal = !!(props && (props as any).horizontal === true);
|
||||
if ((t === ScrollView || t === FlatList || t === SectionList) && !isHorizontal) return true;
|
||||
const c: React.ReactNode | undefined = props && 'children' in props
|
||||
? (props.children as React.ReactNode)
|
||||
: undefined;
|
||||
if (Array.isArray(c)) return c.some(isScrollableElement);
|
||||
return c ? isScrollableElement(c) : false;
|
||||
};
|
||||
if (Array.isArray(node)) return node.some(isScrollableElement);
|
||||
return isScrollableElement(node);
|
||||
};
|
||||
|
||||
const childIsNativeScrollable = isNodeScrollable(children);
|
||||
|
||||
// 说明:避免双重补白
|
||||
// KeyboardAwareScrollView 内部会自动处理键盘高度。
|
||||
// 我们主要关注非键盘状态下的 Safe Area 管理。
|
||||
|
||||
// 解析安全区设置
|
||||
const hasTop = safeAreaEdges.includes('top');
|
||||
const hasBottom = safeAreaEdges.includes('bottom');
|
||||
const hasLeft = safeAreaEdges.includes('left');
|
||||
const hasRight = safeAreaEdges.includes('right');
|
||||
|
||||
// 强制禁用 iOS 自动调整内容区域,完全由手动 padding 控制,消除系统自动计算带来的多余空白
|
||||
const contentInsetBehaviorIOS = 'never';
|
||||
|
||||
const wrapperStyle: ViewStyle = {
|
||||
flex: 1,
|
||||
backgroundColor,
|
||||
paddingTop: hasTop ? insets.top : 0,
|
||||
paddingLeft: hasLeft ? insets.left : 0,
|
||||
paddingRight: hasRight ? insets.right : 0,
|
||||
// 当页面不使用外层 ScrollView 时(子树本身可滚动),由外层 View 负责底部安全区
|
||||
paddingBottom: (childIsNativeScrollable && hasBottom)
|
||||
? (keyboardShown ? 0 : insets.bottom)
|
||||
: 0,
|
||||
};
|
||||
|
||||
// 若子树不可滚动,则外层使用 KeyboardAwareScrollView 提供“全局页面滑动”能力
|
||||
const useScrollContainer = !childIsNativeScrollable;
|
||||
|
||||
// 2. 滚动容器配置
|
||||
// 如果使用滚动容器,则使用 KeyboardAwareScrollView 替代原有的 ScrollView
|
||||
const Container = useScrollContainer ? KeyboardAwareScrollView : View;
|
||||
|
||||
const containerProps = useScrollContainer ? {
|
||||
contentContainerStyle: {
|
||||
flexGrow: 1,
|
||||
// 滚动模式下,Bottom 安全区由内容容器处理,保证内容能完整显示且不被 Home Indicator 遮挡,同时背景色能延伸到底部
|
||||
paddingBottom: hasBottom ? (keyboardShown ? 0 : insets.bottom) : 0,
|
||||
},
|
||||
keyboardShouldPersistTaps: 'handled' as const,
|
||||
showsVerticalScrollIndicator: false,
|
||||
keyboardDismissMode: 'on-drag' as const,
|
||||
enableOnAndroid: true,
|
||||
extraHeight: 100, // 替代原代码手动计算的 offset
|
||||
// iOS 顶部白条修复:强制不自动添加顶部安全区
|
||||
...(Platform.OS === 'ios'
|
||||
? { contentInsetAdjustmentBehavior: contentInsetBehaviorIOS }
|
||||
: {}),
|
||||
} : {};
|
||||
|
||||
// 3. 若子元素自身包含滚动容器,给该滚动容器单独添加键盘避让,不影响其余固定元素(如底部栏)
|
||||
const wrapScrollableWithKeyboardAvoid = (nodes: React.ReactNode): React.ReactNode => {
|
||||
const isVerticalScrollable = (el: React.ReactElement<any, any>): boolean => {
|
||||
const t = el.type;
|
||||
const elementProps = (el as any).props || {};
|
||||
const isHorizontal = !!(elementProps as any).horizontal;
|
||||
return (t === ScrollView || t === FlatList || t === SectionList) && !isHorizontal;
|
||||
};
|
||||
|
||||
const wrapIfNeeded = (el: React.ReactElement<any, any>, idx?: number): React.ReactElement => {
|
||||
if (isVerticalScrollable(el)) {
|
||||
return (
|
||||
<KeyboardAwareScrollable
|
||||
key={el.key ?? idx}
|
||||
element={el}
|
||||
extraPadding={keyboardShown ? 0 : (hasBottom ? insets.bottom : 0)}
|
||||
contentInsetBehaviorIOS={contentInsetBehaviorIOS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
if (Array.isArray(nodes)) {
|
||||
return nodes.map((n, idx) => {
|
||||
if (React.isValidElement(n)) {
|
||||
return wrapIfNeeded(n as React.ReactElement<any, any>, idx);
|
||||
}
|
||||
return n;
|
||||
});
|
||||
}
|
||||
if (React.isValidElement(nodes)) {
|
||||
return wrapIfNeeded(nodes as React.ReactElement<any, any>, 0);
|
||||
}
|
||||
return nodes;
|
||||
};
|
||||
|
||||
return (
|
||||
// 核心原则:严禁使用 SafeAreaView,统一使用 View + padding 手动管理
|
||||
<View style={wrapperStyle}>
|
||||
{/* 状态栏配置:强制透明背景 + 沉浸式,以支持背景图延伸 */}
|
||||
<StatusBar
|
||||
style={statusBarStyle}
|
||||
backgroundColor={statusBarColor}
|
||||
translucent
|
||||
/>
|
||||
|
||||
{/* 键盘避让:仅当外层使用 ScrollView 时启用,避免固定底部栏随键盘上移 */}
|
||||
{useScrollContainer ? (
|
||||
// 替换为 KeyboardAwareScrollView,移除原先的 KeyboardAvoidingView 包裹
|
||||
// 因为 KeyboardAwareScrollView 已经内置了处理逻辑
|
||||
<Container style={[styles.innerContainer, style]} {...containerProps}>
|
||||
{children}
|
||||
</Container>
|
||||
) : (
|
||||
// 页面自身已处理滚动,不启用全局键盘避让,保证固定底部栏不随键盘上移
|
||||
childIsNativeScrollable ? (
|
||||
<View style={[styles.innerContainer, style]}>
|
||||
{wrapScrollableWithKeyboardAvoid(children)}
|
||||
</View>
|
||||
) : (
|
||||
<TouchableWithoutFeedback onPress={Keyboard.dismiss} disabled={Platform.OS === 'web'}>
|
||||
<View style={[styles.innerContainer, style]}>
|
||||
{children}
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
innerContainer: {
|
||||
flex: 1,
|
||||
// 确保内部容器透明,避免背景色遮挡
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
});
|
||||
238
client/components/SmartDateInput.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Keyboard,
|
||||
Platform,
|
||||
useColorScheme,
|
||||
ViewStyle,
|
||||
TextStyle
|
||||
} from 'react-native';
|
||||
import DateTimePickerModal from 'react-native-modal-datetime-picker';
|
||||
import dayjs from 'dayjs';
|
||||
import { FontAwesome6 } from '@expo/vector-icons';
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 1. 配置 Dayjs
|
||||
// --------------------------------------------------------
|
||||
// 即使服务端返回 '2023-10-20T10:00:00Z' (UTC),
|
||||
// dayjs(utcString).format() 会自动转为手机当前的本地时区显示。
|
||||
// 如果需要传回给后端,我们再转回 ISO 格式。
|
||||
|
||||
interface SmartDateInputProps {
|
||||
label?: string; // 表单标题 (可选)
|
||||
value?: string | null; // 服务端返回的时间字符串 (ISO 8601, 带 T)
|
||||
onChange: (isoDate: string) => void; // 回调给父组件的值,依然是标准 ISO 字符串
|
||||
placeholder?: string;
|
||||
mode?: 'date' | 'time' | 'datetime'; // 支持日期、时间、或两者
|
||||
displayFormat?: string; // UI展示的格式,默认 YYYY-MM-DD
|
||||
error?: string; // 错误信息
|
||||
|
||||
// 样式自定义(可选)
|
||||
containerStyle?: ViewStyle; // 外层容器样式
|
||||
inputStyle?: ViewStyle; // 输入框样式
|
||||
textStyle?: TextStyle; // 文字样式
|
||||
labelStyle?: TextStyle; // 标签样式
|
||||
placeholderTextStyle?: TextStyle; // 占位符文字样式
|
||||
errorTextStyle?: TextStyle; // 错误信息文字样式
|
||||
iconColor?: string; // 图标颜色
|
||||
iconSize?: number; // 图标大小
|
||||
}
|
||||
|
||||
export const SmartDateInput = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '请选择',
|
||||
mode = 'date',
|
||||
displayFormat,
|
||||
error,
|
||||
containerStyle,
|
||||
inputStyle,
|
||||
textStyle,
|
||||
labelStyle,
|
||||
placeholderTextStyle,
|
||||
errorTextStyle,
|
||||
iconColor,
|
||||
iconSize = 18
|
||||
}: SmartDateInputProps) => {
|
||||
const [isDatePickerVisible, setDatePickerVisibility] = useState(false);
|
||||
const colorScheme = useColorScheme();
|
||||
const isDark = colorScheme === 'dark';
|
||||
|
||||
// 默认展示格式
|
||||
const format = displayFormat || (mode === 'time' ? 'HH:mm' : 'YYYY-MM-DD');
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 2. 核心:数据转换逻辑
|
||||
// --------------------------------------------------------
|
||||
|
||||
// 解析服务端值,确保无效值不传给控件;time 模式兼容仅时间字符串
|
||||
const parsedValue = useMemo(() => {
|
||||
if (!value) return null;
|
||||
|
||||
const direct = dayjs(value);
|
||||
if (direct.isValid()) return direct;
|
||||
|
||||
if (mode === 'time') {
|
||||
const timeOnly = dayjs(`1970-01-01T${value}`);
|
||||
if (timeOnly.isValid()) return timeOnly;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [value, mode]);
|
||||
|
||||
// A. 将字符串转为 JS Date 对象给控件使用
|
||||
// 如果 value 是空或无效,回退到当前时间
|
||||
const dateObjectForPicker = useMemo(() => {
|
||||
return parsedValue ? parsedValue.toDate() : new Date();
|
||||
}, [parsedValue]);
|
||||
|
||||
// B. 将 Date 对象转为展示字符串
|
||||
const displayString = useMemo(() => {
|
||||
if (!parsedValue) return '';
|
||||
return parsedValue.format(format);
|
||||
}, [parsedValue, format]);
|
||||
|
||||
// --------------------------------------------------------
|
||||
// 3. 核心:交互逻辑 (解决键盘遮挡/无法收起)
|
||||
// --------------------------------------------------------
|
||||
|
||||
const showDatePicker = () => {
|
||||
// 【关键点】打开日期控件前,必须强制收起键盘!
|
||||
// 否则键盘会遮挡 iOS 的底部滚轮,或者导致 Android 焦点混乱
|
||||
Keyboard.dismiss();
|
||||
setDatePickerVisibility(true);
|
||||
};
|
||||
|
||||
const hideDatePicker = () => {
|
||||
setDatePickerVisibility(false);
|
||||
};
|
||||
|
||||
const handleConfirm = (date: Date) => {
|
||||
hideDatePicker();
|
||||
// 采用带本地偏移的 ISO 字符串,避免 date 模式在非 UTC 时区出现跨天
|
||||
const serverString = dayjs(date).format(format);
|
||||
onChange(serverString);
|
||||
};
|
||||
|
||||
// 根据 mode 选择图标
|
||||
const iconName = mode === 'time' ? 'clock' : 'calendar';
|
||||
|
||||
return (
|
||||
<View style={[styles.container, containerStyle]}>
|
||||
{/* 标题 */}
|
||||
{label && <Text style={[styles.label, labelStyle]}>{label}</Text>}
|
||||
|
||||
{/*
|
||||
这里用 TouchableOpacity 模拟 Input。
|
||||
模拟组件永远不会唤起键盘。
|
||||
*/}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.inputBox,
|
||||
error ? styles.inputBoxError : null,
|
||||
inputStyle
|
||||
]}
|
||||
onPress={showDatePicker}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.text,
|
||||
textStyle,
|
||||
!value && styles.placeholder,
|
||||
!value && placeholderTextStyle
|
||||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{displayString || placeholder}
|
||||
</Text>
|
||||
|
||||
<FontAwesome6
|
||||
name={iconName}
|
||||
size={iconSize}
|
||||
color={iconColor || (value ? '#4B5563' : '#9CA3AF')}
|
||||
style={styles.icon}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{error && <Text style={[styles.errorText, errorTextStyle]}>{error}</Text>}
|
||||
|
||||
{/*
|
||||
DateTimePickerModal 是 React Native Modal。
|
||||
它会覆盖在所有 View 之上。
|
||||
*/}
|
||||
<DateTimePickerModal
|
||||
isVisible={isDatePickerVisible}
|
||||
mode={mode}
|
||||
date={dateObjectForPicker} // 传入 Date 对象
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={hideDatePicker}
|
||||
// iOS 只有用这个 display 样式才最稳,避免乱七八糟的 inline 样式
|
||||
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
|
||||
// 自动适配系统深色模式,或者根据 isDark 变量控制
|
||||
isDarkModeEnabled={isDark}
|
||||
// 强制使用中文环境
|
||||
locale="zh-CN"
|
||||
confirmTextIOS="确定"
|
||||
cancelTextIOS="取消"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 设计样式
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginBottom: 20,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#374151', // Gray 700
|
||||
marginBottom: 8,
|
||||
marginLeft: 2,
|
||||
},
|
||||
inputBox: {
|
||||
height: 52, // 增加高度提升触控体验
|
||||
backgroundColor: '#FFFFFF',
|
||||
borderRadius: 12, // 更圆润的角
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E5E7EB', // Gray 200
|
||||
// 增加轻微阴影提升层次感 (iOS)
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 2,
|
||||
// Android
|
||||
elevation: 1,
|
||||
},
|
||||
inputBoxError: {
|
||||
borderColor: '#EF4444', // Red 500
|
||||
backgroundColor: '#FEF2F2', // Red 50
|
||||
},
|
||||
text: {
|
||||
fontSize: 16,
|
||||
color: '#111827', // Gray 900
|
||||
flex: 1,
|
||||
},
|
||||
placeholder: {
|
||||
color: '#9CA3AF', // Gray 400 - 标准占位符颜色
|
||||
},
|
||||
icon: {
|
||||
marginLeft: 12,
|
||||
},
|
||||
errorText: {
|
||||
marginTop: 4,
|
||||
marginLeft: 2,
|
||||
fontSize: 12,
|
||||
color: '#EF4444',
|
||||
}
|
||||
});
|
||||
33
client/components/ThemedText.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import { Text, TextProps, TextStyle } from 'react-native';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { Typography } from '@/constants/theme';
|
||||
|
||||
type TypographyVariant = keyof typeof Typography;
|
||||
|
||||
interface ThemedTextProps extends TextProps {
|
||||
variant?: TypographyVariant;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export function ThemedText({
|
||||
variant = 'body',
|
||||
color,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: ThemedTextProps) {
|
||||
const { theme } = useTheme();
|
||||
const typographyStyle = Typography[variant];
|
||||
|
||||
const textStyle: TextStyle = {
|
||||
...typographyStyle,
|
||||
color: color ?? theme.textPrimary,
|
||||
};
|
||||
|
||||
return (
|
||||
<Text style={[textStyle, style]} {...props}>
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
37
client/components/ThemedView.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { View, ViewProps, ViewStyle } from 'react-native';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
type BackgroundLevel = 'root' | 'default' | 'tertiary';
|
||||
|
||||
interface ThemedViewProps extends ViewProps {
|
||||
level?: BackgroundLevel;
|
||||
backgroundColor?: string;
|
||||
}
|
||||
|
||||
const backgroundMap: Record<BackgroundLevel, string> = {
|
||||
root: 'backgroundRoot',
|
||||
default: 'backgroundDefault',
|
||||
tertiary: 'backgroundTertiary',
|
||||
};
|
||||
|
||||
export function ThemedView({
|
||||
level = 'root',
|
||||
backgroundColor,
|
||||
style,
|
||||
children,
|
||||
...props
|
||||
}: ThemedViewProps) {
|
||||
const { theme } = useTheme();
|
||||
const bgColor = backgroundColor ?? (theme as any)[backgroundMap[level]];
|
||||
|
||||
const viewStyle: ViewStyle = {
|
||||
backgroundColor: bgColor,
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[viewStyle, style]} {...props}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
177
client/constants/theme.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
export const Colors = {
|
||||
light: {
|
||||
textPrimary: "#1F2937",
|
||||
textSecondary: "#6B7280",
|
||||
textMuted: "#9CA3AF",
|
||||
primary: "#059669", // Emerald-600 - 健康运动主色
|
||||
accent: "#10B981", // Emerald-500 - 辅助色
|
||||
success: "#10B981",
|
||||
error: "#EF4444",
|
||||
backgroundRoot: "#F8FAFC", // 冷瓷白
|
||||
backgroundDefault: "#FFFFFF",
|
||||
backgroundTertiary: "#F1F5F9",
|
||||
buttonPrimaryText: "#FFFFFF",
|
||||
tabIconSelected: "#059669",
|
||||
border: "#E2E8F0",
|
||||
borderLight: "#F1F5F9",
|
||||
},
|
||||
dark: {
|
||||
textPrimary: "#F9FAFB",
|
||||
textSecondary: "#9CA3AF",
|
||||
textMuted: "#6B7280",
|
||||
primary: "#34D399", // Emerald-400 - 暗色模式主色
|
||||
accent: "#10B981",
|
||||
success: "#34D399",
|
||||
error: "#F87171",
|
||||
backgroundRoot: "#111827", // 更深的背景色
|
||||
backgroundDefault: "#1F2937",
|
||||
backgroundTertiary: "#374151",
|
||||
buttonPrimaryText: "#111827",
|
||||
tabIconSelected: "#34D399",
|
||||
border: "#374151",
|
||||
borderLight: "#4B5563",
|
||||
},
|
||||
};
|
||||
|
||||
export const Spacing = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 20,
|
||||
"2xl": 24,
|
||||
"3xl": 32,
|
||||
"4xl": 40,
|
||||
"5xl": 48,
|
||||
"6xl": 64,
|
||||
};
|
||||
|
||||
export const BorderRadius = {
|
||||
xs: 4,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 20,
|
||||
"2xl": 24,
|
||||
"3xl": 28,
|
||||
"4xl": 32,
|
||||
full: 9999,
|
||||
};
|
||||
|
||||
export const Typography = {
|
||||
display: {
|
||||
fontSize: 112,
|
||||
lineHeight: 112,
|
||||
fontWeight: "200" as const,
|
||||
letterSpacing: -4,
|
||||
},
|
||||
displayLarge: {
|
||||
fontSize: 112,
|
||||
lineHeight: 112,
|
||||
fontWeight: "200" as const,
|
||||
letterSpacing: -2,
|
||||
},
|
||||
displayMedium: {
|
||||
fontSize: 48,
|
||||
lineHeight: 56,
|
||||
fontWeight: "200" as const,
|
||||
},
|
||||
h1: {
|
||||
fontSize: 32,
|
||||
lineHeight: 40,
|
||||
fontWeight: "700" as const,
|
||||
},
|
||||
h2: {
|
||||
fontSize: 28,
|
||||
lineHeight: 36,
|
||||
fontWeight: "700" as const,
|
||||
},
|
||||
h3: {
|
||||
fontSize: 24,
|
||||
lineHeight: 32,
|
||||
fontWeight: "300" as const,
|
||||
},
|
||||
h4: {
|
||||
fontSize: 20,
|
||||
lineHeight: 28,
|
||||
fontWeight: "600" as const,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
lineHeight: 24,
|
||||
fontWeight: "700" as const,
|
||||
},
|
||||
body: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: "400" as const,
|
||||
},
|
||||
bodyMedium: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: "500" as const,
|
||||
},
|
||||
small: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
fontWeight: "400" as const,
|
||||
},
|
||||
smallMedium: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
fontWeight: "500" as const,
|
||||
},
|
||||
caption: {
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
fontWeight: "400" as const,
|
||||
},
|
||||
captionMedium: {
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
fontWeight: "500" as const,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
fontWeight: "500" as const,
|
||||
letterSpacing: 2,
|
||||
textTransform: "uppercase" as const,
|
||||
},
|
||||
labelSmall: {
|
||||
fontSize: 12,
|
||||
lineHeight: 16,
|
||||
fontWeight: "500" as const,
|
||||
letterSpacing: 1,
|
||||
textTransform: "uppercase" as const,
|
||||
},
|
||||
labelTitle: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
fontWeight: "700" as const,
|
||||
letterSpacing: 2,
|
||||
textTransform: "uppercase" as const,
|
||||
},
|
||||
link: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
fontWeight: "400" as const,
|
||||
},
|
||||
stat: {
|
||||
fontSize: 30,
|
||||
lineHeight: 36,
|
||||
fontWeight: "300" as const,
|
||||
},
|
||||
tiny: {
|
||||
fontSize: 10,
|
||||
lineHeight: 14,
|
||||
fontWeight: "400" as const,
|
||||
},
|
||||
navLabel: {
|
||||
fontSize: 10,
|
||||
lineHeight: 14,
|
||||
fontWeight: "500" as const,
|
||||
},
|
||||
};
|
||||
|
||||
export type Theme = typeof Colors.light;
|
||||
49
client/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
// @ts-nocheck
|
||||
/**
|
||||
* 通用认证上下文
|
||||
*
|
||||
* 基于固定的 API 接口实现,可复用到其他项目
|
||||
* 其他项目使用时,只需修改 @api 的导入路径指向项目的 api 模块
|
||||
*
|
||||
* 注意:
|
||||
* - 如果需要登录/鉴权场景,请扩展本文件,完善 login/logout、token 管理、用户信息获取与刷新等逻辑
|
||||
* - 将示例中的占位实现替换为项目实际的接口调用与状态管理
|
||||
*/
|
||||
import React, { createContext, useContext, ReactNode } from "react";
|
||||
|
||||
interface UserOut {
|
||||
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: UserOut | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
login: (token: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
updateUser: (userData: Partial<UserOut>) => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const value: AuthContextType = {
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
login: async (token: string) => {},
|
||||
logout: async () => {},
|
||||
updateUser: () => {},
|
||||
};
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export const useAuth = (): AuthContextType => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
5
client/declarations.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
// declarations.d.ts
|
||||
|
||||
declare module 'expo-file-system/legacy' {
|
||||
export * from 'expo-file-system';
|
||||
}
|
||||
49
client/eslint-formatter-simple.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
export default function (results) {
|
||||
|
||||
return results
|
||||
.flatMap(file =>
|
||||
file.messages.map(m => {
|
||||
// split into lines
|
||||
const lines = m.message.split('\n');
|
||||
|
||||
// 第一行(句子):直接用
|
||||
const first = lines[0];
|
||||
|
||||
// 附加解释:过滤掉所有 codeframe/箭头/行号/重复路径
|
||||
const details = lines
|
||||
.slice(1)
|
||||
.filter(l => {
|
||||
// 移除空行
|
||||
if (!l.trim()) return false;
|
||||
|
||||
// 移除 "58 | xxx" 这样的行
|
||||
if (/^\s*\d+\s*\|/.test(l)) return false;
|
||||
|
||||
// 移除 "> 60 | ..." 这样的箭头行
|
||||
if (/^\s*>/.test(l)) return false;
|
||||
|
||||
// 移除只有箭头提示的行,如 "| ^^^^^"
|
||||
if (/^\s*\|/.test(l)) return false;
|
||||
|
||||
// 移除 "…" 省略号行
|
||||
if (/^\s*…/.test(l)) return false;
|
||||
|
||||
// 移除重复路径行(eslint message 有时夹带 file:line)
|
||||
if (/\.tsx:\d+:\d+/.test(l)) return false;
|
||||
|
||||
return true;
|
||||
})
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
let output = `${file.filePath}:${m.line}:${m.column} ${
|
||||
m.severity === 2 ? 'error' : 'warn'
|
||||
} ${first}`;
|
||||
|
||||
if (details) output += `\n${details}\n`;
|
||||
|
||||
return output;
|
||||
})
|
||||
)
|
||||
.join('\n');
|
||||
};
|
||||
121
client/eslint.config.mjs
Normal file
@@ -0,0 +1,121 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import pluginReact from 'eslint-plugin-react';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import regexp from 'eslint-plugin-regexp';
|
||||
import pluginImport from 'eslint-plugin-import';
|
||||
import fontawesome6 from '../eslint-plugins/fontawesome6/index.js';
|
||||
import reanimated from '../eslint-plugins/reanimated/index.js';
|
||||
import reactnative from '../eslint-plugins/react-native/index.js';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'**/dist/**',
|
||||
'**/node_modules/**',
|
||||
'api/**', // 排除自动生成的 API 代码
|
||||
'src/api/**', // 排除 src 下的自动生成 API
|
||||
'.expo/**', // 排除 Expo 自动生成的文件
|
||||
'tailwind.config.js', // 排除 Tailwind 配置文件
|
||||
'**/*.d.ts',
|
||||
'eslint.config.*',
|
||||
],
|
||||
},
|
||||
regexp.configs["flat/recommended"],
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
// React 的推荐配置
|
||||
pluginReact.configs.flat.recommended,
|
||||
pluginReact.configs.flat['jsx-runtime'],
|
||||
reactHooks.configs.flat.recommended,
|
||||
{
|
||||
files: ["**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"],
|
||||
|
||||
// 语言选项:设置全局变量
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.es2021,
|
||||
'__DEV__': 'readonly',
|
||||
},
|
||||
},
|
||||
|
||||
// React 版本自动检测
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
project: ['./tsconfig.json'],
|
||||
alwaysTryTypes: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
plugins: {
|
||||
import: pluginImport,
|
||||
fontawesome6,
|
||||
reanimated,
|
||||
reactnative,
|
||||
},
|
||||
rules: {
|
||||
// 关闭代码风格规则
|
||||
'semi': 'off',
|
||||
'quotes': 'off',
|
||||
'indent': 'off',
|
||||
"no-empty": ["error", { "allowEmptyCatch": true }],
|
||||
"no-unused-expressions": "warn",
|
||||
"no-useless-escape": "warn",
|
||||
'import/no-unresolved': 'error',
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/no-empty-object-type': 'off',
|
||||
'no-prototype-builtins': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'react/jsx-uses-react': 'off',
|
||||
'fontawesome6/valid-name': 'error',
|
||||
'reanimated/ban-mix-use': 'error',
|
||||
// 禁止使用 via.placeholder.com 服务
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
'selector': 'Literal[value=/via\\.placeholder\\.com/]',
|
||||
'message': 'via.placeholder.com 服务不可用,禁止在代码中使用',
|
||||
},
|
||||
{
|
||||
'selector': 'TemplateLiteral > TemplateElement[value.raw=/via\\.placeholder\\.com/]',
|
||||
'message': 'via.placeholder.com 服务不可用,禁止在代码中使用',
|
||||
},
|
||||
],
|
||||
'reactnative/wrap-horizontal-scrollview-inside-view': ['error'],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
files: [
|
||||
"metro.config.js",
|
||||
"scripts/**/*.js",
|
||||
"expo/scripts/**/*.js",
|
||||
"eslint.config.js",
|
||||
"babel.config.js",
|
||||
"server/**/*.js"
|
||||
],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// 在 .js 文件中关闭 TS 规则
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
// 在 Node.js 文件中允许 require
|
||||
'@typescript-eslint/no-var-requires': 'off',
|
||||
'no-undef': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
48
client/hooks/useColorScheme.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { createContext, Dispatch, ReactNode, SetStateAction, useContext, useEffect, useState } from 'react';
|
||||
import { ColorSchemeName, useColorScheme as useReactNativeColorScheme, Platform } from 'react-native';
|
||||
|
||||
const ColorSchemeContext = createContext<'light' | 'dark' | null | undefined>(null);
|
||||
|
||||
const ColorSchemeProvider = function ({ children }: { children?: ReactNode }) {
|
||||
const systemColorScheme = useReactNativeColorScheme();
|
||||
const [colorScheme, setColorScheme] = useState(systemColorScheme);
|
||||
|
||||
useEffect(() => {
|
||||
setColorScheme(systemColorScheme);
|
||||
}, [systemColorScheme]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMessage(e: MessageEvent<{ event: string; colorScheme: ColorSchemeName; } | undefined>) {
|
||||
if (e.data?.event === 'coze.workbench.colorScheme') {
|
||||
const cs = e.data.colorScheme;
|
||||
if (typeof cs === 'string' && typeof setColorScheme === 'function') {
|
||||
setColorScheme(cs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
window.addEventListener('message', handleMessage, false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (Platform.OS === 'web') {
|
||||
window.removeEventListener('message', handleMessage, false);
|
||||
}
|
||||
}
|
||||
}, [setColorScheme]);
|
||||
|
||||
return <ColorSchemeContext.Provider value={colorScheme}>
|
||||
{children}
|
||||
</ColorSchemeContext.Provider>
|
||||
};
|
||||
|
||||
function useColorScheme() {
|
||||
const colorScheme = useContext(ColorSchemeContext);
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
export {
|
||||
ColorSchemeProvider,
|
||||
useColorScheme,
|
||||
}
|
||||
152
client/hooks/useSafeRouter.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 安全路由 Hook - 完全代替原生的 useRouter 和 useLocalSearchParams
|
||||
*
|
||||
* 提供的 Hook:
|
||||
* - useSafeRouter: 代替 useRouter,包含所有路由方法,并对 push/replace/navigate/setParams 进行安全编码
|
||||
* - useSafeSearchParams: 代替 useLocalSearchParams,获取路由参数
|
||||
*
|
||||
* 解决的问题:
|
||||
* 1. URI 编解码不对称 - useLocalSearchParams 会自动解码,但 router.push 不会自动编码,
|
||||
* 当参数包含 % 等特殊字符时会拿到错误的值
|
||||
* 2. 类型丢失 - URL 参数全是 string,Number/Boolean 类型会丢失
|
||||
* 3. 嵌套对象无法传递 - URL search params 不支持嵌套结构
|
||||
*
|
||||
* 解决方案:
|
||||
* 采用 Payload 模式,将所有参数打包成 JSON 并 Base64 编码后传递,
|
||||
* 接收时再解码还原,确保数据完整性和类型安全。
|
||||
*
|
||||
* 优点:
|
||||
* 1. 自动处理所有特殊字符(如 %、&、=、中文、Emoji 等)
|
||||
* 2. 保留数据类型(Number、Boolean 不会变成 String)
|
||||
* 3. 支持嵌套对象和数组传递
|
||||
* 4. 三端兼容(iOS、Android、Web)
|
||||
*
|
||||
* 使用方式:
|
||||
* ```tsx
|
||||
* // 发送端 - 使用 useSafeRouter 代替 useRouter
|
||||
* const router = useSafeRouter();
|
||||
* router.push('/detail', { id: 123, uri: 'file:///path/%40test.mp3' });
|
||||
* router.replace('/home', { tab: 'settings' });
|
||||
* router.navigate('/profile', { userId: 456 });
|
||||
* router.back();
|
||||
* if (router.canGoBack()) { ... }
|
||||
* router.setParams({ updated: true });
|
||||
*
|
||||
* // 接收端 - 使用 useSafeSearchParams 代替 useLocalSearchParams
|
||||
* const { id, uri } = useSafeSearchParams<{ id: number; uri: string }>();
|
||||
* ```
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { useRouter as useExpoRouter, useLocalSearchParams as useExpoParams } from 'expo-router';
|
||||
import { Base64 } from 'js-base64';
|
||||
|
||||
const PAYLOAD_KEY = '__safeRouterPayload__';
|
||||
const LOG_PREFIX = '[SafeRouter]';
|
||||
|
||||
|
||||
const getCurrentParams = (rawParams: Record<string, string | string[]>): Record<string, unknown> => {
|
||||
const payload = rawParams[PAYLOAD_KEY];
|
||||
if (payload && typeof payload === 'string') {
|
||||
const decoded = deserializeParams<Record<string, unknown>>(payload);
|
||||
if (decoded && Object.keys(decoded).length > 0) {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
const { [PAYLOAD_KEY]: _, ...rest } = rawParams;
|
||||
return rest as Record<string, unknown>;
|
||||
};
|
||||
|
||||
const serializeParams = (params: Record<string, unknown>): string => {
|
||||
try {
|
||||
const jsonStr = JSON.stringify(params);
|
||||
return Base64.encode(jsonStr);
|
||||
} catch (error) {
|
||||
console.error(LOG_PREFIX, 'serialize failed:', error instanceof Error ? error.message : 'Unknown error');
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const deserializeParams = <T = Record<string, unknown>>(
|
||||
payload: string | string[] | undefined
|
||||
): T | null => {
|
||||
if (!payload || typeof payload !== 'string') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const jsonStr = Base64.decode(payload);
|
||||
return JSON.parse(jsonStr) as T;
|
||||
} catch (error) {
|
||||
console.error(LOG_PREFIX, 'deserialize failed:', error instanceof Error ? error.message : 'Unknown error');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 安全路由 Hook,用于页面跳转,代替 useRouter
|
||||
* @returns 路由方法(继承 useRouter 所有方法,并对以下方法进行安全编码)
|
||||
* - push(pathname, params) - 入栈新页面
|
||||
* - replace(pathname, params) - 替换当前页面
|
||||
* - navigate(pathname, params) - 智能跳转(已存在则返回,否则 push)
|
||||
* - setParams(params) - 更新当前页面参数(合并现有参数)
|
||||
*/
|
||||
export function useSafeRouter() {
|
||||
const router = useExpoRouter();
|
||||
const rawParams = useExpoParams<Record<string, string | string[]>>();
|
||||
|
||||
const push = (pathname: string, params: Record<string, unknown> = {}) => {
|
||||
const encodedPayload = serializeParams(params);
|
||||
router.push({
|
||||
pathname: pathname as `/${string}`,
|
||||
params: { [PAYLOAD_KEY]: encodedPayload },
|
||||
});
|
||||
};
|
||||
|
||||
const replace = (pathname: string, params: Record<string, unknown> = {}) => {
|
||||
const encodedPayload = serializeParams(params);
|
||||
router.replace({
|
||||
pathname: pathname as `/${string}`,
|
||||
params: { [PAYLOAD_KEY]: encodedPayload },
|
||||
});
|
||||
};
|
||||
|
||||
const navigate = (pathname: string, params: Record<string, unknown> = {}) => {
|
||||
const encodedPayload = serializeParams(params);
|
||||
router.navigate({
|
||||
pathname: pathname as `/${string}`,
|
||||
params: { [PAYLOAD_KEY]: encodedPayload },
|
||||
});
|
||||
};
|
||||
|
||||
const setParams = (params: Record<string, unknown>) => {
|
||||
const currentParams = getCurrentParams(rawParams);
|
||||
const mergedParams = { ...currentParams, ...params };
|
||||
const encodedPayload = serializeParams(mergedParams);
|
||||
router.setParams({ [PAYLOAD_KEY]: encodedPayload });
|
||||
};
|
||||
|
||||
return {
|
||||
...router,
|
||||
push,
|
||||
replace,
|
||||
navigate,
|
||||
setParams,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 安全获取路由参数 Hook,用于接收方,代替 useLocalSearchParams
|
||||
* 兼容两种跳转方式:
|
||||
* 1. useSafeRouter 跳转 - 自动解码 Payload
|
||||
* 2. 外部跳转(深链接、浏览器直接访问等)- 回退到原始参数
|
||||
* @returns 解码后的参数对象,类型安全
|
||||
*/
|
||||
export function useSafeSearchParams<T = Record<string, unknown>>(): T {
|
||||
const rawParams = useExpoParams<Record<string, string | string[]>>();
|
||||
|
||||
const decodedParams = useMemo(() => {
|
||||
return getCurrentParams(rawParams) as T;
|
||||
}, [rawParams]);
|
||||
|
||||
return decodedParams;
|
||||
}
|
||||
33
client/hooks/useTheme.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Colors } from '@/constants/theme';
|
||||
import { useColorScheme } from '@/hooks/useColorScheme';
|
||||
|
||||
enum COLOR_SCHEME_CHOICE {
|
||||
FOLLOW_SYSTEM = 'follow-system', // 跟随系统自动变化
|
||||
DARK = 'dark', // 固定为 dark 主题,不随系统变化
|
||||
LIGHT = 'light', // 固定为 light 主题,不随系统变化
|
||||
};
|
||||
|
||||
const userPreferColorScheme: COLOR_SCHEME_CHOICE = COLOR_SCHEME_CHOICE.FOLLOW_SYSTEM;
|
||||
|
||||
function getTheme(colorScheme?: 'dark' | 'light' | null) {
|
||||
const isDark = colorScheme === 'dark';
|
||||
const theme = Colors[colorScheme ?? 'light'];
|
||||
|
||||
return {
|
||||
theme,
|
||||
isDark,
|
||||
};
|
||||
}
|
||||
|
||||
function useTheme() {
|
||||
const systemColorScheme = useColorScheme()
|
||||
const colorScheme = userPreferColorScheme === COLOR_SCHEME_CHOICE.FOLLOW_SYSTEM ?
|
||||
systemColorScheme :
|
||||
userPreferColorScheme;
|
||||
|
||||
return getTheme(colorScheme);
|
||||
}
|
||||
|
||||
export {
|
||||
useTheme,
|
||||
}
|
||||
121
client/metro.config.js
Normal file
@@ -0,0 +1,121 @@
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
const connect = require('connect');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// 安全地获取 Expo 的默认排除列表
|
||||
const existingBlockList = [].concat(config.resolver.blockList || []);
|
||||
|
||||
config.resolver.blockList = [
|
||||
...existingBlockList,
|
||||
/.*\/\.expo\/.*/, // Expo 的缓存和构建产物目录
|
||||
|
||||
// 1. 原生代码 (Java/C++/Objective-C)
|
||||
/.*\/react-native\/ReactAndroid\/.*/,
|
||||
/.*\/react-native\/ReactCommon\/.*/,
|
||||
|
||||
// 2. 纯开发和调试工具
|
||||
// 这些工具只在开发电脑上运行,不会被打包到应用中
|
||||
/.*\/@typescript-eslint\/eslint-plugin\/.*/,
|
||||
|
||||
// 3. 构建时数据
|
||||
// 这个数据库只在打包过程中使用,应用运行时不需要
|
||||
/.*\/caniuse-lite\/data\/.*/,
|
||||
|
||||
// 4. 通用规则
|
||||
/.*\/__tests__\/.*/, // 排除所有测试目录
|
||||
/.*\.git\/.*/, // 排除 Git 目录
|
||||
];
|
||||
|
||||
const BACKEND_TARGET = 'http://localhost:9091';
|
||||
|
||||
const apiProxy = createProxyMiddleware({
|
||||
target: BACKEND_TARGET,
|
||||
changeOrigin: true,
|
||||
logLevel: 'debug',
|
||||
proxyTimeout: 86400000,
|
||||
onProxyReq: (proxyReq, req) => {
|
||||
const accept = req.headers.accept || '';
|
||||
if (accept.includes('text/event-stream')) {
|
||||
proxyReq.setHeader('accept-encoding', 'identity');
|
||||
}
|
||||
},
|
||||
onProxyRes: (proxyRes, req, res) => {
|
||||
const contentType = proxyRes.headers['content-type'] || '';
|
||||
if (contentType.includes('text/event-stream') || contentType.includes('application/stream')) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
if (typeof res.flushHeaders === 'function') {
|
||||
try { res.flushHeaders(); } catch {}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const streamProxy = createProxyMiddleware({
|
||||
target: BACKEND_TARGET,
|
||||
changeOrigin: true,
|
||||
logLevel: 'debug',
|
||||
ws: true,
|
||||
proxyTimeout: 86400000,
|
||||
onProxyReq: (proxyReq, req) => {
|
||||
const upgrade = req.headers.upgrade;
|
||||
const accept = req.headers.accept || '';
|
||||
if (upgrade && upgrade.toLowerCase() === 'websocket') {
|
||||
proxyReq.setHeader('Connection', 'upgrade');
|
||||
proxyReq.setHeader('Upgrade', req.headers.upgrade);
|
||||
} else if (accept.includes('text/event-stream')) {
|
||||
proxyReq.setHeader('accept-encoding', 'identity');
|
||||
proxyReq.setHeader('Connection', 'keep-alive');
|
||||
}
|
||||
},
|
||||
onProxyRes: (proxyRes, req, res) => {
|
||||
const contentType = proxyRes.headers['content-type'] || '';
|
||||
if (contentType.includes('text/event-stream') || contentType.includes('application/stream')) {
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
if (typeof res.flushHeaders === 'function') {
|
||||
try { res.flushHeaders(); } catch {}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const shouldProxyToBackend = (url) => {
|
||||
if (!url) return false;
|
||||
if (/^\/api\/v\d+\//.test(url)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const isWebSocketRequest = (req) =>
|
||||
!!(req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket');
|
||||
const isSSERequest = (req) => {
|
||||
const accept = req.headers.accept || '';
|
||||
return accept.includes('text/event-stream');
|
||||
};
|
||||
|
||||
config.server = {
|
||||
...config.server,
|
||||
enhanceMiddleware: (metroMiddleware, metroServer) => {
|
||||
return connect()
|
||||
.use((req, res, next) => {
|
||||
if (shouldProxyToBackend(req.url)) {
|
||||
console.log(`[Metro Proxy] Forwarding ${req.method} ${req.url}`);
|
||||
|
||||
if (isWebSocketRequest(req) || isSSERequest(req)) {
|
||||
return streamProxy(req, res, next);
|
||||
}
|
||||
return apiProxy(req, res, next);
|
||||
}
|
||||
next();
|
||||
})
|
||||
.use(metroMiddleware);
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
96
client/package.json
Normal file
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"name": "expo-app",
|
||||
"description": "my-expo-app",
|
||||
"main": "expo-router/entry",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"check-deps": "npx depcheck",
|
||||
"postinstall": "npm run install-missing",
|
||||
"install-missing": "node ./scripts/install-missing-deps.js",
|
||||
"lint": "expo lint",
|
||||
"start": "expo start --web --clear",
|
||||
"test": "jest --watchAll"
|
||||
},
|
||||
"jest": {
|
||||
"preset": "jest-expo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "^6.1.2",
|
||||
"@expo/vector-icons": "^15.0.0",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-native-community/datetimepicker": "^8.5.0",
|
||||
"@react-native-community/slider": "^5.0.1",
|
||||
"@react-native-masked-view/masked-view": "^0.3.2",
|
||||
"@react-native-picker/picker": "^2.11.0",
|
||||
"@react-navigation/bottom-tabs": "^7.2.0",
|
||||
"@react-navigation/native": "^7.0.14",
|
||||
"dayjs": "^1.11.19",
|
||||
"expo": "54.0.32",
|
||||
"expo-auth-session": "^7.0.9",
|
||||
"expo-av": "~16.0.6",
|
||||
"expo-blur": "~15.0.6",
|
||||
"expo-camera": "~17.0.10",
|
||||
"expo-constants": "~18.0.8",
|
||||
"expo-crypto": "^15.0.7",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-font": "~14.0.7",
|
||||
"expo-haptics": "~15.0.6",
|
||||
"expo-image": "^3.0.11",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-linear-gradient": "~15.0.6",
|
||||
"expo-linking": "~8.0.7",
|
||||
"expo-location": "~19.0.7",
|
||||
"expo-router": "~6.0.0",
|
||||
"expo-splash-screen": "~31.0.8",
|
||||
"expo-status-bar": "~3.0.7",
|
||||
"expo-symbols": "~1.0.6",
|
||||
"expo-system-ui": "~6.0.9",
|
||||
"expo-web-browser": "~15.0.10",
|
||||
"js-base64": "^3.7.7",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-native": "0.81.5",
|
||||
"react-native-chart-kit": "^6.12.0",
|
||||
"react-native-gesture-handler": "~2.28.0",
|
||||
"react-native-keyboard-aware-scroll-view": "^0.9.5",
|
||||
"react-native-modal-datetime-picker": "18.0.0",
|
||||
"react-native-reanimated": "~4.1.0",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-sse": "^1.2.1",
|
||||
"react-native-svg": "15.15.0",
|
||||
"react-native-toast-message": "^2.3.3",
|
||||
"react-native-web": "^0.21.2",
|
||||
"react-native-webview": "~13.15.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@eslint/js": "^9.27.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/react": "~19.1.0",
|
||||
"@types/react-test-renderer": "19.1.0",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"babel-preset-expo": "^54.0.9",
|
||||
"chalk": "^4.1.2",
|
||||
"connect": "^3.7.0",
|
||||
"depcheck": "^1.4.7",
|
||||
"esbuild": "0.27.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-formatter-compact": "^9.0.1",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-regexp": "^2.10.0",
|
||||
"globals": "^16.1.0",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"jest": "^29.2.1",
|
||||
"jest-expo": "~54.0.10",
|
||||
"react-test-renderer": "19.1.0",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.32.1"
|
||||
}
|
||||
}
|
||||
239
client/screens/chat/index.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { View, ScrollView, TextInput, KeyboardAvoidingView, Platform, TouchableOpacity } from 'react-native';
|
||||
import RNSSE from 'react-native-sse';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { FontAwesome6 } from '@expo/vector-icons';
|
||||
import { createStyles } from './styles';
|
||||
|
||||
interface Message {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export default function ChatScreen() {
|
||||
const { theme, isDark } = useTheme();
|
||||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>(() => [{
|
||||
role: 'assistant',
|
||||
content: '你好!我是你的健康饮食和减脂顾问助手。我可以帮助你:\n\n• 制定科学的饮食计划\n• 分析食物营养成分\n• 提供减脂建议\n• 解答健康问题\n\n请问有什么可以帮助你的吗?',
|
||||
timestamp: Date.now(),
|
||||
}]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const sseRef = useRef<RNSSE | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (sseRef.current) {
|
||||
sseRef.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!inputText.trim() || loading) return;
|
||||
|
||||
const userMessage: Message = {
|
||||
role: 'user',
|
||||
content: inputText.trim(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setInputText('');
|
||||
setLoading(true);
|
||||
|
||||
// 构建对话历史
|
||||
const conversationHistory = messages
|
||||
.slice(1) // 跳过欢迎消息
|
||||
.map((msg) => ({ role: msg.role, content: msg.content }));
|
||||
|
||||
try {
|
||||
/**
|
||||
* 服务端文件:server/src/routes/ai-chat.ts
|
||||
* 接口:POST /api/v1/ai-chat/chat
|
||||
* Body 参数:message: string, conversationHistory?: Array<{role: string, content: string}>
|
||||
*/
|
||||
const url = `${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/ai-chat/chat`;
|
||||
|
||||
const sse = new RNSSE(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
message: inputText.trim(),
|
||||
conversationHistory,
|
||||
}),
|
||||
});
|
||||
|
||||
sseRef.current = sse;
|
||||
|
||||
let assistantContent = '';
|
||||
const assistantMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// 添加空的助手消息
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
|
||||
sse.addEventListener('message', (event: any) => {
|
||||
if (event.data === '[DONE]') {
|
||||
sse.close();
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.content) {
|
||||
assistantContent += data.content;
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1] = {
|
||||
...updated[updated.length - 1],
|
||||
content: assistantContent,
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Parse error:', e);
|
||||
}
|
||||
});
|
||||
|
||||
sse.addEventListener('error', (error: any) => {
|
||||
console.error('SSE error:', error);
|
||||
sse.close();
|
||||
setLoading(false);
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
updated[updated.length - 1] = {
|
||||
...updated[updated.length - 1],
|
||||
content: assistantContent || '抱歉,我遇到了一些问题,请稍后再试。',
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Send message error:', error);
|
||||
setLoading(false);
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: '抱歉,发送消息失败,请检查网络连接。',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen backgroundColor={theme.backgroundRoot} statusBarStyle={isDark ? 'light' : 'dark'}>
|
||||
<ThemedView level="root" style={styles.header}>
|
||||
<ThemedText variant="h4" color={theme.textPrimary}>
|
||||
AI 健康助手
|
||||
</ThemedText>
|
||||
<View style={styles.headerStatus}>
|
||||
<View style={[styles.statusDot, { backgroundColor: loading ? '#10B981' : '#9CA3AF' }]} />
|
||||
<ThemedText variant="caption" color={theme.textMuted}>
|
||||
{loading ? '正在回复...' : '在线'}
|
||||
</ThemedText>
|
||||
</View>
|
||||
</ThemedView>
|
||||
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.messagesContainer}
|
||||
contentContainerStyle={styles.messagesContent}
|
||||
onContentSizeChange={() => {
|
||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||
}}
|
||||
>
|
||||
{messages.map((message, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.messageWrapper,
|
||||
message.role === 'user' ? styles.userMessage : styles.assistantMessage,
|
||||
]}
|
||||
>
|
||||
<ThemedView
|
||||
level="default"
|
||||
style={[
|
||||
styles.messageBubble,
|
||||
message.role === 'user'
|
||||
? { backgroundColor: theme.primary }
|
||||
: { backgroundColor: theme.backgroundTertiary },
|
||||
]}
|
||||
>
|
||||
<ThemedText
|
||||
variant="body"
|
||||
color={message.role === 'user' ? theme.buttonPrimaryText : theme.textPrimary}
|
||||
style={styles.messageText}
|
||||
>
|
||||
{message.content}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<ThemedText variant="tiny" color={theme.textMuted} style={styles.messageTime}>
|
||||
{new Date(message.timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</ThemedText>
|
||||
</View>
|
||||
))}
|
||||
|
||||
{loading && messages[messages.length - 1]?.role === 'assistant' && (
|
||||
<View style={[styles.messageWrapper, styles.assistantMessage]}>
|
||||
<ThemedView level="default" style={[styles.messageBubble, { backgroundColor: theme.backgroundTertiary }]}>
|
||||
<View style={styles.typingIndicator}>
|
||||
<View style={[styles.typingDot, { backgroundColor: theme.textMuted }]} />
|
||||
<View style={[styles.typingDot, { backgroundColor: theme.textMuted }]} />
|
||||
<View style={[styles.typingDot, { backgroundColor: theme.textMuted }]} />
|
||||
</View>
|
||||
</ThemedView>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
|
||||
>
|
||||
<ThemedView level="root" style={styles.inputContainer}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="输入你的问题..."
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
multiline
|
||||
maxLength={500}
|
||||
editable={!loading}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButton, { opacity: !inputText.trim() || loading ? 0.5 : 1 }]}
|
||||
onPress={sendMessage}
|
||||
disabled={!inputText.trim() || loading}
|
||||
>
|
||||
<FontAwesome6
|
||||
name="paper-plane"
|
||||
size={20}
|
||||
color={theme.buttonPrimaryText}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</ThemedView>
|
||||
</KeyboardAvoidingView>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
102
client/screens/chat/styles.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { Spacing, BorderRadius, Theme } from '@/constants/theme';
|
||||
|
||||
export const createStyles = (theme: Theme) => {
|
||||
return StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingVertical: Spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.border,
|
||||
},
|
||||
headerStatus: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.xs,
|
||||
},
|
||||
statusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
messagesContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
messagesContent: {
|
||||
padding: Spacing.lg,
|
||||
gap: Spacing.md,
|
||||
paddingBottom: Spacing["2xl"],
|
||||
},
|
||||
messageWrapper: {
|
||||
gap: Spacing.xs,
|
||||
},
|
||||
userMessage: {
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
assistantMessage: {
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
messageBubble: {
|
||||
maxWidth: '80%',
|
||||
padding: Spacing.md,
|
||||
borderRadius: BorderRadius.lg,
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
messageText: {
|
||||
lineHeight: 22,
|
||||
},
|
||||
messageTime: {
|
||||
paddingHorizontal: Spacing.sm,
|
||||
},
|
||||
typingIndicator: {
|
||||
flexDirection: 'row',
|
||||
gap: Spacing.xs,
|
||||
paddingVertical: Spacing.xs,
|
||||
},
|
||||
typingDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
gap: Spacing.md,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingVertical: Spacing.md,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.border,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
borderRadius: BorderRadius.lg,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingVertical: Spacing.md,
|
||||
color: theme.textPrimary,
|
||||
fontSize: 16,
|
||||
maxHeight: 120,
|
||||
},
|
||||
sendButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: BorderRadius.lg,
|
||||
backgroundColor: theme.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 2,
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
});
|
||||
};
|
||||
25
client/screens/demo/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { View, Text } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { styles } from './styles';
|
||||
|
||||
export default function DemoPage() {
|
||||
const { theme, isDark } = useTheme();
|
||||
|
||||
return (
|
||||
<Screen backgroundColor={theme.backgroundRoot} statusBarStyle={isDark ? 'light' : 'dark'}>
|
||||
<View
|
||||
style={styles.container}
|
||||
>
|
||||
<Image
|
||||
style={styles.logo}
|
||||
source="https://lf-coze-web-cdn.coze.cn/obj/eden-cn/lm-lgvj/ljhwZthlaukjlkulzlp/coze-coding/expo/coze-loading.gif"
|
||||
></Image>
|
||||
<Text style={{...styles.title, color: theme.textPrimary}}>APP 开发中</Text>
|
||||
<Text style={{...styles.description, color: theme.textSecondary}}>即将为您呈现应用界面</Text>
|
||||
</View>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
28
client/screens/demo/styles.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Spacing } from '@/constants/theme';
|
||||
import { StyleSheet } from 'react-native';
|
||||
|
||||
export const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
logo: {
|
||||
width: 130,
|
||||
height: 109,
|
||||
},
|
||||
title: {
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
marginTop: Spacing.sm,
|
||||
},
|
||||
});
|
||||
206
client/screens/home/index.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { View, ScrollView, TouchableOpacity, RefreshControl } from 'react-native';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { FontAwesome6 } from '@expo/vector-icons';
|
||||
import { useSafeRouter } from '@/hooks/useSafeRouter';
|
||||
import { createStyles } from './styles';
|
||||
|
||||
// 模拟用户ID(实际应用中应该从用户认证系统获取)
|
||||
const MOCK_USER_ID = 'mock-user-001';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { theme, isDark } = useTheme();
|
||||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||
const router = useSafeRouter();
|
||||
|
||||
const [totalCalories, setTotalCalories] = useState(0);
|
||||
const [targetCalories] = useState(2000);
|
||||
const [currentWeight, setCurrentWeight] = useState<number | null>(null);
|
||||
const [targetWeight, setTargetWeight] = useState(65);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 获取今日热量和体重数据
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 获取今日总热量
|
||||
/**
|
||||
* 服务端文件:server/src/routes/food-records.ts
|
||||
* 接口:GET /api/v1/food-records/total-calories
|
||||
* Query 参数:userId: string, date?: string
|
||||
*/
|
||||
const caloriesRes = await fetch(
|
||||
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/food-records/total-calories?userId=${MOCK_USER_ID}`
|
||||
);
|
||||
const caloriesData = await caloriesRes.json();
|
||||
if (caloriesData.success) {
|
||||
setTotalCalories(caloriesData.data.totalCalories);
|
||||
}
|
||||
|
||||
// 获取体重统计
|
||||
/**
|
||||
* 服务端文件:server/src/routes/weight-records.ts
|
||||
* 接口:GET /api/v1/weight-records/stats
|
||||
* Query 参数:userId: string
|
||||
*/
|
||||
const weightRes = await fetch(
|
||||
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/weight-records/stats?userId=${MOCK_USER_ID}`
|
||||
);
|
||||
const weightData = await weightRes.json();
|
||||
if (weightData.success) {
|
||||
setCurrentWeight(weightData.data.currentWeight);
|
||||
if (weightData.data.targetWeight) {
|
||||
setTargetWeight(weightData.data.targetWeight);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const caloriePercentage = Math.min((totalCalories / targetCalories) * 100, 100);
|
||||
|
||||
return (
|
||||
<Screen backgroundColor={theme.backgroundRoot} statusBarStyle={isDark ? 'light' : 'dark'}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={loading} onRefresh={fetchData} tintColor={theme.primary} />
|
||||
}
|
||||
>
|
||||
{/* Header */}
|
||||
<ThemedView level="root" style={styles.header}>
|
||||
<ThemedText variant="h2" color={theme.textPrimary}>
|
||||
今日概览
|
||||
</ThemedText>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
坚持就是胜利 💪
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
{/* 热量卡片 */}
|
||||
<ThemedView level="default" style={styles.calorieCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.iconContainer}>
|
||||
<FontAwesome6 name="fire-flame-curved" size={24} color={theme.primary} />
|
||||
</View>
|
||||
<ThemedText variant="h4" color={theme.textPrimary}>
|
||||
今日热量
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.calorieContent}>
|
||||
<ThemedText variant="displayLarge" color={theme.primary}>
|
||||
{totalCalories}
|
||||
</ThemedText>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
/ {targetCalories} kcal
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.progressBar}>
|
||||
<View style={[styles.progressFill, { width: `${caloriePercentage}%` }]} />
|
||||
</View>
|
||||
|
||||
<ThemedText variant="small" color={theme.textMuted} style={styles.remainingText}>
|
||||
还可摄入 {Math.max(0, targetCalories - totalCalories)} kcal
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
|
||||
{/* 体重卡片 */}
|
||||
<ThemedView level="default" style={styles.weightCard}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.iconContainer}>
|
||||
<FontAwesome6 name="weight-scale" size={24} color={theme.primary} />
|
||||
</View>
|
||||
<ThemedText variant="h4" color={theme.textPrimary}>
|
||||
当前体重
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.weightContent}>
|
||||
<ThemedText variant="displayLarge" color={theme.primary}>
|
||||
{currentWeight || '--'}
|
||||
</ThemedText>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
kg
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
{currentWeight && (
|
||||
<ThemedText variant="small" color={theme.textSecondary}>
|
||||
目标体重:{targetWeight} kg
|
||||
{currentWeight > targetWeight ? ` (还需减 ${(currentWeight - targetWeight).toFixed(1)} kg)` : ' ✨'}
|
||||
</ThemedText>
|
||||
)}
|
||||
</ThemedView>
|
||||
|
||||
{/* 快捷操作 */}
|
||||
<View style={styles.quickActions}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.cameraButton]}
|
||||
onPress={() => router.push('/record')}
|
||||
>
|
||||
<View style={styles.actionIconContainer}>
|
||||
<FontAwesome6 name="camera" size={28} color={theme.buttonPrimaryText} />
|
||||
</View>
|
||||
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
|
||||
拍照识别
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.chartButton]}
|
||||
onPress={() => router.push('/stats')}
|
||||
>
|
||||
<View style={styles.actionIconContainer}>
|
||||
<FontAwesome6 name="chart-line" size={28} color={theme.buttonPrimaryText} />
|
||||
</View>
|
||||
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
|
||||
数据统计
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.aiButton]}
|
||||
onPress={() => router.push('/chat')}
|
||||
>
|
||||
<View style={styles.actionIconContainer}>
|
||||
<FontAwesome6 name="robot" size={28} color={theme.buttonPrimaryText} />
|
||||
</View>
|
||||
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
|
||||
AI 助手
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 最近记录 */}
|
||||
<ThemedView level="root" style={styles.recentSection}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<ThemedText variant="h4" color={theme.textPrimary}>
|
||||
最近记录
|
||||
</ThemedText>
|
||||
<TouchableOpacity onPress={() => router.push('/record')}>
|
||||
<ThemedText variant="smallMedium" color={theme.primary}>
|
||||
查看全部
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ThemedText variant="small" color={theme.textMuted} style={styles.emptyText}>
|
||||
暂无记录,快去拍照识别吧!
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
117
client/screens/home/styles.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { Spacing, BorderRadius, Theme } from '@/constants/theme';
|
||||
|
||||
export const createStyles = (theme: Theme) => {
|
||||
return StyleSheet.create({
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingTop: Spacing.xl,
|
||||
paddingBottom: Spacing["5xl"],
|
||||
},
|
||||
header: {
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
calorieCard: {
|
||||
padding: Spacing.xl,
|
||||
marginBottom: Spacing.lg,
|
||||
borderRadius: BorderRadius.xl,
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
weightCard: {
|
||||
padding: Spacing.xl,
|
||||
marginBottom: Spacing.xl,
|
||||
borderRadius: BorderRadius.xl,
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: BorderRadius.lg,
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: Spacing.md,
|
||||
},
|
||||
calorieContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
weightContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
progressBar: {
|
||||
height: 8,
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
borderRadius: BorderRadius.full,
|
||||
overflow: 'hidden',
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: theme.primary,
|
||||
borderRadius: BorderRadius.full,
|
||||
},
|
||||
remainingText: {
|
||||
textAlign: 'right',
|
||||
},
|
||||
quickActions: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: Spacing.xl,
|
||||
gap: Spacing.md,
|
||||
},
|
||||
actionButton: {
|
||||
flex: 1,
|
||||
padding: Spacing.lg,
|
||||
borderRadius: BorderRadius.xl,
|
||||
alignItems: 'center',
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
cameraButton: {
|
||||
backgroundColor: theme.primary,
|
||||
},
|
||||
chartButton: {
|
||||
backgroundColor: '#10B981',
|
||||
},
|
||||
aiButton: {
|
||||
backgroundColor: '#059669',
|
||||
},
|
||||
actionIconContainer: {
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
recentSection: {
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
paddingVertical: Spacing["2xl"],
|
||||
},
|
||||
});
|
||||
};
|
||||
401
client/screens/record/index.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { View, ScrollView, TouchableOpacity, Modal, TextInput, Alert, Image } from 'react-native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { FontAwesome6 } from '@expo/vector-icons';
|
||||
import { createFormDataFile } from '@/utils';
|
||||
import { createStyles } from './styles';
|
||||
|
||||
const MOCK_USER_ID = 'mock-user-001';
|
||||
|
||||
type MealType = 'breakfast' | 'lunch' | 'dinner' | 'snack';
|
||||
|
||||
interface RecognizedFood {
|
||||
foodName: string;
|
||||
weight: number;
|
||||
calories: number;
|
||||
imageUrl: string;
|
||||
imageKey: string;
|
||||
}
|
||||
|
||||
export default function RecordScreen() {
|
||||
const { theme, isDark } = useTheme();
|
||||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [recognizedFood, setRecognizedFood] = useState<RecognizedFood | null>(null);
|
||||
const [manualFood, setManualFood] = useState({
|
||||
name: '',
|
||||
calories: '',
|
||||
weight: '',
|
||||
mealType: 'breakfast' as MealType,
|
||||
});
|
||||
const [recognizing, setRecognizing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [imageUri, setImageUri] = useState<string | null>(null);
|
||||
|
||||
// 请求相机权限
|
||||
const requestCameraPermission = async () => {
|
||||
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('权限提示', '需要相机权限才能拍照识别食物');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// 拍照
|
||||
const takePicture = async () => {
|
||||
const hasPermission = await requestCameraPermission();
|
||||
if (!hasPermission) return;
|
||||
|
||||
try {
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: false,
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
setImageUri(result.assets[0].uri);
|
||||
await recognizeFood(result.assets[0].uri);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Camera error:', error);
|
||||
Alert.alert('错误', '拍照失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 从相册选择
|
||||
const pickImage = async () => {
|
||||
try {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ['images'],
|
||||
allowsEditing: false,
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
setImageUri(result.assets[0].uri);
|
||||
await recognizeFood(result.assets[0].uri);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Image picker error:', error);
|
||||
Alert.alert('错误', '选择图片失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 识别食物
|
||||
const recognizeFood = async (uri: string) => {
|
||||
setRecognizing(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
const file = await createFormDataFile(uri, 'food_photo.jpg', 'image/jpeg');
|
||||
formData.append('image', file as any);
|
||||
|
||||
/**
|
||||
* 服务端文件:server/src/routes/food-records.ts
|
||||
* 接口:POST /api/v1/food-records/recognize
|
||||
* Body 参数:image: File (FormData)
|
||||
*/
|
||||
const response = await fetch(
|
||||
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/food-records/recognize`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setRecognizedFood(data.data);
|
||||
setManualFood({
|
||||
name: data.data.foodName,
|
||||
calories: data.data.calories.toString(),
|
||||
weight: data.data.weight.toString(),
|
||||
mealType: 'breakfast',
|
||||
});
|
||||
setModalVisible(true);
|
||||
} else {
|
||||
Alert.alert('识别失败', data.error || '无法识别食物,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Recognition error:', error);
|
||||
Alert.alert('错误', '识别失败,请检查网络连接');
|
||||
} finally {
|
||||
setRecognizing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 保存记录
|
||||
const saveRecord = async () => {
|
||||
if (!manualFood.name || !manualFood.calories || !manualFood.weight) {
|
||||
Alert.alert('提示', '请填写完整的食物信息');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const recordData = {
|
||||
userId: MOCK_USER_ID,
|
||||
foodName: manualFood.name,
|
||||
calories: parseInt(manualFood.calories),
|
||||
weight: parseFloat(manualFood.weight),
|
||||
mealType: manualFood.mealType,
|
||||
recordedAt: new Date().toISOString(),
|
||||
imageUrl: recognizedFood?.imageUrl,
|
||||
};
|
||||
|
||||
/**
|
||||
* 服务端文件:server/src/routes/food-records.ts
|
||||
* 接口:POST /api/v1/food-records
|
||||
* Body 参数:userId: string, foodName: string, calories: number, weight: number, mealType: string, recordedAt: string, imageUrl?: string
|
||||
*/
|
||||
const response = await fetch(
|
||||
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/food-records`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(recordData),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
Alert.alert('成功', '记录已保存', [
|
||||
{ text: '确定', onPress: () => resetForm() },
|
||||
]);
|
||||
} else {
|
||||
Alert.alert('失败', data.error || '保存失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
Alert.alert('错误', '保存失败,请检查网络连接');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setModalVisible(false);
|
||||
setRecognizedFood(null);
|
||||
setImageUri(null);
|
||||
setManualFood({
|
||||
name: '',
|
||||
calories: '',
|
||||
weight: '',
|
||||
mealType: 'breakfast',
|
||||
});
|
||||
};
|
||||
|
||||
const mealTypes: { key: MealType; label: string }[] = [
|
||||
{ key: 'breakfast', label: '早餐' },
|
||||
{ key: 'lunch', label: '午餐' },
|
||||
{ key: 'dinner', label: '晚餐' },
|
||||
{ key: 'snack', label: '加餐' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Screen backgroundColor={theme.backgroundRoot} statusBarStyle={isDark ? 'light' : 'dark'}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<ThemedText variant="h2" color={theme.textPrimary} style={styles.title}>
|
||||
记录食物
|
||||
</ThemedText>
|
||||
|
||||
{/* 识别方式 */}
|
||||
<View style={styles.methodButtons}>
|
||||
<TouchableOpacity
|
||||
style={[styles.methodButton, styles.cameraButton]}
|
||||
onPress={takePicture}
|
||||
disabled={recognizing}
|
||||
>
|
||||
<FontAwesome6 name="camera" size={32} color={theme.buttonPrimaryText} />
|
||||
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText} style={styles.buttonText}>
|
||||
{recognizing ? '识别中...' : '拍照识别'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.methodButton, styles.galleryButton]}
|
||||
onPress={pickImage}
|
||||
disabled={recognizing}
|
||||
>
|
||||
<FontAwesome6 name="image" size={32} color={theme.buttonPrimaryText} />
|
||||
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText} style={styles.buttonText}>
|
||||
{recognizing ? '识别中...' : '相册选择'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 手动添加 */}
|
||||
<ThemedView level="root" style={styles.manualSection}>
|
||||
<ThemedText variant="h4" color={theme.textPrimary} style={styles.sectionTitle}>
|
||||
手动添加
|
||||
</ThemedText>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<ThemedText variant="small" color={theme.textSecondary} style={styles.label}>
|
||||
食物名称
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="输入食物名称"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={manualFood.name}
|
||||
onChangeText={(text) => setManualFood({ ...manualFood, name: text })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputRow}>
|
||||
<View style={[styles.inputGroup, styles.halfInput]}>
|
||||
<ThemedText variant="small" color={theme.textSecondary} style={styles.label}>
|
||||
热量 (kcal)
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="0"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={manualFood.calories}
|
||||
onChangeText={(text) => setManualFood({ ...manualFood, calories: text })}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.inputGroup, styles.halfInput]}>
|
||||
<ThemedText variant="small" color={theme.textSecondary} style={styles.label}>
|
||||
重量 (g)
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="0"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={manualFood.weight}
|
||||
onChangeText={(text) => setManualFood({ ...manualFood, weight: text })}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<ThemedText variant="small" color={theme.textSecondary} style={styles.label}>
|
||||
餐次
|
||||
</ThemedText>
|
||||
<View style={styles.mealTypes}>
|
||||
{mealTypes.map((type) => (
|
||||
<TouchableOpacity
|
||||
key={type.key}
|
||||
style={[
|
||||
styles.mealTypeButton,
|
||||
manualFood.mealType === type.key && styles.mealTypeButtonActive,
|
||||
]}
|
||||
onPress={() => setManualFood({ ...manualFood, mealType: type.key })}
|
||||
>
|
||||
<ThemedText
|
||||
variant="smallMedium"
|
||||
color={manualFood.mealType === type.key ? theme.buttonPrimaryText : theme.textPrimary}
|
||||
>
|
||||
{type.label}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, { opacity: saving ? 0.6 : 1 }]}
|
||||
onPress={saveRecord}
|
||||
disabled={saving}
|
||||
>
|
||||
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
|
||||
{saving ? '保存中...' : '保存记录'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
|
||||
{/* 识别结果 Modal */}
|
||||
<Modal visible={modalVisible} transparent animationType="slide">
|
||||
<View style={styles.modalContainer}>
|
||||
<ThemedView level="default" style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<ThemedText variant="h4" color={theme.textPrimary}>
|
||||
{recognizedFood ? '识别结果' : '确认信息'}
|
||||
</ThemedText>
|
||||
<TouchableOpacity onPress={resetForm}>
|
||||
<FontAwesome6 name="xmark" size={24} color={theme.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.modalBody}>
|
||||
{imageUri && (
|
||||
<Image source={{ uri: imageUri }} style={styles.previewImage} />
|
||||
)}
|
||||
|
||||
<View style={styles.resultItem}>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
食物名称
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
style={styles.resultInput}
|
||||
value={manualFood.name}
|
||||
onChangeText={(text) => setManualFood({ ...manualFood, name: text })}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputRow}>
|
||||
<View style={[styles.resultItem, styles.halfInput]}>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
热量
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
style={styles.resultInput}
|
||||
value={manualFood.calories}
|
||||
onChangeText={(text) => setManualFood({ ...manualFood, calories: text })}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={[styles.resultItem, styles.halfInput]}>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
重量 (g)
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
style={styles.resultInput}
|
||||
value={manualFood.weight}
|
||||
onChangeText={(text) => setManualFood({ ...manualFood, weight: text })}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View style={styles.modalFooter}>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, styles.cancelButton]}
|
||||
onPress={resetForm}
|
||||
>
|
||||
<ThemedText variant="smallMedium" color={theme.textSecondary}>
|
||||
取消
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, styles.confirmButton]}
|
||||
onPress={saveRecord}
|
||||
>
|
||||
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
|
||||
{saving ? '保存中...' : '确认保存'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</View>
|
||||
</Modal>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
163
client/screens/record/styles.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { StyleSheet, Image } from 'react-native';
|
||||
import { Spacing, BorderRadius, Theme } from '@/constants/theme';
|
||||
|
||||
export const createStyles = (theme: Theme) => {
|
||||
return StyleSheet.create({
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingTop: Spacing.xl,
|
||||
paddingBottom: Spacing["5xl"],
|
||||
},
|
||||
title: {
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
methodButtons: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
gap: Spacing.md,
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
methodButton: {
|
||||
flex: 1,
|
||||
padding: Spacing.xl,
|
||||
borderRadius: BorderRadius.xl,
|
||||
alignItems: 'center',
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
cameraButton: {
|
||||
backgroundColor: theme.primary,
|
||||
},
|
||||
galleryButton: {
|
||||
backgroundColor: '#10B981',
|
||||
},
|
||||
buttonText: {
|
||||
marginTop: Spacing.sm,
|
||||
},
|
||||
manualSection: {
|
||||
padding: Spacing.xl,
|
||||
borderRadius: BorderRadius.xl,
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
gap: Spacing.md,
|
||||
},
|
||||
halfInput: {
|
||||
flex: 1,
|
||||
},
|
||||
label: {
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
borderRadius: BorderRadius.lg,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingVertical: Spacing.md,
|
||||
color: theme.textPrimary,
|
||||
fontSize: 16,
|
||||
},
|
||||
mealTypes: {
|
||||
flexDirection: 'row',
|
||||
gap: Spacing.sm,
|
||||
},
|
||||
mealTypeButton: {
|
||||
flex: 1,
|
||||
paddingVertical: Spacing.md,
|
||||
borderRadius: BorderRadius.lg,
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
mealTypeButtonActive: {
|
||||
backgroundColor: theme.primary,
|
||||
borderColor: theme.primary,
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: theme.primary,
|
||||
paddingVertical: Spacing.lg,
|
||||
borderRadius: BorderRadius.lg,
|
||||
alignItems: 'center',
|
||||
marginTop: Spacing.md,
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
borderTopLeftRadius: BorderRadius["2xl"],
|
||||
borderTopRightRadius: BorderRadius["2xl"],
|
||||
maxHeight: '80%',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: Spacing.xl,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.border,
|
||||
},
|
||||
modalBody: {
|
||||
padding: Spacing.xl,
|
||||
},
|
||||
previewImage: {
|
||||
width: '100%',
|
||||
height: 200,
|
||||
borderRadius: BorderRadius.lg,
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
resultItem: {
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
resultInput: {
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
borderRadius: BorderRadius.lg,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingVertical: Spacing.md,
|
||||
color: theme.textPrimary,
|
||||
fontSize: 16,
|
||||
marginTop: Spacing.sm,
|
||||
},
|
||||
modalFooter: {
|
||||
flexDirection: 'row',
|
||||
gap: Spacing.md,
|
||||
padding: Spacing.xl,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.border,
|
||||
},
|
||||
modalButton: {
|
||||
flex: 1,
|
||||
paddingVertical: Spacing.lg,
|
||||
borderRadius: BorderRadius.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
},
|
||||
confirmButton: {
|
||||
backgroundColor: theme.primary,
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
});
|
||||
};
|
||||
346
client/screens/stats/index.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { View, ScrollView, TouchableOpacity, TextInput, Modal, Alert } from 'react-native';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import { Screen } from '@/components/Screen';
|
||||
import { ThemedText } from '@/components/ThemedText';
|
||||
import { ThemedView } from '@/components/ThemedView';
|
||||
import { FontAwesome6 } from '@expo/vector-icons';
|
||||
import { createStyles } from './styles';
|
||||
|
||||
const MOCK_USER_ID = 'mock-user-001';
|
||||
|
||||
interface WeightRecord {
|
||||
id: string;
|
||||
weight: number;
|
||||
note: string;
|
||||
recordedAt: string;
|
||||
}
|
||||
|
||||
export default function StatsScreen() {
|
||||
const { theme, isDark } = useTheme();
|
||||
const styles = useMemo(() => createStyles(theme), [theme]);
|
||||
|
||||
const [weightRecords, setWeightRecords] = useState<WeightRecord[]>([]);
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [newWeight, setNewWeight] = useState('');
|
||||
const [newNote, setNewNote] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// 获取体重记录
|
||||
const fetchWeightRecords = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
/**
|
||||
* 服务端文件:server/src/routes/weight-records.ts
|
||||
* 接口:GET /api/v1/weight-records
|
||||
* Query 参数:userId: string
|
||||
*/
|
||||
const response = await fetch(
|
||||
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/weight-records?userId=${MOCK_USER_ID}`
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setWeightRecords(data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch weight records:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchWeightRecords();
|
||||
}, []);
|
||||
|
||||
// 添加体重记录
|
||||
const addWeightRecord = async () => {
|
||||
if (!newWeight) {
|
||||
Alert.alert('提示', '请输入体重');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const recordData = {
|
||||
userId: MOCK_USER_ID,
|
||||
weight: parseFloat(newWeight),
|
||||
note: newNote,
|
||||
recordedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
/**
|
||||
* 服务端文件:server/src/routes/weight-records.ts
|
||||
* 接口:POST /api/v1/weight-records
|
||||
* Body 参数:userId: string, weight: number, note?: string, recordedAt: string
|
||||
*/
|
||||
const response = await fetch(
|
||||
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/weight-records`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(recordData),
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
Alert.alert('成功', '记录已保存', [
|
||||
{ text: '确定', onPress: () => resetModal() },
|
||||
]);
|
||||
fetchWeightRecords();
|
||||
} else {
|
||||
Alert.alert('失败', data.error || '保存失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
Alert.alert('错误', '保存失败,请检查网络连接');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetModal = () => {
|
||||
setModalVisible(false);
|
||||
setNewWeight('');
|
||||
setNewNote('');
|
||||
};
|
||||
|
||||
// 删除记录
|
||||
const deleteRecord = async (id: string) => {
|
||||
Alert.alert('确认删除', '确定要删除这条记录吗?', [
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
/**
|
||||
* 服务端文件:server/src/routes/weight-records.ts
|
||||
* 接口:DELETE /api/v1/weight-records/:id
|
||||
* Path 参数:id: string
|
||||
*/
|
||||
const response = await fetch(
|
||||
`${process.env.EXPO_PUBLIC_BACKEND_BASE_URL}/api/v1/weight-records/${id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
}
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
fetchWeightRecords();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Screen backgroundColor={theme.backgroundRoot} statusBarStyle={isDark ? 'light' : 'dark'}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
<View style={styles.header}>
|
||||
<ThemedText variant="h2" color={theme.textPrimary}>
|
||||
数据统计
|
||||
</ThemedText>
|
||||
<TouchableOpacity
|
||||
style={styles.addButton}
|
||||
onPress={() => setModalVisible(true)}
|
||||
>
|
||||
<FontAwesome6 name="plus" size={18} color={theme.buttonPrimaryText} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 体重趋势 */}
|
||||
<ThemedView level="root" style={styles.chartSection}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<ThemedText variant="h4" color={theme.textPrimary}>
|
||||
体重记录
|
||||
</ThemedText>
|
||||
{weightRecords.length > 0 && (
|
||||
<ThemedText variant="small" color={theme.primary}>
|
||||
{weightRecords.length} 条记录
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{weightRecords.length === 0 ? (
|
||||
<ThemedText variant="small" color={theme.textMuted} style={styles.emptyText}>
|
||||
暂无记录,点击右上角添加
|
||||
</ThemedText>
|
||||
) : (
|
||||
<View style={styles.recordsList}>
|
||||
{weightRecords.map((record, index) => (
|
||||
<View key={record.id} style={styles.recordItem}>
|
||||
<View style={styles.recordInfo}>
|
||||
<View style={styles.recordWeight}>
|
||||
<ThemedText variant="h3" color={theme.primary}>
|
||||
{record.weight}
|
||||
</ThemedText>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
kg
|
||||
</ThemedText>
|
||||
</View>
|
||||
<View>
|
||||
<ThemedText variant="small" color={theme.textSecondary}>
|
||||
{new Date(record.recordedAt).toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</ThemedText>
|
||||
{record.note && (
|
||||
<ThemedText variant="caption" color={theme.textMuted}>
|
||||
{record.note}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{index > 0 && (
|
||||
<View style={styles.changeBadge}>
|
||||
<FontAwesome6
|
||||
name={record.weight < weightRecords[index - 1].weight ? 'arrow-down' : 'arrow-up'}
|
||||
size={12}
|
||||
color={record.weight < weightRecords[index - 1].weight ? '#10B981' : '#EF4444'}
|
||||
/>
|
||||
<ThemedText
|
||||
variant="caption"
|
||||
color={record.weight < weightRecords[index - 1].weight ? '#10B981' : '#EF4444'}
|
||||
>
|
||||
{Math.abs(record.weight - weightRecords[index - 1].weight).toFixed(1)}
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity onPress={() => deleteRecord(record.id)}>
|
||||
<FontAwesome6 name="trash" size={16} color={theme.textMuted} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ThemedView>
|
||||
|
||||
{/* 统计信息 */}
|
||||
{weightRecords.length >= 2 && (
|
||||
<ThemedView level="root" style={styles.statsSection}>
|
||||
<ThemedText variant="h4" color={theme.textPrimary} style={styles.sectionTitle}>
|
||||
统计信息
|
||||
</ThemedText>
|
||||
|
||||
<View style={styles.statRow}>
|
||||
<View style={styles.statItem}>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
初始体重
|
||||
</ThemedText>
|
||||
<ThemedText variant="h4" color={theme.textPrimary}>
|
||||
{weightRecords[weightRecords.length - 1].weight} kg
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={[styles.statItem, styles.statBorder]}>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
当前体重
|
||||
</ThemedText>
|
||||
<ThemedText variant="h4" color={theme.primary}>
|
||||
{weightRecords[0].weight} kg
|
||||
</ThemedText>
|
||||
</View>
|
||||
|
||||
<View style={styles.statItem}>
|
||||
<ThemedText variant="small" color={theme.textMuted}>
|
||||
累计变化
|
||||
</ThemedText>
|
||||
<ThemedText
|
||||
variant="h4"
|
||||
color={
|
||||
weightRecords[0].weight < weightRecords[weightRecords.length - 1].weight
|
||||
? '#10B981'
|
||||
: '#EF4444'
|
||||
}
|
||||
>
|
||||
{weightRecords[0].weight < weightRecords[weightRecords.length - 1].weight ? '-' : '+'}
|
||||
{Math.abs(
|
||||
weightRecords[0].weight - weightRecords[weightRecords.length - 1].weight
|
||||
).toFixed(1)}{' '}
|
||||
kg
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
</ThemedView>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* 添加记录 Modal */}
|
||||
<Modal visible={modalVisible} transparent animationType="fade">
|
||||
<View style={styles.modalContainer}>
|
||||
<ThemedView level="default" style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<ThemedText variant="h4" color={theme.textPrimary}>
|
||||
添加体重记录
|
||||
</ThemedText>
|
||||
<TouchableOpacity onPress={resetModal}>
|
||||
<FontAwesome6 name="xmark" size={24} color={theme.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.modalBody}>
|
||||
<View style={styles.inputGroup}>
|
||||
<ThemedText variant="small" color={theme.textSecondary} style={styles.label}>
|
||||
体重 (kg)
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="输入体重"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={newWeight}
|
||||
onChangeText={setNewWeight}
|
||||
keyboardType="decimal-pad"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<ThemedText variant="small" color={theme.textSecondary} style={styles.label}>
|
||||
备注 (可选)
|
||||
</ThemedText>
|
||||
<TextInput
|
||||
style={[styles.input, styles.textArea]}
|
||||
placeholder="添加备注"
|
||||
placeholderTextColor={theme.textMuted}
|
||||
value={newNote}
|
||||
onChangeText={setNewNote}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.modalFooter}>
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, styles.cancelButton]}
|
||||
onPress={resetModal}
|
||||
>
|
||||
<ThemedText variant="smallMedium" color={theme.textSecondary}>
|
||||
取消
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.modalButton, styles.confirmButton]}
|
||||
onPress={addWeightRecord}
|
||||
>
|
||||
<ThemedText variant="smallMedium" color={theme.buttonPrimaryText}>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ThemedView>
|
||||
</View>
|
||||
</Modal>
|
||||
</Screen>
|
||||
);
|
||||
}
|
||||
161
client/screens/stats/styles.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { Spacing, BorderRadius, Theme } from '@/constants/theme';
|
||||
|
||||
export const createStyles = (theme: Theme) => {
|
||||
return StyleSheet.create({
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingTop: Spacing.xl,
|
||||
paddingBottom: Spacing["5xl"],
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.xl,
|
||||
},
|
||||
addButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: BorderRadius.lg,
|
||||
backgroundColor: theme.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
chartSection: {
|
||||
padding: Spacing.xl,
|
||||
borderRadius: BorderRadius.xl,
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
paddingVertical: Spacing["2xl"],
|
||||
},
|
||||
recordsList: {
|
||||
gap: Spacing.md,
|
||||
},
|
||||
recordItem: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: Spacing.lg,
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
borderRadius: BorderRadius.lg,
|
||||
},
|
||||
recordInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
recordWeight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'baseline',
|
||||
marginBottom: Spacing.xs,
|
||||
},
|
||||
changeBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: Spacing.sm,
|
||||
paddingVertical: Spacing.xs,
|
||||
backgroundColor: theme.backgroundDefault,
|
||||
borderRadius: BorderRadius.sm,
|
||||
marginRight: Spacing.md,
|
||||
},
|
||||
statsSection: {
|
||||
padding: Spacing.xl,
|
||||
borderRadius: BorderRadius.xl,
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
statRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
statItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
statBorder: {
|
||||
borderLeftWidth: 1,
|
||||
borderRightWidth: 1,
|
||||
borderColor: theme.border,
|
||||
},
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: Spacing.lg,
|
||||
},
|
||||
modalContent: {
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
borderRadius: BorderRadius["2xl"],
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: Spacing.xl,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: theme.border,
|
||||
},
|
||||
modalBody: {
|
||||
padding: Spacing.xl,
|
||||
},
|
||||
inputGroup: {
|
||||
marginBottom: Spacing.lg,
|
||||
},
|
||||
label: {
|
||||
marginBottom: Spacing.sm,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
borderRadius: BorderRadius.lg,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
paddingVertical: Spacing.md,
|
||||
color: theme.textPrimary,
|
||||
fontSize: 16,
|
||||
},
|
||||
textArea: {
|
||||
height: 80,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
modalFooter: {
|
||||
flexDirection: 'row',
|
||||
gap: Spacing.md,
|
||||
padding: Spacing.xl,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: theme.border,
|
||||
},
|
||||
modalButton: {
|
||||
flex: 1,
|
||||
paddingVertical: Spacing.lg,
|
||||
borderRadius: BorderRadius.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: theme.backgroundTertiary,
|
||||
},
|
||||
confirmButton: {
|
||||
backgroundColor: theme.primary,
|
||||
shadowColor: theme.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
});
|
||||
};
|
||||
105
client/scripts/install-missing-deps.js
Normal file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* 自动检测并安装缺失的依赖
|
||||
* 使用方法: node scripts/install-missing-deps.js
|
||||
*/
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
console.log('🔍 检测缺失的依赖...\n');
|
||||
|
||||
try {
|
||||
// 运行 depcheck 并获取 JSON 输出
|
||||
// 注意:depcheck 发现问题时会返回非零退出码,但这不是错误
|
||||
let depcheckOutput;
|
||||
try {
|
||||
depcheckOutput = execSync('npx depcheck --json', {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (execError) {
|
||||
// depcheck 返回非零退出码时仍然有输出
|
||||
if (execError.stdout) {
|
||||
depcheckOutput = execError.stdout;
|
||||
} else {
|
||||
throw execError;
|
||||
}
|
||||
}
|
||||
|
||||
const result = JSON.parse(depcheckOutput);
|
||||
|
||||
// 获取缺失的依赖
|
||||
const missing = result.missing || {};
|
||||
|
||||
// 需要忽略的文件模式
|
||||
const ignoreFilePatterns = [
|
||||
/template\.config\.(ts|js)$/, // 模板配置文件
|
||||
/\.template\./, // 其他模板文件
|
||||
/declarations\.d\.ts$/, // 项目配置文件
|
||||
];
|
||||
|
||||
// 过滤包:排除内部别名和只被模板文件引用的包
|
||||
const missingPackages = Object.keys(missing).filter(pkg => {
|
||||
// 排除内部路径别名
|
||||
if (pkg.startsWith('@api/') || pkg.startsWith('@/') || pkg === '@api') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 获取引用该包的文件列表
|
||||
const referencingFiles = missing[pkg] || [];
|
||||
|
||||
// 过滤掉模板配置文件
|
||||
const nonTemplateFiles = referencingFiles.filter(file => {
|
||||
return !ignoreFilePatterns.some(pattern => pattern.test(file));
|
||||
});
|
||||
|
||||
// 只有当存在非模板文件引用时才保留该包
|
||||
return nonTemplateFiles.length > 0;
|
||||
});
|
||||
|
||||
if (missingPackages.length === 0) {
|
||||
console.log('✅ 没有发现缺失的依赖!');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('📦 发现以下缺失的依赖:');
|
||||
missingPackages.forEach((pkg, index) => {
|
||||
const files = missing[pkg];
|
||||
console.log(` ${index + 1}. ${pkg}`);
|
||||
console.log(
|
||||
` 被引用于: ${files.slice(0, 2).join(', ')}${files.length > 2 ? ' ...' : ''}`,
|
||||
);
|
||||
});
|
||||
|
||||
console.log('\n🚀 开始安装...\n');
|
||||
|
||||
// 使用 expo install 安装所有缺失的包
|
||||
const packagesToInstall = missingPackages.join(' ');
|
||||
|
||||
try {
|
||||
execSync(`pnpm expo install ${packagesToInstall}`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
console.log('\n✅ 所有缺失的依赖已安装完成!');
|
||||
} catch (installError) {
|
||||
console.log('\n⚠️ expo install 失败,尝试使用 npm install...\n');
|
||||
|
||||
execSync(`npm install ${packagesToInstall}`, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
console.log('\n✅ 所有缺失的依赖已通过 npm 安装完成!');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.message.includes('depcheck')) {
|
||||
console.error('❌ depcheck 未安装或运行失败');
|
||||
console.log('💡 尝试运行: npm install -g depcheck');
|
||||
} else {
|
||||
console.error('❌ 发生错误:', error.message);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
24
client/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": ".",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"**/*.spec.ts",
|
||||
"**/*.test.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/*.test.tsx",
|
||||
"dist",
|
||||
"build",
|
||||
".expo"
|
||||
]
|
||||
}
|
||||
76
client/utils/index.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { Platform } from 'react-native';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
dayjs.extend(utc);
|
||||
|
||||
const API_BASE = (process.env.EXPO_PUBLIC_API_BASE ?? '').replace(/\/$/, '');
|
||||
|
||||
/**
|
||||
* 创建跨平台兼容的文件对象,用于 FormData.append()
|
||||
* - Web 端返回 File 对象
|
||||
* - 移动端返回 { uri, type, name } 对象(RN fetch 会自动处理)
|
||||
* @param fileUri Expo 媒体库(如 expo-image-picker、expo-camera)返回的 uri
|
||||
* @param fileName 上传时的文件名,如 'photo.jpg'
|
||||
* @param mimeType 文件 MIME 类型,如 'image/jpeg'、'audio/mpeg'
|
||||
*/
|
||||
export async function createFormDataFile(
|
||||
fileUri: string,
|
||||
fileName: string,
|
||||
mimeType: string
|
||||
): Promise<File | { uri: string; type: string; name: string }> {
|
||||
if (Platform.OS === 'web') {
|
||||
const response = await fetch(fileUri);
|
||||
const blob = await response.blob();
|
||||
return new File([blob], fileName, { type: mimeType });
|
||||
}
|
||||
return { uri: fileUri, type: mimeType, name: fileName };
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建文件或图片完整的URL
|
||||
* @param url 相对或绝对路径
|
||||
* @param w 宽度 (px) - 自动向下取整
|
||||
* @param h 高度 (px)
|
||||
*/
|
||||
export const buildAssetUrl = (url?: string | null, w?: number, h?: number): string | undefined => {
|
||||
if (!url) return undefined;
|
||||
if (/^https?:\/\//i.test(url)) return url; // 绝对路径直接返回
|
||||
|
||||
// 1. 去除 Base 尾部和 Path 头部的斜杠
|
||||
const base = API_BASE;
|
||||
const path = url.replace(/^\//, '');
|
||||
const abs = `${base}/${path}`;
|
||||
|
||||
// 2. 无需缩略图则直接返回
|
||||
if (!w && !h) return abs;
|
||||
|
||||
// 3. 构造参数,保留原有 Query (如有)
|
||||
const separator = abs.includes('?') ? '&' : '?';
|
||||
const query = [
|
||||
w ? `w=${Math.floor(w)}` : '',
|
||||
h ? `h=${Math.floor(h)}` : ''
|
||||
].filter(Boolean).join('&');
|
||||
return `${abs}${separator}${query}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将UTC时间字符串转换为本地时间字符串
|
||||
* @param utcDateStr UTC时间字符串,格式如:2025-11-26T01:49:48.009573
|
||||
* @returns 本地时间字符串,格式如:2025-11-26 08:49:48
|
||||
*/
|
||||
export const convertToLocalTimeStr = (utcDateStr: string): string => {
|
||||
if (!utcDateStr) {
|
||||
return utcDateStr;
|
||||
}
|
||||
const microUtcRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{1,6}/;
|
||||
if (!microUtcRegex.test(utcDateStr)) {
|
||||
console.log('invalid utcDateStr:', utcDateStr);
|
||||
return utcDateStr;
|
||||
}
|
||||
const normalized = utcDateStr.replace(/\.(\d{6})$/, (_, frac) => `.${frac.slice(0, 3)}`);
|
||||
const d = dayjs.utc(normalized);
|
||||
if (!d.isValid()) {
|
||||
return utcDateStr;
|
||||
}
|
||||
return d.local().format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
9
eslint-plugins/fontawesome6/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const validName = require('./rule')
|
||||
|
||||
const plugin = {
|
||||
rules: {
|
||||
'valid-name': validName,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = plugin
|
||||
1889
eslint-plugins/fontawesome6/names.js
Normal file
174
eslint-plugins/fontawesome6/rule.js
Normal file
@@ -0,0 +1,174 @@
|
||||
const names = require('./names')
|
||||
const v5OnlyNames = require('./v5-only-names')
|
||||
const v5OnlyNamesSet = new Set(v5OnlyNames)
|
||||
|
||||
const DEFAULTS = {
|
||||
whitelist: names,
|
||||
componentName: 'FontAwesome6',
|
||||
attributeName: 'name',
|
||||
};
|
||||
|
||||
function getJSXAttribute(openingElement, attrName) {
|
||||
return openingElement.attributes.find(
|
||||
(attr) =>
|
||||
attr &&
|
||||
attr.type === 'JSXAttribute' &&
|
||||
attr.name &&
|
||||
attr.name.name === attrName
|
||||
);
|
||||
}
|
||||
|
||||
function getStringLiteralValue(attribute) {
|
||||
if (!attribute || !attribute.value) return null;
|
||||
|
||||
const val = attribute.value;
|
||||
|
||||
// <Comp name="hello" />
|
||||
if (val.type === 'Literal' && typeof val.value === 'string') {
|
||||
return val.value;
|
||||
}
|
||||
|
||||
// <Comp name={'hello'} />
|
||||
if (
|
||||
val.type === 'JSXExpressionContainer' &&
|
||||
val.expression &&
|
||||
val.expression.type === 'Literal' &&
|
||||
typeof val.expression.value === 'string'
|
||||
) {
|
||||
return val.expression.value;
|
||||
}
|
||||
|
||||
// <Comp name={`hello`} /> template literal without expressions
|
||||
if (
|
||||
val.type === 'JSXExpressionContainer' &&
|
||||
val.expression &&
|
||||
val.expression.type === 'TemplateLiteral' &&
|
||||
val.expression.expressions.length === 0
|
||||
) {
|
||||
return val.expression.quasis[0]?.value?.cooked ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const replacements = {
|
||||
'plus-circle': 'circle-plus',
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Ensure FontAwesome6 name prop is a string literal in whitelist',
|
||||
recommended: false,
|
||||
},
|
||||
schema: [
|
||||
{
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
whitelist: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
default: DEFAULTS.whitelist,
|
||||
},
|
||||
componentName: {
|
||||
type: 'string',
|
||||
default: DEFAULTS.componentName,
|
||||
},
|
||||
attributeName: {
|
||||
type: 'string',
|
||||
default: DEFAULTS.attributeName,
|
||||
},
|
||||
caseSensitive: {
|
||||
type: 'boolean',
|
||||
default: true
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
messages: {
|
||||
invalidName:
|
||||
'{{componentName}} 中不存在图标 {{name}},{{suggestion}}',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const options = context.options && context.options[0] ? context.options[0] : {};
|
||||
const componentName = options.componentName || DEFAULTS.componentName;
|
||||
const attributeName = options.attributeName || DEFAULTS.attributeName;
|
||||
const caseSensitive = options.caseSensitive ?? true;
|
||||
|
||||
const whitelistRaw = Array.isArray(options.whitelist)
|
||||
? options.whitelist
|
||||
: DEFAULTS.whitelist;
|
||||
|
||||
const normalize = (s) =>
|
||||
caseSensitive ? String(s) : String(s).toLowerCase();
|
||||
|
||||
const whitelist = new Set(whitelistRaw.map(normalize));
|
||||
|
||||
function isTargetComponent(node) {
|
||||
// Supports: <FontAwesome6 />, <NS.FontAwesome6 />
|
||||
const nameNode = node.name;
|
||||
if (!nameNode) return false;
|
||||
|
||||
if (nameNode.type === 'JSXIdentifier') {
|
||||
return nameNode.name === componentName;
|
||||
}
|
||||
|
||||
if (nameNode.type === 'JSXMemberExpression') {
|
||||
// e.g., UI.FontAwesome6
|
||||
let base = nameNode;
|
||||
while (base.type === 'JSXMemberExpression') {
|
||||
if (base.property && base.property.name === componentName) {
|
||||
return true;
|
||||
}
|
||||
base = base.object;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
JSXOpeningElement(opening) {
|
||||
if (!isTargetComponent(opening)) return;
|
||||
|
||||
const attrNode = getJSXAttribute(opening, attributeName);
|
||||
if (!attrNode) return;
|
||||
|
||||
const literal = getStringLiteralValue(attrNode);
|
||||
|
||||
// Only lint when it's a string literal
|
||||
if (literal == null) return;
|
||||
|
||||
const normalized = normalize(literal);
|
||||
if (!whitelist.has(normalized)) {
|
||||
context.report({
|
||||
node: attrNode.value || attrNode,
|
||||
messageId: 'invalidName',
|
||||
data: {
|
||||
componentName,
|
||||
name: literal,
|
||||
suggestion: getSuggestion(normalized, literal),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function getSuggestion(name, originalName) {
|
||||
if (replacements[name]) {
|
||||
return `请更换为 ${replacements[name]}`
|
||||
}
|
||||
|
||||
if (v5OnlyNamesSet.has(name)) {
|
||||
return `${originalName} 只能和 FontAwesome5 组件一起使用`
|
||||
}
|
||||
|
||||
return '请更换为其他图标'
|
||||
}
|
||||
388
eslint-plugins/fontawesome6/v5-only-names.js
Normal file
@@ -0,0 +1,388 @@
|
||||
const v5names = [
|
||||
"acquisitions-incorporated",
|
||||
"behance-square",
|
||||
"dribbble-square",
|
||||
"facebook-square",
|
||||
"font-awesome-alt",
|
||||
"font-awesome-flag",
|
||||
"font-awesome-logo-full",
|
||||
"git-square",
|
||||
"github-square",
|
||||
"google-plus-square",
|
||||
"hacker-news-square",
|
||||
"innosoft",
|
||||
"instagram-square",
|
||||
"js-square",
|
||||
"lastfm-square",
|
||||
"medium-m",
|
||||
"odnoklassniki-square",
|
||||
"penny-arcade",
|
||||
"pied-piper-square",
|
||||
"pinterest-square",
|
||||
"reddit-square",
|
||||
"slack-hash",
|
||||
"snapchat-ghost",
|
||||
"snapchat-square",
|
||||
"steam-square",
|
||||
"telegram-plane",
|
||||
"tripadvisor",
|
||||
"tumblr-square",
|
||||
"twitter-square",
|
||||
"viadeo-square",
|
||||
"vimeo-square",
|
||||
"whatsapp-square",
|
||||
"xing-square",
|
||||
"youtube-square",
|
||||
"angry",
|
||||
"arrow-alt-circle-down",
|
||||
"arrow-alt-circle-left",
|
||||
"arrow-alt-circle-right",
|
||||
"arrow-alt-circle-up",
|
||||
"calendar-alt",
|
||||
"calendar-times",
|
||||
"caret-square-down",
|
||||
"caret-square-left",
|
||||
"caret-square-right",
|
||||
"caret-square-up",
|
||||
"check-circle",
|
||||
"check-square",
|
||||
"comment-alt",
|
||||
"dizzy",
|
||||
"dot-circle",
|
||||
"edit",
|
||||
"file-alt",
|
||||
"file-archive",
|
||||
"flushed",
|
||||
"frown-open",
|
||||
"frown",
|
||||
"grimace",
|
||||
"grin-alt",
|
||||
"grin-beam-sweat",
|
||||
"grin-beam",
|
||||
"grin-hearts",
|
||||
"grin-squint-tears",
|
||||
"grin-squint",
|
||||
"grin-stars",
|
||||
"grin-tears",
|
||||
"grin-tongue-squint",
|
||||
"grin-tongue-wink",
|
||||
"grin-tongue",
|
||||
"grin-wink",
|
||||
"grin",
|
||||
"hand-paper",
|
||||
"hand-rock",
|
||||
"hdd",
|
||||
"kiss-beam",
|
||||
"kiss-wink-heart",
|
||||
"kiss",
|
||||
"laugh-beam",
|
||||
"laugh-squint",
|
||||
"laugh-wink",
|
||||
"laugh",
|
||||
"list-alt",
|
||||
"meh-blank",
|
||||
"meh-rolling-eyes",
|
||||
"meh",
|
||||
"minus-square",
|
||||
"money-bill-alt",
|
||||
"pause-circle",
|
||||
"play-circle",
|
||||
"plus-square",
|
||||
"question-circle",
|
||||
"sad-cry",
|
||||
"sad-tear",
|
||||
"save",
|
||||
"share-square",
|
||||
"smile-beam",
|
||||
"smile-wink",
|
||||
"smile",
|
||||
"sticky-note",
|
||||
"stop-circle",
|
||||
"surprise",
|
||||
"times-circle",
|
||||
"tired",
|
||||
"trash-alt",
|
||||
"user-circle",
|
||||
"window-close",
|
||||
"ad",
|
||||
"adjust",
|
||||
"air-freshener",
|
||||
"allergies",
|
||||
"ambulance",
|
||||
"american-sign-language-interpreting",
|
||||
"angle-double-down",
|
||||
"angle-double-left",
|
||||
"angle-double-right",
|
||||
"angle-double-up",
|
||||
"apple-alt",
|
||||
"archive",
|
||||
"arrow-circle-down",
|
||||
"arrow-circle-left",
|
||||
"arrow-circle-right",
|
||||
"arrow-circle-up",
|
||||
"arrows-alt-h",
|
||||
"arrows-alt-v",
|
||||
"arrows-alt",
|
||||
"assistive-listening-systems",
|
||||
"atlas",
|
||||
"backspace",
|
||||
"balance-scale-left",
|
||||
"balance-scale-right",
|
||||
"balance-scale",
|
||||
"band-aid",
|
||||
"baseball-ball",
|
||||
"basketball-ball",
|
||||
"beer",
|
||||
"bible",
|
||||
"biking",
|
||||
"birthday-cake",
|
||||
"blind",
|
||||
"book-dead",
|
||||
"book-reader",
|
||||
"border-style",
|
||||
"boxes",
|
||||
"broadcast-tower",
|
||||
"burn",
|
||||
"bus-alt",
|
||||
"car-alt",
|
||||
"car-crash",
|
||||
"chalkboard-teacher",
|
||||
"chevron-circle-down",
|
||||
"chevron-circle-left",
|
||||
"chevron-circle-right",
|
||||
"chevron-circle-up",
|
||||
"clinic-medical",
|
||||
"cloud-download-alt",
|
||||
"cloud-upload-alt",
|
||||
"cocktail",
|
||||
"coffee",
|
||||
"cog",
|
||||
"cogs",
|
||||
"columns",
|
||||
"compress-alt",
|
||||
"compress-arrows-alt",
|
||||
"concierge-bell",
|
||||
"crop-alt",
|
||||
"cut",
|
||||
"deaf",
|
||||
"diagnoses",
|
||||
"digital-tachograph",
|
||||
"directions",
|
||||
"dolly-flatbed",
|
||||
"donate",
|
||||
"drafting-compass",
|
||||
"ellipsis-h",
|
||||
"ellipsis-v",
|
||||
"envelope-square",
|
||||
"exchange-alt",
|
||||
"exclamation-circle",
|
||||
"exclamation-triangle",
|
||||
"expand-alt",
|
||||
"expand-arrows-alt",
|
||||
"external-link-alt",
|
||||
"external-link-square-alt",
|
||||
"fast-backward",
|
||||
"fast-forward",
|
||||
"feather-alt",
|
||||
"female",
|
||||
"fighter-jet",
|
||||
"file-download",
|
||||
"file-medical-alt",
|
||||
"file-upload",
|
||||
"fire-alt",
|
||||
"first-aid",
|
||||
"fist-raised",
|
||||
"football-ball",
|
||||
"funnel-dollar",
|
||||
"glass-cheers",
|
||||
"glass-martini-alt",
|
||||
"glass-martini",
|
||||
"glass-whiskey",
|
||||
"globe-africa",
|
||||
"globe-americas",
|
||||
"globe-asia",
|
||||
"globe-europe",
|
||||
"golf-ball",
|
||||
"grip-horizontal",
|
||||
"h-square",
|
||||
"hamburger",
|
||||
"hand-holding-usd",
|
||||
"hand-holding-water",
|
||||
"hands-helping",
|
||||
"hands-wash",
|
||||
"handshake-alt-slash",
|
||||
"hard-hat",
|
||||
"headphones-alt",
|
||||
"heart-broken",
|
||||
"heartbeat",
|
||||
"hiking",
|
||||
"history",
|
||||
"home",
|
||||
"hospital-alt",
|
||||
"hospital-symbol",
|
||||
"hot-tub",
|
||||
"house-damage",
|
||||
"hryvnia",
|
||||
"id-card-alt",
|
||||
"info-circle",
|
||||
"journal-whills",
|
||||
"laptop-house",
|
||||
"level-down-alt",
|
||||
"level-up-alt",
|
||||
"long-arrow-alt-down",
|
||||
"long-arrow-alt-left",
|
||||
"long-arrow-alt-right",
|
||||
"long-arrow-alt-up",
|
||||
"low-vision",
|
||||
"luggage-cart",
|
||||
"magic",
|
||||
"mail-bulk",
|
||||
"male",
|
||||
"map-marked-alt",
|
||||
"map-marked",
|
||||
"map-marker-alt",
|
||||
"map-marker",
|
||||
"map-signs",
|
||||
"mars-stroke-h",
|
||||
"mars-stroke-v",
|
||||
"medkit",
|
||||
"microphone-alt-slash",
|
||||
"microphone-alt",
|
||||
"minus-circle",
|
||||
"mobile-alt",
|
||||
"money-bill-wave-alt",
|
||||
"money-check-alt",
|
||||
"mouse-pointer",
|
||||
"mouse",
|
||||
"paint-brush",
|
||||
"parking",
|
||||
"pastafarianism",
|
||||
"pen-alt",
|
||||
"pen-square",
|
||||
"pencil-alt",
|
||||
"pencil-ruler",
|
||||
"people-carry",
|
||||
"percentage",
|
||||
"phone-alt",
|
||||
"phone-square-alt",
|
||||
"phone-square",
|
||||
"photo-video",
|
||||
"plus-circle",
|
||||
"poll-h",
|
||||
"poll",
|
||||
"portrait",
|
||||
"pound-sign",
|
||||
"pray",
|
||||
"praying-hands",
|
||||
"prescription-bottle-alt",
|
||||
"procedures",
|
||||
"project-diagram",
|
||||
"quidditch",
|
||||
"quran",
|
||||
"radiation-alt",
|
||||
"random",
|
||||
"redo-alt",
|
||||
"redo",
|
||||
"remove-format",
|
||||
"rss-square",
|
||||
"running",
|
||||
"search-dollar",
|
||||
"search-location",
|
||||
"search-minus",
|
||||
"search-plus",
|
||||
"search",
|
||||
"share-alt-square",
|
||||
"share-alt",
|
||||
"shield-alt",
|
||||
"shipping-fast",
|
||||
"shopping-bag",
|
||||
"shopping-basket",
|
||||
"shopping-cart",
|
||||
"shuttle-van",
|
||||
"sign-in-alt",
|
||||
"sign-language",
|
||||
"sign-out-alt",
|
||||
"sign",
|
||||
"skating",
|
||||
"skiing-nordic",
|
||||
"skiing",
|
||||
"sliders-h",
|
||||
"smoking-ban",
|
||||
"sms",
|
||||
"snowboarding",
|
||||
"sort-alpha-down-alt",
|
||||
"sort-alpha-down",
|
||||
"sort-alpha-up-alt",
|
||||
"sort-alpha-up",
|
||||
"sort-amount-down-alt",
|
||||
"sort-amount-down",
|
||||
"sort-amount-up-alt",
|
||||
"sort-amount-up",
|
||||
"sort-numeric-down-alt",
|
||||
"sort-numeric-down",
|
||||
"sort-numeric-up-alt",
|
||||
"sort-numeric-up",
|
||||
"space-shuttle",
|
||||
"square-root-alt",
|
||||
"star-half-alt",
|
||||
"step-backward",
|
||||
"step-forward",
|
||||
"store-alt-slash",
|
||||
"store-alt",
|
||||
"stream",
|
||||
"subway",
|
||||
"swimmer",
|
||||
"swimming-pool",
|
||||
"sync-alt",
|
||||
"sync",
|
||||
"table-tennis",
|
||||
"tablet-alt",
|
||||
"tachometer-alt",
|
||||
"tasks",
|
||||
"tenge",
|
||||
"th-large",
|
||||
"th-list",
|
||||
"th",
|
||||
"theater-masks",
|
||||
"thermometer-empty",
|
||||
"thermometer-full",
|
||||
"thermometer-half",
|
||||
"thermometer-quarter",
|
||||
"thermometer-three-quarters",
|
||||
"ticket-alt",
|
||||
"times",
|
||||
"tint-slash",
|
||||
"tint",
|
||||
"tools",
|
||||
"torah",
|
||||
"tram",
|
||||
"transgender-alt",
|
||||
"trash-restore-alt",
|
||||
"trash-restore",
|
||||
"truck-loading",
|
||||
"tshirt",
|
||||
"undo-alt",
|
||||
"undo",
|
||||
"university",
|
||||
"unlink",
|
||||
"unlock-alt",
|
||||
"user-alt-slash",
|
||||
"user-alt",
|
||||
"user-cog",
|
||||
"user-edit",
|
||||
"user-friends",
|
||||
"user-md",
|
||||
"user-times",
|
||||
"users-cog",
|
||||
"utensil-spoon",
|
||||
"volleyball-ball",
|
||||
"volume-down",
|
||||
"volume-mute",
|
||||
"volume-up",
|
||||
"vote-yea",
|
||||
"walking",
|
||||
"weight",
|
||||
"wine-glass-alt"
|
||||
]
|
||||
|
||||
module.exports = v5names
|
||||
9
eslint-plugins/react-native/index.js
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
const wrapScrollViewInView = require('./rule')
|
||||
|
||||
const plugin = {
|
||||
rules: {
|
||||
'wrap-horizontal-scrollview-inside-view': wrapScrollViewInView,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = plugin
|
||||
64
eslint-plugins/react-native/rule.js
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: '禁止带有 horizontal: true 属性的 ScrollView 单独使用',
|
||||
recommended: false,
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
noSiblings: '禁止带有 props.horizontal: true 的 ScrollView 单独使用,需要在 ScrollView 外层使用一个单独的 View 组件进行包裹',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
JSXElement(node) {
|
||||
const isScrollView = node.openingElement.name.name === 'ScrollView';
|
||||
|
||||
if (!isScrollView) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasHorizontalProp = node.openingElement.attributes.some(attr => {
|
||||
if (attr.type === 'JSXAttribute' && attr.name.name === 'horizontal') {
|
||||
if (!attr.value) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
attr.value.type === 'JSXExpressionContainer' &&
|
||||
attr.value.expression.value === true
|
||||
) {
|
||||
return true; // horizontal={true}
|
||||
}
|
||||
if (attr.value.type === 'Literal' && attr.value.value === true) {
|
||||
return true; // horizontal={true} 的另一种形式
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (!hasHorizontalProp) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = node.parent;
|
||||
|
||||
if (
|
||||
parent.type === 'JSXFragment' ||
|
||||
parent.type === 'JSXElement'
|
||||
) {
|
||||
const siblings = parent.children.filter(
|
||||
child => child.type === 'JSXElement' && child !== node
|
||||
);
|
||||
|
||||
if (siblings.length > 0) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'noSiblings',
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
}
|
||||
9
eslint-plugins/reanimated/index.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const banMixUse = require('./rule')
|
||||
|
||||
const plugin = {
|
||||
rules: {
|
||||
'ban-mix-use': banMixUse,
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = plugin
|
||||
88
eslint-plugins/reanimated/rule.js
Normal file
@@ -0,0 +1,88 @@
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow passing useAnimatedScrollHandler return value to ScrollView',
|
||||
recommended: 'error',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
noUseAnimatedScrollHandlerOnScroll:
|
||||
'Do not pass useAnimatedScrollHandler return value to ScrollView, pass to Animated.ScrollView instead.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
// 记录 useAnimatedScrollHandler 的本地名字
|
||||
let useAnimatedScrollHandlerImportName = null;
|
||||
// 记录 ScrollView 的本地名字
|
||||
let scrollViewImportName = null;
|
||||
// 记录调用 useAnimatedScrollHandler 返回值的变量名
|
||||
const animatedScrollHandlerVars = new Set();
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value === 'react-native-reanimated') {
|
||||
for (const specifier of node.specifiers) {
|
||||
if (
|
||||
specifier.type === 'ImportSpecifier' &&
|
||||
specifier.imported.name === 'useAnimatedScrollHandler'
|
||||
) {
|
||||
useAnimatedScrollHandlerImportName = specifier.local.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node.source.value === 'react-native') {
|
||||
for (const specifier of node.specifiers) {
|
||||
if (
|
||||
specifier.type === 'ImportSpecifier' &&
|
||||
specifier.imported.name === 'ScrollView'
|
||||
) {
|
||||
scrollViewImportName = specifier.local.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
VariableDeclarator(node) {
|
||||
if (
|
||||
node.init &&
|
||||
node.init.type === 'CallExpression' &&
|
||||
node.init.callee.type === 'Identifier' &&
|
||||
node.init.callee.name === useAnimatedScrollHandlerImportName
|
||||
) {
|
||||
if (node.id.type === 'Identifier') {
|
||||
animatedScrollHandlerVars.add(node.id.name);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
JSXOpeningElement(node) {
|
||||
if (
|
||||
node.name.type === 'JSXIdentifier' &&
|
||||
node.name.name === scrollViewImportName
|
||||
) {
|
||||
for (const attr of node.attributes) {
|
||||
if (
|
||||
attr.type === 'JSXAttribute' &&
|
||||
attr.name.name === 'onScroll' &&
|
||||
attr.value &&
|
||||
attr.value.type === 'JSXExpressionContainer' &&
|
||||
attr.value.expression.type === 'Identifier'
|
||||
) {
|
||||
const varName = attr.value.expression.name;
|
||||
if (animatedScrollHandlerVars.has(varName)) {
|
||||
context.report({
|
||||
node: attr,
|
||||
messageId: 'noUseAnimatedScrollHandlerOnScroll',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
25
package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "expo-express-monorepo",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "bash .cozeproj/scripts/dev_run.sh",
|
||||
"build": "bash .cozeproj/scripts/prod_build.sh",
|
||||
"start": "bash .cozeproj/scripts/prod_run.sh",
|
||||
"preinstall": "npx only-allow pnpm"
|
||||
},
|
||||
"dependencies": {},
|
||||
"devDependencies": {},
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"engines": {
|
||||
"pnpm": ">=9.0.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"esbuild": "0.27.2"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"expo@54.0.32": "patches/expo@54.0.32.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
44
patches/expo@54.0.32.patch
Normal file
@@ -0,0 +1,44 @@
|
||||
diff --git a/src/async-require/hmr.ts b/src/async-require/hmr.ts
|
||||
index 33ce50ee2950c40d2b0553b148710f1e24e44f3d..a13d6f2da10dea858019cc991c21753f64f01fd0 100644
|
||||
--- a/src/async-require/hmr.ts
|
||||
+++ b/src/async-require/hmr.ts
|
||||
@@ -216,6 +216,39 @@ const HMRClient: HMRClientNativeInterface = {
|
||||
|
||||
client.on('update-done', () => {
|
||||
hideLoading();
|
||||
+ if (process.env.EXPO_PUBLIC_COZE_PROJECT_ID && typeof window !== 'undefined' && window.location) {
|
||||
+ if((window as any).__updateTimeoutId) {
|
||||
+ clearTimeout((window as any).__updateTimeoutId);
|
||||
+ }
|
||||
+ const updateDoneTime = Date.now();
|
||||
+ (window as any).__updateDoneTime = updateDoneTime;
|
||||
+ (window as any).__updateTimeoutId = setTimeout(() => {
|
||||
+ const lastUpdateTime = (window as any).__updateDoneTime;
|
||||
+ if (lastUpdateTime !== updateDoneTime) return;
|
||||
+ const checkServerAndReload = (retriesLeft: number) => {
|
||||
+ if ((window as any).__updateDoneTime !== updateDoneTime) return;
|
||||
+ fetch(`/favicon.ico?_t=${Date.now()}`)
|
||||
+ .then((response) => {
|
||||
+ if (response.status === 200) {
|
||||
+ console.warn('[HMR] Server is ready (200), reloading now.');
|
||||
+ window.location.reload();
|
||||
+ } else {
|
||||
+ throw new Error(`Server status: ${response.status}`);
|
||||
+ }
|
||||
+ }).catch((error) => {
|
||||
+ console.warn(`[HMR] Check failed (${error.message}). Retries left: ${retriesLeft}`);
|
||||
+ if (retriesLeft > 0) {
|
||||
+ setTimeout(() => {
|
||||
+ checkServerAndReload(retriesLeft - 1);
|
||||
+ }, 5000);
|
||||
+ } else {
|
||||
+ console.error('[HMR] Server unreachable after 6 attempts. Abort reload.');
|
||||
+ }
|
||||
+ });
|
||||
+ };
|
||||
+ checkServerAndReload(6);
|
||||
+ }, 35_000);
|
||||
+ }
|
||||
});
|
||||
|
||||
client.on('error', (data: { type: string; message: string }) => {
|
||||
14065
pnpm-lock.yaml
generated
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
packages:
|
||||
- client
|
||||
- server
|
||||
21
server/build.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as esbuild from 'esbuild';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const pkg = require('./package.json');
|
||||
const dependencies = pkg.dependencies || {};
|
||||
const externalList = Object.keys(dependencies).filter(dep => dep !== 'dayjs');
|
||||
try {
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/index.ts'],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'esm',
|
||||
outdir: 'dist',
|
||||
external: externalList,
|
||||
});
|
||||
console.log('⚡ Build complete!');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
}
|
||||
32
server/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "server",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"dev": "bash ../.cozeproj/scripts/server_dev_run.sh",
|
||||
"build": "node build.js",
|
||||
"start": "NODE_ENV=production PORT=${PORT:-5000} node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.22.1",
|
||||
"cors": "^2.8.5",
|
||||
"coze-coding-dev-sdk": "^0.7.2",
|
||||
"dayjs": "^1.11.19",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"multer": "^2.0.2",
|
||||
"pg": "^8.16.3",
|
||||
"zod": "^4.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.6",
|
||||
"tsx": "^4.21.0",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/pg": "^8.16.0",
|
||||
"esbuild": "0.27.2",
|
||||
"typescript": "^5.8.3",
|
||||
"drizzle-kit": "^0.31.8"
|
||||
}
|
||||
}
|
||||
31
server/src/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import usersRouter from './routes/users.js';
|
||||
import foodRecordsRouter from './routes/food-records.js';
|
||||
import weightRecordsRouter from './routes/weight-records.js';
|
||||
import aiChatRouter from './routes/ai-chat.js';
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 9091;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
||||
|
||||
// Health check
|
||||
app.get('/api/v1/health', (req, res) => {
|
||||
res.status(200).json({ status: 'ok' });
|
||||
});
|
||||
|
||||
// API Routes
|
||||
// 注意:静态路由必须在动态路由之前
|
||||
app.use('/api/v1/users', usersRouter);
|
||||
app.use('/api/v1/food-records', foodRecordsRouter);
|
||||
app.use('/api/v1/weight-records', weightRecordsRouter);
|
||||
app.use('/api/v1/ai-chat', aiChatRouter);
|
||||
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Server listening at http://localhost:${port}/`);
|
||||
});
|
||||
72
server/src/routes/ai-chat.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Router } from 'express';
|
||||
import { LLMClient, Config } from 'coze-coding-dev-sdk';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 初始化大语言模型
|
||||
const llmConfig = new Config();
|
||||
const llmClient = new LLMClient(llmConfig);
|
||||
|
||||
// AI 对话接口(流式)
|
||||
router.post('/chat', async (req, res) => {
|
||||
try {
|
||||
const { message, conversationHistory } = req.body;
|
||||
|
||||
if (!message) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Message is required'
|
||||
});
|
||||
}
|
||||
|
||||
// 设置 SSE 响应头
|
||||
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, no-transform, must-revalidate');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
// 构建消息历史
|
||||
const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是一个专业的健康饮食和减脂顾问助手。你的任务是帮助用户进行科学的体重管理,提供以下方面的建议:\n\n1. 饮食建议:根据用户的饮食习惯和目标,提供个性化的饮食方案\n2. 营养分析:分析食物的营养成分,推荐健康的食物选择\n3. 减脂指导:提供科学的减脂方法和运动建议\n4. 激励支持:鼓励用户坚持健康生活方式\n\n请用友好、专业、鼓舞人心的语气与用户交流,避免提供极端或有害的减脂建议。'
|
||||
}
|
||||
];
|
||||
|
||||
// 如果有对话历史,添加到消息列表
|
||||
if (conversationHistory && Array.isArray(conversationHistory)) {
|
||||
messages.push(...conversationHistory);
|
||||
}
|
||||
|
||||
// 添加当前用户消息
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: message
|
||||
});
|
||||
|
||||
// 流式生成响应
|
||||
const stream = llmClient.stream(messages, {
|
||||
model: 'doubao-seed-1-8-251228',
|
||||
temperature: 0.7
|
||||
});
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.content) {
|
||||
const text = chunk.content.toString();
|
||||
// 发送 SSE 事件
|
||||
res.write(`data: ${JSON.stringify({ content: text })}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送结束标记
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('AI chat error:', error);
|
||||
res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`);
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
229
server/src/routes/food-records.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { Router } from 'express';
|
||||
import multer from 'multer';
|
||||
import { foodRecordManager } from '../storage/database';
|
||||
import { S3Storage } from 'coze-coding-dev-sdk';
|
||||
import { LLMClient, Config } from 'coze-coding-dev-sdk';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 配置 multer 中间件
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 50 * 1024 * 1024 } // 限制 50MB
|
||||
});
|
||||
|
||||
// 初始化对象存储
|
||||
const storage = new S3Storage({
|
||||
endpointUrl: process.env.COZE_BUCKET_ENDPOINT_URL,
|
||||
accessKey: '',
|
||||
secretKey: '',
|
||||
bucketName: process.env.COZE_BUCKET_NAME,
|
||||
region: 'cn-beijing',
|
||||
});
|
||||
|
||||
// 初始化大语言模型
|
||||
const llmConfig = new Config();
|
||||
const llmClient = new LLMClient(llmConfig);
|
||||
|
||||
// 获取食物记录列表
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.query;
|
||||
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'userId is required'
|
||||
});
|
||||
}
|
||||
|
||||
const { mealType, startDate, endDate } = req.query;
|
||||
|
||||
const records = await foodRecordManager.getFoodRecords({
|
||||
userId,
|
||||
mealType: mealType as string,
|
||||
startDate: startDate ? new Date(startDate as string) : undefined,
|
||||
endDate: endDate ? new Date(endDate as string) : undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: records
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 创建食物记录(手动添加)
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const record = await foodRecordManager.createFoodRecord(req.body);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: record
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 删除食物记录
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const success = await foodRecordManager.deleteFoodRecord(id);
|
||||
|
||||
res.json({
|
||||
success,
|
||||
message: success ? 'Record deleted successfully' : 'Record not found'
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 拍照识别食物
|
||||
router.post('/recognize', upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'No image file provided'
|
||||
});
|
||||
}
|
||||
|
||||
const { buffer, originalname, mimetype } = req.file;
|
||||
|
||||
// 1. 上传图片到对象存储
|
||||
const imageKey = await storage.uploadFile({
|
||||
fileContent: buffer,
|
||||
fileName: `food-photos/${originalname}`,
|
||||
contentType: mimetype,
|
||||
});
|
||||
|
||||
// 生成图片访问 URL
|
||||
const imageUrl = await storage.generatePresignedUrl({
|
||||
key: imageKey,
|
||||
expireTime: 86400 // 1天有效期
|
||||
});
|
||||
|
||||
// 2. 调用大语言模型识别食物
|
||||
const base64Image = buffer.toString('base64');
|
||||
const dataUri = `data:${mimetype};base64,${base64Image}`;
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: 'system' as const,
|
||||
content: '你是一个专业的食物营养分析助手。请分析图片中的食物,识别食物名称、估算重量,并计算热量。返回JSON格式,包含:foodName(食物名称)、weight(估算重量,单位克)、calories(估算热量,单位千卡)。'
|
||||
},
|
||||
{
|
||||
role: 'user' as const,
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: '请识别这张图片中的食物,并告诉我食物名称、估算重量和热量。'
|
||||
},
|
||||
{
|
||||
type: 'image_url' as const,
|
||||
image_url: {
|
||||
url: dataUri,
|
||||
detail: 'high' as const
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const response = await llmClient.invoke(messages, {
|
||||
model: 'doubao-seed-1-6-vision-250815',
|
||||
temperature: 0.3
|
||||
});
|
||||
|
||||
// 3. 解析AI返回的结果
|
||||
let foodInfo: any = {};
|
||||
try {
|
||||
// 尝试从响应中提取JSON
|
||||
const jsonMatch = response.content.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
foodInfo = JSON.parse(jsonMatch[0]);
|
||||
} else {
|
||||
// 如果没有返回JSON,使用默认值
|
||||
foodInfo = {
|
||||
foodName: '未知食物',
|
||||
weight: 100,
|
||||
calories: 100
|
||||
};
|
||||
}
|
||||
} catch (parseError) {
|
||||
// JSON解析失败,使用默认值
|
||||
foodInfo = {
|
||||
foodName: '未知食物',
|
||||
weight: 100,
|
||||
calories: 100
|
||||
};
|
||||
}
|
||||
|
||||
// 4. 返回识别结果
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
foodName: foodInfo.foodName || '未知食物',
|
||||
weight: Number(foodInfo.weight) || 100,
|
||||
calories: Number(foodInfo.calories) || 100,
|
||||
imageUrl,
|
||||
imageKey,
|
||||
aiResponse: response.content
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Food recognition error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Failed to recognize food'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 获取今日总热量
|
||||
router.get('/total-calories', async (req, res) => {
|
||||
try {
|
||||
const { userId, date } = req.query;
|
||||
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'userId is required'
|
||||
});
|
||||
}
|
||||
|
||||
const queryDate = date ? new Date(date as string) : new Date();
|
||||
const totalCalories = await foodRecordManager.getTotalCaloriesByDate(userId, queryDate);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
date: queryDate.toISOString().split('T')[0],
|
||||
totalCalories
|
||||
}
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
81
server/src/routes/users.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Router } from 'express';
|
||||
import { userManager } from '../storage/database';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 获取用户信息
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.query;
|
||||
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'userId is required'
|
||||
});
|
||||
}
|
||||
|
||||
const user = await userManager.getUserById(userId);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: user
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 创建用户
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const user = await userManager.createUser(req.body);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: user
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 更新用户信息
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const user = await userManager.updateUser(id, req.body);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: user
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
99
server/src/routes/weight-records.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Router } from 'express';
|
||||
import { weightRecordManager } from '../storage/database';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 获取体重记录列表
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.query;
|
||||
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'userId is required'
|
||||
});
|
||||
}
|
||||
|
||||
const { startDate, endDate } = req.query;
|
||||
|
||||
const records = await weightRecordManager.getWeightRecords({
|
||||
userId,
|
||||
startDate: startDate ? new Date(startDate as string) : undefined,
|
||||
endDate: endDate ? new Date(endDate as string) : undefined,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: records
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 创建体重记录
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const record = await weightRecordManager.createWeightRecord(req.body);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: record
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 删除体重记录
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const success = await weightRecordManager.deleteWeightRecord(id);
|
||||
|
||||
res.json({
|
||||
success,
|
||||
message: success ? 'Record deleted successfully' : 'Record not found'
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 获取体重统计
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
const { userId } = req.query;
|
||||
|
||||
if (!userId || typeof userId !== 'string') {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'userId is required'
|
||||
});
|
||||
}
|
||||
|
||||
const stats = await weightRecordManager.getWeightStats(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats
|
||||
});
|
||||
} catch (error: any) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
84
server/src/storage/database/foodRecordManager.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { eq, and, SQL, desc, gte, lte } from "drizzle-orm";
|
||||
import { getDb } from "coze-coding-dev-sdk";
|
||||
import { foodRecords, insertFoodRecordSchema } from "./shared/schema";
|
||||
import type { FoodRecord, InsertFoodRecord } from "./shared/schema";
|
||||
|
||||
export class FoodRecordManager {
|
||||
async createFoodRecord(data: InsertFoodRecord): Promise<FoodRecord> {
|
||||
const db = await getDb();
|
||||
const validated = insertFoodRecordSchema.parse(data);
|
||||
const [record] = await db.insert(foodRecords).values(validated).returning();
|
||||
return record;
|
||||
}
|
||||
|
||||
async getFoodRecords(options: {
|
||||
userId: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
mealType?: string;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}): Promise<FoodRecord[]> {
|
||||
const { userId, skip = 0, limit = 100, mealType, startDate, endDate } = options;
|
||||
const db = await getDb();
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
conditions.push(eq(foodRecords.userId, userId));
|
||||
|
||||
if (mealType) {
|
||||
conditions.push(eq(foodRecords.mealType, mealType));
|
||||
}
|
||||
|
||||
if (startDate) {
|
||||
conditions.push(gte(foodRecords.recordedAt, startDate));
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
conditions.push(lte(foodRecords.recordedAt, endDate));
|
||||
}
|
||||
|
||||
let query = db.select().from(foodRecords).$dynamic();
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query = query.where(and(...conditions));
|
||||
}
|
||||
|
||||
return query.orderBy(desc(foodRecords.recordedAt)).limit(limit).offset(skip);
|
||||
}
|
||||
|
||||
async getFoodRecordById(id: string): Promise<FoodRecord | null> {
|
||||
const db = await getDb();
|
||||
const [record] = await db.select().from(foodRecords).where(eq(foodRecords.id, id));
|
||||
return record || null;
|
||||
}
|
||||
|
||||
async deleteFoodRecord(id: string): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
const result = await db.delete(foodRecords).where(eq(foodRecords.id, id));
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
async getTotalCaloriesByDate(userId: string, date: Date): Promise<number> {
|
||||
const db = await getDb();
|
||||
const startOfDay = new Date(date);
|
||||
startOfDay.setHours(0, 0, 0, 0);
|
||||
|
||||
const endOfDay = new Date(date);
|
||||
endOfDay.setHours(23, 59, 59, 999);
|
||||
|
||||
const records = await db
|
||||
.select()
|
||||
.from(foodRecords)
|
||||
.where(
|
||||
and(
|
||||
eq(foodRecords.userId, userId),
|
||||
gte(foodRecords.recordedAt, startOfDay),
|
||||
lte(foodRecords.recordedAt, endOfDay)
|
||||
)
|
||||
);
|
||||
|
||||
return records.reduce((sum, record) => sum + record.calories, 0);
|
||||
}
|
||||
}
|
||||
|
||||
export const foodRecordManager = new FoodRecordManager();
|
||||
7
server/src/storage/database/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Export instances
|
||||
export { userManager } from "./userManager";
|
||||
export { foodRecordManager } from "./foodRecordManager";
|
||||
export { weightRecordManager } from "./weightRecordManager";
|
||||
|
||||
// Export types and schemas
|
||||
export * from "./shared/schema";
|
||||
3
server/src/storage/database/shared/relations.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { relations } from "drizzle-orm/relations";
|
||||
import { } from "./schema";
|
||||
|
||||
130
server/src/storage/database/shared/schema.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { pgTable, text, varchar, timestamp, boolean, integer, decimal, jsonb, index } from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { createSchemaFactory } from "drizzle-zod";
|
||||
import { z } from "zod";
|
||||
|
||||
// 用户表
|
||||
export const users = pgTable(
|
||||
"users",
|
||||
{
|
||||
id: varchar("id", { length: 36 })
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
name: varchar("name", { length: 128 }).notNull(),
|
||||
gender: varchar("gender", { length: 10 }), // 'male' | 'female' | 'other'
|
||||
age: integer("age"),
|
||||
height: integer("height"), // 身高(厘米)
|
||||
targetWeight: decimal("target_weight", { precision: 5, scale: 2 }), // 目标体重(千克)
|
||||
currentWeight: decimal("current_weight", { precision: 5, scale: 2 }), // 当前体重(千克)
|
||||
metadata: jsonb("metadata"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
nameIdx: index("users_name_idx").on(table.name),
|
||||
})
|
||||
);
|
||||
|
||||
// 食物记录表
|
||||
export const foodRecords = pgTable(
|
||||
"food_records",
|
||||
{
|
||||
id: varchar("id", { length: 36 })
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
userId: varchar("user_id", { length: 36 }).notNull(),
|
||||
foodName: varchar("food_name", { length: 256 }).notNull(),
|
||||
calories: integer("calories").notNull(), // 热量(千卡)
|
||||
weight: decimal("weight", { precision: 5, scale: 2 }), // 重量(克)
|
||||
imageUrl: text("image_url"), // 食物图片 URL
|
||||
mealType: varchar("meal_type", { length: 20 }).notNull(), // 'breakfast' | 'lunch' | 'dinner' | 'snack'
|
||||
recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull(), // 记录时间
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index("food_records_user_id_idx").on(table.userId),
|
||||
recordedAtIdx: index("food_records_recorded_at_idx").on(table.recordedAt),
|
||||
})
|
||||
);
|
||||
|
||||
// 体重记录表
|
||||
export const weightRecords = pgTable(
|
||||
"weight_records",
|
||||
{
|
||||
id: varchar("id", { length: 36 })
|
||||
.primaryKey()
|
||||
.default(sql`gen_random_uuid()`),
|
||||
userId: varchar("user_id", { length: 36 }).notNull(),
|
||||
weight: decimal("weight", { precision: 5, scale: 2 }).notNull(), // 体重(千克)
|
||||
note: text("note"), // 备注
|
||||
recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull(), // 记录时间
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: index("weight_records_user_id_idx").on(table.userId),
|
||||
recordedAtIdx: index("weight_records_recorded_at_idx").on(table.recordedAt),
|
||||
})
|
||||
);
|
||||
|
||||
// 使用 createSchemaFactory 配置 date coercion(处理前端 string → Date 转换)
|
||||
const { createInsertSchema: createCoercedInsertSchema } = createSchemaFactory({
|
||||
coerce: { date: true },
|
||||
});
|
||||
|
||||
// Users schemas
|
||||
export const insertUserSchema = createCoercedInsertSchema(users).pick({
|
||||
name: true,
|
||||
gender: true,
|
||||
age: true,
|
||||
height: true,
|
||||
targetWeight: true,
|
||||
currentWeight: true,
|
||||
metadata: true,
|
||||
});
|
||||
|
||||
export const updateUserSchema = createCoercedInsertSchema(users)
|
||||
.pick({
|
||||
name: true,
|
||||
gender: true,
|
||||
age: true,
|
||||
height: true,
|
||||
targetWeight: true,
|
||||
currentWeight: true,
|
||||
})
|
||||
.partial();
|
||||
|
||||
// Food records schemas
|
||||
export const insertFoodRecordSchema = createCoercedInsertSchema(foodRecords).pick({
|
||||
userId: true,
|
||||
foodName: true,
|
||||
calories: true,
|
||||
weight: true,
|
||||
imageUrl: true,
|
||||
mealType: true,
|
||||
recordedAt: true,
|
||||
});
|
||||
|
||||
// Weight records schemas
|
||||
export const insertWeightRecordSchema = createCoercedInsertSchema(weightRecords).pick({
|
||||
userId: true,
|
||||
weight: true,
|
||||
note: true,
|
||||
recordedAt: true,
|
||||
});
|
||||
|
||||
// TypeScript types
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type InsertUser = z.infer<typeof insertUserSchema>;
|
||||
export type UpdateUser = z.infer<typeof updateUserSchema>;
|
||||
|
||||
export type FoodRecord = typeof foodRecords.$inferSelect;
|
||||
export type InsertFoodRecord = z.infer<typeof insertFoodRecordSchema>;
|
||||
|
||||
export type WeightRecord = typeof weightRecords.$inferSelect;
|
||||
export type InsertWeightRecord = z.infer<typeof insertWeightRecordSchema>;
|
||||
47
server/src/storage/database/userManager.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { eq, and, SQL } from "drizzle-orm";
|
||||
import { getDb } from "coze-coding-dev-sdk";
|
||||
import { users, insertUserSchema, updateUserSchema } from "./shared/schema";
|
||||
import type { User, InsertUser, UpdateUser } from "./shared/schema";
|
||||
|
||||
export class UserManager {
|
||||
async createUser(data: InsertUser): Promise<User> {
|
||||
const db = await getDb();
|
||||
const validated = insertUserSchema.parse(data);
|
||||
const [user] = await db.insert(users).values(validated).returning();
|
||||
return user;
|
||||
}
|
||||
|
||||
async getUsers(options: {
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
} = {}): Promise<User[]> {
|
||||
const { skip = 0, limit = 100 } = options;
|
||||
const db = await getDb();
|
||||
return db.select().from(users).limit(limit).offset(skip);
|
||||
}
|
||||
|
||||
async getUserById(id: string): Promise<User | null> {
|
||||
const db = await getDb();
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id));
|
||||
return user || null;
|
||||
}
|
||||
|
||||
async updateUser(id: string, data: UpdateUser): Promise<User | null> {
|
||||
const db = await getDb();
|
||||
const validated = updateUserSchema.parse(data);
|
||||
const [user] = await db
|
||||
.update(users)
|
||||
.set({ ...validated, updatedAt: new Date() })
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
return user || null;
|
||||
}
|
||||
|
||||
async deleteUser(id: string): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
const result = await db.delete(users).where(eq(users.id, id));
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const userManager = new UserManager();
|
||||
97
server/src/storage/database/weightRecordManager.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { eq, and, SQL, desc, gte, lte } from "drizzle-orm";
|
||||
import { getDb } from "coze-coding-dev-sdk";
|
||||
import { weightRecords, insertWeightRecordSchema } from "./shared/schema";
|
||||
import type { WeightRecord, InsertWeightRecord } from "./shared/schema";
|
||||
|
||||
export class WeightRecordManager {
|
||||
async createWeightRecord(data: InsertWeightRecord): Promise<WeightRecord> {
|
||||
const db = await getDb();
|
||||
const validated = insertWeightRecordSchema.parse(data);
|
||||
const [record] = await db.insert(weightRecords).values(validated).returning();
|
||||
return record;
|
||||
}
|
||||
|
||||
async getWeightRecords(options: {
|
||||
userId: string;
|
||||
skip?: number;
|
||||
limit?: number;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
}): Promise<WeightRecord[]> {
|
||||
const { userId, skip = 0, limit = 100, startDate, endDate } = options;
|
||||
const db = await getDb();
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
conditions.push(eq(weightRecords.userId, userId));
|
||||
|
||||
if (startDate) {
|
||||
conditions.push(gte(weightRecords.recordedAt, startDate));
|
||||
}
|
||||
|
||||
if (endDate) {
|
||||
conditions.push(lte(weightRecords.recordedAt, endDate));
|
||||
}
|
||||
|
||||
let query = db.select().from(weightRecords).$dynamic();
|
||||
|
||||
if (conditions.length > 0) {
|
||||
query = query.where(and(...conditions));
|
||||
}
|
||||
|
||||
return query.orderBy(desc(weightRecords.recordedAt)).limit(limit).offset(skip);
|
||||
}
|
||||
|
||||
async getWeightRecordById(id: string): Promise<WeightRecord | null> {
|
||||
const db = await getDb();
|
||||
const [record] = await db.select().from(weightRecords).where(eq(weightRecords.id, id));
|
||||
return record || null;
|
||||
}
|
||||
|
||||
async deleteWeightRecord(id: string): Promise<boolean> {
|
||||
const db = await getDb();
|
||||
const result = await db.delete(weightRecords).where(eq(weightRecords.id, id));
|
||||
return (result.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
async getLatestWeight(userId: string): Promise<number | null> {
|
||||
const db = await getDb();
|
||||
const [record] = await db
|
||||
.select()
|
||||
.from(weightRecords)
|
||||
.where(eq(weightRecords.userId, userId))
|
||||
.orderBy(desc(weightRecords.recordedAt))
|
||||
.limit(1);
|
||||
|
||||
return record ? parseFloat(record.weight) : null;
|
||||
}
|
||||
|
||||
async getWeightStats(userId: string): Promise<{
|
||||
currentWeight: number | null;
|
||||
startWeight: number | null;
|
||||
targetWeight: number | null;
|
||||
}> {
|
||||
const db = await getDb();
|
||||
|
||||
const records = await db
|
||||
.select()
|
||||
.from(weightRecords)
|
||||
.where(eq(weightRecords.userId, userId))
|
||||
.orderBy(weightRecords.recordedAt)
|
||||
.limit(1);
|
||||
|
||||
const currentWeight = await this.getLatestWeight(userId);
|
||||
const startWeight = records.length > 0 ? parseFloat(records[0].weight) : null;
|
||||
|
||||
// 获取用户目标体重
|
||||
// 这里暂时返回 null,后续可以关联用户表
|
||||
const targetWeight = null;
|
||||
|
||||
return {
|
||||
currentWeight,
|
||||
startWeight,
|
||||
targetWeight,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const weightRecordManager = new WeightRecordManager();
|
||||
24
server/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://www.schemastore.org/tsconfig",
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"es2024",
|
||||
"ESNext.Array",
|
||||
"ESNext.Collection",
|
||||
"ESNext.Iterator",
|
||||
"ESNext.Promise"
|
||||
],
|
||||
"module": "preserve",
|
||||
"target": "es2024",
|
||||
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
|
||||
"rewriteRelativeImportExtensions": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
tsconfig.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||