Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| decc7f9564 | |||
| 173cee75d5 | |||
|
|
076bd5636f | ||
|
|
154f347ddb | ||
|
|
dec3ac82e1 | ||
|
|
e552e22de9 | ||
|
|
1fbc0aa3f8 | ||
|
|
b5532df161 | ||
| 5515726465 | |||
|
|
ee2d9f44ea | ||
| 4578d503e7 | |||
|
|
f03d36b5e9 |
@@ -1,4 +1,4 @@
|
|||||||
当前有一个很大的 spec.md(大需求规范),需要按业务逻辑拆分成多个子模块规范。
|
当前有一个很大的 spec.md(大需求规范),需要按业务逻辑合理拆分成多个子模块规范。
|
||||||
|
|
||||||
请按以下规则拆分:
|
请按以下规则拆分:
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ jobs:
|
|||||||
# 健康检查路径(未配置则默认 /health;如果你没有 health 接口,可改为 /docs 或 /)
|
# 健康检查路径(未配置则默认 /health;如果你没有 health 接口,可改为 /docs 或 /)
|
||||||
HEALTHCHECK_PATH: ${{ vars.HEALTHCHECK_PATH }}
|
HEALTHCHECK_PATH: ${{ vars.HEALTHCHECK_PATH }}
|
||||||
|
|
||||||
|
# 定时服务健康检查路径(检查 Redis/Worker/Beat;未配置则默认 /v1/push/scheduler/health)
|
||||||
|
SCHEDULER_HEALTHCHECK_PATH: ${{ vars.SCHEDULER_HEALTHCHECK_PATH }}
|
||||||
|
|
||||||
# 可选:远端 env 文件路径(例如 /opt/mindfulness-server/.env.prod),存在则 docker run --env-file
|
# 可选:远端 env 文件路径(例如 /opt/mindfulness-server/.env.prod),存在则 docker run --env-file
|
||||||
REMOTE_ENV_FILE: ${{ vars.REMOTE_ENV_FILE }}
|
REMOTE_ENV_FILE: ${{ vars.REMOTE_ENV_FILE }}
|
||||||
|
|
||||||
@@ -258,11 +261,12 @@ jobs:
|
|||||||
GREEN_PORT="${GREEN_PORT:-8002}"
|
GREEN_PORT="${GREEN_PORT:-8002}"
|
||||||
CONTAINER_PORT="${CONTAINER_PORT:-8000}"
|
CONTAINER_PORT="${CONTAINER_PORT:-8000}"
|
||||||
HEALTHCHECK_PATH="${HEALTHCHECK_PATH:-/health}"
|
HEALTHCHECK_PATH="${HEALTHCHECK_PATH:-/health}"
|
||||||
|
SCHEDULER_HEALTHCHECK_PATH="${SCHEDULER_HEALTHCHECK_PATH:-/v1/push/scheduler/health}"
|
||||||
|
|
||||||
echo "准备部署:${DOCKER_IMAGE}:${DEPLOY_TAG} -> ${DEPLOY_ENV} (${SSH_USER}@${SSH_HOST}:${SSH_PORT})"
|
echo "准备部署:${DOCKER_IMAGE}:${DEPLOY_TAG} -> ${DEPLOY_ENV} (${SSH_USER}@${SSH_HOST}:${SSH_PORT})"
|
||||||
|
|
||||||
ssh -p "${SSH_PORT}" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes "${SSH_USER}@${SSH_HOST}" bash -s -- \
|
ssh -p "${SSH_PORT}" -i ~/.ssh/id_rsa -o StrictHostKeyChecking=yes -o IdentitiesOnly=yes "${SSH_USER}@${SSH_HOST}" bash -s -- \
|
||||||
"${DOCKER_IMAGE}" "${DEPLOY_TAG}" "${NGINX_UPSTREAM_FILE}" "${NGINX_UPSTREAM_NAME}" "${BLUE_PORT}" "${GREEN_PORT}" "${CONTAINER_PORT}" "${HEALTHCHECK_PATH}" "${REMOTE_ENV_FILE:-}" <<'REMOTE'
|
"${DOCKER_IMAGE}" "${DEPLOY_TAG}" "${NGINX_UPSTREAM_FILE}" "${NGINX_UPSTREAM_NAME}" "${BLUE_PORT}" "${GREEN_PORT}" "${CONTAINER_PORT}" "${HEALTHCHECK_PATH}" "${SCHEDULER_HEALTHCHECK_PATH}" "${REMOTE_ENV_FILE:-}" <<'REMOTE'
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
IMAGE="$1"
|
IMAGE="$1"
|
||||||
@@ -273,7 +277,8 @@ jobs:
|
|||||||
GREEN_PORT="$6"
|
GREEN_PORT="$6"
|
||||||
CONTAINER_PORT="$7"
|
CONTAINER_PORT="$7"
|
||||||
HEALTHCHECK_PATH="$8"
|
HEALTHCHECK_PATH="$8"
|
||||||
REMOTE_ENV_FILE="$9"
|
SCHEDULER_HEALTHCHECK_PATH="$9"
|
||||||
|
REMOTE_ENV_FILE="${10}"
|
||||||
|
|
||||||
APP_DIR="/opt/mindfulness-server"
|
APP_DIR="/opt/mindfulness-server"
|
||||||
ACTIVE_FILE="${APP_DIR}/active_color"
|
ACTIVE_FILE="${APP_DIR}/active_color"
|
||||||
@@ -307,9 +312,18 @@ jobs:
|
|||||||
# 拉取镜像
|
# 拉取镜像
|
||||||
${SUDO} docker pull "${IMAGE}:${TAG}"
|
${SUDO} docker pull "${IMAGE}:${TAG}"
|
||||||
|
|
||||||
# 启动新颜色容器
|
# 启动新颜色容器(API + Worker + Beat)
|
||||||
|
API_NAME="mindfulness-server-api-${NEW_COLOR}"
|
||||||
|
WORKER_NAME="mindfulness-server-worker-${NEW_COLOR}"
|
||||||
|
BEAT_NAME="mindfulness-server-beat-${NEW_COLOR}"
|
||||||
|
|
||||||
|
# 兼容旧命名:之前可能只有一个 mindfulness-server-blue/green
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" >/dev/null 2>&1 || true
|
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
${SUDO} docker rm -f "${API_NAME}" >/dev/null 2>&1 || true
|
||||||
|
${SUDO} docker rm -f "${WORKER_NAME}" >/dev/null 2>&1 || true
|
||||||
|
${SUDO} docker rm -f "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
ENV_FILE_ARGS=()
|
ENV_FILE_ARGS=()
|
||||||
if [[ -n "${REMOTE_ENV_FILE}" && -f "${REMOTE_ENV_FILE}" ]]; then
|
if [[ -n "${REMOTE_ENV_FILE}" && -f "${REMOTE_ENV_FILE}" ]]; then
|
||||||
ENV_FILE_ARGS=(--env-file "${REMOTE_ENV_FILE}")
|
ENV_FILE_ARGS=(--env-file "${REMOTE_ENV_FILE}")
|
||||||
@@ -318,11 +332,29 @@ jobs:
|
|||||||
echo "提示:REMOTE_ENV_FILE 已配置但文件不存在:${REMOTE_ENV_FILE}(将忽略 env-file)"
|
echo "提示:REMOTE_ENV_FILE 已配置但文件不存在:${REMOTE_ENV_FILE}(将忽略 env-file)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# API(对外暴露端口,仅该容器参与蓝绿切流)
|
||||||
${SUDO} docker run -d \
|
${SUDO} docker run -d \
|
||||||
--name "mindfulness-server-${NEW_COLOR}" \
|
--name "${API_NAME}" \
|
||||||
--restart=always \
|
--restart=always \
|
||||||
-p "${NEW_PORT}:${CONTAINER_PORT}" \
|
-p "${NEW_PORT}:${CONTAINER_PORT}" \
|
||||||
"${ENV_FILE_ARGS[@]}" \
|
"${ENV_FILE_ARGS[@]}" \
|
||||||
|
-e START_API=1 -e START_WORKER=0 -e START_BEAT=0 \
|
||||||
|
"${IMAGE}:${TAG}"
|
||||||
|
|
||||||
|
# Worker(处理异步/ETA 任务,不暴露端口)
|
||||||
|
${SUDO} docker run -d \
|
||||||
|
--name "${WORKER_NAME}" \
|
||||||
|
--restart=always \
|
||||||
|
"${ENV_FILE_ARGS[@]}" \
|
||||||
|
-e START_API=0 -e START_WORKER=1 -e START_BEAT=0 \
|
||||||
|
"${IMAGE}:${TAG}"
|
||||||
|
|
||||||
|
# Beat(定时调度,不暴露端口)
|
||||||
|
${SUDO} docker run -d \
|
||||||
|
--name "${BEAT_NAME}" \
|
||||||
|
--restart=always \
|
||||||
|
"${ENV_FILE_ARGS[@]}" \
|
||||||
|
-e START_API=0 -e START_WORKER=0 -e START_BEAT=1 \
|
||||||
"${IMAGE}:${TAG}"
|
"${IMAGE}:${TAG}"
|
||||||
|
|
||||||
# 健康检查
|
# 健康检查
|
||||||
@@ -350,8 +382,37 @@ jobs:
|
|||||||
|
|
||||||
if [[ "$i" -eq 30 ]]; then
|
if [[ "$i" -eq 30 ]]; then
|
||||||
echo "健康检查失败:新版本未就绪,回滚并退出"
|
echo "健康检查失败:新版本未就绪,回滚并退出"
|
||||||
${SUDO} docker logs --tail 200 "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker logs --tail 200 "${API_NAME}" || true
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
# 定时服务健康检查(确保 Redis/Worker/Beat 都 OK,避免“接口正常但定时任务没跑”)
|
||||||
|
if [[ "${SCHEDULER_HEALTHCHECK_PATH}" != /* ]]; then
|
||||||
|
SCHEDULER_HEALTHCHECK_PATH="/${SCHEDULER_HEALTHCHECK_PATH}"
|
||||||
|
fi
|
||||||
|
SCHED_URL="http://127.0.0.1:${NEW_PORT}${SCHEDULER_HEALTHCHECK_PATH}"
|
||||||
|
echo "定时服务健康检查:${SCHED_URL}"
|
||||||
|
|
||||||
|
# Beat 心跳是按分钟刷新,这里最多等 90 秒(45*2s)
|
||||||
|
for i in $(seq 1 45); do
|
||||||
|
RES="$(curl -fsS "${SCHED_URL}" 2>/dev/null || true)"
|
||||||
|
if [[ -n "${RES}" ]] \
|
||||||
|
&& echo "${RES}" | grep -q '"redis":{"ok":true' \
|
||||||
|
&& echo "${RES}" | grep -q '"worker":{"ok":true' \
|
||||||
|
&& echo "${RES}" | grep -q '"beat":{"ok":true' ; then
|
||||||
|
echo "定时服务健康检查通过:${RES}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$i" -eq 45 ]]; then
|
||||||
|
echo "定时服务健康检查失败:${RES}"
|
||||||
|
echo "Worker/Beat 日志(各 120 行):"
|
||||||
|
${SUDO} docker logs --tail 120 "${WORKER_NAME}" || true
|
||||||
|
${SUDO} docker logs --tail 120 "${BEAT_NAME}" || true
|
||||||
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
sleep 2
|
sleep 2
|
||||||
@@ -360,24 +421,24 @@ jobs:
|
|||||||
# 切换 Nginx upstream(在同一个 conf 文件中通过 backup 做主备切换)
|
# 切换 Nginx upstream(在同一个 conf 文件中通过 backup 做主备切换)
|
||||||
if [[ ! -f "${UPSTREAM_FILE}" ]]; then
|
if [[ ! -f "${UPSTREAM_FILE}" ]]; then
|
||||||
echo "未找到 Nginx upstream 配置文件:${UPSTREAM_FILE}"
|
echo "未找到 Nginx upstream 配置文件:${UPSTREAM_FILE}"
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! ${SUDO} grep -qE "upstream[[:space:]]+${UPSTREAM_NAME}[[:space:]]*\\{" "${UPSTREAM_FILE}"; then
|
if ! ${SUDO} grep -qE "upstream[[:space:]]+${UPSTREAM_NAME}[[:space:]]*\\{" "${UPSTREAM_FILE}"; then
|
||||||
echo "在 ${UPSTREAM_FILE} 中未找到 upstream:${UPSTREAM_NAME}"
|
echo "在 ${UPSTREAM_FILE} 中未找到 upstream:${UPSTREAM_NAME}"
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${BLUE_PORT}" "${UPSTREAM_FILE}"; then
|
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${BLUE_PORT}" "${UPSTREAM_FILE}"; then
|
||||||
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${BLUE_PORT}(请先按参考配置写入 upstream)"
|
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${BLUE_PORT}(请先按参考配置写入 upstream)"
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${GREEN_PORT}" "${UPSTREAM_FILE}"; then
|
if ! ${SUDO} grep -qE "server[[:space:]]+127\\.0\\.0\\.1:${GREEN_PORT}" "${UPSTREAM_FILE}"; then
|
||||||
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${GREEN_PORT}(请先按参考配置写入 upstream)"
|
echo "在 ${UPSTREAM_FILE} 中未找到 server 127.0.0.1:${GREEN_PORT}(请先按参考配置写入 upstream)"
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -416,7 +477,7 @@ jobs:
|
|||||||
echo "Nginx 配置校验失败,回滚 upstream 配置并退出"
|
echo "Nginx 配置校验失败,回滚 upstream 配置并退出"
|
||||||
${SUDO} cp -f "${BACKUP_FILE}" "${UPSTREAM_FILE}" || true
|
${SUDO} cp -f "${BACKUP_FILE}" "${UPSTREAM_FILE}" || true
|
||||||
${SUDO} nginx -t && ${SUDO} nginx -s reload || true
|
${SUDO} nginx -t && ${SUDO} nginx -s reload || true
|
||||||
${SUDO} docker rm -f "mindfulness-server-${NEW_COLOR}" || true
|
${SUDO} docker rm -f "${API_NAME}" "${WORKER_NAME}" "${BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -424,6 +485,11 @@ jobs:
|
|||||||
echo "${NEW_COLOR}" | ${SUDO} tee "${ACTIVE_FILE}" >/dev/null
|
echo "${NEW_COLOR}" | ${SUDO} tee "${ACTIVE_FILE}" >/dev/null
|
||||||
|
|
||||||
# 下线旧容器(切流后再停旧的)
|
# 下线旧容器(切流后再停旧的)
|
||||||
|
OLD_API_NAME="mindfulness-server-api-${OLD_COLOR}"
|
||||||
|
OLD_WORKER_NAME="mindfulness-server-worker-${OLD_COLOR}"
|
||||||
|
OLD_BEAT_NAME="mindfulness-server-beat-${OLD_COLOR}"
|
||||||
|
${SUDO} docker rm -f "${OLD_API_NAME}" "${OLD_WORKER_NAME}" "${OLD_BEAT_NAME}" >/dev/null 2>&1 || true
|
||||||
|
# 兼容旧命名
|
||||||
${SUDO} docker rm -f "mindfulness-server-${OLD_COLOR}" >/dev/null 2>&1 || true
|
${SUDO} docker rm -f "mindfulness-server-${OLD_COLOR}" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
echo "部署完成:${NEW_COLOR} 已上线"
|
echo "部署完成:${NEW_COLOR} 已上线"
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
|||||||
const projectId =
|
const projectId =
|
||||||
process.env.EXPO_PUBLIC_EAS_PROJECT_ID ||
|
process.env.EXPO_PUBLIC_EAS_PROJECT_ID ||
|
||||||
// 兼容部分 CI/EAS 注入的变量名
|
// 兼容部分 CI/EAS 注入的变量名
|
||||||
process.env.EAS_PROJECT_ID ||
|
process.env.EAS_PROJECT_ID;
|
||||||
undefined;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...config,
|
...config,
|
||||||
|
// ExpoConfig 的类型要求 name 必填,避免 `...config` 的可选类型导致 tsc 报错
|
||||||
|
name: config.name ?? 'client',
|
||||||
|
// slug 在绝大多数场景也建议固定为非空字符串(保持与 app.json 一致)
|
||||||
|
slug: config.slug ?? 'client',
|
||||||
extra: {
|
extra: {
|
||||||
...(config.extra ?? {}),
|
...(config.extra ?? {}),
|
||||||
eas: {
|
eas: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "Hey Mama",
|
"name": "Dear Mama",
|
||||||
"slug": "client",
|
"slug": "client",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/images/splashScreen.png",
|
"image": "./assets/images/Screen_page.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#EAD2BA"
|
"backgroundColor": "#EAD2BA"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
|
import { useEffect, useLayoutEffect, useMemo, useState, useCallback, useRef } from 'react';
|
||||||
import { StyleSheet, View, Dimensions, Text, Pressable, PanResponder, Animated as RNAnimated, ImageBackground } from 'react-native';
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
Dimensions,
|
||||||
|
Text,
|
||||||
|
Pressable,
|
||||||
|
PanResponder,
|
||||||
|
Animated as RNAnimated,
|
||||||
|
ImageBackground,
|
||||||
|
Platform,
|
||||||
|
} from 'react-native';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useFocusEffect } from 'expo-router';
|
import { useFocusEffect } from 'expo-router';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
@@ -43,6 +53,9 @@ import LikeIcon from '@/assets/images/icon/like_icon.svg';
|
|||||||
|
|
||||||
import { getBootId } from '@/src/utils/bootSession';
|
import { getBootId } from '@/src/utils/bootSession';
|
||||||
import { advanceSuixinState, buildInitialSuixinState, NEUTRAL_THEME_COLORS } from '@/src/features/suixinTheme';
|
import { advanceSuixinState, buildInitialSuixinState, NEUTRAL_THEME_COLORS } from '@/src/features/suixinTheme';
|
||||||
|
import { wrapText } from '@/src/features/textWrap';
|
||||||
|
import { defaultMeasureWidthImpl } from '@/src/features/textWrap/measure';
|
||||||
|
import { ensureDailyWidgetRecoUpToDate } from '@/src/modules/dailyWidgetReco';
|
||||||
|
|
||||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -97,6 +110,21 @@ export default function HomeScreen() {
|
|||||||
const [likeFilled, setLikeFilled] = useState(false);
|
const [likeFilled, setLikeFilled] = useState(false);
|
||||||
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
|
const [feedItems, setFeedItems] = useState<FeedItem[]>([]);
|
||||||
const [isFetching, setIsFetching] = useState(false);
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
|
const [cardWidth, setCardWidth] = useState<number | null>(null);
|
||||||
|
const [wrappedText, setWrappedText] = useState<string>('');
|
||||||
|
const wrapLogRef = useRef<{ key: string } | null>(null);
|
||||||
|
const busyRef = useRef(false);
|
||||||
|
const indexRef = useRef(0);
|
||||||
|
const currentFeedRef = useRef<FeedItem[]>([]);
|
||||||
|
const likedIdsRef = useRef<Set<string>>(new Set());
|
||||||
|
const likeInFlightRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
busyRef.current = busy;
|
||||||
|
}, [busy]);
|
||||||
|
useEffect(() => {
|
||||||
|
indexRef.current = index;
|
||||||
|
}, [index]);
|
||||||
|
|
||||||
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
|
// 解决语言切换时重复触发拉取/清空导致“文案不停跳动”的问题:
|
||||||
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
// 用 ref 持有最新状态,避免 useCallback 依赖 feedItems/isFetching 造成函数 identity 变化 → effect 重复执行
|
||||||
@@ -167,6 +195,9 @@ export default function HomeScreen() {
|
|||||||
text: t(item.textKey)
|
text: t(item.textKey)
|
||||||
}));
|
}));
|
||||||
}, [feedItems, t]);
|
}, [feedItems, t]);
|
||||||
|
useEffect(() => {
|
||||||
|
currentFeedRef.current = currentFeed;
|
||||||
|
}, [currentFeed]);
|
||||||
|
|
||||||
const item = useMemo(() => {
|
const item = useMemo(() => {
|
||||||
const data = currentFeed[index % currentFeed.length];
|
const data = currentFeed[index % currentFeed.length];
|
||||||
@@ -176,6 +207,133 @@ export default function HomeScreen() {
|
|||||||
};
|
};
|
||||||
}, [currentFeed, index]);
|
}, [currentFeed, index]);
|
||||||
|
|
||||||
|
// Home 文案:使用自主换行算法(Text Wrap 模块)
|
||||||
|
// - 通过 onLayout 获取容器宽度
|
||||||
|
// - 注入真实测量实现,确保“宽度派”评分与实际渲染一致
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
// 未拿到宽度前先用原文(避免闪烁)
|
||||||
|
if (!cardWidth || cardWidth <= 0) {
|
||||||
|
if (__DEV__) {
|
||||||
|
const key = `noWidth|${item.id}|${String(cardWidth)}`;
|
||||||
|
if (wrapLogRef.current?.key !== key) {
|
||||||
|
wrapLogRef.current = { key };
|
||||||
|
console.log('[TextWrap][Home] cardWidth 未就绪,先回退原文', {
|
||||||
|
itemId: item.id,
|
||||||
|
lang: recoLang,
|
||||||
|
cardWidth,
|
||||||
|
themeMode,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setWrappedText(item.text);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddingHorizontal = themeMode === 'scenery' ? 50 : 30;
|
||||||
|
const availableWidth = Math.max(0, Math.floor(cardWidth - paddingHorizontal * 2));
|
||||||
|
|
||||||
|
const lang = recoLang === 'en' ? 'EN' : 'TC';
|
||||||
|
|
||||||
|
const fontFamily =
|
||||||
|
lang === 'EN'
|
||||||
|
? 'STIXTwoText'
|
||||||
|
: Platform.select({
|
||||||
|
ios: 'System',
|
||||||
|
android: 'sans-serif',
|
||||||
|
default: 'System',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fontSpec = {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: lang === 'EN' ? '600' : '700',
|
||||||
|
fontFamily: String(fontFamily ?? 'System'),
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
if (__DEV__) {
|
||||||
|
const key = `start|${item.id}|${lang}|${availableWidth}|${themeMode}`;
|
||||||
|
if (wrapLogRef.current?.key !== key) {
|
||||||
|
wrapLogRef.current = { key };
|
||||||
|
console.log('[TextWrap][Home] wrapText 开始', {
|
||||||
|
itemId: item.id,
|
||||||
|
lang,
|
||||||
|
themeMode,
|
||||||
|
cardWidth,
|
||||||
|
paddingHorizontal,
|
||||||
|
availableWidth,
|
||||||
|
fontSpec,
|
||||||
|
textPreview: String(item.text ?? '').slice(0, 80),
|
||||||
|
textLength: String(item.text ?? '').length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await wrapText({
|
||||||
|
text: item.text,
|
||||||
|
lang,
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth,
|
||||||
|
maxLines: 3,
|
||||||
|
overflowMode: 'CLIP',
|
||||||
|
lineMode: 'AUTO',
|
||||||
|
// Home:采用“更偏好语气停顿/更好看”的排版风格微调(不影响算法默认 v1)
|
||||||
|
configVersion: 'v1-home',
|
||||||
|
debug: __DEV__,
|
||||||
|
fontSpec,
|
||||||
|
contextProfile: `APP|${Platform.OS}|home|${lang}`,
|
||||||
|
measureWidthImpl: defaultMeasureWidthImpl,
|
||||||
|
scoringOverrides:
|
||||||
|
lang === 'TC'
|
||||||
|
? {
|
||||||
|
// 更偏好在逗号/句号等处断行(即便宽度允许也不一定要塞满)
|
||||||
|
weights: { R_PUNCT_BREAK: 180 },
|
||||||
|
// 让“理想行宽”更短,避免宽屏下过度延后断行
|
||||||
|
idealWidthRatio: { APP: 0.82 },
|
||||||
|
// 更宽容短行(尤其是第一行在标点处停顿)
|
||||||
|
minPreferredRatio: 0.45,
|
||||||
|
shortLastLineRatio: 0.45,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('[TextWrap][Home] wrapText 成功', {
|
||||||
|
itemId: item.id,
|
||||||
|
wrappedText: res.wrappedText,
|
||||||
|
linesCount: res.lines.length,
|
||||||
|
meta: res.meta,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setWrappedText(res.wrappedText);
|
||||||
|
} catch (error) {
|
||||||
|
// 任何异常都回退到原文,避免影响 Home 主流程
|
||||||
|
if (__DEV__) {
|
||||||
|
console.log('[TextWrap][Home] wrapText 异常,回退原文', {
|
||||||
|
itemId: item.id,
|
||||||
|
lang,
|
||||||
|
availableWidth,
|
||||||
|
fontSpec,
|
||||||
|
errorName: (error as any)?.name,
|
||||||
|
errorMessage: String((error as any)?.message ?? error),
|
||||||
|
errorStack: (error as any)?.stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
setWrappedText(item.text);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [item.text, cardWidth, recoLang, themeMode]);
|
||||||
|
|
||||||
// 异步拉取新文案
|
// 异步拉取新文案
|
||||||
const fetchNewFeed = useCallback(async () => {
|
const fetchNewFeed = useCallback(async () => {
|
||||||
if (isFetchingRef.current) return;
|
if (isFetchingRef.current) return;
|
||||||
@@ -248,6 +406,11 @@ export default function HomeScreen() {
|
|||||||
setIndex(0);
|
setIndex(0);
|
||||||
fetchNewFeed();
|
fetchNewFeed();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Widget:前台辅助刷新(尽力而为)
|
||||||
|
// - 写入 App Group 的 dailyReco 缓存
|
||||||
|
// - 生成 wrapped_text_by_family,供 Widget 直接渲染
|
||||||
|
ensureDailyWidgetRecoUpToDate({ reason: 'home_focus' }).catch(() => {});
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -282,24 +445,60 @@ export default function HomeScreen() {
|
|||||||
transform: [{ scale: likeScale.value }],
|
transform: [{ scale: likeScale.value }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const setBusySafe = useCallback((next: boolean) => {
|
||||||
|
busyRef.current = next;
|
||||||
|
setBusy(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setLikeInFlight = useCallback((next: boolean) => {
|
||||||
|
likeInFlightRef.current = next;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncLikeFilledByIndex = useCallback((nextIndex: number) => {
|
||||||
|
const list = currentFeedRef.current;
|
||||||
|
const len = list.length;
|
||||||
|
if (!len) {
|
||||||
|
setLikeFilled(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const safe = ((nextIndex % len) + len) % len;
|
||||||
|
const nextId = String(list[safe]?.content_id);
|
||||||
|
setLikeFilled(likedIdsRef.current.has(nextId));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyIndexChange = useCallback((nextIndex: number) => {
|
||||||
|
indexRef.current = nextIndex;
|
||||||
|
setIndex(nextIndex);
|
||||||
|
syncLikeFilledByIndex(nextIndex);
|
||||||
|
}, [syncLikeFilledByIndex]);
|
||||||
|
|
||||||
|
const maybeFetchNewFeedIfNeeded = useCallback((nextIndex: number) => {
|
||||||
|
const len = currentFeedRef.current.length;
|
||||||
|
if (!len) return;
|
||||||
|
// 当接近当前列表末尾时(例如还剩 5 条)提前拉取
|
||||||
|
if (nextIndex + 5 >= len && !isFetchingRef.current) {
|
||||||
|
fetchNewFeed();
|
||||||
|
}
|
||||||
|
}, [fetchNewFeed]);
|
||||||
|
|
||||||
// 切换到下一条文案的统一动画逻辑
|
// 切换到下一条文案的统一动画逻辑
|
||||||
const triggerNextContent = useCallback(() => {
|
const triggerNextContent = useCallback(() => {
|
||||||
if (busy) return;
|
if (busyRef.current) return;
|
||||||
setBusy(true);
|
setBusySafe(true);
|
||||||
|
|
||||||
|
// 注意:不要在 Reanimated worklet 回调里读取 React ref(例如 indexRef/currentFeedRef),会导致值不更新或异常
|
||||||
|
const nextIndex = indexRef.current + 1;
|
||||||
|
|
||||||
// 1. 当前文案向上移动并消失
|
// 1. 当前文案向上移动并消失
|
||||||
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
translateY.value = withTiming(-40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||||
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||||
if (finished) {
|
if (finished) {
|
||||||
// 2. 切换数据索引
|
// 2. 切换数据索引
|
||||||
runOnJS(setIndex)(index + 1);
|
runOnJS(applyIndexChange)(nextIndex);
|
||||||
runOnJS(setLikeFilled)(false);
|
|
||||||
runOnJS(advanceSuixinOnNextContent)();
|
runOnJS(advanceSuixinOnNextContent)();
|
||||||
|
|
||||||
// 检查是否需要拉取新文案(当接近当前列表末尾时,例如还剩 5 条)
|
// 检查是否需要拉取新文案(注意:不要把匿名函数塞进 runOnJS,可能导致原生崩溃)
|
||||||
if (index + 5 >= currentFeed.length && !isFetching) {
|
runOnJS(maybeFetchNewFeedIfNeeded)(nextIndex);
|
||||||
runOnJS(fetchNewFeed)();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 准备下一条文案:先瞬移到下方 40pt
|
// 3. 准备下一条文案:先瞬移到下方 40pt
|
||||||
translateY.value = 40;
|
translateY.value = 40;
|
||||||
@@ -308,20 +507,51 @@ export default function HomeScreen() {
|
|||||||
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
|
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
|
||||||
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
|
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
|
||||||
if (finished) {
|
if (finished) {
|
||||||
runOnJS(setBusy)(false);
|
runOnJS(setBusySafe)(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [busy, index, currentFeed.length, isFetching, fetchNewFeed, translateY, opacity]);
|
}, [applyIndexChange, setBusySafe, translateY, opacity, advanceSuixinOnNextContent, maybeFetchNewFeedIfNeeded]);
|
||||||
|
|
||||||
|
// 切换到上一条文案的统一动画逻辑(下滑触发)
|
||||||
|
const triggerPrevContent = useCallback(() => {
|
||||||
|
if (busyRef.current) return;
|
||||||
|
setBusySafe(true);
|
||||||
|
|
||||||
|
// 注意:同上,不要在 worklet 里读取 React ref
|
||||||
|
const len = currentFeedRef.current.length;
|
||||||
|
const raw = indexRef.current - 1;
|
||||||
|
const nextIndex = len ? ((raw % len) + len) % len : Math.max(0, raw);
|
||||||
|
|
||||||
|
// 1. 当前文案向下移动并消失
|
||||||
|
translateY.value = withTiming(40, { duration: 300, easing: Easing.out(Easing.quad) });
|
||||||
|
opacity.value = withTiming(0, { duration: 300 }, (finished) => {
|
||||||
|
if (finished) {
|
||||||
|
// 2. 切换数据索引(循环回退)
|
||||||
|
runOnJS(applyIndexChange)(nextIndex);
|
||||||
|
|
||||||
|
// 3. 准备上一条文案:先瞬移到上方 40pt
|
||||||
|
translateY.value = -40;
|
||||||
|
|
||||||
|
// 4. 上一条文案向下移动到原位并显现
|
||||||
|
translateY.value = withTiming(0, { duration: 400, easing: Easing.out(Easing.back(1)) });
|
||||||
|
opacity.value = withTiming(1, { duration: 400 }, (finished) => {
|
||||||
|
if (finished) {
|
||||||
|
runOnJS(setBusySafe)(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [applyIndexChange, setBusySafe, translateY, opacity]);
|
||||||
|
|
||||||
const lastTapRef = useRef<number>(0);
|
const lastTapRef = useRef<number>(0);
|
||||||
|
|
||||||
// 使用 Ref 解决 PanResponder 闭包陷阱,确保手势回调能拿到最新的 state 和 function
|
// 使用 Ref 解决 PanResponder 闭包陷阱,确保手势回调能拿到最新的 state 和 function
|
||||||
const handlersRef = useRef({ onPressLike, triggerNextContent });
|
const handlersRef = useRef({ onPressLike, triggerNextContent, triggerPrevContent });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handlersRef.current = { onPressLike, triggerNextContent };
|
handlersRef.current = { onPressLike, triggerNextContent, triggerPrevContent };
|
||||||
}, [onPressLike, triggerNextContent]);
|
}, [onPressLike, triggerNextContent, triggerPrevContent]);
|
||||||
|
|
||||||
// 使用系统自带的 PanResponder 代替第三方手势库
|
// 使用系统自带的 PanResponder 代替第三方手势库
|
||||||
const panResponder = useRef(
|
const panResponder = useRef(
|
||||||
@@ -346,16 +576,32 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
lastTapRef.current = now;
|
lastTapRef.current = now;
|
||||||
|
|
||||||
// 2. 上滑逻辑判定
|
// 2. 上滑/下滑逻辑判定
|
||||||
if (gestureState.dy < -50) { // 上滑超过 50pt
|
if (gestureState.dy < -50) { // 上滑超过 50pt
|
||||||
runOnJS(handlersRef.current.triggerNextContent)();
|
runOnJS(handlersRef.current.triggerNextContent)();
|
||||||
|
} else if (gestureState.dy > 50) { // 下滑超过 50pt
|
||||||
|
runOnJS(handlersRef.current.triggerPrevContent)();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
).current;
|
).current;
|
||||||
|
|
||||||
async function onPressLike() {
|
async function onPressLike() {
|
||||||
if (busy) return;
|
if (busyRef.current) return;
|
||||||
|
if (likeInFlightRef.current) return;
|
||||||
|
|
||||||
|
// 已经喜欢过:不重复写入收藏,直接当作“下一条”
|
||||||
|
if (likeFilled || likedIdsRef.current.has(item.id)) {
|
||||||
|
triggerNextContent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
likeInFlightRef.current = true;
|
||||||
|
|
||||||
|
const likedItemId = item.id;
|
||||||
|
const likedItemText = item.text;
|
||||||
|
// 先记下“已喜欢”,保证回退时能恢复点亮状态(即便异步保存稍后才完成)
|
||||||
|
likedIdsRef.current.add(likedItemId);
|
||||||
setLikeFilled(true);
|
setLikeFilled(true);
|
||||||
|
|
||||||
// 1. 获取当前日期
|
// 1. 获取当前日期
|
||||||
@@ -365,24 +611,33 @@ export default function HomeScreen() {
|
|||||||
// 2. 保存到收藏夹,包含当前背景信息
|
// 2. 保存到收藏夹,包含当前背景信息
|
||||||
const favItem = {
|
const favItem = {
|
||||||
favId: String(Date.now()), // 生成唯一 ID
|
favId: String(Date.now()), // 生成唯一 ID
|
||||||
id: item.id,
|
id: likedItemId,
|
||||||
text: item.text,
|
text: likedItemText,
|
||||||
date: dateStr,
|
date: dateStr,
|
||||||
themeMode: themeMode,
|
themeMode: themeMode,
|
||||||
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
|
background: themeMode === 'scenery' ? String(natureImageIndex) : backgroundColor,
|
||||||
};
|
};
|
||||||
console.log('Home: Triggering addFavorite', JSON.stringify(favItem));
|
console.log('Home: Triggering addFavorite', JSON.stringify(favItem));
|
||||||
await addFavorite(favItem);
|
try {
|
||||||
|
await addFavorite(favItem);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Home: addFavorite 失败', error);
|
||||||
|
}
|
||||||
|
|
||||||
// 3. 记录到后端 Reaction(喜欢)
|
// 3. 记录到后端 Reaction(喜欢)
|
||||||
console.log('Home: Triggering setReaction', item.id);
|
console.log('Home: Triggering setReaction', item.id);
|
||||||
await setReaction(item.id, 'like');
|
try {
|
||||||
|
await setReaction(item.id, 'like');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Home: setReaction 失败', error);
|
||||||
|
}
|
||||||
|
|
||||||
// 4. 爱心缩放动画
|
// 4. 爱心缩放动画
|
||||||
likeScale.value = withSequence(
|
likeScale.value = withSequence(
|
||||||
withTiming(0.8, { duration: 100 }),
|
withTiming(0.8, { duration: 100 }),
|
||||||
withTiming(1.2, { duration: 150 }),
|
withTiming(1.2, { duration: 150 }),
|
||||||
withTiming(1, { duration: 100 }, (finished) => {
|
withTiming(1, { duration: 100 }, (finished) => {
|
||||||
|
runOnJS(setLikeInFlight)(false);
|
||||||
if (finished) {
|
if (finished) {
|
||||||
console.log('Home: Like animation finished, triggering next content');
|
console.log('Home: Like animation finished, triggering next content');
|
||||||
runOnJS(triggerNextContent)();
|
runOnJS(triggerNextContent)();
|
||||||
@@ -420,19 +675,25 @@ export default function HomeScreen() {
|
|||||||
onPress={() => setThemeOpen(true)}
|
onPress={() => setThemeOpen(true)}
|
||||||
accessibilityLabel={t('home.theme')}
|
accessibilityLabel={t('home.theme')}
|
||||||
>
|
>
|
||||||
<ThemeIcon width={18} height={18} />
|
<ThemeIcon width={20} height={20} />
|
||||||
</CircleIconButton>
|
</CircleIconButton>
|
||||||
<CircleIconButton
|
<CircleIconButton
|
||||||
onPress={() => setProfileOpen(true)}
|
onPress={() => setProfileOpen(true)}
|
||||||
accessibilityLabel={t('home.profile')}
|
accessibilityLabel={t('home.profile')}
|
||||||
>
|
>
|
||||||
<MyIcon width={18} height={18} />
|
<MyIcon width={20} height={20} />
|
||||||
</CircleIconButton>
|
</CircleIconButton>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Animated.View style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}>
|
<Animated.View
|
||||||
|
style={[styles.card, textAnimatedStyle, themeMode === 'scenery' && styles.sceneryCard]}
|
||||||
|
onLayout={(e) => {
|
||||||
|
const w = e.nativeEvent.layout.width;
|
||||||
|
if (Number.isFinite(w) && w > 0) setCardWidth(w);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
|
<Text style={[styles.text, isEnglish && styles.textEnglish, themeMode === 'scenery' && styles.sceneryText]}>
|
||||||
{item.text}
|
{wrappedText || item.text}
|
||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
@@ -442,15 +703,16 @@ export default function HomeScreen() {
|
|||||||
onPress={onPressLike}
|
onPress={onPressLike}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t('home.like')}
|
accessibilityLabel={t('home.like')}
|
||||||
hitSlop={20}
|
// 稍微增大可点击区域,提升单手操作成功率
|
||||||
|
hitSlop={24}
|
||||||
style={styles.reactionInner}
|
style={styles.reactionInner}
|
||||||
>
|
>
|
||||||
{likeFilled ? (
|
{likeFilled ? (
|
||||||
<LikeFilledIcon width={35} height={36} color="#EA6969" />
|
<LikeFilledIcon width={40} height={41} color="#EA6969" />
|
||||||
) : (
|
) : (
|
||||||
<LikeIcon
|
<LikeIcon
|
||||||
width={35}
|
width={40}
|
||||||
height={36}
|
height={41}
|
||||||
color={themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28'}
|
color={themeMode === 'scenery' ? '#FFFFFF' : '#5E2A28'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -480,7 +742,8 @@ function CircleIconButton({
|
|||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
hitSlop={10}
|
// 稍微增大可点击区域,提升易用性
|
||||||
|
hitSlop={14}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={accessibilityLabel}
|
accessibilityLabel={accessibilityLabel}
|
||||||
style={styles.circleBtn}
|
style={styles.circleBtn}
|
||||||
@@ -505,9 +768,9 @@ const styles = StyleSheet.create({
|
|||||||
zIndex: 30,
|
zIndex: 30,
|
||||||
},
|
},
|
||||||
circleBtn: {
|
circleBtn: {
|
||||||
width: 34,
|
width: 40,
|
||||||
height: 34,
|
height: 40,
|
||||||
borderRadius: 17,
|
borderRadius: 20,
|
||||||
backgroundColor: 'rgba(255,255,255,0.75)',
|
backgroundColor: 'rgba(255,255,255,0.75)',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { buildUserProfileFromQuestionnaire, mapOnboardingSelectionsToQuestionnai
|
|||||||
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco';
|
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromScoring } from '@/src/modules/dailyWidgetReco';
|
||||||
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
|
import { toBackendLocaleFromLanguageTag } from '@/src/i18n/locale';
|
||||||
import { fetchRecoFeed } from '@/src/services/recoApi';
|
import { fetchRecoFeed } from '@/src/services/recoApi';
|
||||||
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
|
import { ensurePushTokenRegisteredIfPermitted, setPushPreferences } from '@/src/services/pushApi';
|
||||||
import {
|
import {
|
||||||
recordRecoFeedServed,
|
recordRecoFeedServed,
|
||||||
setOnboardingCompleted,
|
setOnboardingCompleted,
|
||||||
@@ -44,6 +44,7 @@ export default function OnboardingScreen() {
|
|||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [selections, setSelections] = useState<Record<string, string[]>>({});
|
const [selections, setSelections] = useState<Record<string, string[]>>({});
|
||||||
const [reminderTimes, setReminderTimes] = useState(3);
|
const [reminderTimes, setReminderTimes] = useState(3);
|
||||||
|
const [finishing, setFinishing] = useState(false);
|
||||||
|
|
||||||
const currentStep = STEPS[stepIndex];
|
const currentStep = STEPS[stepIndex];
|
||||||
const currentTitle = useMemo(() => t(`onboardingSurvey.steps.${currentStep.id}.title`), [t, currentStep.id]);
|
const currentTitle = useMemo(() => t(`onboardingSurvey.steps.${currentStep.id}.title`), [t, currentStep.id]);
|
||||||
@@ -56,6 +57,9 @@ export default function OnboardingScreen() {
|
|||||||
}, [t, currentStep]);
|
}, [t, currentStep]);
|
||||||
|
|
||||||
async function onFinish() {
|
async function onFinish() {
|
||||||
|
if (finishing) return;
|
||||||
|
setFinishing(true);
|
||||||
|
try {
|
||||||
// 用户选择每日次数 > 0:在此页直接触发系统通知权限(已移除单独的 push 引导页)。
|
// 用户选择每日次数 > 0:在此页直接触发系统通知权限(已移除单独的 push 引导页)。
|
||||||
const wantsPush = reminderTimes > 0;
|
const wantsPush = reminderTimes > 0;
|
||||||
|
|
||||||
@@ -124,7 +128,8 @@ export default function OnboardingScreen() {
|
|||||||
await setPushPromptState('unknown');
|
await setPushPromptState('unknown');
|
||||||
try {
|
try {
|
||||||
const { status } = await Notifications.requestPermissionsAsync();
|
const { status } = await Notifications.requestPermissionsAsync();
|
||||||
if (status !== 'granted') {
|
// iOS 可能出现 provisional(临时授权),也应视为“已授权”
|
||||||
|
if (status !== 'granted' && status !== ('provisional' as any)) {
|
||||||
await setPushPromptState('skipped');
|
await setPushPromptState('skipped');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -136,10 +141,8 @@ export default function OnboardingScreen() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) 获取 Expo Push Token(失败才认为“推送开启失败”)
|
// 1) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
|
||||||
const expoPushToken = await getExpoPushTokenOrThrow();
|
await ensurePushTokenRegisteredIfPermitted();
|
||||||
// 2) 上报 token 到后端(幂等;失败才认为“推送开启失败”)
|
|
||||||
await registerPushToken({ pushToken: expoPushToken });
|
|
||||||
|
|
||||||
// 3) 上报推送偏好(幂等)
|
// 3) 上报推送偏好(幂等)
|
||||||
// 注意:这一步失败时,后端仍可能已成功接收 token。
|
// 注意:这一步失败时,后端仍可能已成功接收 token。
|
||||||
@@ -159,9 +162,17 @@ export default function OnboardingScreen() {
|
|||||||
} finally {
|
} finally {
|
||||||
router.replace('/(app)/home');
|
router.replace('/(app)/home');
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 极端情况下(例如本地存储/初始化异常)避免卡死在 loading:提示并允许用户重试
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.warn('[OnboardingFinish] 异常:', msg);
|
||||||
|
Alert.alert(t('push.errorTitle'), t('push.errorDesc'));
|
||||||
|
setFinishing(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onNext = () => {
|
const onNext = () => {
|
||||||
|
if (finishing) return;
|
||||||
if (stepIndex < STEPS.length - 1) {
|
if (stepIndex < STEPS.length - 1) {
|
||||||
setStepIndex(stepIndex + 1);
|
setStepIndex(stepIndex + 1);
|
||||||
} else {
|
} else {
|
||||||
@@ -170,6 +181,7 @@ export default function OnboardingScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onBack = () => {
|
const onBack = () => {
|
||||||
|
if (finishing) return;
|
||||||
if (stepIndex > 0) {
|
if (stepIndex > 0) {
|
||||||
setStepIndex(stepIndex - 1);
|
setStepIndex(stepIndex - 1);
|
||||||
}
|
}
|
||||||
@@ -177,6 +189,7 @@ export default function OnboardingScreen() {
|
|||||||
|
|
||||||
/** 只跳過當前這一步(不填/不選當前題,進入下一步) */
|
/** 只跳過當前這一步(不填/不選當前題,進入下一步) */
|
||||||
const handleSkipCurrentStep = () => {
|
const handleSkipCurrentStep = () => {
|
||||||
|
if (finishing) return;
|
||||||
if (currentStep.type === 'name') {
|
if (currentStep.type === 'name') {
|
||||||
onNext();
|
onNext();
|
||||||
} else if (currentStep.type === 'selection') {
|
} else if (currentStep.type === 'selection') {
|
||||||
@@ -200,6 +213,7 @@ export default function OnboardingScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSkipStep = () => {
|
const handleSkipStep = () => {
|
||||||
|
if (finishing) return;
|
||||||
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
|
setSelections((prev) => ({ ...prev, [currentStep.id]: [] }));
|
||||||
onNext();
|
onNext();
|
||||||
};
|
};
|
||||||
@@ -236,6 +250,7 @@ export default function OnboardingScreen() {
|
|||||||
value={Math.max(1, reminderTimes)}
|
value={Math.max(1, reminderTimes)}
|
||||||
onChange={setReminderTimes}
|
onChange={setReminderTimes}
|
||||||
onFinish={onFinish}
|
onFinish={onFinish}
|
||||||
|
loading={finishing}
|
||||||
onSkip={() => {
|
onSkip={() => {
|
||||||
// 跳过每日提醒:视为 0 次(关闭)
|
// 跳过每日提醒:视为 0 次(关闭)
|
||||||
setReminderTimes(0);
|
setReminderTimes(0);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useColorScheme } from '@/components/useColorScheme';
|
|||||||
import { initI18n } from '@/src/i18n';
|
import { initI18n } from '@/src/i18n';
|
||||||
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
|
import { ensureDailyWidgetRecoUpToDate, syncWidgetConfig, syncWidgetUserProfileFromStorage } from '@/src/modules/dailyWidgetReco';
|
||||||
import { getOrCreateClientUserId } from '@/src/storage/appStorage';
|
import { getOrCreateClientUserId } from '@/src/storage/appStorage';
|
||||||
|
import { ensurePushTokenRegisteredIfPermitted } from '@/src/services/pushApi';
|
||||||
|
|
||||||
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
|
// 配置通知处理方式(即使不发送也建议配置,以确保权限接口正常)
|
||||||
Notifications.setNotificationHandler({
|
Notifications.setNotificationHandler({
|
||||||
@@ -75,6 +76,28 @@ export default function RootLayout() {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 只要系统通知权限已经 granted,就主动上报 Push Token(不依赖用户在“每日提醒”里点确认)
|
||||||
|
ensurePushTokenRegisteredIfPermitted()
|
||||||
|
.then((res) => {
|
||||||
|
if (__DEV__) console.log('[push_token_sync]', res);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (__DEV__) console.warn('[push_token_sync] 失败(不阻塞启动)', e);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 兜底:当用户在系统弹窗/系统设置里变更权限后,App 回到前台时再同步一次 token
|
||||||
|
const sub = AppState.addEventListener('change', (state) => {
|
||||||
|
if (state !== 'active') return;
|
||||||
|
ensurePushTokenRegisteredIfPermitted().catch(() => {
|
||||||
|
// ignore:不阻塞
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return () => sub.remove();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 字体与 i18n 都准备好后,允许渲染 App(原生 splash 的隐藏交给 onLayout,避免“硬切/闪白”)
|
// 字体与 i18n 都准备好后,允许渲染 App(原生 splash 的隐藏交给 onLayout,避免“硬切/闪白”)
|
||||||
if (loaded && i18nReady) setAppReady(true);
|
if (loaded && i18nReady) setAppReady(true);
|
||||||
@@ -113,7 +136,7 @@ export default function RootLayout() {
|
|||||||
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { opacity: splashOpacity }]}>
|
<Animated.View pointerEvents="none" style={[StyleSheet.absoluteFill, { opacity: splashOpacity }]}>
|
||||||
<View style={styles.splashOverlay}>
|
<View style={styles.splashOverlay}>
|
||||||
<Image
|
<Image
|
||||||
source={require('../assets/images/splashScreen.png')}
|
source={require('../assets/images/Screen_page.png')}
|
||||||
style={styles.splashImage}
|
style={styles.splashImage}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
|
|||||||
BIN
client/assets/images/Screen_page.png
Normal file
BIN
client/assets/images/Screen_page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 152 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB |
@@ -41,7 +41,7 @@ import QuestionIcon from '@/assets/images/home/Profile/widget/question_icon.svg'
|
|||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { changeLanguage } from '@/src/i18n';
|
import { changeLanguage } from '@/src/i18n';
|
||||||
import { fetchLegalLinks } from '@/src/services/legalApi';
|
import { fetchLegalLinks } from '@/src/services/legalApi';
|
||||||
import { getExpoPushTokenOrThrow, registerPushToken, setPushPreferences } from '@/src/services/pushApi';
|
import { ensurePushTokenRegisteredIfPermitted, setPushPreferences } from '@/src/services/pushApi';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -430,14 +430,13 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
|||||||
// 调试:打印状态
|
// 调试:打印状态
|
||||||
console.log('Push Permission Status:', status);
|
console.log('Push Permission Status:', status);
|
||||||
|
|
||||||
if (status === 'granted') {
|
if (status === 'granted' || (status as any) === 'provisional') {
|
||||||
setPushEnabled(true);
|
setPushEnabled(true);
|
||||||
setHasSystemPermission(true);
|
setHasSystemPermission(true);
|
||||||
|
|
||||||
// 获取 token 并上报后端(幂等)
|
// 获取 token 并上报后端(幂等)
|
||||||
try {
|
try {
|
||||||
const expoPushToken = await getExpoPushTokenOrThrow();
|
await ensurePushTokenRegisteredIfPermitted();
|
||||||
await registerPushToken({ pushToken: expoPushToken });
|
|
||||||
// 偏好同步失败不应被用户感知为“开启失败”
|
// 偏好同步失败不应被用户感知为“开启失败”
|
||||||
// (常见现象:后端已接收 token,但偏好接口短暂失败/超时)
|
// (常见现象:后端已接收 token,但偏好接口短暂失败/超时)
|
||||||
try {
|
try {
|
||||||
@@ -479,6 +478,13 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
|||||||
const next: DailyReminderSettings = { timesPerDay: nextTimes, pushEnabled: nextEnabled };
|
const next: DailyReminderSettings = { timesPerDay: nextTimes, pushEnabled: nextEnabled };
|
||||||
await setDailyReminderSettings(next);
|
await setDailyReminderSettings(next);
|
||||||
|
|
||||||
|
// 若系统权限已授予且用户意愿为开启:兜底同步一次 token(避免“没点开关/没触发 toggle 导致后端无 token”)
|
||||||
|
if (nextEnabled) {
|
||||||
|
ensurePushTokenRegisteredIfPermitted().catch(() => {
|
||||||
|
// ignore:不阻塞保存
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 同步后端偏好(幂等;失败不阻塞)
|
// 同步后端偏好(幂等;失败不阻塞)
|
||||||
try {
|
try {
|
||||||
await setPushPreferences({ enabled: nextEnabled, timesPerDay: nextTimes });
|
await setPushPreferences({ enabled: nextEnabled, timesPerDay: nextTimes });
|
||||||
@@ -553,6 +559,8 @@ function DailyReminderPage({ visible, onDone }: { visible: boolean; onDone: () =
|
|||||||
function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
|
function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const currentLang = i18n.language;
|
const currentLang = i18n.language;
|
||||||
|
// 需求:个人主页弹窗「小工具」页暂时隐藏锁屏小工具说明/入口
|
||||||
|
const showLockScreenWidget = false;
|
||||||
|
|
||||||
// 根据语言选择图片
|
// 根据语言选择图片
|
||||||
const widget1 = currentLang === 'en'
|
const widget1 = currentLang === 'en'
|
||||||
@@ -570,10 +578,12 @@ function WidgetPage({ onOpenHowTo }: { onOpenHowTo: () => void }) {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
<View style={styles.widgetScroll}>
|
<View style={styles.widgetScroll}>
|
||||||
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
{showLockScreenWidget ? (
|
||||||
<Image source={widget1} style={styles.widgetImg1} resizeMode="contain" />
|
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
||||||
<Text style={styles.widgetLabel}>{t('widget.lockScreen')}</Text>
|
<Image source={widget1} style={styles.widgetImg1} resizeMode="contain" />
|
||||||
</Pressable>
|
<Text style={styles.widgetLabel}>{t('widget.lockScreen')}</Text>
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
<Pressable style={styles.widgetItem} onPress={onOpenHowTo}>
|
||||||
<Image source={widget2} style={styles.widgetImg2} resizeMode="contain" />
|
<Image source={widget2} style={styles.widgetImg2} resizeMode="contain" />
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function WidgetModal({ visible, onClose }: Props) {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SheetModal visible={visible} title={t('profile.widget')} onClose={onClose}>
|
<SheetModal visible={visible} title={t('widget.howToTitle')} onClose={onClose}>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
<View style={styles.row}>
|
<View style={styles.row}>
|
||||||
<PreviewCard label={t('widget.lockScreen')}>
|
<PreviewCard label={t('widget.lockScreen')}>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, StyleSheet, TouchableOpacity, Text, Platform } from 'react-native';
|
import { View, StyleSheet, TouchableOpacity, Text, Platform, ActivityIndicator } from 'react-native';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
import { OnboardingColors } from '@/constants/OnboardingTheme';
|
||||||
import AddIcon from '@/assets/images/icon/add_icon.svg';
|
import AddIcon from '@/assets/images/icon/add_icon.svg';
|
||||||
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
|
import ReduceIcon from '@/assets/images/icon/reduce_icon.svg';
|
||||||
@@ -12,9 +13,11 @@ interface ReminderStepProps {
|
|||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
onFinish: () => void;
|
onFinish: () => void;
|
||||||
onSkip?: () => void;
|
onSkip?: () => void;
|
||||||
|
/** 完成后请求通知权限时的加载态 */
|
||||||
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
export function ReminderStep({ value, onChange, onFinish, loading = false }: ReminderStepProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
@@ -30,7 +33,7 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
|||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.counterContainer}>
|
<View style={styles.counterContainer}>
|
||||||
<TouchableOpacity onPress={handleReduce} activeOpacity={0.7}>
|
<TouchableOpacity onPress={handleReduce} disabled={loading} activeOpacity={0.7}>
|
||||||
<ReduceIcon width={47} height={47} />
|
<ReduceIcon width={47} height={47} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
@@ -41,14 +44,27 @@ export function ReminderStep({ value, onChange, onFinish }: ReminderStepProps) {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity onPress={handleAdd} activeOpacity={0.7}>
|
<TouchableOpacity onPress={handleAdd} disabled={loading} activeOpacity={0.7}>
|
||||||
<AddIcon width={47} height={47} />
|
<AddIcon width={47} height={47} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
|
<View style={[styles.footer, { bottom: insets.bottom + 16 }]}>
|
||||||
<TouchableOpacity onPress={onFinish} activeOpacity={0.8}>
|
<TouchableOpacity onPress={onFinish} disabled={loading} activeOpacity={0.8}>
|
||||||
<BtnClicked width={87} height={57} />
|
<View style={styles.finishWrap}>
|
||||||
|
{loading ? (
|
||||||
|
<LinearGradient
|
||||||
|
colors={['#F69F7B', '#F99CC0']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={[styles.loadingPill, styles.finishDisabled]}
|
||||||
|
>
|
||||||
|
<ActivityIndicator size="small" color="#FFFFFF" />
|
||||||
|
</LinearGradient>
|
||||||
|
) : (
|
||||||
|
<BtnClicked width={87} height={57} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -92,4 +108,20 @@ const styles = StyleSheet.create({
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
|
finishWrap: {
|
||||||
|
width: 87,
|
||||||
|
height: 57,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
finishDisabled: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
loadingPill: {
|
||||||
|
width: 87,
|
||||||
|
height: 57,
|
||||||
|
borderRadius: 28.5,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
21
client/eas.json
Normal file
21
client/eas.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.32.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
|
A8C1D2E3F4A5B6C7D8E9F0B2 /* AppGroupStorageBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */; };
|
||||||
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; };
|
B5A7FE9A125F7C79753EC5BF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */; };
|
||||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||||
|
C0A1B2C3D4E5F60718293A4E /* Screen_page.png in Resources */ = {isa = PBXBuildFile; fileRef = C0A1B2C3D4E5F60718293A4D /* Screen_page.png */; };
|
||||||
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
EB3DAF812F2A4B8E00450593 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF802F2A4B8D00450593 /* WidgetKit.framework */; };
|
||||||
EB3DAF832F2A4B8E00450593 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */; };
|
EB3DAF832F2A4B8E00450593 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB3DAF822F2A4B8E00450593 /* SwiftUI.framework */; };
|
||||||
EB3DAF942F2A4B8F00450593 /* 情绪小组件Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
EB3DAF942F2A4B8F00450593 /* 情绪小组件Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
@@ -48,7 +49,7 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
13B07F961A680F5B00A75B9A /* HeyMama.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HeyMama.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
13B07F961A680F5B00A75B9A /* DearMama.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DearMama.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = client/Images.xcassets; sourceTree = "<group>"; };
|
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = client/Images.xcassets; sourceTree = "<group>"; };
|
||||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; };
|
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = client/Info.plist; sourceTree = "<group>"; };
|
||||||
3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = "<group>"; };
|
3C76CA16D0801CBF0D731C7C /* Pods-client.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-client.release.xcconfig"; path = "Target Support Files/Pods-client/Pods-client.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = "<group>"; };
|
A8C1D2E3F4A5B6C7D8E9F0B1 /* AppGroupStorageBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = AppGroupStorageBridge.m; path = client/AppGroupStorageBridge.m; sourceTree = "<group>"; };
|
||||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; };
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = client/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||||
|
C0A1B2C3D4E5F60718293A4D /* Screen_page.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = Screen_page.png; path = ../assets/images/Screen_page.png; sourceTree = "<group>"; };
|
||||||
C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
C7DB40C26E3A46F6D06769EA /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-client/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||||
E3328F0E595C1F4A244DF238 /* libPods-client.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-client.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
E3328F0E595C1F4A244DF238 /* libPods-client.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-client.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "情绪小组件Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "情绪小组件Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
@@ -131,6 +133,7 @@
|
|||||||
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
||||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
||||||
|
C0A1B2C3D4E5F60718293A4D /* Screen_page.png */,
|
||||||
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */,
|
75F52ADE07CAE9D9736D7671 /* PrivacyInfo.xcprivacy */,
|
||||||
);
|
);
|
||||||
name = client;
|
name = client;
|
||||||
@@ -183,7 +186,7 @@
|
|||||||
83CBBA001A601CBA00E9B192 /* Products */ = {
|
83CBBA001A601CBA00E9B192 /* Products */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
13B07F961A680F5B00A75B9A /* HeyMama.app */,
|
13B07F961A680F5B00A75B9A /* DearMama.app */,
|
||||||
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */,
|
EB3DAF7F2F2A4B8D00450593 /* 情绪小组件Extension.appex */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
@@ -247,7 +250,7 @@
|
|||||||
);
|
);
|
||||||
name = client;
|
name = client;
|
||||||
productName = client;
|
productName = client;
|
||||||
productReference = 13B07F961A680F5B00A75B9A /* HeyMama.app */;
|
productReference = 13B07F961A680F5B00A75B9A /* DearMama.app */;
|
||||||
productType = "com.apple.product-type.application";
|
productType = "com.apple.product-type.application";
|
||||||
};
|
};
|
||||||
EB3DAF7E2F2A4B8D00450593 /* 情绪小组件Extension */ = {
|
EB3DAF7E2F2A4B8D00450593 /* 情绪小组件Extension */ = {
|
||||||
@@ -298,6 +301,7 @@
|
|||||||
knownRegions = (
|
knownRegions = (
|
||||||
en,
|
en,
|
||||||
Base,
|
Base,
|
||||||
|
"zh-Hant",
|
||||||
);
|
);
|
||||||
mainGroup = 83CBB9F61A601CBA00E9B192;
|
mainGroup = 83CBB9F61A601CBA00E9B192;
|
||||||
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
|
productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */;
|
||||||
@@ -318,6 +322,7 @@
|
|||||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||||
|
C0A1B2C3D4E5F60718293A4E /* Screen_page.png in Resources */,
|
||||||
0BE245B56A79D95AB0A7B4BA /* PrivacyInfo.xcprivacy in Resources */,
|
0BE245B56A79D95AB0A7B4BA /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
@@ -522,7 +527,7 @@
|
|||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
||||||
PRODUCT_NAME = HeyMama;
|
PRODUCT_NAME = DearMama;
|
||||||
SKIP_INSTALL = NO;
|
SKIP_INSTALL = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
@@ -563,7 +568,7 @@
|
|||||||
);
|
);
|
||||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
PRODUCT_BUNDLE_IDENTIFIER = com.damer.mindfulness;
|
||||||
PRODUCT_NAME = HeyMama;
|
PRODUCT_NAME = DearMama;
|
||||||
SKIP_INSTALL = NO;
|
SKIP_INSTALL = NO;
|
||||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
SUPPORTS_MACCATALYST = NO;
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
BuildableName = "HeyMama.app"
|
BuildableName = "DearMama.app"
|
||||||
BlueprintName = "client"
|
BlueprintName = "client"
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
BuildableName = "HeyMama.app"
|
BuildableName = "DearMama.app"
|
||||||
BlueprintName = "client"
|
BlueprintName = "client"
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
BuildableName = "HeyMama.app"
|
BuildableName = "DearMama.app"
|
||||||
BlueprintName = "client"
|
BlueprintName = "client"
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
</AnalyzeAction>
|
</AnalyzeAction>
|
||||||
<ArchiveAction
|
<ArchiveAction
|
||||||
buildConfiguration = "Release"
|
buildConfiguration = "Release"
|
||||||
customArchiveName = "Hey Mama"
|
customArchiveName = "Dear Mama"
|
||||||
revealArchiveInOrganizer = "YES">
|
revealArchiveInOrganizer = "YES">
|
||||||
<PostActions>
|
<PostActions>
|
||||||
<ExecutionAction
|
<ExecutionAction
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
<BuildableReference
|
<BuildableReference
|
||||||
BuildableIdentifier = "primary"
|
BuildableIdentifier = "primary"
|
||||||
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
BlueprintIdentifier = "13B07F861A680F5B00A75B9A"
|
||||||
BuildableName = "HeyMama.app"
|
BuildableName = "DearMama.app"
|
||||||
BlueprintName = "client"
|
BlueprintName = "client"
|
||||||
ReferencedContainer = "container:client.xcodeproj">
|
ReferencedContainer = "container:client.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Hey Mama</string>
|
<string>Dear Mama</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||||
<subviews>
|
<subviews>
|
||||||
<imageView id="EXPO-SplashScreen" userLabel="SplashScreenLegacy" image="SplashScreenLegacy" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
|
<imageView id="EXPO-SplashScreen" userLabel="Screen_page" image="Screen_page" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
|
||||||
<rect key="frame" x="0" y="0" width="414" height="736"/>
|
<rect key="frame" x="0" y="0" width="414" height="736"/>
|
||||||
</imageView>
|
</imageView>
|
||||||
</subviews>
|
</subviews>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
<resources>
|
<resources>
|
||||||
<image name="SplashScreenLegacy" width="414" height="736"/>
|
<image name="Screen_page" width="414" height="736"/>
|
||||||
<systemColor name="systemBackgroundColor">
|
<systemColor name="systemBackgroundColor">
|
||||||
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
|
||||||
</systemColor>
|
</systemColor>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ if [[ -z "$APP_PLIST" ]]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
APP_DIR="$(/usr/bin/dirname "$APP_PLIST")"
|
APP_DIR="$(/usr/bin/dirname "$APP_PLIST")"
|
||||||
APP_NAME="$(/usr/bin/basename "$APP_DIR")" # 例如 HeyMama.app
|
APP_NAME="$(/usr/bin/basename "$APP_DIR")" # 例如 DearMama.app
|
||||||
APP_REL_PATH="Applications/$APP_NAME"
|
APP_REL_PATH="Applications/$APP_NAME"
|
||||||
|
|
||||||
bundle_id="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
bundle_id="$(/usr/bin/plutil -extract CFBundleIdentifier raw -o - "$APP_PLIST" 2>/dev/null || true)"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ private let keyWidgetConfig = "widget.config.v1"
|
|||||||
private let keyWidgetUserProfile = "widget.userProfile.v1_2"
|
private let keyWidgetUserProfile = "widget.userProfile.v1_2"
|
||||||
private let keyWidgetDailyReco = "widget.dailyReco.v1"
|
private let keyWidgetDailyReco = "widget.dailyReco.v1"
|
||||||
|
|
||||||
private let fallbackTextTC = "你已经很努力了,今天也值得被温柔对待。"
|
private let fallbackTextTC = "你已經很努力了,今天也值得被溫柔對待。"
|
||||||
private let fallbackTextEN = "You’ve been doing great — you deserve kindness today."
|
private let fallbackTextEN = "You’ve been doing great — you deserve kindness today."
|
||||||
|
|
||||||
private func defaults() -> UserDefaults? {
|
private func defaults() -> UserDefaults? {
|
||||||
@@ -29,17 +29,24 @@ private func localDayKey(_ date: Date = Date()) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func resolveLang() -> String {
|
private func resolveLang() -> String {
|
||||||
// 仅支持 en/tc
|
// 仅支持 en/tc:根据设备语言选择
|
||||||
let preferred = Locale.preferredLanguages.first?.lowercased() ?? "en"
|
// - 传统中文(Hant / TW / HK / MO)=> tc
|
||||||
return preferred.hasPrefix("zh") ? "tc" : "en"
|
// - 其他语言(含简中 zh-Hans / zh-CN)=> en(默认)
|
||||||
|
let preferred = (Locale.preferredLanguages.first ?? "en").lowercased()
|
||||||
|
if preferred.hasPrefix("zh-hant") { return "tc" }
|
||||||
|
if preferred.hasPrefix("zh-tw") { return "tc" }
|
||||||
|
if preferred.hasPrefix("zh-hk") { return "tc" }
|
||||||
|
if preferred.hasPrefix("zh-mo") { return "tc" }
|
||||||
|
return "en"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveTitle(lang: String) -> String {
|
private func resolveTitle(lang: String) -> String {
|
||||||
lang == "en" ? "Mindfulness" : "正念"
|
// 需求:品牌文案统一为 Dear Mama
|
||||||
|
return "Dear Mama"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveFooterHint(lang: String) -> String {
|
private func resolveFooterHint(lang: String) -> String {
|
||||||
lang == "en" ? "Tap to open the app" : "点我回到 App"
|
lang == "en" ? "Tap to open the app" : "點我回到 App"
|
||||||
}
|
}
|
||||||
|
|
||||||
private func joinUrl(base: String, path: String) -> String {
|
private func joinUrl(base: String, path: String) -> String {
|
||||||
@@ -76,16 +83,44 @@ private func writeJsonDict(_ dict: [String: Any], forKey key: String) {
|
|||||||
defaults()?.set(raw, forKey: key)
|
defaults()?.set(raw, forKey: key)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func readCachedText() -> (dayKey: String?, lang: String, text: String)? {
|
private func readCachedText(family: WidgetFamily) -> (dayKey: String?, lang: String, text: String)? {
|
||||||
guard let d = readJsonDict(forKey: keyWidgetDailyReco) else { return nil }
|
guard let d = readJsonDict(forKey: keyWidgetDailyReco) else { return nil }
|
||||||
let lang = (d["lang"] as? String) ?? resolveLang()
|
let lang = (d["lang"] as? String) ?? resolveLang()
|
||||||
let dayKey = d["day_key"] as? String
|
let dayKey = d["day_key"] as? String
|
||||||
if let item = d["item"] as? [String: Any], let text = item["text"] as? String, !text.isEmpty {
|
if let item = d["item"] as? [String: Any] {
|
||||||
return (dayKey: dayKey, lang: lang, text: text)
|
if let text = pickWidgetText(item: item, family: family), !text.isEmpty {
|
||||||
|
return (dayKey: dayKey, lang: lang, text: text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func familyKey(_ family: WidgetFamily) -> String {
|
||||||
|
switch family {
|
||||||
|
case .systemSmall:
|
||||||
|
return "small"
|
||||||
|
case .systemMedium:
|
||||||
|
return "medium"
|
||||||
|
case .systemLarge:
|
||||||
|
return "large"
|
||||||
|
default:
|
||||||
|
return "small"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pickWidgetText(item: [String: Any], family: WidgetFamily?) -> String? {
|
||||||
|
// 优先使用 App 侧预换行的结果(wrapped_text_by_family),否则回退 raw text
|
||||||
|
if let family = family,
|
||||||
|
let wrappedByFamily = item["wrapped_text_by_family"] as? [String: Any] {
|
||||||
|
let key = familyKey(family)
|
||||||
|
if let v = wrappedByFamily[key] as? String, !v.isEmpty {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let raw = item["text"] as? String, !raw.isEmpty { return raw }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
private func readApiBaseUrl() -> String? {
|
private func readApiBaseUrl() -> String? {
|
||||||
guard let d = readJsonDict(forKey: keyWidgetConfig) else { return nil }
|
guard let d = readJsonDict(forKey: keyWidgetConfig) else { return nil }
|
||||||
let base = d["apiBaseUrl"] as? String
|
let base = d["apiBaseUrl"] as? String
|
||||||
@@ -174,7 +209,7 @@ struct EmotionProvider: TimelineProvider {
|
|||||||
let today = localDayKey(Date())
|
let today = localDayKey(Date())
|
||||||
|
|
||||||
// 1) 今日缓存优先
|
// 1) 今日缓存优先
|
||||||
if let cached = readCachedText(), cached.dayKey == today {
|
if let cached = readCachedText(family: context.family), cached.dayKey == today {
|
||||||
let entry = EmotionEntry(
|
let entry = EmotionEntry(
|
||||||
date: Date(),
|
date: Date(),
|
||||||
lang: cached.lang,
|
lang: cached.lang,
|
||||||
@@ -201,7 +236,7 @@ struct EmotionProvider: TimelineProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3) 网络失败:用最近缓存或兜底
|
// 3) 网络失败:用最近缓存或兜底
|
||||||
if let cached = readCachedText() {
|
if let cached = readCachedText(family: context.family) {
|
||||||
let entry = EmotionEntry(
|
let entry = EmotionEntry(
|
||||||
date: Date(),
|
date: Date(),
|
||||||
lang: cached.lang,
|
lang: cached.lang,
|
||||||
@@ -332,8 +367,9 @@ struct EmotionWidget: Widget {
|
|||||||
StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in
|
StaticConfiguration(kind: kind, provider: EmotionProvider()) { entry in
|
||||||
EmotionWidgetView(entry: entry)
|
EmotionWidgetView(entry: entry)
|
||||||
}
|
}
|
||||||
.configurationDisplayName("情绪小组件")
|
// 名称/描述:支持多语言(使用 Widget Extension 自己的 Localizable.strings)
|
||||||
.description("一段温柔提醒,陪你回到当下。")
|
.configurationDisplayName("WIDGET_DISPLAY_NAME")
|
||||||
|
.description("WIDGET_DESCRIPTION")
|
||||||
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
.supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
3
client/ios/情绪小组件/en.lproj/Localizable.strings
Normal file
3
client/ios/情绪小组件/en.lproj/Localizable.strings
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"WIDGET_DISPLAY_NAME" = "Emotion Widget";
|
||||||
|
"WIDGET_DESCRIPTION" = "A gentle reminder to return to the present.";
|
||||||
|
|
||||||
3
client/ios/情绪小组件/zh-Hant.lproj/Localizable.strings
Normal file
3
client/ios/情绪小组件/zh-Hant.lproj/Localizable.strings
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"WIDGET_DISPLAY_NAME" = "情緒小組件";
|
||||||
|
"WIDGET_DESCRIPTION" = "一段溫柔提醒,陪你回到當下。";
|
||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// Metro 配置:支持 import 本地 .svg 为 React 组件
|
// Metro 配置:支持 import 本地 .svg 为 React 组件
|
||||||
// 说明:Expo SDK 54 + react-native-svg-transformer 的常见配置方式
|
// 说明:Expo SDK 54 + react-native-svg-transformer 的常见配置方式
|
||||||
|
const path = require('path');
|
||||||
const { getDefaultConfig } = require('expo/metro-config');
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
|
||||||
/** @type {import('expo/metro-config').MetroConfig} */
|
/** @type {import('expo/metro-config').MetroConfig} */
|
||||||
@@ -14,6 +15,10 @@ config.resolver = {
|
|||||||
...config.resolver,
|
...config.resolver,
|
||||||
assetExts: config.resolver.assetExts.filter((ext) => ext !== 'svg'),
|
assetExts: config.resolver.assetExts.filter((ext) => ext !== 'svg'),
|
||||||
sourceExts: [...config.resolver.sourceExts, 'svg'],
|
sourceExts: [...config.resolver.sourceExts, 'svg'],
|
||||||
|
// 确保 react-native-text-size 从项目 node_modules 解析(避免 Metro 解析不到)
|
||||||
|
extraNodeModules: {
|
||||||
|
'react-native-text-size': path.resolve(__dirname, 'node_modules/react-native-text-size'),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
|
|||||||
16
client/package-lock.json
generated
16
client/package-lock.json
generated
@@ -25,6 +25,7 @@
|
|||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.10",
|
||||||
|
"grapheme-splitter": "^1.0.4",
|
||||||
"i18next": "^25.8.0",
|
"i18next": "^25.8.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "15.12.1",
|
"react-native-svg": "15.12.1",
|
||||||
"react-native-svg-transformer": "^1.5.3",
|
"react-native-svg-transformer": "^1.5.3",
|
||||||
|
"react-native-text-size": "^4.0.0-rc.1",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.5.1"
|
"react-native-worklets": "0.5.1"
|
||||||
},
|
},
|
||||||
@@ -6958,6 +6960,11 @@
|
|||||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/grapheme-splitter": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ=="
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
@@ -9708,6 +9715,15 @@
|
|||||||
"react-native-svg": ">=12.0.0"
|
"react-native-svg": ">=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-text-size": {
|
||||||
|
"version": "4.0.0-rc.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/react-native-text-size/-/react-native-text-size-4.0.0-rc.1.tgz",
|
||||||
|
"integrity": "sha512-CysqjU2jK6Yc+a+kEI222pUyTY2ywcU2HqbFqf1KHymW6OPTdvBBHqbEJKL0QiLhQaFYDbqicM+h990s9TP00g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native": ">=0.59.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-web": {
|
"node_modules/react-native-web": {
|
||||||
"version": "0.21.2",
|
"version": "0.21.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz",
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"start:clean": "expo start -c",
|
"start:clean": "expo start -c",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios --scheme \"Hey Mama\"",
|
"ios": "expo run:ios --scheme \"Dear Mama\"",
|
||||||
"ios:clean": "npm run clean:cache && npm run clean:ios-build && expo run:ios --scheme \"Hey Mama\"",
|
"ios:clean": "npm run clean:cache && npm run clean:ios-build && expo run:ios --scheme \"Dear Mama\"",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"clean:cache": "rm -rf node_modules/.cache .expo 2>/dev/null; echo 'Cleared .expo and node_modules/.cache'",
|
"clean:cache": "rm -rf node_modules/.cache .expo 2>/dev/null; echo 'Cleared .expo and node_modules/.cache'",
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"expo-splash-screen": "~31.0.13",
|
"expo-splash-screen": "~31.0.13",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"expo-web-browser": "~15.0.10",
|
"expo-web-browser": "~15.0.10",
|
||||||
|
"grapheme-splitter": "^1.0.4",
|
||||||
"i18next": "^25.8.0",
|
"i18next": "^25.8.0",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
@@ -42,6 +43,7 @@
|
|||||||
"react-native-screens": "~4.16.0",
|
"react-native-screens": "~4.16.0",
|
||||||
"react-native-svg": "15.12.1",
|
"react-native-svg": "15.12.1",
|
||||||
"react-native-svg-transformer": "^1.5.3",
|
"react-native-svg-transformer": "^1.5.3",
|
||||||
|
"react-native-text-size": "^4.0.0-rc.1",
|
||||||
"react-native-web": "~0.21.0",
|
"react-native-web": "~0.21.0",
|
||||||
"react-native-worklets": "0.5.1"
|
"react-native-worklets": "0.5.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export type AppRuntimeEnv = 'local' | 'dev' | 'prod';
|
|||||||
* 默认回退到 prod(避免误打到 localhost 导致真机“无法发起网络请求”)。
|
* 默认回退到 prod(避免误打到 localhost 导致真机“无法发起网络请求”)。
|
||||||
*/
|
*/
|
||||||
const DEFAULT_RUNTIME_ENV: AppRuntimeEnv =
|
const DEFAULT_RUNTIME_ENV: AppRuntimeEnv =
|
||||||
typeof __DEV__ !== 'undefined' && __DEV__ ? 'local' : 'prod';
|
typeof __DEV__ !== 'undefined' && __DEV__ ? 'local' : 'dev';
|
||||||
|
|
||||||
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', DEFAULT_RUNTIME_ENV) as AppRuntimeEnv) ?? DEFAULT_RUNTIME_ENV;
|
export const APP_ENV = (getOptionalEnv('EXPO_PUBLIC_ENV', DEFAULT_RUNTIME_ENV) as AppRuntimeEnv) ?? DEFAULT_RUNTIME_ENV;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { wrapText } from '../index';
|
||||||
|
|
||||||
|
describe('textWrap integration wrapText', () => {
|
||||||
|
it('SYSTEM_DEFAULT:meta 标记 SYSTEM_DEFAULT 且仍返回 lines/wrappedText', async () => {
|
||||||
|
const res = await wrapText({
|
||||||
|
text: 'I am so tired',
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 1,
|
||||||
|
maxLines: 1,
|
||||||
|
overflowMode: 'SYSTEM_DEFAULT',
|
||||||
|
// 不提供测量能力,逼迫走兜底
|
||||||
|
fontSpec: null,
|
||||||
|
measureWidthImpl: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.lines.length).toBeGreaterThan(0);
|
||||||
|
expect(res.wrappedText.length).toBeGreaterThan(0);
|
||||||
|
expect(res.meta?.fallback_type).toBe('SYSTEM_DEFAULT');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { generateBreakpoints } from '../index';
|
||||||
|
|
||||||
|
function t(text: string, start: number): { text: string; start: number; end: number } {
|
||||||
|
return { text, start, end: start + text.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('textWrap breakpoint-candidates', () => {
|
||||||
|
it('EN: tokens=[I,am,so,tired] -> pos=[1,2,3](升序)', () => {
|
||||||
|
const tokens = [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)];
|
||||||
|
const { breakpoints, meta } = generateBreakpoints({
|
||||||
|
tokens: tokens as any,
|
||||||
|
lang: 'EN',
|
||||||
|
maxLines: 2,
|
||||||
|
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [',', '。'], balanceRange: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(breakpoints.map((b) => b.pos)).toEqual([1, 2, 3]);
|
||||||
|
expect(meta.originalCount).toBe(3);
|
||||||
|
expect(meta.finalCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC: 标点后断点(,)应生成 pos=i+1', () => {
|
||||||
|
const tokens = [t('我', 0), t('好', 1), t('累', 2), t(',', 3), t('😮💨', 4)];
|
||||||
|
const { breakpoints } = generateBreakpoints({
|
||||||
|
tokens: tokens as any,
|
||||||
|
lang: 'TC',
|
||||||
|
maxLines: 2,
|
||||||
|
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [','], balanceRange: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(breakpoints.some((b) => b.kind === 'PUNCT' && b.pos === 4)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC: forbiddenBreakRanges(闭区间)命中必须剔除(含 start/end)', () => {
|
||||||
|
const tokens = [t('我', 0), t('好', 1), t('累', 2), t(',', 3), t('😮💨', 4)];
|
||||||
|
const { breakpoints } = generateBreakpoints({
|
||||||
|
tokens: tokens as any,
|
||||||
|
lang: 'TC',
|
||||||
|
maxLines: 2,
|
||||||
|
constraints: { forbiddenBreakRanges: [{ start: 4, end: 4 }] },
|
||||||
|
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [','], balanceRange: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(breakpoints.some((b) => b.pos === 4)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC: 去重规则:同 pos 优先保留 priority 更高者;同 priority 按 kind 序', () => {
|
||||||
|
// 构造:同一个 pos=2 同时来自 SPACE(priority=20) 和 BALANCE(priority=5),必须保留 SPACE
|
||||||
|
const tokens = [t('我', 0), t(' ', 1), t('好', 2)];
|
||||||
|
const { breakpoints } = generateBreakpoints({
|
||||||
|
tokens: tokens as any,
|
||||||
|
lang: 'TC',
|
||||||
|
maxLines: 2,
|
||||||
|
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [], balanceRange: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const b2 = breakpoints.find((b) => b.pos === 2);
|
||||||
|
expect(b2?.kind).toBe('SPACE');
|
||||||
|
expect(b2?.priority).toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC: 裁剪上限生效且输出按 pos 升序(确定性)', () => {
|
||||||
|
const tokens = Array.from({ length: 60 }).map((_, i) => t('哈', i));
|
||||||
|
const res = generateBreakpoints({
|
||||||
|
tokens: tokens as any,
|
||||||
|
lang: 'TC',
|
||||||
|
maxLines: 3,
|
||||||
|
config: { tcMaxCandidateBreaks: 8, tcPunctuations: [], balanceRange: 6 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.breakpoints.length).toBe(8);
|
||||||
|
// pos 升序
|
||||||
|
for (let i = 1; i < res.breakpoints.length; i++) {
|
||||||
|
expect(res.breakpoints[i]!.pos).toBeGreaterThanOrEqual(res.breakpoints[i - 1]!.pos);
|
||||||
|
}
|
||||||
|
// 确定性
|
||||||
|
const res2 = generateBreakpoints({
|
||||||
|
tokens: tokens as any,
|
||||||
|
lang: 'TC',
|
||||||
|
maxLines: 3,
|
||||||
|
config: { tcMaxCandidateBreaks: 8, tcPunctuations: [], balanceRange: 6 },
|
||||||
|
});
|
||||||
|
expect(res).toEqual(res2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('边界:N<=1 时不输出任何候选断点', () => {
|
||||||
|
const { breakpoints } = generateBreakpoints({
|
||||||
|
tokens: [t('我', 0)] as any,
|
||||||
|
lang: 'TC',
|
||||||
|
maxLines: 2,
|
||||||
|
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [','], balanceRange: 3 },
|
||||||
|
});
|
||||||
|
expect(breakpoints).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
22
client/src/features/textWrap/breakpoints/enCandidates.ts
Normal file
22
client/src/features/textWrap/breakpoints/enCandidates.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Breakpoint } from './types';
|
||||||
|
import type { Token } from '../core/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EN 候选断点生成(极简派)
|
||||||
|
*
|
||||||
|
* 口径:
|
||||||
|
* - tokens 仅为 WORD(不包含 SPACE token)
|
||||||
|
* - 候选断点只生成在 `pos ∈ [1, N-1]`
|
||||||
|
* - kind 固定为 SPACE,priority 固定为 10
|
||||||
|
*/
|
||||||
|
export function generateEnCandidates(tokens: Token[]): Breakpoint[] {
|
||||||
|
const n = tokens.length;
|
||||||
|
if (n <= 1) return [];
|
||||||
|
|
||||||
|
const out: Breakpoint[] = [];
|
||||||
|
for (let pos = 1; pos <= n - 1; pos++) {
|
||||||
|
out.push({ pos, kind: 'SPACE', priority: 10 });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
89
client/src/features/textWrap/breakpoints/filterAndDedup.ts
Normal file
89
client/src/features/textWrap/breakpoints/filterAndDedup.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { Breakpoint, BreakpointKind, BreakpointMeta, BreakpointConstraints } from './types';
|
||||||
|
|
||||||
|
const KIND_ORDER: Record<BreakpointKind, number> = {
|
||||||
|
PUNCT: 0,
|
||||||
|
SPACE: 1,
|
||||||
|
BALANCE: 2,
|
||||||
|
OTHER: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
function betterBreakpoint(a: Breakpoint, b: Breakpoint): Breakpoint {
|
||||||
|
if (a.priority !== b.priority) return a.priority > b.priority ? a : b;
|
||||||
|
const ka = KIND_ORDER[a.kind] ?? 999;
|
||||||
|
const kb = KIND_ORDER[b.kind] ?? 999;
|
||||||
|
if (ka !== kb) return ka < kb ? a : b;
|
||||||
|
// 完全相同优先级时,稳定选择 pos 更小者(但同 pos 才会进入该比较)
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* forbiddenBreakRanges 过滤(闭区间口径)
|
||||||
|
* - start <= pos && pos <= end 命中则剔除
|
||||||
|
*/
|
||||||
|
export function filterForbiddenBreakRanges(
|
||||||
|
breakpoints: Breakpoint[],
|
||||||
|
constraints: BreakpointConstraints | undefined
|
||||||
|
): Breakpoint[] {
|
||||||
|
const ranges = constraints?.forbiddenBreakRanges;
|
||||||
|
if (!ranges || ranges.length === 0) return breakpoints;
|
||||||
|
|
||||||
|
return breakpoints.filter((bp) => {
|
||||||
|
for (const r of ranges) {
|
||||||
|
const s = r.start | 0;
|
||||||
|
const e = r.end | 0;
|
||||||
|
if (s <= bp.pos && bp.pos <= e) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 去重:同 pos 只保留一个 breakpoint(priority 更高者优先;同 priority 按 kind 固定序)
|
||||||
|
*/
|
||||||
|
export function dedupByPos(breakpoints: Breakpoint[]): Breakpoint[] {
|
||||||
|
const map = new Map<number, Breakpoint>();
|
||||||
|
for (const bp of breakpoints) {
|
||||||
|
const prev = map.get(bp.pos);
|
||||||
|
if (!prev) {
|
||||||
|
map.set(bp.pos, bp);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
map.set(bp.pos, betterBreakpoint(prev, bp));
|
||||||
|
}
|
||||||
|
return Array.from(map.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function distanceToNearestIdeal(pos: number, ideals: number[] | undefined): number {
|
||||||
|
if (!ideals || ideals.length === 0) return 1_000_000_000;
|
||||||
|
let best = 1_000_000_000;
|
||||||
|
for (const p of ideals) {
|
||||||
|
const d = Math.abs(pos - p);
|
||||||
|
if (d < best) best = d;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TC 裁剪(确定性排序后截断)
|
||||||
|
* - priority desc
|
||||||
|
* - distToIdeal asc
|
||||||
|
* - pos asc
|
||||||
|
*/
|
||||||
|
export function pruneTcCandidates(breakpoints: Breakpoint[], ideals: number[], tcMaxCandidateBreaks: number): Breakpoint[] {
|
||||||
|
if (breakpoints.length <= tcMaxCandidateBreaks) return breakpoints;
|
||||||
|
|
||||||
|
const sorted = [...breakpoints].sort((a, b) => {
|
||||||
|
if (a.priority !== b.priority) return b.priority - a.priority;
|
||||||
|
const da = distanceToNearestIdeal(a.pos, ideals);
|
||||||
|
const db = distanceToNearestIdeal(b.pos, ideals);
|
||||||
|
if (da !== db) return da - db;
|
||||||
|
return a.pos - b.pos;
|
||||||
|
});
|
||||||
|
|
||||||
|
return sorted.slice(0, tcMaxCandidateBreaks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMeta(originalCount: number, finalCount: number): BreakpointMeta {
|
||||||
|
return { originalCount, finalCount, pruned: finalCount < originalCount };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type { Breakpoint, BreakpointMeta, GenerateBreakpointsInput } from './types';
|
||||||
|
import { generateEnCandidates } from './enCandidates';
|
||||||
|
import { generateTcCandidates } from './tcCandidates';
|
||||||
|
import { buildMeta, dedupByPos, filterForbiddenBreakRanges, pruneTcCandidates } from './filterAndDedup';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成候选断点(确定性)
|
||||||
|
*
|
||||||
|
* 统一口径:
|
||||||
|
* - 只输出 `pos ∈ [1, N-1]`
|
||||||
|
* - forbiddenBreakRanges 使用闭区间过滤:start <= pos && pos <= end
|
||||||
|
* - 去重按 priority 优先;priority 相同按 kind 固定序:PUNCT > SPACE > BALANCE > OTHER
|
||||||
|
* - TC 超过上限时按固定排序裁剪,然后最终按 pos 升序输出
|
||||||
|
*/
|
||||||
|
export function generateBreakpoints(input: GenerateBreakpointsInput): { breakpoints: Breakpoint[]; meta: BreakpointMeta } {
|
||||||
|
const tokens = input.tokens ?? [];
|
||||||
|
const n = tokens.length;
|
||||||
|
const lang = input.lang;
|
||||||
|
|
||||||
|
if (n <= 1) {
|
||||||
|
return { breakpoints: [], meta: buildMeta(0, 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
let raw: Breakpoint[] = [];
|
||||||
|
let ideals: number[] = [];
|
||||||
|
|
||||||
|
if (lang === 'EN') {
|
||||||
|
raw = generateEnCandidates(tokens);
|
||||||
|
} else {
|
||||||
|
const out = generateTcCandidates(tokens, input.maxLines, {
|
||||||
|
tcPunctuations: input.config.tcPunctuations,
|
||||||
|
balanceRange: input.config.balanceRange,
|
||||||
|
});
|
||||||
|
raw = out.candidates;
|
||||||
|
ideals = out.idealPositions;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalCount = raw.length;
|
||||||
|
|
||||||
|
// 过滤 forbiddenBreakRanges(闭区间)
|
||||||
|
let filtered = filterForbiddenBreakRanges(raw, input.constraints);
|
||||||
|
|
||||||
|
// 去重(同 pos 保留最佳)
|
||||||
|
filtered = dedupByPos(filtered);
|
||||||
|
|
||||||
|
// TC 裁剪(规模上限)
|
||||||
|
if (lang === 'TC') {
|
||||||
|
const max = Math.max(0, input.config.tcMaxCandidateBreaks | 0);
|
||||||
|
if (max > 0) {
|
||||||
|
filtered = pruneTcCandidates(filtered, ideals, max);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最终按 pos 升序输出
|
||||||
|
filtered.sort((a, b) => a.pos - b.pos);
|
||||||
|
|
||||||
|
return { breakpoints: filtered, meta: buildMeta(originalCount, filtered.length) };
|
||||||
|
}
|
||||||
|
|
||||||
11
client/src/features/textWrap/breakpoints/index.ts
Normal file
11
client/src/features/textWrap/breakpoints/index.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export type {
|
||||||
|
Breakpoint,
|
||||||
|
BreakpointConfig,
|
||||||
|
BreakpointConstraints,
|
||||||
|
BreakpointKind,
|
||||||
|
BreakpointMeta,
|
||||||
|
GenerateBreakpointsInput,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { generateBreakpoints } from './generateBreakpoints';
|
||||||
|
|
||||||
78
client/src/features/textWrap/breakpoints/tcCandidates.ts
Normal file
78
client/src/features/textWrap/breakpoints/tcCandidates.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import type { Breakpoint } from './types';
|
||||||
|
import type { Token } from '../core/types';
|
||||||
|
|
||||||
|
type TcCandidateConfig = {
|
||||||
|
tcPunctuations: string[];
|
||||||
|
balanceRange: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 BALANCE 的 idealPos 列表(确定性简化版)。
|
||||||
|
*
|
||||||
|
* - N=tokens.length
|
||||||
|
* - targetLines=min(maxLines,N)
|
||||||
|
* - lineIndex in 1..targetLines-1:
|
||||||
|
* idealPos=round(N*lineIndex/targetLines)
|
||||||
|
*/
|
||||||
|
export function computeTcIdealPositions(tokens: Token[], maxLines: number): number[] {
|
||||||
|
const n = tokens.length;
|
||||||
|
const targetLines = Math.max(1, Math.min(maxLines | 0, n));
|
||||||
|
const ideals: number[] = [];
|
||||||
|
|
||||||
|
for (let lineIndex = 1; lineIndex <= targetLines - 1; lineIndex++) {
|
||||||
|
const idealPos = Math.round((n * lineIndex) / targetLines);
|
||||||
|
ideals.push(idealPos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去重并排序(稳定)
|
||||||
|
ideals.sort((a, b) => a - b);
|
||||||
|
return ideals.filter((v, idx) => idx === 0 || v !== ideals[idx - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TC 候选断点生成:
|
||||||
|
* - 标点后:kind=PUNCT,priority=30
|
||||||
|
* - 空格后:kind=SPACE,priority=20
|
||||||
|
* - BALANCE:kind=BALANCE,priority=5(围绕 idealPos ± balanceRange)
|
||||||
|
*
|
||||||
|
* 注意:BALANCE 断点允许生成在短语 span 内,是否可用交给评分阶段强惩罚淘汰。
|
||||||
|
*/
|
||||||
|
export function generateTcCandidates(tokens: Token[], maxLines: number, config: TcCandidateConfig): { candidates: Breakpoint[]; idealPositions: number[] } {
|
||||||
|
const n = tokens.length;
|
||||||
|
if (n <= 1) return { candidates: [], idealPositions: [] };
|
||||||
|
|
||||||
|
const punctSet = new Set(config.tcPunctuations);
|
||||||
|
const candidates: Breakpoint[] = [];
|
||||||
|
|
||||||
|
// PUNCT / SPACE(扫描 token)
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const t = tokens[i]?.text ?? '';
|
||||||
|
const pos = i + 1;
|
||||||
|
|
||||||
|
// 只允许行内断点
|
||||||
|
if (pos < 1 || pos > n - 1) continue;
|
||||||
|
|
||||||
|
if (punctSet.has(t)) {
|
||||||
|
candidates.push({ pos, kind: 'PUNCT', priority: 30 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t === ' ') {
|
||||||
|
candidates.push({ pos, kind: 'SPACE', priority: 20 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BALANCE
|
||||||
|
const idealPositions = computeTcIdealPositions(tokens, maxLines);
|
||||||
|
const range = Math.max(0, config.balanceRange | 0);
|
||||||
|
if (range > 0 && idealPositions.length > 0) {
|
||||||
|
for (const ideal of idealPositions) {
|
||||||
|
for (let pos = ideal - range; pos <= ideal + range; pos++) {
|
||||||
|
if (pos < 1 || pos > n - 1) continue;
|
||||||
|
candidates.push({ pos, kind: 'BALANCE', priority: 5 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { candidates, idealPositions };
|
||||||
|
}
|
||||||
|
|
||||||
37
client/src/features/textWrap/breakpoints/types.ts
Normal file
37
client/src/features/textWrap/breakpoints/types.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
|
||||||
|
export type BreakpointKind = 'PUNCT' | 'SPACE' | 'BALANCE' | 'OTHER';
|
||||||
|
|
||||||
|
export type Breakpoint = {
|
||||||
|
/** 断点位置(token 边界):切分为 [0..pos) + [pos..N) */
|
||||||
|
pos: number;
|
||||||
|
kind: BreakpointKind;
|
||||||
|
/** 候选优先级(用于去重/裁剪阶段),越大越优先 */
|
||||||
|
priority: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BreakpointMeta = {
|
||||||
|
pruned: boolean;
|
||||||
|
originalCount: number;
|
||||||
|
finalCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BreakpointConstraints = {
|
||||||
|
protectedPhrases?: string[];
|
||||||
|
forbiddenBreakRanges?: Array<{ start: number; end: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BreakpointConfig = {
|
||||||
|
tcMaxCandidateBreaks: number;
|
||||||
|
tcPunctuations: string[];
|
||||||
|
balanceRange: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GenerateBreakpointsInput = {
|
||||||
|
tokens: Token[];
|
||||||
|
lang: 'TC' | 'EN';
|
||||||
|
maxLines: number;
|
||||||
|
constraints?: BreakpointConstraints;
|
||||||
|
config: BreakpointConfig;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_PUNCTUATION_STRIP_SET_EN,
|
||||||
|
compareBreaksLexicographically,
|
||||||
|
joinTokens,
|
||||||
|
matchENKeyword,
|
||||||
|
normalizeENKeyword,
|
||||||
|
normalizeWhitespace,
|
||||||
|
tokenizeEN,
|
||||||
|
} from '../index';
|
||||||
|
|
||||||
|
describe('textWrap core-contract', () => {
|
||||||
|
it('normalizeWhitespace(NORMALIZE): 折叠空白 + 去首尾', () => {
|
||||||
|
const out = normalizeWhitespace(' a b \n c ', 'NORMALIZE');
|
||||||
|
expect(out.normalizedText).toBe('a b c');
|
||||||
|
expect(out.hadMultiWhitespace).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tokenizeEN: 只按空白分词(极简派,不拆标点)', () => {
|
||||||
|
const { normalizedText } = normalizeWhitespace('I am so tired.', 'NORMALIZE');
|
||||||
|
const tokens = tokenizeEN(normalizedText);
|
||||||
|
expect(tokens.map((t) => t.text)).toEqual(['I', 'am', 'so', 'tired.']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joinTokens: 默认用单空格重组', () => {
|
||||||
|
const { normalizedText } = normalizeWhitespace('I am so tired', 'NORMALIZE');
|
||||||
|
const tokens = tokenizeEN(normalizedText);
|
||||||
|
expect(joinTokens(tokens, 0, 2)).toBe('I am');
|
||||||
|
expect(joinTokens(tokens, 2, 4)).toBe('so tired');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizeENKeyword/matchENKeyword: 全词等值匹配,不做 substring', () => {
|
||||||
|
const config = { punctuationStripSetEN: DEFAULT_PUNCTUATION_STRIP_SET_EN };
|
||||||
|
|
||||||
|
expect(normalizeENKeyword('BUT,', config)).toBe('but');
|
||||||
|
expect(matchENKeyword('but,', 'but', config)).toBe(true);
|
||||||
|
expect(matchENKeyword('rebuttal', 'but', config)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('compareBreaksLexicographically: 字典序 + 短数组优先', () => {
|
||||||
|
expect(compareBreaksLexicographically([2], [3])).toBeLessThan(0);
|
||||||
|
expect(compareBreaksLexicographically([2], [2, 5])).toBeLessThan(0);
|
||||||
|
expect(compareBreaksLexicographically([2, 3], [2])).toBeGreaterThan(0);
|
||||||
|
expect(compareBreaksLexicographically([2, 3], [2, 3])).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
25
client/src/features/textWrap/core/compare.ts
Normal file
25
client/src/features/textWrap/core/compare.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* breaks[] 字典序比较(确定性口径)
|
||||||
|
*
|
||||||
|
* 规则(必须):
|
||||||
|
* - 从 index=0 起逐项比较:
|
||||||
|
* - 首个不同元素更小者视为更小
|
||||||
|
* - 若公共前缀完全相同:
|
||||||
|
* - 更短的数组视为更小
|
||||||
|
*/
|
||||||
|
export function compareBreaksLexicographically(a: number[], b: number[]): number {
|
||||||
|
const aa = Array.isArray(a) ? a : [];
|
||||||
|
const bb = Array.isArray(b) ? b : [];
|
||||||
|
|
||||||
|
const n = Math.min(aa.length, bb.length);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const x = aa[i]!;
|
||||||
|
const y = bb[i]!;
|
||||||
|
if (x === y) continue;
|
||||||
|
return x < y ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aa.length === bb.length) return 0;
|
||||||
|
return aa.length < bb.length ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
68
client/src/features/textWrap/core/enKeyword.ts
Normal file
68
client/src/features/textWrap/core/enKeyword.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import type { CoreConfig } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EN 常见标点(默认集合)
|
||||||
|
*
|
||||||
|
* 说明:此集合只用于“关键词命中判定”的两端 strip,不影响 tokenize(仍为极简派)。
|
||||||
|
* 必须全端一致;后续若扩展,也必须通过 configVersion 管理。
|
||||||
|
*/
|
||||||
|
export const DEFAULT_PUNCTUATION_STRIP_SET_EN: string[] = [
|
||||||
|
',',
|
||||||
|
'.',
|
||||||
|
'!',
|
||||||
|
'?',
|
||||||
|
':',
|
||||||
|
';',
|
||||||
|
'"',
|
||||||
|
"'",
|
||||||
|
'…',
|
||||||
|
'—',
|
||||||
|
'–',
|
||||||
|
'(',
|
||||||
|
')',
|
||||||
|
'[',
|
||||||
|
']',
|
||||||
|
'{',
|
||||||
|
'}',
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildStripSet(config?: Pick<CoreConfig, 'punctuationStripSetEN'>): Set<string> {
|
||||||
|
const list = config?.punctuationStripSetEN?.length ? config.punctuationStripSetEN : DEFAULT_PUNCTUATION_STRIP_SET_EN;
|
||||||
|
return new Set(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 归一化一个 token,用于 EN 关键词命中(全词等值匹配)。
|
||||||
|
*
|
||||||
|
* 口径(必须):
|
||||||
|
* - lowercase
|
||||||
|
* - strip 两端常见标点(可重复剥离,例如 `"\"but,\""`)
|
||||||
|
* - 不允许 substring/contains
|
||||||
|
*/
|
||||||
|
export function normalizeENKeyword(tokenText: string, config?: Pick<CoreConfig, 'punctuationStripSetEN'>): string {
|
||||||
|
const stripSet = buildStripSet(config);
|
||||||
|
|
||||||
|
let s = String(tokenText ?? '').toLowerCase();
|
||||||
|
if (!s) return '';
|
||||||
|
|
||||||
|
// 仅剥离两端的“常见标点”,内部字符不处理
|
||||||
|
let start = 0;
|
||||||
|
let end = s.length;
|
||||||
|
|
||||||
|
while (start < end && stripSet.has(s[start]!)) start++;
|
||||||
|
while (end > start && stripSet.has(s[end - 1]!)) end--;
|
||||||
|
|
||||||
|
// 如果剥离后还剩空白,再做一次 trim(避免 `"but "` 这种输入)
|
||||||
|
return s.slice(start, end).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EN 关键词命中:全词等值匹配(禁止 substring)。
|
||||||
|
*/
|
||||||
|
export function matchENKeyword(tokenText: string, keyword: string, config?: Pick<CoreConfig, 'punctuationStripSetEN'>): boolean {
|
||||||
|
const a = normalizeENKeyword(tokenText, config);
|
||||||
|
const b = normalizeENKeyword(keyword, config);
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return a === b;
|
||||||
|
}
|
||||||
|
|
||||||
8
client/src/features/textWrap/core/index.ts
Normal file
8
client/src/features/textWrap/core/index.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export type { CoreConfig, Lang, NormalizeWhitespaceResult, Token, WhitespacePolicy } from './types';
|
||||||
|
|
||||||
|
export { normalizeWhitespace } from './normalizeWhitespace';
|
||||||
|
export { tokenizeEN } from './tokenizeEN';
|
||||||
|
export { joinTokens } from './joinTokens';
|
||||||
|
export { DEFAULT_PUNCTUATION_STRIP_SET_EN, matchENKeyword, normalizeENKeyword } from './enKeyword';
|
||||||
|
export { compareBreaksLexicographically } from './compare';
|
||||||
|
|
||||||
37
client/src/features/textWrap/core/joinTokens.ts
Normal file
37
client/src/features/textWrap/core/joinTokens.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Token } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 token 区间 `[start..end)` 重组回文本(跨端口径)。
|
||||||
|
*
|
||||||
|
* - 默认:用单空格 join(适用于 whitespacePolicy=NORMALIZE)
|
||||||
|
* - 若传入 rawSeparators:按原始分隔符拼接(适用于 whitespacePolicy=PRESERVE)
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - `start/end` 为半开区间
|
||||||
|
* - 空区间返回空字符串;上层需要用硬约束避免空行
|
||||||
|
*/
|
||||||
|
export function joinTokens(tokens: Token[], start: number, end: number, rawSeparators?: string[]): string {
|
||||||
|
const s = Math.max(0, start | 0);
|
||||||
|
const e = Math.max(0, end | 0);
|
||||||
|
if (!Array.isArray(tokens) || tokens.length === 0) return '';
|
||||||
|
if (s >= e) return '';
|
||||||
|
|
||||||
|
const slice = tokens.slice(s, e).map((t) => t.text);
|
||||||
|
if (!rawSeparators) {
|
||||||
|
return slice.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawSeparators[i] 表示 tokens[i] 与 tokens[i+1] 之间的分隔符
|
||||||
|
// 这里只实现最小可用:若 separators 缺失则回退到单空格。
|
||||||
|
let out = '';
|
||||||
|
for (let idx = s; idx < e; idx++) {
|
||||||
|
const t = tokens[idx];
|
||||||
|
if (!t) continue;
|
||||||
|
out += t.text;
|
||||||
|
if (idx < e - 1) {
|
||||||
|
out += rawSeparators[idx] ?? ' ';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
28
client/src/features/textWrap/core/normalizeWhitespace.ts
Normal file
28
client/src/features/textWrap/core/normalizeWhitespace.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { NormalizeWhitespaceResult, WhitespacePolicy } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 空白归一化(跨端口径)
|
||||||
|
*
|
||||||
|
* - NORMALIZE:折叠连续空白为单空格,并去首尾空白
|
||||||
|
* - PRESERVE:仅去首尾空白(内部空白保持原样)
|
||||||
|
*
|
||||||
|
* 注意:该函数会改变输入文本(至少会 trim),必须作为“算法契约”的一部分固定下来。
|
||||||
|
*/
|
||||||
|
export function normalizeWhitespace(text: string, policy: WhitespacePolicy = 'NORMALIZE'): NormalizeWhitespaceResult {
|
||||||
|
const input = String(text ?? '');
|
||||||
|
|
||||||
|
if (policy === 'PRESERVE') {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
// 只要发生 trim,或内部存在非单空格的分隔(如 \n/\t/多个空格),都算 hadMultiWhitespace
|
||||||
|
const hadMultiWhitespace = trimmed !== input || /[\t\r\n]/.test(trimmed) || / +/.test(trimmed);
|
||||||
|
return { normalizedText: trimmed, hadMultiWhitespace };
|
||||||
|
}
|
||||||
|
|
||||||
|
// NORMALIZE:把任意连续空白折叠为 1 个空格,并去首尾空白
|
||||||
|
// 说明:这里使用 \s+ 覆盖空格/换行/制表等;跨端需要保证采用同等语义的实现。
|
||||||
|
const trimmed = input.trim();
|
||||||
|
const collapsed = trimmed.replace(/\s+/g, ' ');
|
||||||
|
const hadMultiWhitespace = collapsed !== input;
|
||||||
|
return { normalizedText: collapsed, hadMultiWhitespace };
|
||||||
|
}
|
||||||
|
|
||||||
38
client/src/features/textWrap/core/tokenizeEN.ts
Normal file
38
client/src/features/textWrap/core/tokenizeEN.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Token } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EN tokenize(极简派)
|
||||||
|
*
|
||||||
|
* 口径:
|
||||||
|
* - tokens 只包含 WORD,不生成 SPACE token
|
||||||
|
* - 标点视为“词内字符”,不额外拆分(例如 "tired."、"Wait..."、"hello—world" 都是一个 token)
|
||||||
|
* - 断点只允许发生在“词与词之间”(由上层生成 breakpoint 时遵守)
|
||||||
|
*
|
||||||
|
* start/end 索引以 normalizedText 为基准(建议先调用 normalizeWhitespace)。
|
||||||
|
*/
|
||||||
|
export function tokenizeEN(normalizedText: string): Token[] {
|
||||||
|
const s = String(normalizedText ?? '');
|
||||||
|
if (!s) return [];
|
||||||
|
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
|
||||||
|
// 由于 NORMALIZE 模式下空白都折叠为单空格,这里按空格扫描即可。
|
||||||
|
// 为了稳健性,也允许出现意外多空格(会跳过空段)。
|
||||||
|
let i = 0;
|
||||||
|
const n = s.length;
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
// 跳过空格(或其他空白字符)
|
||||||
|
while (i < n && /\s/.test(s[i]!)) i++;
|
||||||
|
if (i >= n) break;
|
||||||
|
|
||||||
|
const start = i;
|
||||||
|
while (i < n && !/\s/.test(s[i]!)) i++;
|
||||||
|
const end = i;
|
||||||
|
|
||||||
|
tokens.push({ text: s.slice(start, end), start, end });
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
40
client/src/features/textWrap/core/types.ts
Normal file
40
client/src/features/textWrap/core/types.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Text Wrap - core-contract
|
||||||
|
*
|
||||||
|
* 本文件定义“跨端一致的基础口径”所需的最小类型集合。
|
||||||
|
* 注意:这里的索引(start/end)默认以 normalizedText(归一化后的文本)为基准。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Lang = 'TC' | 'EN';
|
||||||
|
|
||||||
|
export type WhitespacePolicy = 'NORMALIZE' | 'PRESERVE';
|
||||||
|
|
||||||
|
export type Token = {
|
||||||
|
/** token 的原始文本(EN:词;TC:字符簇) */
|
||||||
|
text: string;
|
||||||
|
/** token 在 normalizedText 中的起始索引(包含) */
|
||||||
|
start: number;
|
||||||
|
/** token 在 normalizedText 中的结束索引(不包含) */
|
||||||
|
end: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NormalizeWhitespaceResult = {
|
||||||
|
/**
|
||||||
|
* 归一化后的文本。
|
||||||
|
* - NORMALIZE:折叠连续空白为单空格,并去首尾空白
|
||||||
|
* - PRESERVE:仅去首尾空白(内部空白保持原样)
|
||||||
|
*/
|
||||||
|
normalizedText: string;
|
||||||
|
/** 是否发生过“空白折叠/trim/非单空格分隔”等(用于后续 meta 打点) */
|
||||||
|
hadMultiWhitespace: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CoreConfig = {
|
||||||
|
whitespacePolicy: WhitespacePolicy;
|
||||||
|
/**
|
||||||
|
* EN 关键词命中:两端可剥离的常见标点集合(必须全端一致)。
|
||||||
|
* 来源建议与默认值参考:`设计说明文档/文档换行算法.md`(v1.2.1)2.3.1A-1
|
||||||
|
*/
|
||||||
|
punctuationStripSetEN: string[];
|
||||||
|
};
|
||||||
|
|
||||||
97
client/src/features/textWrap/golden/__tests__/golden.test.ts
Normal file
97
client/src/features/textWrap/golden/__tests__/golden.test.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { Token } from '../../core/types';
|
||||||
|
import { normalizeWhitespace, tokenizeEN } from '../../core/index';
|
||||||
|
import { segmentGraphemes } from '../../grapheme/index';
|
||||||
|
import { wrapText } from '../../index';
|
||||||
|
import { GOLDEN_CASES } from '../fixtures';
|
||||||
|
|
||||||
|
function tokenizeTC(normalizedText: string): Token[] {
|
||||||
|
const { clusters } = segmentGraphemes(normalizedText, 'PREFERRED');
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
let idx = 0;
|
||||||
|
for (const c of clusters) {
|
||||||
|
const start = idx;
|
||||||
|
idx += c.length;
|
||||||
|
tokens.push({ text: c, start, end: idx });
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wrapTextHarness(args: {
|
||||||
|
text: string;
|
||||||
|
lang: 'TC' | 'EN';
|
||||||
|
context: 'APP' | 'WIDGET';
|
||||||
|
availableWidth: number;
|
||||||
|
maxLines: number;
|
||||||
|
}): Promise<{ ok: true; lines: string[]; wrappedText: string } | { ok: false; detail: any }> {
|
||||||
|
const out = await wrapText({
|
||||||
|
text: args.text,
|
||||||
|
lang: args.lang,
|
||||||
|
context: args.context,
|
||||||
|
availableWidth: args.availableWidth,
|
||||||
|
maxLines: args.maxLines,
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: async ({ text }) => text.length,
|
||||||
|
contextProfile: `${args.context}|golden`,
|
||||||
|
lineMode: 'AUTO',
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// wrapText 统一返回成功形态;若进入 SYSTEM_DEFAULT 等兜底,meta 会标记
|
||||||
|
return { ok: true, lines: out.lines, wrappedText: out.wrappedText };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('textWrap golden-tests', () => {
|
||||||
|
for (const c of GOLDEN_CASES) {
|
||||||
|
it(`golden:${c.id}`, async () => {
|
||||||
|
const res = await wrapTextHarness({
|
||||||
|
text: c.text,
|
||||||
|
lang: c.lang,
|
||||||
|
context: c.context,
|
||||||
|
availableWidth: c.availableWidth,
|
||||||
|
maxLines: c.maxLines,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`golden case failed: ${c.id}\n${JSON.stringify(res.detail, null, 2)}`);
|
||||||
|
}
|
||||||
|
if (res.ok) {
|
||||||
|
expect(res.lines).toEqual(c.expected.lines);
|
||||||
|
expect(res.wrappedText).toBe(c.expected.wrappedText);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('property: 确定性(同输入多次调用一致)', async () => {
|
||||||
|
const args = { text: 'I am so tired', lang: 'EN' as const, context: 'APP' as const, availableWidth: 6, maxLines: 2 };
|
||||||
|
const a = await wrapTextHarness(args);
|
||||||
|
const b = await wrapTextHarness(args);
|
||||||
|
const c = await wrapTextHarness(args);
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
expect(b).toEqual(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('property: 近似单调性(availableWidth 变小不会让最大行宽变大)', async () => {
|
||||||
|
const base = { text: 'I am so tired', lang: 'EN' as const, context: 'APP' as const, maxLines: 2 };
|
||||||
|
const wide = await wrapTextHarness({ ...base, availableWidth: 20 });
|
||||||
|
const narrow = await wrapTextHarness({ ...base, availableWidth: 7 });
|
||||||
|
|
||||||
|
if (!wide.ok || !narrow.ok) {
|
||||||
|
throw new Error(`property failed: expected both ok\nwide=${JSON.stringify(wide)}\nnarrow=${JSON.stringify(narrow)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxW = (lines: string[]) => Math.max(...lines.map((s) => s.length));
|
||||||
|
expect(maxW(narrow.lines)).toBeLessThanOrEqual(maxW(wide.lines));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('property: maxLines 不变差(maxLines 增加不应从可解变无解)', async () => {
|
||||||
|
const base = { text: 'I am so tired', lang: 'EN' as const, context: 'APP' as const, availableWidth: 7 };
|
||||||
|
const a = await wrapTextHarness({ ...base, maxLines: 2 });
|
||||||
|
const b = await wrapTextHarness({ ...base, maxLines: 3 });
|
||||||
|
|
||||||
|
expect(a.ok).toBe(true);
|
||||||
|
expect(b.ok).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
56
client/src/features/textWrap/golden/fixtures.ts
Normal file
56
client/src/features/textWrap/golden/fixtures.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
export type GoldenCase = {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
lang: 'TC' | 'EN';
|
||||||
|
context: 'APP' | 'WIDGET';
|
||||||
|
availableWidth: number;
|
||||||
|
maxLines: number;
|
||||||
|
expected: { lines: string[]; wrappedText: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Golden fixtures(首版最小可运行集)
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - APP:测量 mock=string.length,因此 availableWidth 也是“字符数单位”
|
||||||
|
* - WIDGET:widthMode=APPROX,因此 availableWidth 是“token 数单位”
|
||||||
|
*/
|
||||||
|
export const GOLDEN_CASES: GoldenCase[] = [
|
||||||
|
{
|
||||||
|
id: 'en_app_simple_2lines',
|
||||||
|
text: 'I am so tired',
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 7,
|
||||||
|
maxLines: 2,
|
||||||
|
expected: { lines: ['I am so', 'tired'], wrappedText: 'I am so\ntired' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'en_app_punct_keyword',
|
||||||
|
text: 'but, still ok',
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 9,
|
||||||
|
maxLines: 2,
|
||||||
|
expected: { lines: ['but,', 'still ok'], wrappedText: 'but,\nstill ok' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tc_app_punct_2lines',
|
||||||
|
text: '我好累,😮💨',
|
||||||
|
lang: 'TC',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 6,
|
||||||
|
maxLines: 2,
|
||||||
|
expected: { lines: ['我好累,', '😮💨'], wrappedText: '我好累,\n😮💨' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tc_widget_approx_2lines',
|
||||||
|
text: '我好累',
|
||||||
|
lang: 'TC',
|
||||||
|
context: 'WIDGET',
|
||||||
|
availableWidth: 2,
|
||||||
|
maxLines: 2,
|
||||||
|
expected: { lines: ['我', '好累'], wrappedText: '我\n好累' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { segmentGraphemes } from '../index';
|
||||||
|
|
||||||
|
const REQUIRED_SINGLE_CLUSTER_CASES: Array<{ name: string; input: string }> = [
|
||||||
|
{ name: 'ZWJ family', input: '👨👩👧👦' },
|
||||||
|
{ name: 'flag', input: '🇸🇬' },
|
||||||
|
{ name: 'skin tone', input: '👍🏽' },
|
||||||
|
{ name: 'ZWJ + variation', input: '😮💨' },
|
||||||
|
{ name: 'combining mark', input: 'e\u0301' }, // é(组合形式)
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('textWrap grapheme-segmentation', () => {
|
||||||
|
it('基础性质:空字符串 -> []', () => {
|
||||||
|
const out = segmentGraphemes('', 'PREFERRED');
|
||||||
|
expect(out.clusters).toEqual([]);
|
||||||
|
expect(out.clusters.join('')).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('基础性质:可逆性(clusters.join("") === input)', () => {
|
||||||
|
const input = '我好累😮💨';
|
||||||
|
const out = segmentGraphemes(input, 'PREFERRED');
|
||||||
|
expect(out.clusters.join('')).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('基础性质:确定性(同输入同输出)', () => {
|
||||||
|
const input = '👨👩👧👦🇸🇬👍🏽😮💨e\u0301';
|
||||||
|
const a = segmentGraphemes(input, 'PREFERRED');
|
||||||
|
const b = segmentGraphemes(input, 'PREFERRED');
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('必测样例:在 PREFERRED 下必须“不拆”为单个 cluster', () => {
|
||||||
|
for (const c of REQUIRED_SINGLE_CLUSTER_CASES) {
|
||||||
|
const out = segmentGraphemes(c.input, 'PREFERRED');
|
||||||
|
expect(out.clusters.join('')).toBe(c.input);
|
||||||
|
expect(out.clusters.length).toBe(1);
|
||||||
|
expect(out.clusters[0]).toBe(c.input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('必测样例:在强制 FALLBACK 下必须“不拆”为单个 cluster', () => {
|
||||||
|
for (const c of REQUIRED_SINGLE_CLUSTER_CASES) {
|
||||||
|
const out = segmentGraphemes(c.input, 'FALLBACK');
|
||||||
|
expect(out.meta.strategy).toBe('FALLBACK');
|
||||||
|
expect(out.meta.hadFallback).toBe(true);
|
||||||
|
expect(out.clusters.join('')).toBe(c.input);
|
||||||
|
expect(out.clusters.length).toBe(1);
|
||||||
|
expect(out.clusters[0]).toBe(c.input);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('跨策略一致性:若 Intl.Segmenter 可用,则 PREFERRED 与 FALLBACK 输出应一致', () => {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const hasIntl = typeof (globalThis as any)?.Intl?.Segmenter === 'function';
|
||||||
|
if (!hasIntl) return;
|
||||||
|
|
||||||
|
for (const c of REQUIRED_SINGLE_CLUSTER_CASES) {
|
||||||
|
const preferred = segmentGraphemes(c.input, 'PREFERRED');
|
||||||
|
const fallback = segmentGraphemes(c.input, 'FALLBACK');
|
||||||
|
expect(preferred.clusters).toEqual(fallback.clusters);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
9
client/src/features/textWrap/grapheme/index.ts
Normal file
9
client/src/features/textWrap/grapheme/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export type {
|
||||||
|
GraphemeSegmentationMeta,
|
||||||
|
GraphemeSegmentationMode,
|
||||||
|
GraphemeSegmentationResult,
|
||||||
|
GraphemeSegmentationStrategy,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { segmentGraphemes } from './segmentGraphemes';
|
||||||
|
|
||||||
42
client/src/features/textWrap/grapheme/segmentGraphemes.ts
Normal file
42
client/src/features/textWrap/grapheme/segmentGraphemes.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import type { GraphemeSegmentationMode, GraphemeSegmentationResult } from './types';
|
||||||
|
import { canUseIntlSegmenter, segmentWithIntlSegmenter } from './strategies/intlSegmenter';
|
||||||
|
import { segmentWithFallback } from './strategies/fallback';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把字符串切分为 grapheme clusters(字符簇)。
|
||||||
|
*
|
||||||
|
* 契约(必须):
|
||||||
|
* - clusters.join('') === text(不允许丢字符/改顺序)
|
||||||
|
* - text=='' 时 clusters==[]
|
||||||
|
* - 任何异常都必须降级到 fallback(保证可用性)
|
||||||
|
*/
|
||||||
|
export function segmentGraphemes(text: string, mode: GraphemeSegmentationMode = 'PREFERRED'): GraphemeSegmentationResult {
|
||||||
|
const input = String(text ?? '');
|
||||||
|
if (input === '') {
|
||||||
|
return { clusters: [], meta: { strategy: 'FALLBACK', hadFallback: mode === 'FALLBACK' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 强制 fallback
|
||||||
|
if (mode === 'FALLBACK') {
|
||||||
|
const clusters = segmentWithFallback(input);
|
||||||
|
return { clusters, meta: { strategy: 'FALLBACK', hadFallback: true } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先 Intl.Segmenter
|
||||||
|
if (canUseIntlSegmenter()) {
|
||||||
|
try {
|
||||||
|
const clusters = segmentWithIntlSegmenter(input);
|
||||||
|
// 防御:必须可逆
|
||||||
|
if (clusters.length > 0 && clusters.join('') === input) {
|
||||||
|
return { clusters, meta: { strategy: 'INTL_SEGMENTER', hadFallback: false } };
|
||||||
|
}
|
||||||
|
// 若结果异常(空/丢字符),走 fallback
|
||||||
|
} catch {
|
||||||
|
// ignore -> fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clusters = segmentWithFallback(input);
|
||||||
|
return { clusters, meta: { strategy: 'FALLBACK', hadFallback: true } };
|
||||||
|
}
|
||||||
|
|
||||||
81
client/src/features/textWrap/grapheme/strategies/fallback.ts
Normal file
81
client/src/features/textWrap/grapheme/strategies/fallback.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import GraphemeSplitter from 'grapheme-splitter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fallback 策略:使用 grapheme-splitter 分割 grapheme clusters。
|
||||||
|
*
|
||||||
|
* 选择原因:
|
||||||
|
* - 避免手写不完整的 Unicode 规则导致“漏拆/误拆”
|
||||||
|
* - 作为 Intl.Segmenter 不可用时的稳定兜底
|
||||||
|
*
|
||||||
|
* 注意:
|
||||||
|
* - grapheme-splitter 对少数较新的 emoji ZWJ 序列支持可能不完整(例如 `😮💨`)。
|
||||||
|
* - 为满足本项目“至少不拆 ZWJ sequence/VS16/肤色修饰符/组合字符”的最低要求,这里做一层确定性的后处理合并。
|
||||||
|
*/
|
||||||
|
export function segmentWithFallback(text: string): string[] {
|
||||||
|
const splitter = new GraphemeSplitter();
|
||||||
|
const raw = splitter.splitGraphemes(text);
|
||||||
|
return mergeFallbackClusters(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZWJ = '\u200D';
|
||||||
|
|
||||||
|
function firstCodePoint(s: string): number | null {
|
||||||
|
if (!s) return null;
|
||||||
|
const cp = s.codePointAt(0);
|
||||||
|
return typeof cp === 'number' ? cp : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isVariationSelector(cp: number): boolean {
|
||||||
|
// VS15/VS16
|
||||||
|
return cp === 0xfe0e || cp === 0xfe0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSkinToneModifier(cp: number): boolean {
|
||||||
|
return cp >= 0x1f3fb && cp <= 0x1f3ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCombiningMark(cp: number): boolean {
|
||||||
|
// 常见 combining marks 范围(覆盖 `e\u0301` 等)
|
||||||
|
return (
|
||||||
|
(cp >= 0x0300 && cp <= 0x036f) ||
|
||||||
|
(cp >= 0x1ab0 && cp <= 0x1aff) ||
|
||||||
|
(cp >= 0x1dc0 && cp <= 0x1dff) ||
|
||||||
|
(cp >= 0x20d0 && cp <= 0x20ff) ||
|
||||||
|
(cp >= 0xfe20 && cp <= 0xfe2f)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对 fallback 输出做“最低可用”合并:
|
||||||
|
* - 若上一个 cluster 以 ZWJ 结尾,则必须与下一个合并(ZWJ sequence)
|
||||||
|
* - 若下一个 cluster 以 VS/肤色修饰符/combining mark 开头,也与上一个合并
|
||||||
|
*
|
||||||
|
* 目标:满足文档列出的“不拆”组合要求,且行为完全确定性。
|
||||||
|
*/
|
||||||
|
function mergeFallbackClusters(raw: string[]): string[] {
|
||||||
|
if (!raw.length) return raw;
|
||||||
|
|
||||||
|
const out: string[] = [];
|
||||||
|
let buf = raw[0] ?? '';
|
||||||
|
|
||||||
|
for (let i = 1; i < raw.length; i++) {
|
||||||
|
const next = raw[i] ?? '';
|
||||||
|
const nextCp = firstCodePoint(next);
|
||||||
|
|
||||||
|
const shouldMerge =
|
||||||
|
buf.endsWith(ZWJ) ||
|
||||||
|
(nextCp !== null && (isVariationSelector(nextCp) || isSkinToneModifier(nextCp) || isCombiningMark(nextCp)));
|
||||||
|
|
||||||
|
if (shouldMerge) {
|
||||||
|
buf += next;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(buf);
|
||||||
|
buf = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(buf);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Intl.Segmenter 策略(优先)
|
||||||
|
*
|
||||||
|
* 注意:不同 JS 引擎/版本对 Intl.Segmenter 的支持可能不同,调用方必须捕获异常并降级。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function canUseIntlSegmenter(): boolean {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const Seg = (globalThis as any)?.Intl?.Segmenter;
|
||||||
|
return typeof Seg === 'function';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function segmentWithIntlSegmenter(text: string): string[] {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const Segmenter = (globalThis as any).Intl.Segmenter as new (locales?: string | string[], options?: any) => any;
|
||||||
|
|
||||||
|
// 用 zh-Hant 仅用于选择合适的 locale;grapheme 分割应与语言本身关系不大,但保持固定输入更易对齐跨端。
|
||||||
|
const seg = new Segmenter('zh-Hant', { granularity: 'grapheme' });
|
||||||
|
const it = seg.segment(text);
|
||||||
|
|
||||||
|
const clusters: string[] = [];
|
||||||
|
for (const part of it) {
|
||||||
|
// part: { segment: string, index: number, input: string, isWordLike?: boolean }
|
||||||
|
clusters.push(part.segment);
|
||||||
|
}
|
||||||
|
return clusters;
|
||||||
|
}
|
||||||
|
|
||||||
21
client/src/features/textWrap/grapheme/types.ts
Normal file
21
client/src/features/textWrap/grapheme/types.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* Text Wrap - grapheme-segmentation
|
||||||
|
*
|
||||||
|
* 本模块只负责把字符串切成“字符簇(grapheme clusters)”数组。
|
||||||
|
* 后续 TC 换行断点只能发生在 clusters 边界。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type GraphemeSegmentationMode = 'PREFERRED' | 'FALLBACK';
|
||||||
|
|
||||||
|
export type GraphemeSegmentationStrategy = 'INTL_SEGMENTER' | 'FALLBACK';
|
||||||
|
|
||||||
|
export type GraphemeSegmentationMeta = {
|
||||||
|
strategy: GraphemeSegmentationStrategy;
|
||||||
|
hadFallback: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GraphemeSegmentationResult = {
|
||||||
|
clusters: string[];
|
||||||
|
meta: GraphemeSegmentationMeta;
|
||||||
|
};
|
||||||
|
|
||||||
4
client/src/features/textWrap/index.ts
Normal file
4
client/src/features/textWrap/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type { WrapTextConstraints, WrapTextInput, WrapTextMeta, WrapTextOutput } from './types';
|
||||||
|
|
||||||
|
export { wrapText } from './wrapText';
|
||||||
|
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { MeasureWidthImpl } from '../types';
|
||||||
|
import { MissingFontSpecError } from '../errors';
|
||||||
|
import { buildFontSpecKey } from '../fontSpecKey';
|
||||||
|
import { measureSliceWidthCached } from '../measureSliceWidthCached';
|
||||||
|
import { measureWidthCached } from '../measureWidthCached';
|
||||||
|
|
||||||
|
describe('textWrap width-measurement', () => {
|
||||||
|
it('buildFontSpecKey: 缺字段必须报错(简体中文)', () => {
|
||||||
|
expect(() => buildFontSpecKey({ fontFamily: 'PingFangSC-Regular', fontWeight: '400' } as any)).toThrow(
|
||||||
|
MissingFontSpecError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measureWidthCached: 未提供测量能力 -> approx + WIDTH_UNKNOWN', async () => {
|
||||||
|
const res = await measureWidthCached({
|
||||||
|
text: 'hello',
|
||||||
|
context: 'WIDGET',
|
||||||
|
contextProfile: 'WIDGET',
|
||||||
|
fontSpec: null,
|
||||||
|
measureWidthImpl: undefined,
|
||||||
|
});
|
||||||
|
expect(res.width).toBeNull();
|
||||||
|
expect(res.meta).toEqual({ isApprox: true, reason: 'WIDTH_UNKNOWN' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measureWidthCached: 同 key 命中缓存(不会重复调用底层测量)', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
|
||||||
|
const a = await measureWidthCached({
|
||||||
|
text: 'abc',
|
||||||
|
context: 'APP',
|
||||||
|
contextProfile: 'APP|ios',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
});
|
||||||
|
const b = await measureWidthCached({
|
||||||
|
text: 'abc',
|
||||||
|
context: 'APP',
|
||||||
|
contextProfile: 'APP|ios',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(a.width).toBe(3);
|
||||||
|
expect(b.width).toBe(3);
|
||||||
|
expect(impl).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measureWidthCached: 测量抛错 -> approx + MEASURE_FAILED', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await measureWidthCached({
|
||||||
|
text: 'abc',
|
||||||
|
context: 'APP',
|
||||||
|
contextProfile: 'APP|ios|throw',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.width).toBeNull();
|
||||||
|
expect(res.meta).toEqual({ isApprox: true, reason: 'MEASURE_FAILED' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('measureSliceWidthCached: 同 sliceKey 命中缓存', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
|
||||||
|
const tokens = [
|
||||||
|
{ text: 'I', start: 0, end: 1 },
|
||||||
|
{ text: 'am', start: 2, end: 4 },
|
||||||
|
{ text: 'tired', start: 5, end: 10 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const a = await measureSliceWidthCached({
|
||||||
|
tokens: tokens as any,
|
||||||
|
start: 0,
|
||||||
|
end: 2,
|
||||||
|
context: 'APP',
|
||||||
|
contextProfile: 'APP|ios',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
});
|
||||||
|
const b = await measureSliceWidthCached({
|
||||||
|
tokens: tokens as any,
|
||||||
|
start: 0,
|
||||||
|
end: 2,
|
||||||
|
context: 'APP',
|
||||||
|
contextProfile: 'APP|ios',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(a.width).toBe('I am'.length);
|
||||||
|
expect(b.width).toBe('I am'.length);
|
||||||
|
expect(impl).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
62
client/src/features/textWrap/measure/cache.ts
Normal file
62
client/src/features/textWrap/measure/cache.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Text Wrap - width-measurement 缓存
|
||||||
|
*
|
||||||
|
* 要求:
|
||||||
|
* - 模块级常驻缓存(跨调用复用)
|
||||||
|
* - 有容量上限(避免内存无限增长)
|
||||||
|
* - Key 必须确定性(由上层拼接传入)
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - 这里实现一个最小 LRU:Map 维护插入顺序;get 时“刷新”为最新。
|
||||||
|
* - 缓存 value 允许是 Promise,以便并发请求去重(同 key 只测一次)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class LruCache<V> {
|
||||||
|
private readonly maxSize: number;
|
||||||
|
private readonly map: Map<string, V>;
|
||||||
|
|
||||||
|
constructor(maxSize: number) {
|
||||||
|
if (!Number.isFinite(maxSize) || maxSize <= 0) {
|
||||||
|
throw new Error('LRU 缓存 maxSize 必须为正数。');
|
||||||
|
}
|
||||||
|
this.maxSize = Math.floor(maxSize);
|
||||||
|
this.map = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string): V | undefined {
|
||||||
|
const v = this.map.get(key);
|
||||||
|
if (v === undefined) return undefined;
|
||||||
|
// 刷新为最新
|
||||||
|
this.map.delete(key);
|
||||||
|
this.map.set(key, v);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, value: V): void {
|
||||||
|
if (this.map.has(key)) {
|
||||||
|
this.map.delete(key);
|
||||||
|
}
|
||||||
|
this.map.set(key, value);
|
||||||
|
|
||||||
|
if (this.map.size > this.maxSize) {
|
||||||
|
// 淘汰最旧的
|
||||||
|
const oldestKey = this.map.keys().next().value as string | undefined;
|
||||||
|
if (oldestKey !== undefined) {
|
||||||
|
this.map.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string): void {
|
||||||
|
this.map.delete(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.map.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
size(): number {
|
||||||
|
return this.map.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
19
client/src/features/textWrap/measure/errors.ts
Normal file
19
client/src/features/textWrap/measure/errors.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
/**
|
||||||
|
* Text Wrap - width-measurement 错误定义
|
||||||
|
*
|
||||||
|
* 约束:fontSpec 缺字段必须直接报错(简体中文),禁止隐式默认值。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class MissingFontSpecError extends Error {
|
||||||
|
readonly name = 'MissingFontSpecError';
|
||||||
|
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMissingFontSpecMessage(missingFields: string[]): string {
|
||||||
|
const fields = missingFields.join(', ');
|
||||||
|
return `fontSpec 缺少必填字段:${fields}。请在调用 wrapText() 时补齐 fontSpec(fontFamily/fontWeight/fontSize),禁止在测量层使用隐式默认值。`;
|
||||||
|
}
|
||||||
|
|
||||||
32
client/src/features/textWrap/measure/fontSpecKey.ts
Normal file
32
client/src/features/textWrap/measure/fontSpecKey.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { FontSpec } from './types';
|
||||||
|
import { MissingFontSpecError, buildMissingFontSpecMessage } from './errors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 校验 fontSpec 必填字段。
|
||||||
|
* 约束:缺失字段必须报错(简体中文),避免跨端漂移。
|
||||||
|
*/
|
||||||
|
export function assertFontSpecComplete(fontSpec: Partial<FontSpec> | undefined | null): asserts fontSpec is FontSpec {
|
||||||
|
if (!fontSpec) {
|
||||||
|
throw new MissingFontSpecError(buildMissingFontSpecMessage(['fontFamily', 'fontWeight', 'fontSize']));
|
||||||
|
}
|
||||||
|
|
||||||
|
const missing: string[] = [];
|
||||||
|
if (!fontSpec.fontFamily) missing.push('fontFamily');
|
||||||
|
if (!fontSpec.fontWeight) missing.push('fontWeight');
|
||||||
|
if (fontSpec.fontSize === undefined || fontSpec.fontSize === null) missing.push('fontSize');
|
||||||
|
|
||||||
|
if (missing.length) throw new MissingFontSpecError(buildMissingFontSpecMessage(missing));
|
||||||
|
if (!Number.isFinite(fontSpec.fontSize)) {
|
||||||
|
throw new MissingFontSpecError('fontSpec.fontSize 必须是有限数值(number)。请检查调用 wrapText() 时传入的 fontSize。');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 fontSpecKey(确定性)。
|
||||||
|
* 规则:`fontFamily|fontWeight|fontSize`(顺序固定)
|
||||||
|
*/
|
||||||
|
export function buildFontSpecKey(fontSpec: Partial<FontSpec> | undefined | null): string {
|
||||||
|
assertFontSpecComplete(fontSpec);
|
||||||
|
return `${fontSpec.fontFamily}|${fontSpec.fontWeight}|${String(fontSpec.fontSize)}`;
|
||||||
|
}
|
||||||
|
|
||||||
16
client/src/features/textWrap/measure/index.ts
Normal file
16
client/src/features/textWrap/measure/index.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export type {
|
||||||
|
ContextProfile,
|
||||||
|
FontSpec,
|
||||||
|
MeasureMeta,
|
||||||
|
MeasureReason,
|
||||||
|
MeasureResult,
|
||||||
|
MeasureWidthImpl,
|
||||||
|
TextWrapContext,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { MissingFontSpecError } from './errors';
|
||||||
|
export { assertFontSpecComplete, buildFontSpecKey } from './fontSpecKey';
|
||||||
|
export { defaultMeasureWidthImpl } from './measureWidthImpl';
|
||||||
|
export { measureWidthCached } from './measureWidthCached';
|
||||||
|
export { measureSliceWidthCached } from './measureSliceWidthCached';
|
||||||
|
|
||||||
105
client/src/features/textWrap/measure/measureSliceWidthCached.ts
Normal file
105
client/src/features/textWrap/measure/measureSliceWidthCached.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import type { ContextProfile, FontSpec, MeasureResult, MeasureWidthImpl, TextWrapContext } from './types';
|
||||||
|
import type { Token } from '../core/types';
|
||||||
|
import { LruCache } from './cache';
|
||||||
|
import { assertFontSpecComplete, buildFontSpecKey } from './fontSpecKey';
|
||||||
|
import { joinTokens } from '../core/joinTokens';
|
||||||
|
import { measureWidthCached } from './measureWidthCached';
|
||||||
|
|
||||||
|
// 切片缓存:同一 (start,end,fontSpecKey,contextProfile) 的宽度只测一次
|
||||||
|
const sliceWidthCache = new LruCache<Promise<number>>(4000);
|
||||||
|
|
||||||
|
function fnv1a32(input: string): string {
|
||||||
|
// 32-bit FNV-1a(确定性、实现简单;用于缓存 key 避免过长)
|
||||||
|
let hash = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
hash ^= input.charCodeAt(i);
|
||||||
|
// hash *= 16777619(用位运算模拟 32-bit 溢出)
|
||||||
|
hash = (hash + (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24)) >>> 0;
|
||||||
|
}
|
||||||
|
return hash.toString(16).padStart(8, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSliceKey(args: {
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
fontSpecKey: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
sliceTextHash: string;
|
||||||
|
}): string {
|
||||||
|
// 注意:必须包含 sliceText 的信息,否则不同文本的相同 (start,end) 会发生跨文本串缓存
|
||||||
|
return `${args.contextProfile}|${args.fontSpecKey}|${args.start}|${args.end}|${args.sliceTextHash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测量 token 切片 `[start..end)` 的宽度(带缓存)。
|
||||||
|
*
|
||||||
|
* - 切片文本通过 `joinTokens()` 生成(保证与 core-contract 的重组口径一致)
|
||||||
|
* - 内部复用 `measureWidthCached` 的降级语义
|
||||||
|
*/
|
||||||
|
export async function measureSliceWidthCached(args: {
|
||||||
|
tokens: Token[];
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
context: TextWrapContext;
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
fontSpec?: Partial<FontSpec> | null;
|
||||||
|
measureWidthImpl?: MeasureWidthImpl;
|
||||||
|
widgetEnableMeasure?: boolean;
|
||||||
|
rawSeparators?: string[];
|
||||||
|
}): Promise<MeasureResult> {
|
||||||
|
const enabled =
|
||||||
|
typeof args.measureWidthImpl === 'function' && (args.context === 'APP' || args.widgetEnableMeasure === true);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
// 不启用测量时直接走 width=null(保持与 measureWidthCached 一致)
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'WIDTH_UNKNOWN' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFontSpecComplete(args.fontSpec);
|
||||||
|
const fontSpecKey = buildFontSpecKey(args.fontSpec);
|
||||||
|
const sliceText = joinTokens(args.tokens, args.start, args.end, args.rawSeparators);
|
||||||
|
const key = buildSliceKey({
|
||||||
|
contextProfile: args.contextProfile,
|
||||||
|
fontSpecKey,
|
||||||
|
start: args.start,
|
||||||
|
end: args.end,
|
||||||
|
sliceTextHash: fnv1a32(sliceText),
|
||||||
|
});
|
||||||
|
|
||||||
|
const cached = sliceWidthCache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const w = await cached;
|
||||||
|
return { width: w, meta: { isApprox: false } };
|
||||||
|
} catch {
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
const res = await measureWidthCached({
|
||||||
|
text: sliceText,
|
||||||
|
context: args.context,
|
||||||
|
contextProfile: args.contextProfile,
|
||||||
|
fontSpec: args.fontSpec,
|
||||||
|
measureWidthImpl: args.measureWidthImpl,
|
||||||
|
widgetEnableMeasure: args.widgetEnableMeasure,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.width === null) {
|
||||||
|
throw new Error('切片测量失败或不可用。');
|
||||||
|
}
|
||||||
|
return res.width;
|
||||||
|
})();
|
||||||
|
|
||||||
|
sliceWidthCache.set(key, promise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const w = await promise;
|
||||||
|
return { width: w, meta: { isApprox: false } };
|
||||||
|
} catch {
|
||||||
|
sliceWidthCache.delete(key);
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
110
client/src/features/textWrap/measure/measureWidthCached.ts
Normal file
110
client/src/features/textWrap/measure/measureWidthCached.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import type { ContextProfile, FontSpec, MeasureResult, MeasureWidthImpl, TextWrapContext } from './types';
|
||||||
|
import { LruCache } from './cache';
|
||||||
|
import { assertFontSpecComplete, buildFontSpecKey } from './fontSpecKey';
|
||||||
|
|
||||||
|
// 模块级常驻缓存(两级)
|
||||||
|
const textWidthCache = new LruCache<Promise<number>>(2000);
|
||||||
|
const loggedMeasureFailures = new Set<string>();
|
||||||
|
|
||||||
|
function buildTextKey(args: { contextProfile: ContextProfile; fontSpecKey: string; text: string }): string {
|
||||||
|
// key 拼接规则必须确定性:<contextProfile>|<fontSpecKey>|<text>
|
||||||
|
return `${args.contextProfile}|${args.fontSpecKey}|${args.text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidWidth(v: unknown): v is number {
|
||||||
|
return typeof v === 'number' && Number.isFinite(v) && v >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测量文本宽度(带缓存)。
|
||||||
|
*
|
||||||
|
* 约束:
|
||||||
|
* - 若启用测量(提供 measureWidthImpl),fontSpec 必须完整;缺字段直接报错(简体中文)
|
||||||
|
* - 若不启用测量(measureWidthImpl 缺失或 context=WIDGET 且明确不启用),进入 approx mode:width=null
|
||||||
|
*/
|
||||||
|
export async function measureWidthCached(args: {
|
||||||
|
text: string;
|
||||||
|
context: TextWrapContext;
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
fontSpec?: Partial<FontSpec> | null;
|
||||||
|
measureWidthImpl?: MeasureWidthImpl;
|
||||||
|
/** WIDGET 场景是否启用测量;默认 false(未启用即 WIDTH_UNKNOWN) */
|
||||||
|
widgetEnableMeasure?: boolean;
|
||||||
|
}): Promise<MeasureResult> {
|
||||||
|
const text = String(args.text ?? '');
|
||||||
|
|
||||||
|
const enabled =
|
||||||
|
typeof args.measureWidthImpl === 'function' && (args.context === 'APP' || args.widgetEnableMeasure === true);
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'WIDTH_UNKNOWN' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启用测量时:fontSpec 缺字段必须报错
|
||||||
|
const fontSpec = args.fontSpec;
|
||||||
|
assertFontSpecComplete(fontSpec);
|
||||||
|
const fontSpecKey = buildFontSpecKey(fontSpec);
|
||||||
|
const key = buildTextKey({ contextProfile: args.contextProfile, fontSpecKey, text });
|
||||||
|
|
||||||
|
const cached = textWidthCache.get(key);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const w = await cached;
|
||||||
|
return isValidWidth(w) ? { width: w, meta: { isApprox: false } } : { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||||
|
} catch {
|
||||||
|
if (__DEV__ && !loggedMeasureFailures.has(key)) {
|
||||||
|
loggedMeasureFailures.add(key);
|
||||||
|
console.log('[TextWrap][Measure] measureWidthCached: 命中缓存但 Promise 失败(MEASURE_FAILED)', {
|
||||||
|
context: args.context,
|
||||||
|
contextProfile: args.contextProfile,
|
||||||
|
fontSpec: args.fontSpec,
|
||||||
|
textPreview: text.slice(0, 80),
|
||||||
|
textLength: text.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = (async () => {
|
||||||
|
try {
|
||||||
|
const w = await args.measureWidthImpl!({
|
||||||
|
text,
|
||||||
|
context: args.context,
|
||||||
|
contextProfile: args.contextProfile,
|
||||||
|
fontSpec,
|
||||||
|
});
|
||||||
|
if (!isValidWidth(w)) {
|
||||||
|
throw new Error('测量结果非法(必须为有限且非负的 number)。');
|
||||||
|
}
|
||||||
|
return w;
|
||||||
|
} catch (error) {
|
||||||
|
if (__DEV__ && !loggedMeasureFailures.has(key)) {
|
||||||
|
loggedMeasureFailures.add(key);
|
||||||
|
console.log('[TextWrap][Measure] measureWidthImpl 抛错(MEASURE_FAILED)', {
|
||||||
|
context: args.context,
|
||||||
|
contextProfile: args.contextProfile,
|
||||||
|
fontSpec,
|
||||||
|
textPreview: text.slice(0, 80),
|
||||||
|
textLength: text.length,
|
||||||
|
errorName: (error as any)?.name,
|
||||||
|
errorMessage: String((error as any)?.message ?? error),
|
||||||
|
errorStack: (error as any)?.stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
textWidthCache.set(key, promise);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const w = await promise;
|
||||||
|
return { width: w, meta: { isApprox: false } };
|
||||||
|
} catch {
|
||||||
|
// 若失败,删除缓存,避免缓存住失败结果
|
||||||
|
textWidthCache.delete(key);
|
||||||
|
return { width: null, meta: { isApprox: true, reason: 'MEASURE_FAILED' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
66
client/src/features/textWrap/measure/measureWidthImpl.ts
Normal file
66
client/src/features/textWrap/measure/measureWidthImpl.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import type { FontSpec, MeasureWidthImpl } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认测量实现(成熟方案):react-native-text-size
|
||||||
|
*
|
||||||
|
* 注意:
|
||||||
|
* - 该库的 `measure` 是 Promise 异步接口,本模块以 async 方式对齐。
|
||||||
|
* - 为避免在测试/Node 环境直接 require `react-native` 造成崩溃,这里使用“延迟加载 + 捕获异常”。
|
||||||
|
*/
|
||||||
|
|
||||||
|
type TextSizeMeasureParams = {
|
||||||
|
text: string;
|
||||||
|
width?: number;
|
||||||
|
fontFamily?: string;
|
||||||
|
fontSize?: number;
|
||||||
|
fontWeight?: string;
|
||||||
|
allowFontScaling?: boolean;
|
||||||
|
usePreciseWidth?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TextSizeMeasureResult = { width: number };
|
||||||
|
|
||||||
|
function loadReactNativeTextSize(): {
|
||||||
|
measure: (params: TextSizeMeasureParams) => Promise<TextSizeMeasureResult>;
|
||||||
|
} {
|
||||||
|
// 使用 require 确保 Metro 能解析并打包该原生模块(动态 import 在某些环境下无法被正确解析)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-explicit-any
|
||||||
|
const mod: any = require('react-native-text-size');
|
||||||
|
return mod?.default ?? mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTextSizeFontSpecs(fontSpec: FontSpec): Pick<TextSizeMeasureParams, 'fontFamily' | 'fontSize' | 'fontWeight'> {
|
||||||
|
return {
|
||||||
|
fontFamily: fontSpec.fontFamily,
|
||||||
|
fontSize: fontSpec.fontSize,
|
||||||
|
fontWeight: fontSpec.fontWeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 默认测量函数(可替换/可注入)。
|
||||||
|
* - width 约束设为极大值,避免自动换行影响“单行宽度”测量
|
||||||
|
* - usePreciseWidth=true,取更精确的宽度(开销更大,但对本算法更稳定)
|
||||||
|
*/
|
||||||
|
export const defaultMeasureWidthImpl: MeasureWidthImpl = async ({ text, fontSpec }) => {
|
||||||
|
const TextSize = loadReactNativeTextSize();
|
||||||
|
if (!TextSize || typeof TextSize.measure !== 'function') {
|
||||||
|
// 典型原因:在 Expo Go 中运行,或没有使用包含该原生模块的 Development Build。
|
||||||
|
// 这里抛出更明确的错误,方便上层捕获并在 meta.reason=MEASURE_FAILED 时看到根因。
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
'react-native-text-size 原生模块不可用:TextSize.measure 不是函数。',
|
||||||
|
'请确认你不是在 Expo Go 里运行;需要使用包含该原生模块的 Development Build(expo-dev-client / expo run:ios / EAS dev build)。',
|
||||||
|
].join(' '),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const res = await TextSize.measure({
|
||||||
|
text,
|
||||||
|
width: 1_000_000_000,
|
||||||
|
usePreciseWidth: true,
|
||||||
|
allowFontScaling: true,
|
||||||
|
...toTextSizeFontSpecs(fontSpec),
|
||||||
|
});
|
||||||
|
return res.width;
|
||||||
|
};
|
||||||
|
|
||||||
48
client/src/features/textWrap/measure/types.ts
Normal file
48
client/src/features/textWrap/measure/types.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Text Wrap - width-measurement
|
||||||
|
*
|
||||||
|
* 本模块负责:
|
||||||
|
* - 宽度测量(可注入实现)
|
||||||
|
* - 两级缓存(文本/切片)
|
||||||
|
* - 测量失败时的确定性降级(approx mode)
|
||||||
|
*
|
||||||
|
* 注意:真实测量(如 react-native-text-size)通常是异步 Promise,本模块统一使用 async 形式对齐实际能力。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type TextWrapContext = 'APP' | 'WIDGET';
|
||||||
|
|
||||||
|
export type FontSpec = {
|
||||||
|
fontSize: number;
|
||||||
|
fontFamily: string;
|
||||||
|
fontWeight: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用于区分不同测量语境(缓存隔离)。
|
||||||
|
* 口径建议:`APP|<platform>|<scale?>` 或 `WIDGET|<widgetSize?>`
|
||||||
|
*/
|
||||||
|
export type ContextProfile = string;
|
||||||
|
|
||||||
|
export type MeasureReason = 'WIDTH_UNKNOWN' | 'MEASURE_FAILED';
|
||||||
|
|
||||||
|
export type MeasureMeta = {
|
||||||
|
isApprox: boolean;
|
||||||
|
reason?: MeasureReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MeasureResult = {
|
||||||
|
width: number | null;
|
||||||
|
meta: MeasureMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可注入的测量实现(成熟方法推荐用原生/离屏测量库)。
|
||||||
|
* 约定:返回值必须是“有限且非负”的 number,否则视为测量失败。
|
||||||
|
*/
|
||||||
|
export type MeasureWidthImpl = (args: {
|
||||||
|
text: string;
|
||||||
|
context: TextWrapContext;
|
||||||
|
fontSpec: FontSpec;
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
}) => Promise<number>;
|
||||||
|
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { MeasureWidthImpl } from '../../measure/types';
|
||||||
|
import { applyOverflowFallback } from '../fallback';
|
||||||
|
|
||||||
|
function t(text: string, start: number) {
|
||||||
|
return { text, start, end: start + text.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('textWrap overflow-fallback', () => {
|
||||||
|
it('SYSTEM_DEFAULT:仍返回单行,但 meta 标记 SYSTEM_DEFAULT', async () => {
|
||||||
|
const res = await applyOverflowFallback({
|
||||||
|
tokens: [t('I', 0), t('am', 2), t('tired', 5)],
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 10,
|
||||||
|
maxLines: 2,
|
||||||
|
overflowMode: 'SYSTEM_DEFAULT',
|
||||||
|
ellipsisToken: '…',
|
||||||
|
reason: 'NO_CANDIDATE',
|
||||||
|
partialLayout: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.lines.length).toBe(1);
|
||||||
|
expect(res.meta.fallback_type).toBe('SYSTEM_DEFAULT');
|
||||||
|
expect(res.meta.overflow_type).toBe('NONE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CLIP:截断到 maxLines', async () => {
|
||||||
|
const res = await applyOverflowFallback({
|
||||||
|
tokens: [t('a', 0), t('b', 2), t('c', 4)],
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 10,
|
||||||
|
maxLines: 2,
|
||||||
|
overflowMode: 'CLIP',
|
||||||
|
ellipsisToken: '…',
|
||||||
|
reason: 'TOO_LONG',
|
||||||
|
partialLayout: {
|
||||||
|
breaks: [1, 2],
|
||||||
|
lines: [
|
||||||
|
{ start: 0, end: 1, text: 'a' },
|
||||||
|
{ start: 1, end: 2, text: 'b' },
|
||||||
|
// 造一个“未覆盖到 N”的 partial,触发 overflow
|
||||||
|
{ start: 2, end: 2, text: 'c' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.lines).toEqual(['a', 'b']);
|
||||||
|
expect(res.meta.overflow_type).toBe('CLIP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ELLIPSIS:清理末尾空白与 TC 末尾标点(不输出 \" …\" / \",…\")', async () => {
|
||||||
|
const res = await applyOverflowFallback({
|
||||||
|
tokens: [t('我', 0), t('好', 1), t('累', 2), t(',', 3)],
|
||||||
|
lang: 'TC',
|
||||||
|
context: 'WIDGET',
|
||||||
|
availableWidth: 10,
|
||||||
|
maxLines: 1,
|
||||||
|
overflowMode: 'ELLIPSIS',
|
||||||
|
ellipsisToken: '…',
|
||||||
|
reason: 'NO_CANDIDATE',
|
||||||
|
partialLayout: {
|
||||||
|
breaks: [],
|
||||||
|
// 造一个“未覆盖到 N”的 partial,触发 overflow
|
||||||
|
lines: [{ start: 0, end: 3, text: '我好累, ' }],
|
||||||
|
},
|
||||||
|
tcPunctuations: [','],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.lines[0]).toBe('我好累…');
|
||||||
|
expect(res.meta.overflow_type).toBe('ELLIPSIS');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ELLIPSIS:可测量时,超宽则按 token 回退直到不超宽', async () => {
|
||||||
|
// mock:含省略号时宽度=100,否则宽度=文本长度
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => (text.includes('…') ? 100 : text.length));
|
||||||
|
|
||||||
|
const res = await applyOverflowFallback({
|
||||||
|
tokens: [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)],
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 10,
|
||||||
|
maxLines: 1,
|
||||||
|
overflowMode: 'ELLIPSIS',
|
||||||
|
ellipsisToken: '…',
|
||||||
|
reason: 'NO_CANDIDATE',
|
||||||
|
partialLayout: {
|
||||||
|
breaks: [],
|
||||||
|
// 造一个“未覆盖到 N”的 partial,触发 overflow
|
||||||
|
lines: [{ start: 0, end: 3, text: 'I am so' }],
|
||||||
|
},
|
||||||
|
measure: {
|
||||||
|
context: 'APP',
|
||||||
|
contextProfile: 'APP|ios|ellipsis',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 因为任何带 ellipsis 的测量都超宽,这里会一路回退到空串 + ellipsis
|
||||||
|
expect(res.lines[0]).toBe('…');
|
||||||
|
expect(res.meta.overflow_type).toBe('ELLIPSIS');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
108
client/src/features/textWrap/overflow/ellipsis.ts
Normal file
108
client/src/features/textWrap/overflow/ellipsis.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
import { joinTokens } from '../core/joinTokens';
|
||||||
|
import { measureWidthCached } from '../measure/measureWidthCached';
|
||||||
|
|
||||||
|
import type { ApplyOverflowFallbackInput } from './types';
|
||||||
|
|
||||||
|
const DEFAULT_TC_PUNCTS = [',', '。', '!', '?', ';', ':', '、'];
|
||||||
|
|
||||||
|
export function cleanLineBeforeEllipsis(line: string, lang: 'TC' | 'EN', tcPunctuations?: string[]): string {
|
||||||
|
const s = String(line ?? '');
|
||||||
|
const trimmed = s.replace(/\s+$/g, '');
|
||||||
|
if (trimmed === '') return '';
|
||||||
|
|
||||||
|
if (lang === 'TC') {
|
||||||
|
const puncts = new Set(tcPunctuations && tcPunctuations.length > 0 ? tcPunctuations : DEFAULT_TC_PUNCTS);
|
||||||
|
const last = trimmed.slice(-1);
|
||||||
|
if (puncts.has(last)) return trimmed.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function measureIfPossible(args: {
|
||||||
|
text: string;
|
||||||
|
input: ApplyOverflowFallbackInput;
|
||||||
|
}): Promise<number | null> {
|
||||||
|
const { input, text } = args;
|
||||||
|
if (!input.measure) return null;
|
||||||
|
|
||||||
|
const enabled =
|
||||||
|
typeof input.measure.measureWidthImpl === 'function' &&
|
||||||
|
(input.measure.context === 'APP' || input.measure.widgetEnableMeasure === true);
|
||||||
|
|
||||||
|
if (!enabled) return null;
|
||||||
|
|
||||||
|
const res = await measureWidthCached({
|
||||||
|
text,
|
||||||
|
context: input.measure.context,
|
||||||
|
contextProfile: input.measure.contextProfile,
|
||||||
|
fontSpec: input.measure.fontSpec,
|
||||||
|
measureWidthImpl: input.measure.measureWidthImpl,
|
||||||
|
widgetEnableMeasure: input.measure.widgetEnableMeasure,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 给最后一行加省略号,并在可测量时保证不超宽:
|
||||||
|
* - EN 回退单位:整词(token)
|
||||||
|
* - TC 回退单位:grapheme(token)
|
||||||
|
*/
|
||||||
|
export async function applyEllipsisToLastLine(args: {
|
||||||
|
input: ApplyOverflowFallbackInput;
|
||||||
|
baseLines: Array<{ start: number; end: number; text: string }>;
|
||||||
|
}): Promise<{ lines: string[] }> {
|
||||||
|
const { input } = args;
|
||||||
|
const maxLines = Math.max(1, input.maxLines | 0);
|
||||||
|
const lines = args.baseLines.slice(0, maxLines);
|
||||||
|
if (lines.length === 0) return { lines: [] };
|
||||||
|
|
||||||
|
const lastIdx = lines.length - 1;
|
||||||
|
const last = lines[lastIdx]!;
|
||||||
|
|
||||||
|
// 先按规则清理,再拼接 ellipsisToken
|
||||||
|
const cleaned = cleanLineBeforeEllipsis(last.text, input.lang, input.tcPunctuations);
|
||||||
|
let candidateText = `${cleaned}${input.ellipsisToken}`;
|
||||||
|
|
||||||
|
// 若无测量能力:直接输出(确定性)
|
||||||
|
const measured = await measureIfPossible({ text: candidateText, input });
|
||||||
|
if (measured === null) {
|
||||||
|
const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text));
|
||||||
|
return { lines: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有测量能力:若超宽则按 token 回退
|
||||||
|
if (measured <= input.availableWidth) {
|
||||||
|
const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text));
|
||||||
|
return { lines: out };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:逐步减少最后一行 token 数再加省略号
|
||||||
|
let end = last.end;
|
||||||
|
const start = last.start;
|
||||||
|
let foundFit = false;
|
||||||
|
while (end > start) {
|
||||||
|
end -= 1;
|
||||||
|
const base = joinTokens(input.tokens, start, end);
|
||||||
|
const cleaned2 = cleanLineBeforeEllipsis(base, input.lang, input.tcPunctuations);
|
||||||
|
const nextCandidate = `${cleaned2}${input.ellipsisToken}`;
|
||||||
|
const w = await measureIfPossible({ text: nextCandidate, input });
|
||||||
|
if (w !== null && w <= input.availableWidth) {
|
||||||
|
candidateText = nextCandidate;
|
||||||
|
foundFit = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若连“仅省略号”都无法满足宽度(极窄场景),也必须给出确定性输出:直接输出 ellipsisToken
|
||||||
|
// 注意:这可能仍然超宽,但已是最小可表达形式;上层可通过 meta 做治理。
|
||||||
|
if (!foundFit) {
|
||||||
|
candidateText = input.ellipsisToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = lines.map((l, idx) => (idx === lastIdx ? candidateText : l.text));
|
||||||
|
return { lines: out };
|
||||||
|
}
|
||||||
|
|
||||||
86
client/src/features/textWrap/overflow/fallback.ts
Normal file
86
client/src/features/textWrap/overflow/fallback.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
import { joinTokens } from '../core/joinTokens';
|
||||||
|
|
||||||
|
import { applyEllipsisToLastLine } from './ellipsis';
|
||||||
|
import type { ApplyOverflowFallbackInput, ApplyOverflowFallbackResult, PartialLayoutInput, PartialLayoutLine } from './types';
|
||||||
|
|
||||||
|
function nTokens(tokens: Token[]): number {
|
||||||
|
return Array.isArray(tokens) ? tokens.length : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coversToEnd(partial: PartialLayoutInput | null | undefined, n: number): boolean {
|
||||||
|
if (!partial || !Array.isArray(partial.lines) || partial.lines.length === 0) return false;
|
||||||
|
const last = partial.lines[partial.lines.length - 1];
|
||||||
|
return (last?.end ?? -1) === n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLinesFromPartial(args: {
|
||||||
|
tokens: Token[];
|
||||||
|
partial?: PartialLayoutInput | null;
|
||||||
|
maxLines: number;
|
||||||
|
lang: 'TC' | 'EN';
|
||||||
|
}): Array<{ start: number; end: number; text: string }> {
|
||||||
|
const N = nTokens(args.tokens);
|
||||||
|
const maxLines = Math.max(1, args.maxLines | 0);
|
||||||
|
const rawSeparators = args.lang === 'TC' ? Array.from({ length: Math.max(0, N) }, () => '') : undefined;
|
||||||
|
|
||||||
|
if (args.partial && Array.isArray(args.partial.lines) && args.partial.lines.length > 0) {
|
||||||
|
const out: Array<{ start: number; end: number; text: string }> = [];
|
||||||
|
for (const line of args.partial.lines.slice(0, maxLines)) {
|
||||||
|
const start = Math.max(0, line.start | 0);
|
||||||
|
const end = Math.min(N, Math.max(start, line.end | 0));
|
||||||
|
const text = line.text ?? joinTokens(args.tokens, start, end, rawSeparators);
|
||||||
|
out.push({ start, end, text });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 若没有 partial:把全文当单行 best-effort
|
||||||
|
return [{ start: 0, end: N, text: joinTokens(args.tokens, 0, N, rawSeparators) }];
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWrapped(lines: string[]): { lines: string[]; wrappedText: string } {
|
||||||
|
const outLines = lines.filter((s) => s !== undefined) as string[];
|
||||||
|
return { lines: outLines, wrappedText: outLines.join('\n') };
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSingleLine(tokens: Token[]): { lines: string[]; wrappedText: string } {
|
||||||
|
const N = nTokens(tokens);
|
||||||
|
// 注意:这里不知道语言口径,因此 SYSTEM_DEFAULT 的单行文本在上层保证传入“原文”更稳妥。
|
||||||
|
// 为保持最小可用,这里仍使用默认 joinTokens(EN=空格 join,TC=grapheme 之间可能会有空格)。
|
||||||
|
const text = joinTokens(tokens, 0, N);
|
||||||
|
return { lines: [text], wrappedText: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyOverflowFallback(input: ApplyOverflowFallbackInput): Promise<ApplyOverflowFallbackResult> {
|
||||||
|
const tokens = input.tokens ?? [];
|
||||||
|
const N = nTokens(tokens);
|
||||||
|
const maxLines = Math.max(1, input.maxLines | 0);
|
||||||
|
|
||||||
|
const isOverflow = !coversToEnd(input.partialLayout, N);
|
||||||
|
|
||||||
|
// 若其实没有 overflow:直接返回(NONE)
|
||||||
|
if (!isOverflow) {
|
||||||
|
const base = buildLinesFromPartial({ tokens, partial: input.partialLayout, maxLines, lang: input.lang });
|
||||||
|
const { lines, wrappedText } = toWrapped(base.map((l) => l.text));
|
||||||
|
return { lines, wrappedText, meta: { fallback_type: 'NONE', overflow_type: 'NONE', reason: input.reason } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.overflowMode === 'SYSTEM_DEFAULT') {
|
||||||
|
const { lines, wrappedText } = toSingleLine(tokens);
|
||||||
|
return { lines, wrappedText, meta: { fallback_type: 'SYSTEM_DEFAULT', overflow_type: 'NONE', reason: input.reason } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.overflowMode === 'CLIP') {
|
||||||
|
const base = buildLinesFromPartial({ tokens, partial: input.partialLayout, maxLines, lang: input.lang });
|
||||||
|
const { lines, wrappedText } = toWrapped(base.slice(0, maxLines).map((l) => l.text));
|
||||||
|
return { lines, wrappedText, meta: { fallback_type: 'NONE', overflow_type: 'CLIP', reason: input.reason } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ELLIPSIS
|
||||||
|
const base = buildLinesFromPartial({ tokens, partial: input.partialLayout, maxLines, lang: input.lang });
|
||||||
|
const { lines } = await applyEllipsisToLastLine({ input, baseLines: base.slice(0, maxLines) });
|
||||||
|
const wrapped = toWrapped(lines);
|
||||||
|
return { ...wrapped, meta: { fallback_type: 'NONE', overflow_type: 'ELLIPSIS', reason: input.reason } };
|
||||||
|
}
|
||||||
|
|
||||||
15
client/src/features/textWrap/overflow/index.ts
Normal file
15
client/src/features/textWrap/overflow/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export type {
|
||||||
|
ApplyOverflowFallbackInput,
|
||||||
|
ApplyOverflowFallbackResult,
|
||||||
|
FallbackType,
|
||||||
|
OverflowMeasure,
|
||||||
|
OverflowMode,
|
||||||
|
OverflowReason,
|
||||||
|
OverflowType,
|
||||||
|
PartialLayoutInput,
|
||||||
|
PartialLayoutLine,
|
||||||
|
WrapTextMeta,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { applyOverflowFallback } from './fallback';
|
||||||
|
|
||||||
47
client/src/features/textWrap/overflow/types.ts
Normal file
47
client/src/features/textWrap/overflow/types.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
import type { ContextProfile, FontSpec, MeasureWidthImpl, TextWrapContext } from '../measure/types';
|
||||||
|
|
||||||
|
export type OverflowMode = 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT';
|
||||||
|
export type FallbackType = 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT';
|
||||||
|
export type OverflowType = 'NONE' | 'ELLIPSIS' | 'CLIP';
|
||||||
|
|
||||||
|
export type OverflowReason = 'NO_CANDIDATE' | 'WIDTH_UNKNOWN' | 'TOO_LONG' | 'WIDOW' | 'PARTICLE' | string;
|
||||||
|
|
||||||
|
export type PartialLayoutLine = { start: number; end: number; text?: string };
|
||||||
|
export type PartialLayoutInput = { breaks: number[]; lines: PartialLayoutLine[] };
|
||||||
|
|
||||||
|
export type OverflowMeasure = {
|
||||||
|
context: TextWrapContext;
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
fontSpec: Partial<FontSpec> | null | undefined;
|
||||||
|
measureWidthImpl?: MeasureWidthImpl;
|
||||||
|
widgetEnableMeasure?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApplyOverflowFallbackInput = {
|
||||||
|
tokens: Token[];
|
||||||
|
lang: 'TC' | 'EN';
|
||||||
|
context: TextWrapContext;
|
||||||
|
availableWidth: number;
|
||||||
|
maxLines: number;
|
||||||
|
overflowMode: OverflowMode;
|
||||||
|
ellipsisToken: string;
|
||||||
|
reason: OverflowReason;
|
||||||
|
partialLayout?: PartialLayoutInput | null;
|
||||||
|
measure?: OverflowMeasure;
|
||||||
|
/** TC 标点集合(用于清理 `,…`) */
|
||||||
|
tcPunctuations?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WrapTextMeta = {
|
||||||
|
fallback_type: FallbackType;
|
||||||
|
overflow_type: OverflowType;
|
||||||
|
reason: OverflowReason;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApplyOverflowFallbackResult = {
|
||||||
|
lines: string[];
|
||||||
|
wrappedText: string;
|
||||||
|
meta: WrapTextMeta;
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { Token } from '../../core/types';
|
||||||
|
import { buildTieKey, DEFAULT_LEXICONS, DEFAULT_WEIGHTS, scoreLayout } from '../index';
|
||||||
|
|
||||||
|
function t(text: string, start: number): Token {
|
||||||
|
return { text, start, end: start + text.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
function lexicographicLess(a: Array<number | string>, b: Array<number | string>): boolean {
|
||||||
|
const n = Math.min(a.length, b.length);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const av = a[i] as any;
|
||||||
|
const bv = b[i] as any;
|
||||||
|
if (av < bv) return true;
|
||||||
|
if (av > bv) return false;
|
||||||
|
}
|
||||||
|
return a.length < b.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('textWrap scoring-tiebreak', () => {
|
||||||
|
it('EMOTION_SPLIT:拆分情绪短语 -> 强惩罚 + 追加 spanLen(方案 A)', () => {
|
||||||
|
const tokens: Token[] = [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)];
|
||||||
|
const layoutCandidate = {
|
||||||
|
breaks: [3], // I am so | tired
|
||||||
|
lines: [
|
||||||
|
{ start: 0, end: 3, text: 'I am so', width: 10, tokenCount: 3, charCount: 0 },
|
||||||
|
{ start: 3, end: 4, text: 'tired', width: 10, tokenCount: 1, charCount: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = scoreLayout({
|
||||||
|
tokens,
|
||||||
|
layoutCandidate,
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 100,
|
||||||
|
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||||
|
lexicons: { ...DEFAULT_LEXICONS, emotionPhrasesEN: ['so tired'] },
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 触发强惩罚:-(P_EMOTION_SPLIT + spanLen)
|
||||||
|
const first = res.scoreBreakdown?.terms[0];
|
||||||
|
expect(first?.key).toBe('EMOTION_SPLIT');
|
||||||
|
expect(first?.delta).toBe(-(DEFAULT_WEIGHTS.P_EMOTION_SPLIT + 2));
|
||||||
|
expect(res.flags.emotionSplit).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Shift/Accum/Self:EN 词两端带标点也应命中(全词等值匹配)', () => {
|
||||||
|
const tokens: Token[] = [t('but,', 0), t('still', 5), t('ok', 11)];
|
||||||
|
const layoutCandidate = {
|
||||||
|
breaks: [1], // but, | still ok
|
||||||
|
lines: [
|
||||||
|
// 把 width 设置到 idealWidth 上,避免 length 项干扰 debug Top-3
|
||||||
|
{ start: 0, end: 1, text: 'but,', width: 90, tokenCount: 1, charCount: 0 },
|
||||||
|
{ start: 1, end: 3, text: 'still ok', width: 90, tokenCount: 2, charCount: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = scoreLayout({
|
||||||
|
tokens,
|
||||||
|
layoutCandidate,
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 100, // idealWidth=100*0.9=90
|
||||||
|
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const terms = res.scoreBreakdown?.terms ?? [];
|
||||||
|
expect(terms.some((x) => x.key === 'SHIFT_BREAK')).toBe(true);
|
||||||
|
expect(terms.some((x) => x.key === 'ACCUM_BREAK')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('10.2G:EmotionWord 与 Accum 同时命中 -> Accum 奖励减半(整数)', () => {
|
||||||
|
const tokens: Token[] = [t('already', 0), t('tired', 8)];
|
||||||
|
const layoutCandidate = {
|
||||||
|
breaks: [1], // already | tired
|
||||||
|
lines: [
|
||||||
|
{ start: 0, end: 1, text: 'already', width: 90, tokenCount: 1, charCount: 0 },
|
||||||
|
{ start: 1, end: 2, text: 'tired', width: 90, tokenCount: 1, charCount: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const weights = { ...DEFAULT_WEIGHTS, R_EMOTION_TAIL: 10, R_ACCUM_BREAK: 41 }; // 41/2 -> 20(floor)
|
||||||
|
|
||||||
|
const res = scoreLayout({
|
||||||
|
tokens,
|
||||||
|
layoutCandidate,
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: 100,
|
||||||
|
config: { weights, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
debug: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const terms = res.scoreBreakdown?.terms ?? [];
|
||||||
|
// debug 可能截断为 Top-3,但应包含 EMOTION_TAIL 与 ACCUM_BREAK(顺序确定性)
|
||||||
|
const hasEmotion = terms.some((x) => x.key === 'EMOTION_TAIL' && x.delta === 10);
|
||||||
|
const hasAccumHalf = terms.some((x) => x.key === 'ACCUM_BREAK' && x.delta === Math.floor(41 / 2));
|
||||||
|
expect(hasEmotion).toBe(true);
|
||||||
|
expect(hasAccumHalf).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tieKey:lastLineWidth 更大优先(取 -lastLineWidth)', () => {
|
||||||
|
const base = {
|
||||||
|
breaks: [2],
|
||||||
|
lines: [
|
||||||
|
{ start: 0, end: 2, text: 'a b', width: 50, tokenCount: 2, charCount: 0 },
|
||||||
|
{ start: 2, end: 3, text: 'c', width: 10, tokenCount: 1, charCount: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const scoredA = { score: 0, flags: {}, tieKey: [] as any };
|
||||||
|
const scoredB = { score: 0, flags: {}, tieKey: [] as any };
|
||||||
|
|
||||||
|
const keyShort = buildTieKey({
|
||||||
|
scoredLayout: scoredA as any,
|
||||||
|
layoutCandidate: base as any,
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
tokenCount: 3,
|
||||||
|
availableWidth: 100,
|
||||||
|
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const layoutLongTail = {
|
||||||
|
...base,
|
||||||
|
lines: [
|
||||||
|
base.lines[0]!,
|
||||||
|
{ ...base.lines[1]!, width: 30 }, // lastLineWidth 更大
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const keyLong = buildTieKey({
|
||||||
|
scoredLayout: scoredB as any,
|
||||||
|
layoutCandidate: layoutLongTail as any,
|
||||||
|
lang: 'EN',
|
||||||
|
context: 'APP',
|
||||||
|
tokenCount: 3,
|
||||||
|
availableWidth: 100,
|
||||||
|
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更优的 tieKey 应“更小”(因为 lastLineWidth 取负)
|
||||||
|
expect(lexicographicLess(keyLong, keyShort)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
21
client/src/features/textWrap/scoring/index.ts
Normal file
21
client/src/features/textWrap/scoring/index.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export type {
|
||||||
|
LayoutCandidate,
|
||||||
|
LayoutCandidateLine,
|
||||||
|
Lexicons,
|
||||||
|
ScoredLayout,
|
||||||
|
ScoreBreakdown,
|
||||||
|
ScoreLayoutInput,
|
||||||
|
ScoreTerm,
|
||||||
|
ScoringConfig,
|
||||||
|
TextWrapContext,
|
||||||
|
Weights,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { DEFAULT_LEXICONS, mergeLexicons } from './lexicons';
|
||||||
|
export { DEFAULT_WEIGHTS, mergeWeights } from './weights';
|
||||||
|
|
||||||
|
export { preparePhraseTokens, findPhraseSpans, isSpanSplitByBreaks } from './phraseMatch';
|
||||||
|
export { scoreLayout } from './score';
|
||||||
|
export type { BuildTieKeyInput } from './tieKey';
|
||||||
|
export { buildTieKey } from './tieKey';
|
||||||
|
|
||||||
31
client/src/features/textWrap/scoring/lexicons.ts
Normal file
31
client/src/features/textWrap/scoring/lexicons.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { Lexicons } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* scoring-tiebreak 最小词表(首版写死客户端)
|
||||||
|
*
|
||||||
|
* 来源:`设计说明文档/文档换行算法.md v1.2.1` 第 7 节
|
||||||
|
* 原则:少而准(后续通过打点迭代扩充)
|
||||||
|
*/
|
||||||
|
export const DEFAULT_LEXICONS: Readonly<Lexicons> = Object.freeze({
|
||||||
|
emotionPhrasesTC: [],
|
||||||
|
emotionPhrasesEN: [],
|
||||||
|
protectedPhrases: [],
|
||||||
|
|
||||||
|
shiftWordsTC: ['但', '可是', '然而', '却', '只是', '偏偏'],
|
||||||
|
shiftWordsEN: ['but', 'yet', 'so'],
|
||||||
|
|
||||||
|
accumWordsTC: ['已经', '一直', '曾经', '终于', '还是', '到现在'],
|
||||||
|
accumWordsEN: ['already', 'still', 'even', 'just', 'really'],
|
||||||
|
|
||||||
|
selfWordsTC: ['你', '我', '自己', '我们', '别人'],
|
||||||
|
selfWordsEN: ['you', 'yourself', 'me', 'we'],
|
||||||
|
|
||||||
|
emotionWordsTC: ['累', '痛', '怕', '孤单', '委屈', '撑', '崩溃', '放弃'],
|
||||||
|
emotionWordsEN: ['tired', 'afraid', 'lonely', 'hurt', 'overwhelmed', 'give up'],
|
||||||
|
});
|
||||||
|
|
||||||
|
export function mergeLexicons(overrides?: Partial<Lexicons> | null | undefined): Lexicons {
|
||||||
|
if (!overrides) return { ...DEFAULT_LEXICONS };
|
||||||
|
return { ...DEFAULT_LEXICONS, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
92
client/src/features/textWrap/scoring/phraseMatch.ts
Normal file
92
client/src/features/textWrap/scoring/phraseMatch.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import type { Lang, Token } from '../core/types';
|
||||||
|
import { matchENKeyword, normalizeENKeyword, normalizeWhitespace, tokenizeEN } from '../core/index';
|
||||||
|
import { segmentGraphemes } from '../grapheme/index';
|
||||||
|
|
||||||
|
export type PhraseSpan = { start: number; end: number; length: number };
|
||||||
|
|
||||||
|
function toNonEmptyArray(parts: string[]): string[] {
|
||||||
|
return parts.map((s) => s.trim()).filter((s) => s.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preparePhraseTokens(phrase: string, lang: Lang): string[] {
|
||||||
|
const input = String(phrase ?? '');
|
||||||
|
if (input === '') return [];
|
||||||
|
|
||||||
|
if (lang === 'EN') {
|
||||||
|
const { normalizedText } = normalizeWhitespace(input, 'NORMALIZE');
|
||||||
|
const words = normalizedText.split(' ');
|
||||||
|
// EN:按“全词等值匹配”口径做归一化(小写 + 两端去常见标点)
|
||||||
|
return toNonEmptyArray(words).map((w) => normalizeENKeyword(w));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC:按 grapheme clusters
|
||||||
|
const { clusters } = segmentGraphemes(input, 'PREFERRED');
|
||||||
|
return clusters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenEqualsPhraseToken(tokenText: string, phraseToken: string, lang: Lang): boolean {
|
||||||
|
if (lang === 'EN') return matchENKeyword(tokenText, phraseToken);
|
||||||
|
return tokenText === phraseToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 tokens 中寻找 phraseTokens 的“连续区间完全匹配”(必须)。
|
||||||
|
*
|
||||||
|
* 输出顺序(确定性):
|
||||||
|
* - start 升序
|
||||||
|
* - start 相同:length 降序(更长优先)
|
||||||
|
*/
|
||||||
|
export function findPhraseSpans(tokens: Token[], phraseTokens: string[], lang: Lang): PhraseSpan[] {
|
||||||
|
const n = tokens.length;
|
||||||
|
const m = phraseTokens.length;
|
||||||
|
if (n === 0 || m === 0 || m > n) return [];
|
||||||
|
|
||||||
|
const spans: PhraseSpan[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i <= n - m; i++) {
|
||||||
|
let ok = true;
|
||||||
|
for (let j = 0; j < m; j++) {
|
||||||
|
const t = tokens[i + j]?.text ?? '';
|
||||||
|
const p = phraseTokens[j] ?? '';
|
||||||
|
if (!tokenEqualsPhraseToken(t, p, lang)) {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ok) spans.push({ start: i, end: i + m, length: m });
|
||||||
|
}
|
||||||
|
|
||||||
|
spans.sort((a, b) => {
|
||||||
|
if (a.start !== b.start) return a.start - b.start;
|
||||||
|
return b.length - a.length;
|
||||||
|
});
|
||||||
|
return spans;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断某个 span 是否被断点拆分到不同的行(即:span 内存在 break)。
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - span=[start,end) 是 token 索引区间
|
||||||
|
* - breaks[i] 是 token 边界索引(1..N-1)
|
||||||
|
* - 若存在 start < b < end,则 span 被拆分
|
||||||
|
*/
|
||||||
|
export function isSpanSplitByBreaks(span: PhraseSpan, breaks: number[]): boolean {
|
||||||
|
const s = span.start;
|
||||||
|
const e = span.end;
|
||||||
|
if (e - s <= 1) return false;
|
||||||
|
for (const b of breaks) {
|
||||||
|
if (b > s && b < e) return true;
|
||||||
|
if (b >= e) break; // breaks 升序:可提前退出
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅用于测试:把 EN 文本按 core-contract 口径 tokenize。
|
||||||
|
*/
|
||||||
|
export function tokenizePhraseLikeInputEN(text: string): Token[] {
|
||||||
|
const { normalizedText } = normalizeWhitespace(text, 'NORMALIZE');
|
||||||
|
return tokenizeEN(normalizedText);
|
||||||
|
}
|
||||||
|
|
||||||
367
client/src/features/textWrap/scoring/score.ts
Normal file
367
client/src/features/textWrap/scoring/score.ts
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import type { Lang, Token } from '../core/types';
|
||||||
|
import { normalizeENKeyword } from '../core/index';
|
||||||
|
|
||||||
|
import type { LayoutCandidateLine, ScoreLayoutInput, ScoreTerm, ScoredLayout, Weights } from './types';
|
||||||
|
import { findPhraseSpans, isSpanSplitByBreaks, preparePhraseTokens } from './phraseMatch';
|
||||||
|
import { mergeLexicons } from './lexicons';
|
||||||
|
import { mergeWeights } from './weights';
|
||||||
|
|
||||||
|
function asInt(n: number): number {
|
||||||
|
// 保证输出整数(避免后续误用浮点)
|
||||||
|
return n | 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function absInt(n: number): number {
|
||||||
|
return n < 0 ? -n : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundToInt(n: number): number {
|
||||||
|
return Math.round(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioToBp(ratio: number): number {
|
||||||
|
// 把 0.90 转为 900(bp=1/1000),用于尽量规避浮点带来的跨端差异
|
||||||
|
return Math.round(ratio * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineWidthOrApprox(line: LayoutCandidateLine, lang: Lang): number {
|
||||||
|
if (typeof line.width === 'number' && Number.isFinite(line.width)) return roundToInt(line.width);
|
||||||
|
// width unknown/approx:回退到 tokenCount / charCount(确定性)
|
||||||
|
return lang === 'EN' ? asInt(line.tokenCount) : asInt(line.charCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSet(arr: string[] | undefined, lang: Lang): Set<string> {
|
||||||
|
if (!arr || arr.length === 0) return new Set();
|
||||||
|
if (lang === 'EN') return new Set(arr.map((w) => normalizeENKeyword(w)));
|
||||||
|
return new Set(arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenHitSet(tokenText: string, set: Set<string>, lang: Lang): boolean {
|
||||||
|
if (set.size === 0) return false;
|
||||||
|
if (lang === 'EN') {
|
||||||
|
// 全词等值匹配(允许两端标点包裹):normalize 后做等值命中即可
|
||||||
|
return set.has(normalizeENKeyword(tokenText));
|
||||||
|
}
|
||||||
|
return set.has(tokenText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTcPunctuation(t: string, punctSet: Set<string>): boolean {
|
||||||
|
return punctSet.has(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeEmotionTailFlags(tokens: Token[], lines: LayoutCandidateLine[], lang: Lang, emotionWordsSet: Set<string>): boolean[] {
|
||||||
|
const flags: boolean[] = [];
|
||||||
|
if (emotionWordsSet.size === 0) return lines.map(() => false);
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const start = Math.max(0, line.start | 0);
|
||||||
|
const end = Math.max(start, line.end | 0);
|
||||||
|
const slice = tokens.slice(start, end);
|
||||||
|
|
||||||
|
if (slice.length === 0) {
|
||||||
|
flags.push(false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lang === 'EN') {
|
||||||
|
const last = slice[slice.length - 1]?.text ?? '';
|
||||||
|
flags.push(tokenHitSet(last, emotionWordsSet, 'EN'));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TC:“最后 2 个 grapheme 范围内命中”
|
||||||
|
const last1 = slice[slice.length - 1]?.text ?? '';
|
||||||
|
const last2 = slice.length >= 2 ? slice[slice.length - 2]?.text ?? '' : '';
|
||||||
|
flags.push(tokenHitSet(last1, emotionWordsSet, 'TC') || tokenHitSet(last2, emotionWordsSet, 'TC'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
function limitBreakdownForDebug(terms: ScoreTerm[]): ScoreTerm[] {
|
||||||
|
// 裁决补充 10.4A:debug Top-3 按规则优先级(10.1 顺序)输出
|
||||||
|
// 这里采用最稳妥的实现:直接取“按实现顺序生成的前 3 项”(确定性且满足优先级排序)
|
||||||
|
return terms.slice(0, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreLayout(input: ScoreLayoutInput): ScoredLayout {
|
||||||
|
const weights: Weights = mergeWeights(input.config.weights);
|
||||||
|
const lexicons = mergeLexicons(input.lexicons);
|
||||||
|
|
||||||
|
const tokens = input.tokens ?? [];
|
||||||
|
const breaks = (input.layoutCandidate.breaks ?? []).slice().sort((a, b) => a - b);
|
||||||
|
const lines = input.layoutCandidate.lines ?? [];
|
||||||
|
|
||||||
|
const terms: ScoreTerm[] = [];
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
const flags: ScoredLayout['flags'] = {
|
||||||
|
overflowed: Boolean(input.layoutCandidate.meta?.overflowed),
|
||||||
|
fallback: Boolean(input.layoutCandidate.meta?.fallback),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 10.1-1 情绪短语拆分惩罚(最高优先)
|
||||||
|
// ------------------------------
|
||||||
|
{
|
||||||
|
let deltaEmotion = 0;
|
||||||
|
let deltaProtected = 0;
|
||||||
|
|
||||||
|
const listEmotion = input.lang === 'EN' ? lexicons.emotionPhrasesEN : lexicons.emotionPhrasesTC;
|
||||||
|
const listProtected = lexicons.protectedPhrases ?? [];
|
||||||
|
|
||||||
|
const pushSplit = (key: 'EMOTION_SPLIT' | 'PROTECTED_SPLIT', phrase: string, spanLen: number) => {
|
||||||
|
const base = key === 'EMOTION_SPLIT' ? weights.P_EMOTION_SPLIT : weights.P_PROTECTED_SPLIT;
|
||||||
|
const d = -asInt(base) - asInt(spanLen); // 方案 A:追加“被拆分短语长度”惩罚(更长短语更强保护)
|
||||||
|
if (key === 'EMOTION_SPLIT') deltaEmotion += d;
|
||||||
|
else deltaProtected += d;
|
||||||
|
|
||||||
|
flags.emotionSplit = true;
|
||||||
|
// 记录 detail 便于治理
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
terms.push({ key, delta: d, detail: { phrase, spanLen } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// emotion phrases
|
||||||
|
for (const phrase of listEmotion) {
|
||||||
|
const phraseTokens = preparePhraseTokens(phrase, input.lang);
|
||||||
|
const spans = findPhraseSpans(tokens, phraseTokens, input.lang);
|
||||||
|
for (const span of spans) {
|
||||||
|
if (isSpanSplitByBreaks(span, breaks)) pushSplit('EMOTION_SPLIT', phrase, span.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// protected phrases
|
||||||
|
for (const phrase of listProtected) {
|
||||||
|
const phraseTokens = preparePhraseTokens(phrase, input.lang);
|
||||||
|
const spans = findPhraseSpans(tokens, phraseTokens, input.lang);
|
||||||
|
for (const span of spans) {
|
||||||
|
if (isSpanSplitByBreaks(span, breaks)) pushSplit('PROTECTED_SPLIT', phrase, span.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注意:这里已经把 term 推入了 terms(保持“优先级最高的项”最先出现)
|
||||||
|
score += deltaEmotion + deltaProtected;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 10.1-2 行超长惩罚 + 理想长度奖励
|
||||||
|
// ------------------------------
|
||||||
|
{
|
||||||
|
const idealBp = ratioToBp(input.config.idealWidthRatio[input.context]);
|
||||||
|
const idealWidth = roundToInt((input.availableWidth * idealBp) / 1000);
|
||||||
|
const minPreferredRatio = input.config.minPreferredRatio ?? 0.6;
|
||||||
|
const minPreferredWidth = roundToInt((idealWidth * ratioToBp(minPreferredRatio)) / 1000);
|
||||||
|
|
||||||
|
let overMaxDelta = 0;
|
||||||
|
let idealDelta = 0;
|
||||||
|
let tooShortDelta = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const w = lineWidthOrApprox(line, input.lang);
|
||||||
|
|
||||||
|
if (w > input.availableWidth) {
|
||||||
|
overMaxDelta -= asInt(weights.P_OVER_MAXLEN) * asInt(w - input.availableWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
idealDelta -= absInt(w - idealWidth);
|
||||||
|
|
||||||
|
if (w < minPreferredWidth) {
|
||||||
|
tooShortDelta -= asInt(weights.P_TOO_SHORT) * asInt(minPreferredWidth - w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overMaxDelta !== 0) terms.push({ key: 'OVER_MAXLEN', delta: overMaxDelta, detail: { availableWidth: input.availableWidth } });
|
||||||
|
if (idealDelta !== 0) terms.push({ key: 'IDEAL_LEN', delta: idealDelta, detail: { idealWidth, idealBp } });
|
||||||
|
if (tooShortDelta !== 0) terms.push({ key: 'TOO_SHORT', delta: tooShortDelta, detail: { minPreferredWidth } });
|
||||||
|
|
||||||
|
score += overMaxDelta + idealDelta + tooShortDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 10.1-3 widow(EN)/ 单字行(TC)
|
||||||
|
// ------------------------------
|
||||||
|
{
|
||||||
|
const lastLine = lines[lines.length - 1];
|
||||||
|
if (lastLine) {
|
||||||
|
const lastWidth = lineWidthOrApprox(lastLine, input.lang);
|
||||||
|
|
||||||
|
if (input.lang === 'EN') {
|
||||||
|
const tokenCount = asInt(lastLine.tokenCount);
|
||||||
|
if (tokenCount === 1) {
|
||||||
|
const d = -asInt(weights.P_WIDOW_LINE);
|
||||||
|
terms.push({ key: 'WIDOW_LINE', delta: d });
|
||||||
|
score += d;
|
||||||
|
|
||||||
|
const start = Math.max(0, lastLine.start | 0);
|
||||||
|
const end = Math.max(start, lastLine.end | 0);
|
||||||
|
const only = tokens.slice(start, end)[0]?.text ?? '';
|
||||||
|
const norm = normalizeENKeyword(only);
|
||||||
|
const widowMaxLen = asInt(input.config.widowMaxLen ?? 3);
|
||||||
|
if (norm.length > 0 && norm.length <= widowMaxLen) {
|
||||||
|
const d2 = -asInt(weights.P_WIDOW_WORD);
|
||||||
|
terms.push({ key: 'WIDOW_WORD', delta: d2, detail: { word: norm, widowMaxLen } });
|
||||||
|
score += d2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// TC:最后一行只有 1 个 grapheme(或 charCount<=1)视为“单字行”
|
||||||
|
const c = asInt(lastLine.charCount);
|
||||||
|
if (c <= 1) {
|
||||||
|
const d = -asInt(weights.P_WIDOW_LINE);
|
||||||
|
terms.push({ key: 'WIDOW_LINE', delta: d, detail: { tcSingleChar: true, lastWidth } });
|
||||||
|
score += d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 10.2F TC 助词孤立(在 10.1-3 后落地,保证顺序确定性)
|
||||||
|
// ------------------------------
|
||||||
|
if (input.lang === 'TC' && lines.length > 0) {
|
||||||
|
const particles = new Set(input.config.tcParticles ?? ['啊', '喔', '呢', '啦', '吗', '吧', '呀']);
|
||||||
|
const whitelist = new Set(input.config.tcParticleWhitelist ?? ['啊', '喔', '呢', '啦']);
|
||||||
|
|
||||||
|
let delta = 0;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const start = Math.max(0, line.start | 0);
|
||||||
|
const end = Math.max(start, line.end | 0);
|
||||||
|
const slice = tokens.slice(start, end);
|
||||||
|
if (slice.length === 0) continue;
|
||||||
|
|
||||||
|
const first = slice[0]?.text ?? '';
|
||||||
|
const last = slice[slice.length - 1]?.text ?? '';
|
||||||
|
|
||||||
|
const hitFirst = particles.has(first);
|
||||||
|
const hitLast = particles.has(last);
|
||||||
|
if (!hitFirst && !hitLast) continue;
|
||||||
|
|
||||||
|
const base = asInt(weights.P_PARTICLE_ISO);
|
||||||
|
const half = Math.floor(base / 2);
|
||||||
|
|
||||||
|
if (hitFirst) delta -= whitelist.has(first) ? half : base;
|
||||||
|
if (hitLast) delta -= whitelist.has(last) ? half : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta !== 0) {
|
||||||
|
terms.push({ key: 'PARTICLE_ISO', delta, detail: { particles: Array.from(particles), whitelist: Array.from(whitelist) } });
|
||||||
|
score += delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 10.1-4 标点断点奖励(TC)
|
||||||
|
// ------------------------------
|
||||||
|
if (input.lang === 'TC') {
|
||||||
|
const puncts = new Set(input.config.tcPunctuations ?? [',', '。', '!', '?', ';', ':', '、']);
|
||||||
|
let delta = 0;
|
||||||
|
|
||||||
|
for (const b of breaks) {
|
||||||
|
const prev = tokens[b - 1]?.text ?? '';
|
||||||
|
if (isTcPunctuation(prev, puncts)) delta += asInt(weights.R_PUNCT_BREAK);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta !== 0) {
|
||||||
|
terms.push({ key: 'PUNCT_BREAK', delta, detail: { tcPunctuations: Array.from(puncts) } });
|
||||||
|
score += delta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 10.1-5 Shift/Accum/Self 断点奖励(含 EN 行首/行尾感知)
|
||||||
|
// ------------------------------
|
||||||
|
{
|
||||||
|
const shiftSet = buildSet(input.lang === 'EN' ? lexicons.shiftWordsEN : lexicons.shiftWordsTC, input.lang);
|
||||||
|
const accumSet = buildSet(input.lang === 'EN' ? lexicons.accumWordsEN : lexicons.accumWordsTC, input.lang);
|
||||||
|
const selfSet = buildSet(input.lang === 'EN' ? lexicons.selfWordsEN : lexicons.selfWordsTC, input.lang);
|
||||||
|
const emotionSet = buildSet(input.lang === 'EN' ? lexicons.emotionWordsEN : lexicons.emotionWordsTC, input.lang);
|
||||||
|
|
||||||
|
const emotionTailByLine = computeEmotionTailFlags(tokens, lines, input.lang, emotionSet);
|
||||||
|
|
||||||
|
// EmotionWord 落点强化(默认权重为 0,不影响结果;但用于实现 10.2G 的“Accum 减半”规则)
|
||||||
|
let emotionTailDelta = 0;
|
||||||
|
for (let i = 0; i < emotionTailByLine.length; i++) {
|
||||||
|
if (emotionTailByLine[i]) emotionTailDelta += asInt(weights.R_EMOTION_TAIL);
|
||||||
|
}
|
||||||
|
if (emotionTailDelta !== 0) {
|
||||||
|
terms.push({ key: 'EMOTION_TAIL', delta: emotionTailDelta });
|
||||||
|
score += emotionTailDelta;
|
||||||
|
}
|
||||||
|
|
||||||
|
let shiftDelta = 0;
|
||||||
|
let accumDelta = 0;
|
||||||
|
let selfDelta = 0;
|
||||||
|
|
||||||
|
// 为 Accum 减半规则准备:把每个 break 映射到其左右行索引
|
||||||
|
// 假设 lines 与 breaks 一一对应:breaks[i] 分隔 lines[i] 与 lines[i+1]
|
||||||
|
for (let i = 0; i < breaks.length; i++) {
|
||||||
|
const b = breaks[i]!;
|
||||||
|
const prevLast = tokens[b - 1]?.text ?? '';
|
||||||
|
const nextFirst = tokens[b]?.text ?? '';
|
||||||
|
|
||||||
|
const hitShift = tokenHitSet(nextFirst, shiftSet, input.lang) || tokenHitSet(prevLast, shiftSet, input.lang);
|
||||||
|
const hitAccum = tokenHitSet(nextFirst, accumSet, input.lang) || tokenHitSet(prevLast, accumSet, input.lang);
|
||||||
|
const hitSelf = tokenHitSet(nextFirst, selfSet, input.lang) || tokenHitSet(prevLast, selfSet, input.lang);
|
||||||
|
|
||||||
|
if (hitShift) shiftDelta += asInt(weights.R_SHIFT_BREAK);
|
||||||
|
if (hitSelf) selfDelta += asInt(weights.R_SELF_BREAK);
|
||||||
|
|
||||||
|
if (hitAccum) {
|
||||||
|
// 10.2G:若同一行(或同一断点)同时满足 EmotionWord 强化与 Accum 奖励 -> Accum 减半
|
||||||
|
const leftLineIdx = i;
|
||||||
|
const rightLineIdx = i + 1;
|
||||||
|
const emotionHitNearby = Boolean(emotionTailByLine[leftLineIdx]) || Boolean(emotionTailByLine[rightLineIdx]);
|
||||||
|
const base = asInt(weights.R_ACCUM_BREAK);
|
||||||
|
const reward = emotionHitNearby ? Math.floor(base / 2) : base;
|
||||||
|
accumDelta += reward;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shiftDelta !== 0) {
|
||||||
|
terms.push({ key: 'SHIFT_BREAK', delta: shiftDelta });
|
||||||
|
score += shiftDelta;
|
||||||
|
}
|
||||||
|
if (accumDelta !== 0) {
|
||||||
|
terms.push({ key: 'ACCUM_BREAK', delta: accumDelta });
|
||||||
|
score += accumDelta;
|
||||||
|
}
|
||||||
|
if (selfDelta !== 0) {
|
||||||
|
terms.push({ key: 'SELF_BREAK', delta: selfDelta });
|
||||||
|
score += selfDelta;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// 10.1-6 多行视觉均衡(尾行过短惩罚)
|
||||||
|
// ------------------------------
|
||||||
|
{
|
||||||
|
const lastLine = lines[lines.length - 1];
|
||||||
|
if (lastLine) {
|
||||||
|
const idealBp = ratioToBp(input.config.idealWidthRatio[input.context]);
|
||||||
|
const idealWidth = roundToInt((input.availableWidth * idealBp) / 1000);
|
||||||
|
const shortRatio = input.config.shortLastLineRatio ?? 0.5;
|
||||||
|
const shortThreshold = roundToInt((idealWidth * ratioToBp(shortRatio)) / 1000);
|
||||||
|
|
||||||
|
const lastW = lineWidthOrApprox(lastLine, input.lang);
|
||||||
|
if (lastW < shortThreshold) {
|
||||||
|
const d = -asInt(weights.P_SHORT_LASTLINE);
|
||||||
|
terms.push({ key: 'SHORT_LASTLINE', delta: d, detail: { shortThreshold, lastW, idealWidth } });
|
||||||
|
score += d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const scoreBreakdown = input.debug ? { total: score, terms: limitBreakdownForDebug(terms) } : undefined;
|
||||||
|
|
||||||
|
// tieKey 在 tieKey.ts 单独构造(此处返回占位,调用方可覆盖)
|
||||||
|
const tieKey: Array<number | string> = [
|
||||||
|
flags.emotionSplit ? 1 : 0,
|
||||||
|
flags.overflowed ? 1 : 0,
|
||||||
|
// lastLineWidth/spread/idealDistance 等由 buildTieKey 计算;这里仅保证结构存在
|
||||||
|
];
|
||||||
|
|
||||||
|
return { score, flags, tieKey, scoreBreakdown };
|
||||||
|
}
|
||||||
|
|
||||||
96
client/src/features/textWrap/scoring/tieKey.ts
Normal file
96
client/src/features/textWrap/scoring/tieKey.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import type { Lang } from '../core/types';
|
||||||
|
|
||||||
|
import type { LayoutCandidate, LayoutCandidateLine, ScoredLayout, TextWrapContext } from './types';
|
||||||
|
|
||||||
|
function asInt(n: number): number {
|
||||||
|
return n | 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundToInt(n: number): number {
|
||||||
|
return Math.round(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineWidthOrApprox(line: LayoutCandidateLine, lang: Lang): number {
|
||||||
|
if (typeof line.width === 'number' && Number.isFinite(line.width)) return roundToInt(line.width);
|
||||||
|
return lang === 'EN' ? asInt(line.tokenCount) : asInt(line.charCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeIdealBreakPositions(nTokens: number, lineCount: number): number[] {
|
||||||
|
const n = Math.max(0, nTokens | 0);
|
||||||
|
const targetLines = Math.max(1, Math.min(lineCount | 0, n === 0 ? 1 : n));
|
||||||
|
const ideals: number[] = [];
|
||||||
|
|
||||||
|
for (let i = 1; i <= targetLines - 1; i++) {
|
||||||
|
ideals.push(Math.round((n * i) / targetLines));
|
||||||
|
}
|
||||||
|
|
||||||
|
ideals.sort((a, b) => a - b);
|
||||||
|
return ideals.filter((v, idx) => idx === 0 || v !== ideals[idx - 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumAbs(a: number[], b: number[]): number {
|
||||||
|
const m = Math.min(a.length, b.length);
|
||||||
|
let s = 0;
|
||||||
|
for (let i = 0; i < m; i++) s += Math.abs((a[i] ?? 0) - (b[i] ?? 0));
|
||||||
|
// 若长度不等,用一个确定性惩罚补齐(避免 NaN,并让更“匹配理想结构”的更优)
|
||||||
|
if (a.length !== b.length) s += 100000 * Math.abs(a.length - b.length);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeSpread(widths: number[]): number {
|
||||||
|
if (widths.length === 0) return 0;
|
||||||
|
let min = widths[0]!;
|
||||||
|
let max = widths[0]!;
|
||||||
|
for (const w of widths) {
|
||||||
|
if (w < min) min = w;
|
||||||
|
if (w > max) max = w;
|
||||||
|
}
|
||||||
|
return max - min;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BuildTieKeyInput = {
|
||||||
|
scoredLayout: ScoredLayout;
|
||||||
|
layoutCandidate: LayoutCandidate;
|
||||||
|
lang: Lang;
|
||||||
|
context: TextWrapContext;
|
||||||
|
/** tokens.length(用于理想切分点距离;必须与 breaks/lines 的 token 体系一致) */
|
||||||
|
tokenCount: number;
|
||||||
|
/** 可用宽度(用于理想切分点/尾行宽度判断的辅助项;tieKey 主要使用 width 值) */
|
||||||
|
availableWidth: number;
|
||||||
|
idealWidthRatio: { APP: number; WIDGET: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构造 tieKey(按文档 11 节顺序,必须确定性):
|
||||||
|
* 1) emotionSplit=false 优先
|
||||||
|
* 2) overflowed=false 优先
|
||||||
|
* 3) lastLineWidth 更大优先
|
||||||
|
* 4) 行宽分布更均匀优先(max-min 更小)
|
||||||
|
* 5) 断点更接近理想切分点优先(距离之和更小)
|
||||||
|
* 6) breaks 字典序更靠前优先(在外部比较中按逐项数值比较即可)
|
||||||
|
*/
|
||||||
|
export function buildTieKey(args: BuildTieKeyInput): Array<number | string> {
|
||||||
|
const { scoredLayout, layoutCandidate } = args;
|
||||||
|
const lines = layoutCandidate.lines ?? [];
|
||||||
|
const breaks = (layoutCandidate.breaks ?? []).slice().sort((a, b) => a - b);
|
||||||
|
|
||||||
|
const widths = lines.map((l) => lineWidthOrApprox(l, args.lang));
|
||||||
|
const lastLineWidth = widths.length > 0 ? widths[widths.length - 1]! : 0;
|
||||||
|
const spread = computeSpread(widths);
|
||||||
|
|
||||||
|
const ideals = computeIdealBreakPositions(args.tokenCount, lines.length);
|
||||||
|
const idealDist = sumAbs(breaks, ideals);
|
||||||
|
|
||||||
|
const emotionSplitFlag = scoredLayout.flags.emotionSplit ? 1 : 0;
|
||||||
|
const overflowedFlag = scoredLayout.flags.overflowed ? 1 : 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
emotionSplitFlag,
|
||||||
|
overflowedFlag,
|
||||||
|
-lastLineWidth, // 更大优先 => 取负数实现“更小更优”
|
||||||
|
spread, // 更小更优
|
||||||
|
idealDist, // 更小更优
|
||||||
|
...breaks,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
92
client/src/features/textWrap/scoring/types.ts
Normal file
92
client/src/features/textWrap/scoring/types.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import type { Lang, Token } from '../core/types';
|
||||||
|
|
||||||
|
export type TextWrapContext = 'APP' | 'WIDGET';
|
||||||
|
|
||||||
|
export type LayoutCandidateLine = {
|
||||||
|
/**
|
||||||
|
* 该行对应的 token 区间(半开区间),单位与 `breaks` 一致:
|
||||||
|
* - start:包含
|
||||||
|
* - end:不包含
|
||||||
|
*/
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
/** 该行展示文本(用于 debug;评分以 token/width 为主) */
|
||||||
|
text: string;
|
||||||
|
/**
|
||||||
|
* 该行宽度(同一候选集必须同一单位)。
|
||||||
|
* - App:像素等真实测量单位
|
||||||
|
* - Widget approx:可使用近似单位(例如 wordCount/graphemeCount)
|
||||||
|
*/
|
||||||
|
width: number | null;
|
||||||
|
/** 该行 token 数(EN=词数;TC=grapheme 数) */
|
||||||
|
tokenCount: number;
|
||||||
|
/** 该行字符簇数(TC 使用;EN 可等于 tokenCount 或 0) */
|
||||||
|
charCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LayoutCandidate = {
|
||||||
|
/** 断点序列(token 边界索引),升序 */
|
||||||
|
breaks: number[];
|
||||||
|
/** 每行信息(lines.length = breaks.length + 1) */
|
||||||
|
lines: LayoutCandidateLine[];
|
||||||
|
/** 可选:外部模块(overflow/fallback)可透传的标记 */
|
||||||
|
meta?: { overflowed?: boolean; fallback?: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScoreTerm = { key: string; delta: number; detail?: any };
|
||||||
|
|
||||||
|
export type ScoreBreakdown = { total: number; terms: ScoreTerm[] };
|
||||||
|
|
||||||
|
export type ScoredLayout = {
|
||||||
|
score: number;
|
||||||
|
flags: { emotionSplit?: boolean; overflowed?: boolean; fallback?: boolean };
|
||||||
|
tieKey: Array<number | string>;
|
||||||
|
scoreBreakdown?: ScoreBreakdown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Weights = Record<string, number>;
|
||||||
|
|
||||||
|
export type Lexicons = {
|
||||||
|
emotionPhrasesTC: string[];
|
||||||
|
emotionPhrasesEN: string[];
|
||||||
|
protectedPhrases?: string[];
|
||||||
|
shiftWordsTC: string[];
|
||||||
|
shiftWordsEN: string[];
|
||||||
|
accumWordsTC: string[];
|
||||||
|
accumWordsEN: string[];
|
||||||
|
selfWordsTC: string[];
|
||||||
|
selfWordsEN: string[];
|
||||||
|
emotionWordsTC?: string[];
|
||||||
|
emotionWordsEN?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScoringConfig = {
|
||||||
|
weights: Weights;
|
||||||
|
idealWidthRatio: { APP: number; WIDGET: number };
|
||||||
|
ellipsisToken: string;
|
||||||
|
/** TC 标点集合(用于 PUNCT_BREAK 奖励与行首标点检测) */
|
||||||
|
tcPunctuations?: string[];
|
||||||
|
/** TC 助词集合(用于 PARTICLE_ISO) */
|
||||||
|
tcParticles?: string[];
|
||||||
|
/** TC 语尾语助词白名单:触发 PARTICLE_ISO 时惩罚减半 */
|
||||||
|
tcParticleWhitelist: string[];
|
||||||
|
/** Widow 短词阈值(EN):len(word) <= widowMaxLen(默认 3) */
|
||||||
|
widowMaxLen?: number;
|
||||||
|
/** 过短阈值:minPreferred = idealWidth * minPreferredRatio(默认 0.6) */
|
||||||
|
minPreferredRatio?: number;
|
||||||
|
/** 尾行过短阈值:shortLastLine = idealWidth * shortLastLineRatio(默认 0.5) */
|
||||||
|
shortLastLineRatio?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScoreLayoutInput = {
|
||||||
|
tokens: Token[];
|
||||||
|
layoutCandidate: LayoutCandidate;
|
||||||
|
lang: Lang;
|
||||||
|
context: TextWrapContext;
|
||||||
|
/** 可用宽度(同 width 单位),必须由上游提供 */
|
||||||
|
availableWidth: number;
|
||||||
|
config: ScoringConfig;
|
||||||
|
lexicons: Lexicons;
|
||||||
|
debug?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
35
client/src/features/textWrap/scoring/weights.ts
Normal file
35
client/src/features/textWrap/scoring/weights.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import type { Weights } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* scoring-tiebreak 默认权重(首版写死客户端)
|
||||||
|
*
|
||||||
|
* 来源:`设计说明文档/文档换行算法.md v1.2.1` 10.3
|
||||||
|
*/
|
||||||
|
export const DEFAULT_WEIGHTS = Object.freeze({
|
||||||
|
P_EMOTION_SPLIT: 10000,
|
||||||
|
P_PROTECTED_SPLIT: 10000,
|
||||||
|
P_WIDOW_WORD: 800,
|
||||||
|
P_WIDOW_LINE: 500,
|
||||||
|
P_SHORT_LASTLINE: 300,
|
||||||
|
P_PARTICLE_ISO: 200,
|
||||||
|
R_PUNCT_BREAK: 80,
|
||||||
|
R_SHIFT_BREAK: 60,
|
||||||
|
R_ACCUM_BREAK: 40,
|
||||||
|
R_SELF_BREAK: 20,
|
||||||
|
P_OVER_MAXLEN: 30,
|
||||||
|
P_TOO_SHORT: 10,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预留:情绪词落点强化(文档 7.2 / 10.2G 提到)
|
||||||
|
* - 为避免偏离 10.3 首版权重,本实现默认置 0(不影响结果)
|
||||||
|
* - 若后续需要启用,可通过 overrides 提供非 0 权重,并用 configVersion 管理
|
||||||
|
*/
|
||||||
|
R_EMOTION_TAIL: 0,
|
||||||
|
P_EMOTION_BURIED: 0,
|
||||||
|
} satisfies Weights);
|
||||||
|
|
||||||
|
export function mergeWeights(overrides?: Partial<Weights> | null | undefined): Weights {
|
||||||
|
if (!overrides) return { ...DEFAULT_WEIGHTS };
|
||||||
|
return { ...DEFAULT_WEIGHTS, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { MeasureWidthImpl } from '../../measure/types';
|
||||||
|
import { searchBestLayoutApp } from '../index';
|
||||||
|
import { DEFAULT_LEXICONS, DEFAULT_WEIGHTS } from '../../scoring/index';
|
||||||
|
|
||||||
|
function t(text: string, start: number) {
|
||||||
|
return { text, start, end: start + text.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('textWrap search-engine-app', () => {
|
||||||
|
it('确定性:同输入多次调用 breaks/lines 必须一致', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
const input = {
|
||||||
|
tokens: [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)],
|
||||||
|
lang: 'EN' as const,
|
||||||
|
breakpoints: [
|
||||||
|
{ pos: 1, kind: 'SPACE', priority: 10 },
|
||||||
|
{ pos: 2, kind: 'SPACE', priority: 10 },
|
||||||
|
{ pos: 3, kind: 'SPACE', priority: 10 },
|
||||||
|
],
|
||||||
|
availableWidth: 6,
|
||||||
|
maxLines: 2,
|
||||||
|
lineMode: 'AUTO' as const,
|
||||||
|
measure: {
|
||||||
|
contextProfile: 'APP|ios|test',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
},
|
||||||
|
scoring: {
|
||||||
|
config: {
|
||||||
|
weights: DEFAULT_WEIGHTS,
|
||||||
|
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
|
||||||
|
ellipsisToken: '…',
|
||||||
|
tcParticleWhitelist: [],
|
||||||
|
},
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
debug: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const a = await searchBestLayoutApp(input as any);
|
||||||
|
const b = await searchBestLayoutApp(input as any);
|
||||||
|
const c = await searchBestLayoutApp(input as any);
|
||||||
|
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
expect(b).toEqual(c);
|
||||||
|
expect(impl).toHaveBeenCalled(); // 有测量发生
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lineMode=FIXED:无解必须返回 NO_CANDIDATE', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
const res = await searchBestLayoutApp({
|
||||||
|
tokens: [t('I', 0), t('am', 2)],
|
||||||
|
lang: 'EN',
|
||||||
|
breakpoints: [{ pos: 1, kind: 'SPACE', priority: 10 }],
|
||||||
|
availableWidth: 100,
|
||||||
|
maxLines: 3,
|
||||||
|
lineMode: 'FIXED',
|
||||||
|
measure: {
|
||||||
|
contextProfile: 'APP|ios|fixed',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
},
|
||||||
|
scoring: {
|
||||||
|
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC H6:断点导致下一行行首为标点时必须禁止', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
const res = await searchBestLayoutApp({
|
||||||
|
tokens: [t('我', 0), t('好', 1), t(',', 2), t('累', 3)],
|
||||||
|
lang: 'TC',
|
||||||
|
// 若选择 pos=2,则第二行行首 token 为 ','(应禁止)
|
||||||
|
breakpoints: [{ pos: 2, kind: 'PUNCT', priority: 30 }],
|
||||||
|
availableWidth: 100,
|
||||||
|
maxLines: 2,
|
||||||
|
lineMode: 'FIXED',
|
||||||
|
measure: {
|
||||||
|
contextProfile: 'APP|ios|tc',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
},
|
||||||
|
scoring: {
|
||||||
|
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [], tcPunctuations: [','] },
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// lineMode=FIXED 且唯一断点被禁止,因此无解
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TOO_LONG:超过阈值直接返回 TOO_LONG', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
const tokens = Array.from({ length: 31 }).map((_, i) => t('a', i));
|
||||||
|
const res = await searchBestLayoutApp({
|
||||||
|
tokens,
|
||||||
|
lang: 'EN',
|
||||||
|
breakpoints: [],
|
||||||
|
availableWidth: 100,
|
||||||
|
maxLines: 2,
|
||||||
|
lineMode: 'AUTO',
|
||||||
|
measure: {
|
||||||
|
contextProfile: 'APP|ios|toolong',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
},
|
||||||
|
scoring: {
|
||||||
|
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.reason).toBe('TOO_LONG');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
21
client/src/features/textWrap/searchApp/constraints.ts
Normal file
21
client/src/features/textWrap/searchApp/constraints.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
|
||||||
|
export const DEFAULT_TOO_LONG_THRESHOLDS = Object.freeze({ EN: 30, TC: 60 });
|
||||||
|
|
||||||
|
export function isTooLong(tokens: Token[], lang: 'TC' | 'EN', thresholds: { EN: number; TC: number }): boolean {
|
||||||
|
const n = Math.max(0, tokens.length | 0);
|
||||||
|
const limit = lang === 'EN' ? thresholds.EN : thresholds.TC;
|
||||||
|
return n > limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLineStartPunctTC(tokens: Token[], pos: number, tcPunctuations: string[]): boolean {
|
||||||
|
const p = pos | 0;
|
||||||
|
if (p <= 0) return false; // 第一行不受 H6 影响
|
||||||
|
const t = tokens[p]?.text ?? '';
|
||||||
|
return tcPunctuations.includes(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOverWidth(width: number, availableWidth: number): boolean {
|
||||||
|
return width > availableWidth;
|
||||||
|
}
|
||||||
|
|
||||||
221
client/src/features/textWrap/searchApp/dpTopK.ts
Normal file
221
client/src/features/textWrap/searchApp/dpTopK.ts
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
import type { Breakpoint } from '../breakpoints/types';
|
||||||
|
|
||||||
|
import { joinTokens } from '../core/joinTokens';
|
||||||
|
import { measureSliceWidthCached } from '../measure/measureSliceWidthCached';
|
||||||
|
import { buildTieKey, scoreLayout } from '../scoring/index';
|
||||||
|
|
||||||
|
import { DEFAULT_TOO_LONG_THRESHOLDS, isLineStartPunctTC, isOverWidth, isTooLong } from './constraints';
|
||||||
|
import { insertTopK } from './topK';
|
||||||
|
import type { BestLayout, PartialLayout, SearchAppConfig, SearchAppInput, SearchAppResult } from './types';
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: SearchAppConfig = {
|
||||||
|
topK: 10,
|
||||||
|
tooLongThresholds: DEFAULT_TOO_LONG_THRESHOLDS,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mergeConfig(overrides?: Partial<SearchAppConfig> | null): SearchAppConfig {
|
||||||
|
if (!overrides) return { ...DEFAULT_CONFIG };
|
||||||
|
return {
|
||||||
|
topK: overrides.topK ?? DEFAULT_CONFIG.topK,
|
||||||
|
tooLongThresholds: overrides.tooLongThresholds ?? DEFAULT_CONFIG.tooLongThresholds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueSortedPositions(bps: Breakpoint[], n: number): number[] {
|
||||||
|
const set = new Set<number>();
|
||||||
|
for (const b of bps) {
|
||||||
|
const p = b.pos | 0;
|
||||||
|
if (p >= 1 && p <= n - 1) set.add(p);
|
||||||
|
}
|
||||||
|
const arr = Array.from(set);
|
||||||
|
arr.sort((a, b) => a - b);
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawSeparatorsForLang(tokensLen: number, lang: 'TC' | 'EN'): string[] | undefined {
|
||||||
|
if (lang !== 'TC') return undefined;
|
||||||
|
// TC:默认拼接不插入额外空格;空格作为 token 自身出现
|
||||||
|
return Array.from({ length: Math.max(0, tokensLen) }, () => '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLineText(tokens: Token[], start: number, end: number, rawSeparators?: string[]): string {
|
||||||
|
return joinTokens(tokens, start, end, rawSeparators);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBestLayout(best: PartialLayout, debug?: boolean): BestLayout {
|
||||||
|
const lines = best.lines.map((l) => l.text);
|
||||||
|
const wrappedText = lines.join('\n');
|
||||||
|
const meta = debug
|
||||||
|
? { breaks: best.breaks, scoreTopTerms: best.scoreTopTerms, score: best.score }
|
||||||
|
: { breaks: best.breaks };
|
||||||
|
return { breaks: best.breaks, lines, wrappedText, meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchBestLayoutApp(input: SearchAppInput): Promise<SearchAppResult> {
|
||||||
|
const cfg = mergeConfig(input.config ?? null);
|
||||||
|
const tokens = input.tokens ?? [];
|
||||||
|
const lang = input.lang;
|
||||||
|
const n = tokens.length;
|
||||||
|
|
||||||
|
if (isTooLong(tokens, lang, cfg.tooLongThresholds)) {
|
||||||
|
return { ok: false, reason: 'TOO_LONG', meta: { tokenCount: n } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLines = Math.max(1, input.maxLines | 0);
|
||||||
|
const availableWidth = input.availableWidth;
|
||||||
|
|
||||||
|
const positions = uniqueSortedPositions(input.breakpoints ?? [], n);
|
||||||
|
const rawSep = rawSeparatorsForLang(tokens.length, lang);
|
||||||
|
|
||||||
|
// dp[pos][linesUsed] -> PartialLayout[]
|
||||||
|
const dp: PartialLayout[][][] = Array.from({ length: n + 1 }, () =>
|
||||||
|
Array.from({ length: maxLines + 1 }, () => [])
|
||||||
|
);
|
||||||
|
|
||||||
|
dp[0][0] = [
|
||||||
|
{
|
||||||
|
pos: 0,
|
||||||
|
breaks: [],
|
||||||
|
lines: [],
|
||||||
|
score: 0,
|
||||||
|
tieKey: [0, 0, 0, 0, 0], // 空布局占位,后续会用 buildTieKey 覆盖
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const tcPunctuations = input.scoring.config.tcPunctuations ?? [',', '。', '!', '?', ';', ':', '、'];
|
||||||
|
|
||||||
|
// DP 遍历顺序必须固定
|
||||||
|
for (let pos = 0; pos <= n; pos++) {
|
||||||
|
for (let linesUsed = 0; linesUsed <= maxLines - 1; linesUsed++) {
|
||||||
|
const states = dp[pos][linesUsed];
|
||||||
|
if (!states || states.length === 0) continue;
|
||||||
|
|
||||||
|
// 枚举 nextPos:breakpoints 中 >pos 的位置(升序)+ N
|
||||||
|
const nextList: number[] = [];
|
||||||
|
for (const p of positions) if (p > pos) nextList.push(p);
|
||||||
|
if (n > pos) nextList.push(n);
|
||||||
|
|
||||||
|
for (const state of states) {
|
||||||
|
for (const nextPos of nextList) {
|
||||||
|
if (nextPos <= pos) continue; // 禁止空行
|
||||||
|
|
||||||
|
// H6:TC 禁止“行首标点”(断点导致下一行第一个 token 为标点)
|
||||||
|
// 断点位置为 nextPos,因此要检查 tokens[nextPos]
|
||||||
|
if (lang === 'TC' && nextPos < n && isLineStartPunctTC(tokens, nextPos, tcPunctuations)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineText = buildLineText(tokens, pos, nextPos, rawSep);
|
||||||
|
|
||||||
|
const widthRes = await measureSliceWidthCached({
|
||||||
|
tokens,
|
||||||
|
start: pos,
|
||||||
|
end: nextPos,
|
||||||
|
context: 'APP',
|
||||||
|
contextProfile: input.measure.contextProfile,
|
||||||
|
fontSpec: input.measure.fontSpec,
|
||||||
|
measureWidthImpl: input.measure.measureWidthImpl,
|
||||||
|
rawSeparators: rawSep,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (widthRes.width === null) {
|
||||||
|
// App 搜索必须依赖宽度派;宽度不可用交给 overflow-fallback 统一处理
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
reason: widthRes.meta.reason === 'MEASURE_FAILED' ? 'MEASURE_FAILED' : 'WIDTH_UNKNOWN',
|
||||||
|
meta: { tokenCount: n },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineWidth = widthRes.width;
|
||||||
|
if (isOverWidth(lineWidth, availableWidth)) continue; // H1
|
||||||
|
|
||||||
|
const tokenCount = nextPos - pos;
|
||||||
|
const charCount = lang === 'TC' ? tokenCount : 0;
|
||||||
|
|
||||||
|
const newLine = {
|
||||||
|
start: pos,
|
||||||
|
end: nextPos,
|
||||||
|
text: lineText,
|
||||||
|
width: lineWidth,
|
||||||
|
tokenCount,
|
||||||
|
charCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newLines = state.lines.concat([newLine]);
|
||||||
|
const newBreaks = nextPos === n ? state.breaks : state.breaks.concat([nextPos]);
|
||||||
|
|
||||||
|
// 构造 layoutCandidate 并评分(为保证确定性,首版直接全量评分)
|
||||||
|
const layoutCandidate = { breaks: newBreaks, lines: newLines };
|
||||||
|
|
||||||
|
const scored = scoreLayout({
|
||||||
|
tokens,
|
||||||
|
layoutCandidate: layoutCandidate as any,
|
||||||
|
lang,
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth,
|
||||||
|
config: input.scoring.config,
|
||||||
|
lexicons: input.scoring.lexicons,
|
||||||
|
debug: Boolean(input.scoring.debug),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tieKey = buildTieKey({
|
||||||
|
scoredLayout: scored as any,
|
||||||
|
layoutCandidate: layoutCandidate as any,
|
||||||
|
lang,
|
||||||
|
context: 'APP',
|
||||||
|
tokenCount: n,
|
||||||
|
availableWidth,
|
||||||
|
idealWidthRatio: input.scoring.config.idealWidthRatio,
|
||||||
|
}) as number[];
|
||||||
|
|
||||||
|
const cand: PartialLayout = {
|
||||||
|
pos: nextPos,
|
||||||
|
breaks: newBreaks,
|
||||||
|
lines: newLines,
|
||||||
|
score: scored.score,
|
||||||
|
tieKey,
|
||||||
|
scoreTopTerms: scored.scoreBreakdown?.terms,
|
||||||
|
};
|
||||||
|
|
||||||
|
dp[nextPos][linesUsed + 1] = insertTopK(dp[nextPos][linesUsed + 1], cand, cfg.topK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 结束选择
|
||||||
|
const collect: PartialLayout[] = [];
|
||||||
|
if (input.lineMode === 'FIXED') {
|
||||||
|
collect.push(...dp[n][maxLines]);
|
||||||
|
} else {
|
||||||
|
for (let linesUsed = 1; linesUsed <= maxLines; linesUsed++) {
|
||||||
|
collect.push(...dp[n][linesUsed]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (collect.length === 0) {
|
||||||
|
return { ok: false, reason: 'NO_CANDIDATE', meta: { tokenCount: n } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// TopK 容器内本身已排序,但跨不同 linesUsed 需要再全局选最优
|
||||||
|
collect.sort((a, b) => {
|
||||||
|
// 复用 insertTopK 的 compare 规则(在 topK.ts 内部)会更好,但这里直接再排序一次确保稳定
|
||||||
|
if (a.score !== b.score) return b.score - a.score;
|
||||||
|
const na = a.tieKey;
|
||||||
|
const nb = b.tieKey;
|
||||||
|
const m = Math.min(na.length, nb.length);
|
||||||
|
for (let i = 0; i < m; i++) {
|
||||||
|
const av = na[i] ?? 0;
|
||||||
|
const bv = nb[i] ?? 0;
|
||||||
|
if (av < bv) return -1;
|
||||||
|
if (av > bv) return 1;
|
||||||
|
}
|
||||||
|
return na.length - nb.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const best = collect[0]!;
|
||||||
|
return { ok: true, bestLayout: buildBestLayout(best, Boolean(input.scoring.debug)) };
|
||||||
|
}
|
||||||
|
|
||||||
4
client/src/features/textWrap/searchApp/index.ts
Normal file
4
client/src/features/textWrap/searchApp/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export type { BestLayout, LineMode, SearchAppConfig, SearchAppInput, SearchAppResult, SearchFailureReason } from './types';
|
||||||
|
|
||||||
|
export { searchBestLayoutApp } from './dpTopK';
|
||||||
|
|
||||||
62
client/src/features/textWrap/searchApp/topK.ts
Normal file
62
client/src/features/textWrap/searchApp/topK.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type { PartialLayout } from './types';
|
||||||
|
import { compareBreaksLexicographically } from '../core/index';
|
||||||
|
|
||||||
|
function compareTieKey(a: number[], b: number[]): number {
|
||||||
|
const n = Math.min(a.length, b.length);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const av = a[i] ?? 0;
|
||||||
|
const bv = b[i] ?? 0;
|
||||||
|
if (av < bv) return -1;
|
||||||
|
if (av > bv) return 1;
|
||||||
|
}
|
||||||
|
if (a.length < b.length) return -1;
|
||||||
|
if (a.length > b.length) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TopK 排序(确定性):
|
||||||
|
* - score 越大越优
|
||||||
|
* - tieKey 越小越优(按 11 节构造的数值 key)
|
||||||
|
* - breaks 字典序更小者更优(11.0A)
|
||||||
|
*/
|
||||||
|
export function compareLayouts(a: PartialLayout, b: PartialLayout): number {
|
||||||
|
if (a.score !== b.score) return b.score - a.score;
|
||||||
|
const t = compareTieKey(a.tieKey, b.tieKey);
|
||||||
|
if (t !== 0) return t;
|
||||||
|
return compareBreaksLexicographically(a.breaks, b.breaks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function breaksKey(breaks: number[]): string {
|
||||||
|
// breaks 序列作为去重 key(确定性)
|
||||||
|
return breaks.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插入 TopK(去重 + 排序 + 截断)。
|
||||||
|
*
|
||||||
|
* 去重规则:
|
||||||
|
* - 同 breaks 仅保留最优(score 更高;若相同按 tieKey/breaks 比较)
|
||||||
|
*/
|
||||||
|
export function insertTopK(list: PartialLayout[], cand: PartialLayout, k: number): PartialLayout[] {
|
||||||
|
const K = Math.max(1, k | 0);
|
||||||
|
const out = list.slice();
|
||||||
|
|
||||||
|
const key = breaksKey(cand.breaks);
|
||||||
|
const idx = out.findIndex((x) => breaksKey(x.breaks) === key);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const existing = out[idx]!;
|
||||||
|
if (compareLayouts(cand, existing) < 0) {
|
||||||
|
// cand 更差,忽略
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
out[idx] = cand;
|
||||||
|
} else {
|
||||||
|
out.push(cand);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort(compareLayouts);
|
||||||
|
if (out.length > K) out.length = K;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
71
client/src/features/textWrap/searchApp/types.ts
Normal file
71
client/src/features/textWrap/searchApp/types.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
import type { Breakpoint } from '../breakpoints/types';
|
||||||
|
import type { ContextProfile, FontSpec, MeasureWidthImpl } from '../measure/types';
|
||||||
|
import type { Lexicons, ScoringConfig, ScoreTerm } from '../scoring/types';
|
||||||
|
|
||||||
|
export type LineMode = 'AUTO' | 'FIXED';
|
||||||
|
|
||||||
|
export type SearchAppConfig = {
|
||||||
|
/** TopK(文档建议 App=10) */
|
||||||
|
topK: number;
|
||||||
|
/** 超长阈值(用于 TOO_LONG) */
|
||||||
|
tooLongThresholds: { EN: number; TC: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchMeasureInput = {
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
fontSpec: Partial<FontSpec> | null | undefined;
|
||||||
|
measureWidthImpl?: MeasureWidthImpl;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchScoringInput = {
|
||||||
|
config: ScoringConfig;
|
||||||
|
lexicons: Lexicons;
|
||||||
|
debug?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchAppInput = {
|
||||||
|
tokens: Token[];
|
||||||
|
lang: 'TC' | 'EN';
|
||||||
|
breakpoints: Breakpoint[];
|
||||||
|
availableWidth: number;
|
||||||
|
maxLines: number;
|
||||||
|
lineMode: LineMode;
|
||||||
|
measure: SearchMeasureInput;
|
||||||
|
scoring: SearchScoringInput;
|
||||||
|
config?: Partial<SearchAppConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BestLayout = {
|
||||||
|
breaks: number[];
|
||||||
|
lines: string[];
|
||||||
|
wrappedText: string;
|
||||||
|
meta?: {
|
||||||
|
breaks: number[];
|
||||||
|
scoreTopTerms?: ScoreTerm[];
|
||||||
|
score?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchFailureReason = 'TOO_LONG' | 'WIDTH_UNKNOWN' | 'MEASURE_FAILED' | 'NO_CANDIDATE';
|
||||||
|
|
||||||
|
export type SearchAppResult =
|
||||||
|
| { ok: true; bestLayout: BestLayout }
|
||||||
|
| { ok: false; reason: SearchFailureReason; meta?: { tokenCount: number } };
|
||||||
|
|
||||||
|
export type PartialLayout = {
|
||||||
|
pos: number;
|
||||||
|
breaks: number[];
|
||||||
|
lines: Array<{
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
width: number;
|
||||||
|
tokenCount: number;
|
||||||
|
charCount: number;
|
||||||
|
}>;
|
||||||
|
score: number;
|
||||||
|
tieKey: number[];
|
||||||
|
scoreTopTerms?: ScoreTerm[];
|
||||||
|
};
|
||||||
|
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import type { MeasureWidthImpl } from '../../measure/types';
|
||||||
|
import { DEFAULT_LEXICONS, DEFAULT_WEIGHTS } from '../../scoring/index';
|
||||||
|
import { searchBestLayoutWidget } from '../index';
|
||||||
|
|
||||||
|
function t(text: string, start: number) {
|
||||||
|
return { text, start, end: start + text.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('textWrap search-engine-widget', () => {
|
||||||
|
it('确定性:同输入多次调用 breaks/lines 必须一致', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
const input = {
|
||||||
|
tokens: [t('I', 0), t('am', 2), t('so', 5), t('tired', 8)],
|
||||||
|
lang: 'EN' as const,
|
||||||
|
breakpoints: [
|
||||||
|
{ pos: 1, kind: 'SPACE', priority: 10 },
|
||||||
|
{ pos: 2, kind: 'SPACE', priority: 10 },
|
||||||
|
{ pos: 3, kind: 'SPACE', priority: 10 },
|
||||||
|
],
|
||||||
|
availableWidth: 6,
|
||||||
|
maxLines: 2,
|
||||||
|
context: 'WIDGET' as const,
|
||||||
|
measure: {
|
||||||
|
widthMode: 'MEASURE' as const,
|
||||||
|
contextProfile: 'WIDGET|small|test',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
widgetEnableMeasure: true,
|
||||||
|
},
|
||||||
|
scoring: {
|
||||||
|
config: {
|
||||||
|
weights: DEFAULT_WEIGHTS,
|
||||||
|
idealWidthRatio: { APP: 0.9, WIDGET: 0.95 },
|
||||||
|
ellipsisToken: '…',
|
||||||
|
tcParticleWhitelist: [],
|
||||||
|
},
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
debug: true,
|
||||||
|
},
|
||||||
|
config: { beamK: 5, expandM: 12 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const a = await searchBestLayoutWidget(input as any);
|
||||||
|
const b = await searchBestLayoutWidget(input as any);
|
||||||
|
const c = await searchBestLayoutWidget(input as any);
|
||||||
|
|
||||||
|
expect(a).toEqual(b);
|
||||||
|
expect(b).toEqual(c);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('widthMode=APPROX:仍可输出,并标记 meta.reason=WIDTH_UNKNOWN', async () => {
|
||||||
|
const res = await searchBestLayoutWidget({
|
||||||
|
tokens: [t('我', 0), t('好', 1), t('累', 2)],
|
||||||
|
lang: 'TC',
|
||||||
|
breakpoints: [{ pos: 1, kind: 'BALANCE', priority: 5 }],
|
||||||
|
// APPROX 模式下:availableWidth 与 width 单位都按“token 数”理解
|
||||||
|
availableWidth: 10,
|
||||||
|
maxLines: 2,
|
||||||
|
context: 'WIDGET',
|
||||||
|
measure: { widthMode: 'APPROX' },
|
||||||
|
scoring: {
|
||||||
|
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
debug: true,
|
||||||
|
},
|
||||||
|
config: { beamK: 3, expandM: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
if (res.ok) {
|
||||||
|
expect(res.bestLayout.meta?.reason).toBe('WIDTH_UNKNOWN');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('性能约束:expandM/beamK 生效(通过测量调用次数粗略验证)', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
const res = await searchBestLayoutWidget({
|
||||||
|
tokens: [t('a', 0), t('b', 2), t('c', 4), t('d', 6), t('e', 8)],
|
||||||
|
lang: 'EN',
|
||||||
|
breakpoints: [
|
||||||
|
{ pos: 1, kind: 'SPACE', priority: 10 },
|
||||||
|
{ pos: 2, kind: 'SPACE', priority: 10 },
|
||||||
|
{ pos: 3, kind: 'SPACE', priority: 10 },
|
||||||
|
{ pos: 4, kind: 'SPACE', priority: 10 },
|
||||||
|
],
|
||||||
|
availableWidth: 100,
|
||||||
|
maxLines: 3,
|
||||||
|
context: 'WIDGET',
|
||||||
|
measure: {
|
||||||
|
widthMode: 'MEASURE',
|
||||||
|
contextProfile: 'WIDGET|perf',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
widgetEnableMeasure: true,
|
||||||
|
},
|
||||||
|
scoring: {
|
||||||
|
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [] },
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
},
|
||||||
|
config: { beamK: 2, expandM: 2 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
// 每轮最多 beamK 个 beam,每个 beam 最多 expandM 次扩展 -> 粗略上限:maxLines*beamK*expandM
|
||||||
|
expect((impl as any).mock.calls.length).toBeLessThanOrEqual(3 * 2 * 2 + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('TC H6:行首标点导致的转移必须被禁止', async () => {
|
||||||
|
const impl: MeasureWidthImpl = vi.fn(async ({ text }) => text.length);
|
||||||
|
const res = await searchBestLayoutWidget({
|
||||||
|
tokens: [t('我', 0), t('好', 1), t(',', 2), t('累', 3)],
|
||||||
|
lang: 'TC',
|
||||||
|
breakpoints: [{ pos: 2, kind: 'PUNCT', priority: 30 }],
|
||||||
|
// 让“一行放下全文”不可能(触发超宽过滤),从而必须依赖断点切分
|
||||||
|
availableWidth: 3,
|
||||||
|
maxLines: 2,
|
||||||
|
context: 'WIDGET',
|
||||||
|
measure: {
|
||||||
|
widthMode: 'MEASURE',
|
||||||
|
contextProfile: 'WIDGET|tc',
|
||||||
|
fontSpec: { fontFamily: 'PingFangSC-Regular', fontWeight: '400', fontSize: 16 },
|
||||||
|
measureWidthImpl: impl,
|
||||||
|
widgetEnableMeasure: true,
|
||||||
|
},
|
||||||
|
scoring: {
|
||||||
|
config: { weights: DEFAULT_WEIGHTS, idealWidthRatio: { APP: 0.9, WIDGET: 0.95 }, ellipsisToken: '…', tcParticleWhitelist: [], tcPunctuations: [','] },
|
||||||
|
lexicons: DEFAULT_LEXICONS,
|
||||||
|
},
|
||||||
|
config: { beamK: 3, expandM: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.ok).toBe(false);
|
||||||
|
if (!res.ok) expect(res.reason).toBe('NO_CANDIDATE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
263
client/src/features/textWrap/searchWidget/beam.ts
Normal file
263
client/src/features/textWrap/searchWidget/beam.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
import type { Breakpoint } from '../breakpoints/types';
|
||||||
|
|
||||||
|
import { joinTokens } from '../core/joinTokens';
|
||||||
|
import { measureSliceWidthCached } from '../measure/measureSliceWidthCached';
|
||||||
|
import { buildTieKey, scoreLayout } from '../scoring/index';
|
||||||
|
|
||||||
|
import { approxWidth, DEFAULT_TOO_LONG_THRESHOLDS, isLineStartPunctTC, isTooLong } from './constraints';
|
||||||
|
import { insertBeamTopK } from './topK';
|
||||||
|
import type { BestWidgetLayout, SearchWidgetConfig, SearchWidgetInput, SearchWidgetResult, WidgetBeam } from './types';
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: SearchWidgetConfig = {
|
||||||
|
beamK: 5,
|
||||||
|
expandM: 12,
|
||||||
|
tooLongThresholds: DEFAULT_TOO_LONG_THRESHOLDS,
|
||||||
|
};
|
||||||
|
|
||||||
|
function mergeConfig(overrides?: Partial<SearchWidgetConfig> | null): SearchWidgetConfig {
|
||||||
|
if (!overrides) return { ...DEFAULT_CONFIG };
|
||||||
|
return {
|
||||||
|
beamK: overrides.beamK ?? DEFAULT_CONFIG.beamK,
|
||||||
|
expandM: overrides.expandM ?? DEFAULT_CONFIG.expandM,
|
||||||
|
tooLongThresholds: overrides.tooLongThresholds ?? DEFAULT_CONFIG.tooLongThresholds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueSortedPositions(bps: Breakpoint[], n: number): number[] {
|
||||||
|
const set = new Set<number>();
|
||||||
|
for (const b of bps) {
|
||||||
|
const p = b.pos | 0;
|
||||||
|
if (p >= 1 && p <= n - 1) set.add(p);
|
||||||
|
}
|
||||||
|
const arr = Array.from(set);
|
||||||
|
arr.sort((a, b) => a - b);
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawSeparatorsForLang(tokensLen: number, lang: 'TC' | 'EN'): string[] | undefined {
|
||||||
|
if (lang !== 'TC') return undefined;
|
||||||
|
// TC:默认拼接不插入额外空格;空格 token 自己决定展示
|
||||||
|
return Array.from({ length: Math.max(0, tokensLen) }, () => '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLineText(tokens: Token[], start: number, end: number, rawSeparators?: string[]): string {
|
||||||
|
return joinTokens(tokens, start, end, rawSeparators);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeApproxReason(a?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED', b?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED') {
|
||||||
|
if (a === 'MEASURE_FAILED' || b === 'MEASURE_FAILED') return 'MEASURE_FAILED';
|
||||||
|
if (a === 'WIDTH_UNKNOWN' || b === 'WIDTH_UNKNOWN') return 'WIDTH_UNKNOWN';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBestLayout(best: WidgetBeam, debug?: boolean): BestWidgetLayout {
|
||||||
|
const lines = best.lines.map((l) => l.text);
|
||||||
|
const wrappedText = lines.join('\n');
|
||||||
|
|
||||||
|
const reason = best.lines.reduce<'WIDTH_UNKNOWN' | 'MEASURE_FAILED' | undefined>((acc, l) => {
|
||||||
|
return mergeApproxReason(acc, l.approxReason);
|
||||||
|
}, undefined);
|
||||||
|
|
||||||
|
const meta = debug
|
||||||
|
? { breaks: best.breaks, scoreTopTerms: best.scoreTopTerms, score: best.score, reason }
|
||||||
|
: { breaks: best.breaks, reason };
|
||||||
|
|
||||||
|
return { breaks: best.breaks, lines, wrappedText, meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchBestLayoutWidget(input: SearchWidgetInput): Promise<SearchWidgetResult> {
|
||||||
|
const cfg = mergeConfig(input.config ?? null);
|
||||||
|
const tokens = input.tokens ?? [];
|
||||||
|
const lang = input.lang;
|
||||||
|
const n = tokens.length;
|
||||||
|
|
||||||
|
if (isTooLong(tokens, lang, cfg.tooLongThresholds)) {
|
||||||
|
return { ok: false, reason: 'TOO_LONG', meta: { tokenCount: n } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxLines = Math.max(1, input.maxLines | 0);
|
||||||
|
const availableWidth = input.availableWidth;
|
||||||
|
|
||||||
|
const positions = uniqueSortedPositions(input.breakpoints ?? [], n);
|
||||||
|
const rawSep = rawSeparatorsForLang(tokens.length, lang);
|
||||||
|
|
||||||
|
const tcPunctuations = input.scoring.config.tcPunctuations ?? [',', '。', '!', '?', ';', ':', '、'];
|
||||||
|
|
||||||
|
// 初始 beams
|
||||||
|
let beams: WidgetBeam[] = [
|
||||||
|
{ pos: 0, breaks: [], lines: [], score: 0, tieKey: [0, 0, 0, 0, 0] },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (let lineIndex = 1; lineIndex <= maxLines; lineIndex++) {
|
||||||
|
let newBeams: WidgetBeam[] = [];
|
||||||
|
|
||||||
|
// 关键:保留已完成(pos==N)的 beams,避免“提前完成的解”在后续轮次被丢弃
|
||||||
|
// 否则会错误地倾向“凑满 maxLines 行”的解,造成断句很怪(例如把“村莊”拆开)
|
||||||
|
for (const b of beams) {
|
||||||
|
if (b.pos === n) {
|
||||||
|
newBeams = insertBeamTopK(newBeams, b, cfg.beamK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// beams 顺序固定:按当前排序后的顺序扩展
|
||||||
|
//(insertBeamTopK 会保持排序;这里再 sort 一次防御)
|
||||||
|
beams = beams.slice().sort((a, b) => {
|
||||||
|
if (a.score !== b.score) return b.score - a.score;
|
||||||
|
const na = a.tieKey;
|
||||||
|
const nb = b.tieKey;
|
||||||
|
const m = Math.min(na.length, nb.length);
|
||||||
|
for (let i = 0; i < m; i++) {
|
||||||
|
const av = na[i] ?? 0;
|
||||||
|
const bv = nb[i] ?? 0;
|
||||||
|
if (av < bv) return -1;
|
||||||
|
if (av > bv) return 1;
|
||||||
|
}
|
||||||
|
return na.length - nb.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const beam of beams) {
|
||||||
|
const pos = beam.pos;
|
||||||
|
if (pos >= n) continue;
|
||||||
|
|
||||||
|
// nextPos:pos 升序 + N
|
||||||
|
const nextList: number[] = [];
|
||||||
|
for (const p of positions) if (p > pos) nextList.push(p);
|
||||||
|
if (n > pos) nextList.push(n);
|
||||||
|
|
||||||
|
// expandM 裁剪:只取前 M 个(确定性:按 pos 升序),但必须保证 N(结束边界)始终可选
|
||||||
|
// 否则会出现“明明一行就能结束,却被裁剪掉只能继续拆分”的怪异换行。
|
||||||
|
const M = Math.max(1, cfg.expandM | 0);
|
||||||
|
let trimmed: number[];
|
||||||
|
if (M === 1) {
|
||||||
|
trimmed = [n];
|
||||||
|
} else {
|
||||||
|
const withoutN = nextList.filter((x) => x !== n);
|
||||||
|
trimmed = withoutN.slice(0, M - 1);
|
||||||
|
trimmed.push(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nextPos of trimmed) {
|
||||||
|
if (nextPos <= pos) continue; // 禁止空行
|
||||||
|
|
||||||
|
// H6:TC 禁止行首标点(断点导致下一行第一个 token 为标点)
|
||||||
|
if (lang === 'TC' && nextPos < n && isLineStartPunctTC(tokens, nextPos, tcPunctuations)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenCount = nextPos - pos;
|
||||||
|
const charCount = lang === 'TC' ? tokenCount : 0;
|
||||||
|
const lineText = buildLineText(tokens, pos, nextPos, rawSep);
|
||||||
|
|
||||||
|
let lineWidth: number;
|
||||||
|
let isApprox = false;
|
||||||
|
let approxReason: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED' | undefined;
|
||||||
|
let widthTrusted = false;
|
||||||
|
|
||||||
|
if (input.measure.widthMode === 'APPROX') {
|
||||||
|
isApprox = true;
|
||||||
|
approxReason = 'WIDTH_UNKNOWN';
|
||||||
|
lineWidth = approxWidth(tokenCount, charCount, lang);
|
||||||
|
} else {
|
||||||
|
const widgetEnableMeasure = input.measure.widgetEnableMeasure ?? true;
|
||||||
|
const res = await measureSliceWidthCached({
|
||||||
|
tokens,
|
||||||
|
start: pos,
|
||||||
|
end: nextPos,
|
||||||
|
context: 'WIDGET',
|
||||||
|
contextProfile: input.measure.contextProfile,
|
||||||
|
fontSpec: input.measure.fontSpec,
|
||||||
|
measureWidthImpl: input.measure.measureWidthImpl,
|
||||||
|
widgetEnableMeasure,
|
||||||
|
rawSeparators: rawSep,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.width === null) {
|
||||||
|
isApprox = true;
|
||||||
|
approxReason = res.meta.reason ?? 'WIDTH_UNKNOWN';
|
||||||
|
lineWidth = approxWidth(tokenCount, charCount, lang);
|
||||||
|
} else {
|
||||||
|
widthTrusted = true;
|
||||||
|
lineWidth = res.width;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 超宽过滤:仅当宽度可信时执行
|
||||||
|
if (widthTrusted && lineWidth > availableWidth) continue;
|
||||||
|
|
||||||
|
const newLine = {
|
||||||
|
start: pos,
|
||||||
|
end: nextPos,
|
||||||
|
text: lineText,
|
||||||
|
width: lineWidth,
|
||||||
|
tokenCount,
|
||||||
|
charCount,
|
||||||
|
isApprox,
|
||||||
|
approxReason,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newLines = beam.lines.concat([newLine]);
|
||||||
|
const newBreaks = nextPos === n ? beam.breaks : beam.breaks.concat([nextPos]);
|
||||||
|
const layoutCandidate = { breaks: newBreaks, lines: newLines.map(({ isApprox: _a, approxReason: _b, ...rest }) => rest) };
|
||||||
|
|
||||||
|
const scored = scoreLayout({
|
||||||
|
tokens,
|
||||||
|
layoutCandidate: layoutCandidate as any,
|
||||||
|
lang,
|
||||||
|
context: 'WIDGET',
|
||||||
|
availableWidth,
|
||||||
|
config: input.scoring.config,
|
||||||
|
lexicons: input.scoring.lexicons,
|
||||||
|
debug: Boolean(input.scoring.debug),
|
||||||
|
});
|
||||||
|
|
||||||
|
const tieKey = buildTieKey({
|
||||||
|
scoredLayout: scored as any,
|
||||||
|
layoutCandidate: layoutCandidate as any,
|
||||||
|
lang,
|
||||||
|
context: 'WIDGET',
|
||||||
|
tokenCount: n,
|
||||||
|
availableWidth,
|
||||||
|
idealWidthRatio: input.scoring.config.idealWidthRatio,
|
||||||
|
}) as number[];
|
||||||
|
|
||||||
|
const cand: WidgetBeam = {
|
||||||
|
pos: nextPos,
|
||||||
|
breaks: newBreaks,
|
||||||
|
lines: newLines,
|
||||||
|
score: scored.score,
|
||||||
|
tieKey,
|
||||||
|
scoreTopTerms: scored.scoreBreakdown?.terms,
|
||||||
|
};
|
||||||
|
|
||||||
|
newBeams = insertBeamTopK(newBeams, cand, cfg.beamK);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beams = newBeams;
|
||||||
|
if (beams.length === 0) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finished = beams.filter((b) => b.pos === n);
|
||||||
|
if (finished.length === 0) {
|
||||||
|
return { ok: false, reason: 'NO_CANDIDATE', meta: { tokenCount: n } };
|
||||||
|
}
|
||||||
|
|
||||||
|
finished.sort((a, b) => {
|
||||||
|
if (a.score !== b.score) return b.score - a.score;
|
||||||
|
const na = a.tieKey;
|
||||||
|
const nb = b.tieKey;
|
||||||
|
const m = Math.min(na.length, nb.length);
|
||||||
|
for (let i = 0; i < m; i++) {
|
||||||
|
const av = na[i] ?? 0;
|
||||||
|
const bv = nb[i] ?? 0;
|
||||||
|
if (av < bv) return -1;
|
||||||
|
if (av > bv) return 1;
|
||||||
|
}
|
||||||
|
return na.length - nb.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const best = finished[0]!;
|
||||||
|
return { ok: true, bestLayout: buildBestLayout(best, Boolean(input.scoring.debug)) };
|
||||||
|
}
|
||||||
|
|
||||||
21
client/src/features/textWrap/searchWidget/constraints.ts
Normal file
21
client/src/features/textWrap/searchWidget/constraints.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
|
||||||
|
export const DEFAULT_TOO_LONG_THRESHOLDS = Object.freeze({ EN: 30, TC: 60 });
|
||||||
|
|
||||||
|
export function isTooLong(tokens: Token[], lang: 'TC' | 'EN', thresholds: { EN: number; TC: number }): boolean {
|
||||||
|
const n = Math.max(0, tokens.length | 0);
|
||||||
|
const limit = lang === 'EN' ? thresholds.EN : thresholds.TC;
|
||||||
|
return n > limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLineStartPunctTC(tokens: Token[], nextPos: number, tcPunctuations: string[]): boolean {
|
||||||
|
const p = nextPos | 0;
|
||||||
|
if (p <= 0) return false;
|
||||||
|
const t = tokens[p]?.text ?? '';
|
||||||
|
return tcPunctuations.includes(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function approxWidth(tokenCount: number, charCount: number, lang: 'TC' | 'EN'): number {
|
||||||
|
return lang === 'EN' ? tokenCount : charCount;
|
||||||
|
}
|
||||||
|
|
||||||
13
client/src/features/textWrap/searchWidget/index.ts
Normal file
13
client/src/features/textWrap/searchWidget/index.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export type {
|
||||||
|
BestWidgetLayout,
|
||||||
|
SearchWidgetConfig,
|
||||||
|
SearchWidgetFailureReason,
|
||||||
|
SearchWidgetInput,
|
||||||
|
SearchWidgetMeasureInput,
|
||||||
|
SearchWidgetResult,
|
||||||
|
WidthMode,
|
||||||
|
WidgetBeam,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
export { searchBestLayoutWidget } from './beam';
|
||||||
|
|
||||||
52
client/src/features/textWrap/searchWidget/topK.ts
Normal file
52
client/src/features/textWrap/searchWidget/topK.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { compareBreaksLexicographically } from '../core/index';
|
||||||
|
import type { WidgetBeam } from './types';
|
||||||
|
|
||||||
|
function compareTieKey(a: number[], b: number[]): number {
|
||||||
|
const n = Math.min(a.length, b.length);
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const av = a[i] ?? 0;
|
||||||
|
const bv = b[i] ?? 0;
|
||||||
|
if (av < bv) return -1;
|
||||||
|
if (av > bv) return 1;
|
||||||
|
}
|
||||||
|
if (a.length < b.length) return -1;
|
||||||
|
if (a.length > b.length) return 1;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BeamK 排序(确定性):
|
||||||
|
* - score 越大越优
|
||||||
|
* - tieKey 越小越优
|
||||||
|
* - breaks 字典序更小者更优(11.0A)
|
||||||
|
*/
|
||||||
|
export function compareBeams(a: WidgetBeam, b: WidgetBeam): number {
|
||||||
|
if (a.score !== b.score) return b.score - a.score;
|
||||||
|
const t = compareTieKey(a.tieKey, b.tieKey);
|
||||||
|
if (t !== 0) return t;
|
||||||
|
return compareBreaksLexicographically(a.breaks, b.breaks);
|
||||||
|
}
|
||||||
|
|
||||||
|
function breaksKey(breaks: number[]): string {
|
||||||
|
return breaks.join(',');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function insertBeamTopK(list: WidgetBeam[], cand: WidgetBeam, k: number): WidgetBeam[] {
|
||||||
|
const K = Math.max(1, k | 0);
|
||||||
|
const out = list.slice();
|
||||||
|
|
||||||
|
const key = breaksKey(cand.breaks);
|
||||||
|
const idx = out.findIndex((x) => breaksKey(x.breaks) === key);
|
||||||
|
if (idx >= 0) {
|
||||||
|
const existing = out[idx]!;
|
||||||
|
if (compareBeams(cand, existing) < 0) return out;
|
||||||
|
out[idx] = cand;
|
||||||
|
} else {
|
||||||
|
out.push(cand);
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort(compareBeams);
|
||||||
|
if (out.length > K) out.length = K;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
91
client/src/features/textWrap/searchWidget/types.ts
Normal file
91
client/src/features/textWrap/searchWidget/types.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Token } from '../core/types';
|
||||||
|
import type { Breakpoint } from '../breakpoints/types';
|
||||||
|
import type { ContextProfile, FontSpec, MeasureWidthImpl } from '../measure/types';
|
||||||
|
import type { Lexicons, ScoringConfig, ScoreTerm } from '../scoring/types';
|
||||||
|
|
||||||
|
export type WidthMode = 'MEASURE' | 'APPROX';
|
||||||
|
|
||||||
|
export type SearchWidgetConfig = {
|
||||||
|
beamK: number;
|
||||||
|
expandM: number;
|
||||||
|
tooLongThresholds: { EN: number; TC: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchWidgetMeasureInput =
|
||||||
|
| { widthMode: 'APPROX' }
|
||||||
|
| {
|
||||||
|
widthMode: 'MEASURE';
|
||||||
|
contextProfile: ContextProfile;
|
||||||
|
fontSpec: Partial<FontSpec> | null | undefined;
|
||||||
|
measureWidthImpl?: MeasureWidthImpl;
|
||||||
|
/** Widget 是否启用测量;默认 true(本模块仅在明确 MEASURE 时开启) */
|
||||||
|
widgetEnableMeasure?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchWidgetScoringInput = {
|
||||||
|
config: ScoringConfig;
|
||||||
|
lexicons: Lexicons;
|
||||||
|
debug?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchWidgetInput = {
|
||||||
|
tokens: Token[];
|
||||||
|
lang: 'TC' | 'EN';
|
||||||
|
breakpoints: Breakpoint[];
|
||||||
|
/**
|
||||||
|
* 可用宽度(与 line.width 的单位必须一致):
|
||||||
|
* - MEASURE:像素等真实测量单位
|
||||||
|
* - APPROX:建议用“近似单位”(例如每行可容纳的 token 数)
|
||||||
|
*/
|
||||||
|
availableWidth: number;
|
||||||
|
maxLines: number;
|
||||||
|
context: 'WIDGET';
|
||||||
|
measure: SearchWidgetMeasureInput;
|
||||||
|
scoring: SearchWidgetScoringInput;
|
||||||
|
config?: Partial<SearchWidgetConfig>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetLayoutMeta = {
|
||||||
|
breaks: number[];
|
||||||
|
scoreTopTerms?: ScoreTerm[];
|
||||||
|
score?: number;
|
||||||
|
/**
|
||||||
|
* 仅当进入 approx/降级时填入,便于 integration/打点模块统一处理:
|
||||||
|
* - WIDTH_UNKNOWN:未启用测量或测量能力缺失
|
||||||
|
* - MEASURE_FAILED:测量抛错或返回非法
|
||||||
|
*/
|
||||||
|
reason?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BestWidgetLayout = {
|
||||||
|
breaks: number[];
|
||||||
|
lines: string[];
|
||||||
|
wrappedText: string;
|
||||||
|
meta?: WidgetLayoutMeta;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SearchWidgetFailureReason = 'TOO_LONG' | 'NO_CANDIDATE';
|
||||||
|
|
||||||
|
export type SearchWidgetResult =
|
||||||
|
| { ok: true; bestLayout: BestWidgetLayout }
|
||||||
|
| { ok: false; reason: SearchWidgetFailureReason; meta?: { tokenCount: number } };
|
||||||
|
|
||||||
|
export type WidgetBeam = {
|
||||||
|
pos: number;
|
||||||
|
breaks: number[];
|
||||||
|
lines: Array<{
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
width: number;
|
||||||
|
tokenCount: number;
|
||||||
|
charCount: number;
|
||||||
|
/** 该行是否为近似宽度(用于 meta.reason) */
|
||||||
|
isApprox: boolean;
|
||||||
|
approxReason?: 'WIDTH_UNKNOWN' | 'MEASURE_FAILED';
|
||||||
|
}>;
|
||||||
|
score: number;
|
||||||
|
tieKey: number[];
|
||||||
|
scoreTopTerms?: ScoreTerm[];
|
||||||
|
};
|
||||||
|
|
||||||
71
client/src/features/textWrap/types.ts
Normal file
71
client/src/features/textWrap/types.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import type { MeasureWidthImpl } from './measure/types';
|
||||||
|
import type { ScoreTerm } from './scoring/types';
|
||||||
|
import type { Weights } from './scoring/types';
|
||||||
|
|
||||||
|
export type OverflowMode = 'ELLIPSIS' | 'CLIP' | 'SYSTEM_DEFAULT';
|
||||||
|
export type LineMode = 'AUTO' | 'FIXED';
|
||||||
|
export type TextWrapContext = 'APP' | 'WIDGET';
|
||||||
|
export type Lang = 'TC' | 'EN';
|
||||||
|
|
||||||
|
export type WrapTextConstraints = {
|
||||||
|
protectedPhrases?: string[];
|
||||||
|
forbiddenBreakRanges?: Array<{ start: number; end: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FontSpecInput = {
|
||||||
|
fontSize: number;
|
||||||
|
fontFamily: string;
|
||||||
|
fontWeight: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WrapTextInput = {
|
||||||
|
text: string;
|
||||||
|
lang: Lang;
|
||||||
|
availableWidth: number;
|
||||||
|
maxLines: number;
|
||||||
|
context: TextWrapContext;
|
||||||
|
fontSpec?: Partial<FontSpecInput> | null;
|
||||||
|
/** 可选注入测量实现(APP 场景强烈建议提供;WIDGET 默认不启用) */
|
||||||
|
measureWidthImpl?: MeasureWidthImpl;
|
||||||
|
/**
|
||||||
|
* 可选评分偏好(用于 UI 场景“更好看”的排版风格)。
|
||||||
|
* - 不传:使用算法默认 v1 权重与口径(更贴近文档 10.3)
|
||||||
|
* - 传入:仅在本次 wrapText 调用内生效,不影响全局
|
||||||
|
*/
|
||||||
|
scoringOverrides?: {
|
||||||
|
/** 覆盖/微调默认权重(整数)。建议只改少数项,例如 TC 的标点断行奖励。 */
|
||||||
|
weights?: Partial<Weights>;
|
||||||
|
/** 覆盖理想行宽比例(0~1)。更小会更倾向“提前换行”。 */
|
||||||
|
idealWidthRatio?: Partial<Record<TextWrapContext, number>>;
|
||||||
|
/** 最短偏好比例(相对 idealWidth)。更小会更宽容“短行”。 */
|
||||||
|
minPreferredRatio?: number;
|
||||||
|
/** 末行过短惩罚阈值比例(相对 idealWidth)。更小会更宽容“短末行”。 */
|
||||||
|
shortLastLineRatio?: number;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 测量缓存隔离 key(可选)。
|
||||||
|
* 口径建议:`APP|ios|<scale?>` / `WIDGET|small`。
|
||||||
|
*/
|
||||||
|
contextProfile?: string;
|
||||||
|
overflowMode?: OverflowMode;
|
||||||
|
lineMode?: LineMode;
|
||||||
|
constraints?: WrapTextConstraints;
|
||||||
|
configVersion?: string;
|
||||||
|
debug?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WrapTextMeta = {
|
||||||
|
configVersion?: string;
|
||||||
|
fallback_type?: 'NONE' | 'RELAX_RULES' | 'SYSTEM_DEFAULT';
|
||||||
|
overflow_type?: 'NONE' | 'ELLIPSIS' | 'CLIP';
|
||||||
|
reason?: 'NO_CANDIDATE' | 'WIDTH_UNKNOWN' | 'WIDOW' | 'PARTICLE' | 'TOO_LONG' | string;
|
||||||
|
breaks?: number[];
|
||||||
|
scoreTopTerms?: ScoreTerm[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WrapTextOutput = {
|
||||||
|
lines: string[];
|
||||||
|
wrappedText: string;
|
||||||
|
meta?: WrapTextMeta;
|
||||||
|
};
|
||||||
|
|
||||||
185
client/src/features/textWrap/wrapText.ts
Normal file
185
client/src/features/textWrap/wrapText.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import type { Token } from './core/types';
|
||||||
|
import { normalizeWhitespace, tokenizeEN } from './core/index';
|
||||||
|
import { segmentGraphemes } from './grapheme/index';
|
||||||
|
import { generateBreakpoints } from './breakpoints/index';
|
||||||
|
import { DEFAULT_LEXICONS, DEFAULT_WEIGHTS } from './scoring/index';
|
||||||
|
import { mergeWeights } from './scoring/weights';
|
||||||
|
import { searchBestLayoutApp } from './searchApp/index';
|
||||||
|
import { searchBestLayoutWidget } from './searchWidget/index';
|
||||||
|
import { applyOverflowFallback } from './overflow/index';
|
||||||
|
|
||||||
|
import type { WrapTextInput, WrapTextMeta, WrapTextOutput } from './types';
|
||||||
|
|
||||||
|
function tokenizeTC(normalizedText: string): Token[] {
|
||||||
|
const { clusters } = segmentGraphemes(normalizedText, 'PREFERRED');
|
||||||
|
const tokens: Token[] = [];
|
||||||
|
let idx = 0;
|
||||||
|
for (const c of clusters) {
|
||||||
|
const start = idx;
|
||||||
|
idx += c.length;
|
||||||
|
tokens.push({ text: c, start, end: idx });
|
||||||
|
}
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function wrapText(input: WrapTextInput): Promise<WrapTextOutput> {
|
||||||
|
const lang = input.lang;
|
||||||
|
const context = input.context;
|
||||||
|
const configVersion = input.configVersion ?? 'v1';
|
||||||
|
const overflowMode: WrapTextInput['overflowMode'] =
|
||||||
|
input.overflowMode ?? (context === 'WIDGET' ? 'ELLIPSIS' : 'CLIP');
|
||||||
|
const lineMode = input.lineMode ?? 'AUTO';
|
||||||
|
const debug = Boolean(input.debug);
|
||||||
|
|
||||||
|
const { normalizedText } = normalizeWhitespace(input.text, 'NORMALIZE');
|
||||||
|
const tokens = lang === 'EN' ? tokenizeEN(normalizedText) : tokenizeTC(normalizedText);
|
||||||
|
|
||||||
|
// 断点候选
|
||||||
|
const { breakpoints } = generateBreakpoints({
|
||||||
|
tokens: tokens as any,
|
||||||
|
lang,
|
||||||
|
maxLines: input.maxLines,
|
||||||
|
constraints: input.constraints,
|
||||||
|
config: { tcMaxCandidateBreaks: 80, tcPunctuations: [',', '。', '!', '?', ';', ':', '、'], balanceRange: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// scoring:protectedPhrases 从 constraints 注入
|
||||||
|
const lexicons = { ...DEFAULT_LEXICONS, protectedPhrases: input.constraints?.protectedPhrases ?? DEFAULT_LEXICONS.protectedPhrases };
|
||||||
|
const tcPunctuations: string[] = [',', '。', '!', '?', ';', ':', '、'];
|
||||||
|
|
||||||
|
// 评分偏好(可选):用于 UI 侧“更好看”的排版风格微调
|
||||||
|
const scoringOverrides = input.scoringOverrides ?? null;
|
||||||
|
const weights = scoringOverrides?.weights ? mergeWeights(scoringOverrides.weights) : DEFAULT_WEIGHTS;
|
||||||
|
const idealWidthRatio = {
|
||||||
|
APP: scoringOverrides?.idealWidthRatio?.APP ?? 0.9,
|
||||||
|
WIDGET: scoringOverrides?.idealWidthRatio?.WIDGET ?? 0.95,
|
||||||
|
};
|
||||||
|
const scoringConfig = {
|
||||||
|
weights,
|
||||||
|
idealWidthRatio,
|
||||||
|
ellipsisToken: '…',
|
||||||
|
tcParticleWhitelist: [],
|
||||||
|
tcPunctuations,
|
||||||
|
// 下面两项默认由 score.ts 内部给出;此处仅在有 overrides 时注入
|
||||||
|
minPreferredRatio: scoringOverrides?.minPreferredRatio,
|
||||||
|
shortLastLineRatio: scoringOverrides?.shortLastLineRatio,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
if (context === 'APP') {
|
||||||
|
const res = await searchBestLayoutApp({
|
||||||
|
tokens,
|
||||||
|
lang,
|
||||||
|
breakpoints: breakpoints as any,
|
||||||
|
availableWidth: input.availableWidth,
|
||||||
|
maxLines: input.maxLines,
|
||||||
|
lineMode,
|
||||||
|
measure: {
|
||||||
|
contextProfile: input.contextProfile ?? 'APP',
|
||||||
|
fontSpec: input.fontSpec ?? null,
|
||||||
|
measureWidthImpl: input.measureWidthImpl,
|
||||||
|
},
|
||||||
|
scoring: { config: scoringConfig as any, lexicons: lexicons as any, debug },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const meta: WrapTextMeta = { configVersion, breaks: res.bestLayout.meta?.breaks, scoreTopTerms: res.bestLayout.meta?.scoreTopTerms };
|
||||||
|
return { lines: res.bestLayout.lines, wrappedText: res.bestLayout.wrappedText, meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
// APP:若测量失败/不可用,且调用方未要求 SYSTEM_DEFAULT,则尝试降级为“近似宽度 Beam”
|
||||||
|
// 目的:保证尽量产出带 \n 的 wrappedText(而不是完全依赖系统自动换行)
|
||||||
|
if (overflowMode !== 'SYSTEM_DEFAULT' && (res.reason === 'WIDTH_UNKNOWN' || res.reason === 'MEASURE_FAILED')) {
|
||||||
|
const fontSize = Number.isFinite((input.fontSpec as any)?.fontSize) ? Number((input.fontSpec as any)?.fontSize) : 22;
|
||||||
|
// 把像素宽度粗略换算为“token 容量”,只用于降级路径(确定性)
|
||||||
|
const approxCapacity =
|
||||||
|
lang === 'EN'
|
||||||
|
? Math.max(1, Math.floor(input.availableWidth / Math.max(1, Math.round(fontSize * 0.55))))
|
||||||
|
: Math.max(1, Math.floor(input.availableWidth / Math.max(1, Math.round(fontSize * 0.95))));
|
||||||
|
|
||||||
|
const approxRes = await searchBestLayoutWidget({
|
||||||
|
tokens,
|
||||||
|
lang,
|
||||||
|
breakpoints: breakpoints as any,
|
||||||
|
availableWidth: approxCapacity,
|
||||||
|
maxLines: input.maxLines,
|
||||||
|
context: 'WIDGET',
|
||||||
|
measure: { widthMode: 'APPROX' },
|
||||||
|
scoring: { config: scoringConfig as any, lexicons: lexicons as any, debug },
|
||||||
|
config: { beamK: 5, expandM: 12 },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (approxRes.ok) {
|
||||||
|
const meta: WrapTextMeta = {
|
||||||
|
configVersion,
|
||||||
|
breaks: approxRes.bestLayout.meta?.breaks,
|
||||||
|
scoreTopTerms: approxRes.bestLayout.meta?.scoreTopTerms,
|
||||||
|
reason: res.reason,
|
||||||
|
};
|
||||||
|
return { lines: approxRes.bestLayout.lines, wrappedText: approxRes.bestLayout.wrappedText, meta };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// overflow/fallback(最终兜底)
|
||||||
|
const fb = await applyOverflowFallback({
|
||||||
|
tokens,
|
||||||
|
lang,
|
||||||
|
context: 'APP',
|
||||||
|
availableWidth: input.availableWidth,
|
||||||
|
maxLines: input.maxLines,
|
||||||
|
overflowMode: overflowMode as any,
|
||||||
|
ellipsisToken: scoringConfig.ellipsisToken,
|
||||||
|
reason: res.reason,
|
||||||
|
partialLayout: null,
|
||||||
|
tcPunctuations: scoringConfig.tcPunctuations,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
lines: fb.lines,
|
||||||
|
wrappedText: fb.wrappedText,
|
||||||
|
meta: { configVersion, fallback_type: fb.meta.fallback_type, overflow_type: fb.meta.overflow_type, reason: fb.meta.reason },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// WIDGET:默认 APPROX(单位由 availableWidth 决定),可在上层选择 MEASURE 并注入测量能力
|
||||||
|
const resW = await searchBestLayoutWidget({
|
||||||
|
tokens,
|
||||||
|
lang,
|
||||||
|
breakpoints: breakpoints as any,
|
||||||
|
availableWidth: input.availableWidth,
|
||||||
|
maxLines: input.maxLines,
|
||||||
|
context: 'WIDGET',
|
||||||
|
measure: { widthMode: 'APPROX' },
|
||||||
|
scoring: { config: scoringConfig as any, lexicons: lexicons as any, debug },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resW.ok) {
|
||||||
|
const meta: WrapTextMeta = {
|
||||||
|
configVersion,
|
||||||
|
breaks: resW.bestLayout.meta?.breaks,
|
||||||
|
scoreTopTerms: resW.bestLayout.meta?.scoreTopTerms,
|
||||||
|
reason: resW.bestLayout.meta?.reason,
|
||||||
|
};
|
||||||
|
return { lines: resW.bestLayout.lines, wrappedText: resW.bestLayout.wrappedText, meta };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fb = await applyOverflowFallback({
|
||||||
|
tokens,
|
||||||
|
lang,
|
||||||
|
context: 'WIDGET',
|
||||||
|
availableWidth: input.availableWidth,
|
||||||
|
maxLines: input.maxLines,
|
||||||
|
overflowMode: overflowMode as any,
|
||||||
|
ellipsisToken: scoringConfig.ellipsisToken,
|
||||||
|
reason: resW.reason,
|
||||||
|
partialLayout: null,
|
||||||
|
tcPunctuations: scoringConfig.tcPunctuations,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
lines: fb.lines,
|
||||||
|
wrappedText: fb.wrappedText,
|
||||||
|
meta: { configVersion, fallback_type: fb.meta.fallback_type, overflow_type: fb.meta.overflow_type, reason: fb.meta.reason },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
"errorDesc": "It’s okay if enabling fails. You can keep using the app."
|
"errorDesc": "It’s okay if enabling fails. You can keep using the app."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Mindfulness",
|
"title": "Dear Mama",
|
||||||
"like": "Like",
|
"like": "Like",
|
||||||
"dislike": "Dislike",
|
"dislike": "Dislike",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
"homeScreen": "Home Screen Widget",
|
"homeScreen": "Home Screen Widget",
|
||||||
"howToTitle": "How to add the widget",
|
"howToTitle": "How to add the widget",
|
||||||
"howToDesc1": "Long-press on the Home Screen until the apps jiggle, then tap “+” in the top-left corner.",
|
"howToDesc1": "Long-press on the Home Screen until the apps jiggle, then tap “+” in the top-left corner.",
|
||||||
"howToDesc2": "Search “Mindfulness”, choose a widget size you like, then tap “Add Widget”.",
|
"howToDesc2": "Search “Dear Mama”, choose a widget size you like, then tap “Add Widget”.",
|
||||||
"previewDate": "Thu, Jan 29",
|
"previewDate": "Thu, Jan 29",
|
||||||
"previewQuote": "I’m proud of who I am, even while becoming who I want to be."
|
"previewQuote": "I’m proud of who I am, even while becoming who I want to be."
|
||||||
},
|
},
|
||||||
@@ -141,10 +141,10 @@
|
|||||||
"language": "Language",
|
"language": "Language",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"widgetTitle": "iOS Widget",
|
"widgetTitle": "iOS Widget",
|
||||||
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like."
|
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Dear Mama” → add a size you like."
|
||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"title": "Hey mama.",
|
"title": "Dear mama.",
|
||||||
"subtitle": "You’re doing okay\nright now.",
|
"subtitle": "You’re doing okay\nright now.",
|
||||||
"subtitleSecondary": "",
|
"subtitleSecondary": "",
|
||||||
"agree": "Agree & Continue",
|
"agree": "Agree & Continue",
|
||||||
@@ -260,7 +260,7 @@
|
|||||||
"errorDesc": "開啟失敗也沒關係,你仍然可以繼續使用應用。"
|
"errorDesc": "開啟失敗也沒關係,你仍然可以繼續使用應用。"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "正念",
|
"title": "Dear Mama",
|
||||||
"like": "喜歡",
|
"like": "喜歡",
|
||||||
"dislike": "不喜歡",
|
"dislike": "不喜歡",
|
||||||
"favorites": "收藏",
|
"favorites": "收藏",
|
||||||
@@ -297,9 +297,9 @@
|
|||||||
"widget": {
|
"widget": {
|
||||||
"lockScreen": "鎖屏小工具",
|
"lockScreen": "鎖屏小工具",
|
||||||
"homeScreen": "桌面小工具",
|
"homeScreen": "桌面小工具",
|
||||||
"howToTitle": "如何添加小工具",
|
"howToTitle": "如何加入小工具",
|
||||||
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
|
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
|
||||||
"howToDesc2": "搜尋「正念」,選擇喜歡的尺寸,點「加入小工具」。",
|
"howToDesc2": "搜尋「Dear Mama」,選擇喜歡的尺寸,點「加入小工具」。",
|
||||||
"previewDate": "1月29日週四 · 已至臘月十一",
|
"previewDate": "1月29日週四 · 已至臘月十一",
|
||||||
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
|
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
|
||||||
},
|
},
|
||||||
@@ -313,7 +313,7 @@
|
|||||||
"language": "語言",
|
"language": "語言",
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
"widgetTitle": "iOS 小工具",
|
"widgetTitle": "iOS 小工具",
|
||||||
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
|
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「Dear Mama」 → 添加你喜歡的尺寸。"
|
||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"title": "我們知道,",
|
"title": "我們知道,",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
"errorDesc": "It’s okay if enabling fails. You can keep using the app."
|
"errorDesc": "It’s okay if enabling fails. You can keep using the app."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Mindfulness",
|
"title": "Dear Mama",
|
||||||
"like": "Like",
|
"like": "Like",
|
||||||
"dislike": "Dislike",
|
"dislike": "Dislike",
|
||||||
"favorites": "Favorites",
|
"favorites": "Favorites",
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"language": "Language",
|
"language": "Language",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
"widgetTitle": "iOS Widget",
|
"widgetTitle": "iOS Widget",
|
||||||
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Mindfulness” → add a size you like."
|
"widgetDesc": "Put gentle reminders on your home screen: long-press → tap “+” → search “Dear Mama” → add a size you like."
|
||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"title": "You Are Perfect.",
|
"title": "You Are Perfect.",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"errorDesc": "No pasa nada si falla. Puedes seguir usando la app."
|
"errorDesc": "No pasa nada si falla. Puedes seguir usando la app."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Mindfulness",
|
"title": "Dear Mama",
|
||||||
"like": "Me gusta",
|
"like": "Me gusta",
|
||||||
"dislike": "No me gusta",
|
"dislike": "No me gusta",
|
||||||
"favorites": "Favoritos",
|
"favorites": "Favoritos",
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
"version": "Versión",
|
"version": "Versión",
|
||||||
"widgetTitle": "Widget de iOS",
|
"widgetTitle": "Widget de iOS",
|
||||||
"widgetDesc": "Pon recordatorios en tu pantalla: mantén pulsado → “+” → busca “Mindfulness” → añade el tamaño."
|
"widgetDesc": "Pon recordatorios en tu pantalla: mantén pulsado → “+” → busca “Dear Mama” → añade el tamaño."
|
||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"agree": "Aceptar y Continuar",
|
"agree": "Aceptar y Continuar",
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"errorDesc": "Tudo bem se falhar. Você pode continuar usando o app."
|
"errorDesc": "Tudo bem se falhar. Você pode continuar usando o app."
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "Mindfulness",
|
"title": "Dear Mama",
|
||||||
"like": "Curtir",
|
"like": "Curtir",
|
||||||
"dislike": "Não curtir",
|
"dislike": "Não curtir",
|
||||||
"favorites": "Favoritos",
|
"favorites": "Favoritos",
|
||||||
@@ -78,7 +78,7 @@
|
|||||||
"language": "Idioma",
|
"language": "Idioma",
|
||||||
"version": "Versão",
|
"version": "Versão",
|
||||||
"widgetTitle": "Widget do iOS",
|
"widgetTitle": "Widget do iOS",
|
||||||
"widgetDesc": "Coloque lembretes na tela inicial: pressione e segure → “+” → procure “Mindfulness” → adicione o tamanho."
|
"widgetDesc": "Coloque lembretes na tela inicial: pressione e segure → “+” → procure “Dear Mama” → adicione o tamanho."
|
||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"agree": "Concordar e Continuar",
|
"agree": "Concordar e Continuar",
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
"errorDesc": "开启失败,请稍后重试(模拟器可能无法获取推送 Token,建议用真机测试)。"
|
"errorDesc": "开启失败,请稍后重试(模拟器可能无法获取推送 Token,建议用真机测试)。"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "正念",
|
"title": "Dear Mama",
|
||||||
"like": "点赞",
|
"like": "点赞",
|
||||||
"dislike": "讨厌",
|
"dislike": "讨厌",
|
||||||
"favorites": "收藏",
|
"favorites": "收藏",
|
||||||
@@ -81,7 +81,7 @@
|
|||||||
"language": "语言",
|
"language": "语言",
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
"widgetTitle": "iOS 小组件",
|
"widgetTitle": "iOS 小组件",
|
||||||
"widgetDesc": "把温柔提醒放到桌面上:长按主屏幕 → 点“+” → 搜索“正念” → 添加你喜欢的尺寸。"
|
"widgetDesc": "把温柔提醒放到桌面上:长按主屏幕 → 点“+” → 搜索“Dear Mama” → 添加你喜欢的尺寸。"
|
||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"title": "你本就完美。",
|
"title": "你本就完美。",
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
"errorDesc": "開啟失敗也沒關係,你仍然可以繼續使用應用。"
|
"errorDesc": "開啟失敗也沒關係,你仍然可以繼續使用應用。"
|
||||||
},
|
},
|
||||||
"home": {
|
"home": {
|
||||||
"title": "正念",
|
"title": "Dear Mama",
|
||||||
"like": "喜歡",
|
"like": "喜歡",
|
||||||
"dislike": "不喜歡",
|
"dislike": "不喜歡",
|
||||||
"favorites": "收藏",
|
"favorites": "收藏",
|
||||||
@@ -129,9 +129,9 @@
|
|||||||
"widget": {
|
"widget": {
|
||||||
"lockScreen": "鎖屏小工具",
|
"lockScreen": "鎖屏小工具",
|
||||||
"homeScreen": "桌面小工具",
|
"homeScreen": "桌面小工具",
|
||||||
"howToTitle": "如何添加小工具",
|
"howToTitle": "如何加入小工具",
|
||||||
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
|
"howToDesc1": "長按主畫面空白處進入編輯,點左上角「+」新增小工具。",
|
||||||
"howToDesc2": "搜尋「正念」,選擇喜歡的尺寸,點「加入小工具」。",
|
"howToDesc2": "搜尋「Dear Mama」,選擇喜歡的尺寸,點「加入小工具」。",
|
||||||
"previewDate": "1月29日週四 · 已至臘月十一",
|
"previewDate": "1月29日週四 · 已至臘月十一",
|
||||||
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
|
"previewQuote": "我也對現在的自己感到滿意,即使我仍在努力成為想成為的人。"
|
||||||
},
|
},
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
"language": "語言",
|
"language": "語言",
|
||||||
"version": "版本",
|
"version": "版本",
|
||||||
"widgetTitle": "iOS 小工具",
|
"widgetTitle": "iOS 小工具",
|
||||||
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「正念」 → 添加你喜歡的尺寸。"
|
"widgetDesc": "把溫柔提醒放到桌面上:長按主畫面 → 點「+」 → 搜尋「Dear Mama」 → 添加你喜歡的尺寸。"
|
||||||
},
|
},
|
||||||
"consent": {
|
"consent": {
|
||||||
"title": "我們知道,",
|
"title": "我們知道,",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { fetchRecoWidget } from '@/src/services/recoApi';
|
|||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import { getUserProfileScoring } from '@/src/storage/appStorage';
|
import { getUserProfileScoring } from '@/src/storage/appStorage';
|
||||||
import { getLocalDayKey } from '@/src/utils/date';
|
import { getLocalDayKey } from '@/src/utils/date';
|
||||||
|
import { wrapText } from '@/src/features/textWrap';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
appGroupGetString,
|
appGroupGetString,
|
||||||
@@ -40,6 +41,12 @@ export type WidgetDailyRecoV1 = {
|
|||||||
item: null | {
|
item: null | {
|
||||||
content_id: number;
|
content_id: number;
|
||||||
text: string;
|
text: string;
|
||||||
|
/**
|
||||||
|
* 预换行文案(由 App 侧使用 Text Wrap 算法生成,写入 App Group,供 Widget 直接渲染)。
|
||||||
|
* - key 以 WidgetFamily 归一化:small/medium/large
|
||||||
|
* - 值使用 `\n` 分行;Widget SwiftUI 的 Text 会按换行符显示
|
||||||
|
*/
|
||||||
|
wrapped_text_by_family?: Partial<Record<'small' | 'medium' | 'large', string>>;
|
||||||
final_score?: number;
|
final_score?: number;
|
||||||
fallback_level_final?: number;
|
fallback_level_final?: number;
|
||||||
};
|
};
|
||||||
@@ -56,6 +63,58 @@ function safeJsonParse<T>(raw: string | null): T | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WidgetFamilyKey = 'small' | 'medium' | 'large';
|
||||||
|
|
||||||
|
function widgetPreset(family: WidgetFamilyKey) {
|
||||||
|
// 与 iOS Widget(`EmotionWidget.swift`)保持一致的显示口径(字体/行数/内边距)
|
||||||
|
// 注意:这里的 widthPt 是“常见 iPhone widget 尺寸”的近似值,用于把 pt 宽度换算为“可容纳 token 数”
|
||||||
|
// 真实渲染仍由系统决定,但这个近似能让算法在不同 family 下更稳定地产出更好看的换行。
|
||||||
|
switch (family) {
|
||||||
|
case 'small':
|
||||||
|
return { widthPt: 155, padding: 14, fontSize: 16, maxLines: 5 };
|
||||||
|
case 'medium':
|
||||||
|
return { widthPt: 329, padding: 16, fontSize: 18, maxLines: 6 };
|
||||||
|
case 'large':
|
||||||
|
return { widthPt: 329, padding: 18, fontSize: 22, maxLines: 8 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function approxCapacityFromPt(args: { lang: 'EN' | 'TC'; usablePt: number; fontSize: number }): number {
|
||||||
|
// 与 wrapText(APP 近似降级) 的换算口径一致:把像素/pt 宽度近似换成“可容纳 token 数”
|
||||||
|
const usable = Math.max(0, args.usablePt);
|
||||||
|
const fs = Math.max(1, Math.round(args.fontSize));
|
||||||
|
const denom = args.lang === 'EN' ? Math.max(1, Math.round(fs * 0.55)) : Math.max(1, Math.round(fs * 0.95));
|
||||||
|
return Math.max(1, Math.floor(usable / denom));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildWrappedTextByFamily(args: { text: string; lang: 'EN' | 'TC' }): Promise<Record<WidgetFamilyKey, string>> {
|
||||||
|
const families: WidgetFamilyKey[] = ['small', 'medium', 'large'];
|
||||||
|
const out: Partial<Record<WidgetFamilyKey, string>> = {};
|
||||||
|
|
||||||
|
for (const f of families) {
|
||||||
|
const p = widgetPreset(f);
|
||||||
|
const usablePt = Math.max(0, p.widthPt - p.padding * 2);
|
||||||
|
const capacity = approxCapacityFromPt({ lang: args.lang, usablePt, fontSize: p.fontSize });
|
||||||
|
|
||||||
|
const res = await wrapText({
|
||||||
|
text: args.text,
|
||||||
|
lang: args.lang,
|
||||||
|
context: 'WIDGET',
|
||||||
|
availableWidth: capacity,
|
||||||
|
maxLines: p.maxLines,
|
||||||
|
overflowMode: 'ELLIPSIS',
|
||||||
|
lineMode: 'AUTO',
|
||||||
|
configVersion: 'v1-widget',
|
||||||
|
debug: false,
|
||||||
|
// Widget 侧不依赖真实测量:默认 APPROX 即可(确保可在 Extension 独立工作)
|
||||||
|
});
|
||||||
|
|
||||||
|
out[f] = res.wrappedText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out as Record<WidgetFamilyKey, string>;
|
||||||
|
}
|
||||||
|
|
||||||
function pickUserProfileV1_2(scoringProfile: UserProfileV1_2_Extended): UserProfileV1_2 {
|
function pickUserProfileV1_2(scoringProfile: UserProfileV1_2_Extended): UserProfileV1_2 {
|
||||||
return {
|
return {
|
||||||
profile_version: scoringProfile.profile_version,
|
profile_version: scoringProfile.profile_version,
|
||||||
@@ -126,7 +185,29 @@ export async function ensureDailyWidgetRecoUpToDate(args?: {
|
|||||||
|
|
||||||
const today = getLocalDayKey(new Date());
|
const today = getLocalDayKey(new Date());
|
||||||
const cached = await getWidgetDailyRecoCache();
|
const cached = await getWidgetDailyRecoCache();
|
||||||
if (cached?.schema_version === 1 && cached.day_key === today && cached.item?.text) return;
|
// 若今日已有缓存,但缺少预换行字段:补齐后触发 reload(不必请求后端)
|
||||||
|
if (cached?.schema_version === 1 && cached.day_key === today && cached.item?.text) {
|
||||||
|
const hasWrapped = Boolean(cached.item.wrapped_text_by_family && Object.keys(cached.item.wrapped_text_by_family).length > 0);
|
||||||
|
if (hasWrapped) return;
|
||||||
|
|
||||||
|
const wrapLang: 'EN' | 'TC' = cached.lang === 'en' ? 'EN' : 'TC';
|
||||||
|
try {
|
||||||
|
const wrapped = await buildWrappedTextByFamily({ text: cached.item.text, lang: wrapLang });
|
||||||
|
await setWidgetDailyRecoCache({
|
||||||
|
...cached,
|
||||||
|
saved_at: new Date().toISOString(),
|
||||||
|
item: { ...cached.item, wrapped_text_by_family: wrapped },
|
||||||
|
source: cached.source ?? 'app',
|
||||||
|
});
|
||||||
|
await appGroupReloadAllTimelines();
|
||||||
|
} catch (e) {
|
||||||
|
// 预换行失败不阻塞;Widget 仍可用原文 + 系统换行
|
||||||
|
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
||||||
|
console.log('[DailyWidgetReco] 预换行补齐失败:', args?.reason ?? 'unknown', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const scoringProfile = args?.scoringProfile ?? (await getUserProfileScoring());
|
const scoringProfile = args?.scoringProfile ?? (await getUserProfileScoring());
|
||||||
if (!scoringProfile) return;
|
if (!scoringProfile) return;
|
||||||
@@ -146,6 +227,8 @@ export async function ensureDailyWidgetRecoUpToDate(args?: {
|
|||||||
if (!top?.text) return;
|
if (!top?.text) return;
|
||||||
|
|
||||||
const lang = toBackendLocaleFromLanguageTag(i18n.language);
|
const lang = toBackendLocaleFromLanguageTag(i18n.language);
|
||||||
|
const wrapLang: 'EN' | 'TC' = lang === 'en' ? 'EN' : 'TC';
|
||||||
|
const wrapped = await buildWrappedTextByFamily({ text: top.text, lang: wrapLang });
|
||||||
await setWidgetDailyRecoCache({
|
await setWidgetDailyRecoCache({
|
||||||
schema_version: 1,
|
schema_version: 1,
|
||||||
saved_at: new Date().toISOString(),
|
saved_at: new Date().toISOString(),
|
||||||
@@ -155,6 +238,7 @@ export async function ensureDailyWidgetRecoUpToDate(args?: {
|
|||||||
item: {
|
item: {
|
||||||
content_id: top.content_id,
|
content_id: top.content_id,
|
||||||
text: top.text,
|
text: top.text,
|
||||||
|
wrapped_text_by_family: wrapped,
|
||||||
final_score: top.final_score,
|
final_score: top.final_score,
|
||||||
fallback_level_final: top.fallback_level_final,
|
fallback_level_final: top.fallback_level_final,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ import { Platform } from 'react-native';
|
|||||||
|
|
||||||
import { httpJson } from '../utils/http';
|
import { httpJson } from '../utils/http';
|
||||||
import { APP_ENV } from '../constants/env';
|
import { APP_ENV } from '../constants/env';
|
||||||
import { getDailyReminderSettings, getOrCreateClientUserId, getUserProfileScoring } from '../storage/appStorage';
|
import {
|
||||||
|
getDailyReminderSettings,
|
||||||
|
getLastRegisteredPushPayload,
|
||||||
|
getOrCreateClientUserId,
|
||||||
|
getUserProfileScoring,
|
||||||
|
setLastRegisteredPushPayload,
|
||||||
|
} from '../storage/appStorage';
|
||||||
import type { UserProfileScoring } from '../storage/appStorage';
|
import type { UserProfileScoring } from '../storage/appStorage';
|
||||||
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
|
import { toBackendLocaleFromLanguageTag } from '../i18n/locale';
|
||||||
|
|
||||||
@@ -154,6 +160,40 @@ export async function registerPushToken(args: { pushToken: string }): Promise<vo
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSystemNotificationPermissionGranted(status: unknown): boolean {
|
||||||
|
// iOS 可能出现 provisional(临时授权),在 Push 场景也应视为“可获取 token 并上报”
|
||||||
|
return status === 'granted' || status === 'provisional';
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensurePushTokenRegisteredIfPermitted(): Promise<{ ok: boolean; reason: string }> {
|
||||||
|
// 只要系统权限已授权,就应尽早把 token 写入后端(不依赖用户在“每日提醒”里点确认)
|
||||||
|
const settings = await Notifications.getPermissionsAsync();
|
||||||
|
if (!isSystemNotificationPermissionGranted(settings.status)) return { ok: false, reason: 'permission_not_granted' };
|
||||||
|
|
||||||
|
const clientUserId = await getOrCreateClientUserId();
|
||||||
|
const token = await getExpoPushTokenOrThrow();
|
||||||
|
const env = toPushEnv(APP_ENV);
|
||||||
|
const appId = pickAppId();
|
||||||
|
|
||||||
|
// 去重规则(更严格):
|
||||||
|
// - 只有当 token + client_user_id + env + app_id 全都一致时才跳过
|
||||||
|
// - 避免出现“client_user_id 变化但 token 没变,导致后端绑定不更新”的问题
|
||||||
|
const last = await getLastRegisteredPushPayload().catch(() => null);
|
||||||
|
if (
|
||||||
|
last &&
|
||||||
|
last.pushToken === token &&
|
||||||
|
last.clientUserId === clientUserId &&
|
||||||
|
last.env === env &&
|
||||||
|
last.appId === appId
|
||||||
|
) {
|
||||||
|
return { ok: true, reason: 'already_registered' };
|
||||||
|
}
|
||||||
|
|
||||||
|
await registerPushToken({ pushToken: token });
|
||||||
|
await setLastRegisteredPushPayload({ pushToken: token, clientUserId, env, appId });
|
||||||
|
return { ok: true, reason: 'registered' };
|
||||||
|
}
|
||||||
|
|
||||||
export async function setPushPreferences(args: { enabled: boolean; timesPerDay: number }): Promise<PushPreferencesResponse> {
|
export async function setPushPreferences(args: { enabled: boolean; timesPerDay: number }): Promise<PushPreferencesResponse> {
|
||||||
const clientUserId = await getOrCreateClientUserId();
|
const clientUserId = await getOrCreateClientUserId();
|
||||||
const tz = pickTimezone();
|
const tz = pickTimezone();
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ const KEY_RECO_FEED_HISTORY = 'reco.feedHistory';
|
|||||||
const KEY_UI_THEME_MODE = 'ui.theme.mode';
|
const KEY_UI_THEME_MODE = 'ui.theme.mode';
|
||||||
const KEY_UI_THEME_SUIXIN_STATE = 'ui.theme.suixin.state';
|
const KEY_UI_THEME_SUIXIN_STATE = 'ui.theme.suixin.state';
|
||||||
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
|
const KEY_DAILY_REMINDER_SETTINGS = 'dailyReminder.settings';
|
||||||
|
const KEY_PUSH_LAST_REGISTERED_TOKEN = 'push.lastRegisteredToken'; // 旧:仅 token(保留兼容读取)
|
||||||
|
const KEY_PUSH_LAST_REGISTERED_AT = 'push.lastRegisteredAt'; // 旧:时间(保留兼容)
|
||||||
|
const KEY_PUSH_LAST_REGISTERED_PAYLOAD = 'push.lastRegisteredPayload'; // 新:token+client_user_id+env+app_id
|
||||||
|
|
||||||
export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
|
export type PushPromptState = 'enabled' | 'skipped' | 'unknown';
|
||||||
export type Reaction = 'like' | 'dislike';
|
export type Reaction = 'like' | 'dislike';
|
||||||
@@ -69,6 +72,46 @@ export type DailyReminderSettings = {
|
|||||||
pushEnabled: boolean;
|
pushEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export async function getLastRegisteredPushToken(): Promise<string | null> {
|
||||||
|
const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_REGISTERED_TOKEN);
|
||||||
|
return raw ? String(raw) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLastRegisteredPushToken(token: string): Promise<void> {
|
||||||
|
await AsyncStorage.setItem(KEY_PUSH_LAST_REGISTERED_TOKEN, String(token));
|
||||||
|
await AsyncStorage.setItem(KEY_PUSH_LAST_REGISTERED_AT, new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LastRegisteredPushPayload = {
|
||||||
|
pushToken: string;
|
||||||
|
clientUserId: string;
|
||||||
|
env: 'dev' | 'prod';
|
||||||
|
appId: string;
|
||||||
|
savedAt: string; // ISO8601
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getLastRegisteredPushPayload(): Promise<LastRegisteredPushPayload | null> {
|
||||||
|
const raw = await AsyncStorage.getItem(KEY_PUSH_LAST_REGISTERED_PAYLOAD);
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const obj = JSON.parse(raw) as Partial<LastRegisteredPushPayload>;
|
||||||
|
if (!obj || typeof obj !== 'object') return null;
|
||||||
|
if (!obj.pushToken || !obj.clientUserId || !obj.env || !obj.appId || !obj.savedAt) return null;
|
||||||
|
if (obj.env !== 'dev' && obj.env !== 'prod') return null;
|
||||||
|
return obj as LastRegisteredPushPayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLastRegisteredPushPayload(payload: Omit<LastRegisteredPushPayload, 'savedAt'>): Promise<void> {
|
||||||
|
const savedAt = new Date().toISOString();
|
||||||
|
const full: LastRegisteredPushPayload = { ...payload, savedAt };
|
||||||
|
await AsyncStorage.setItem(KEY_PUSH_LAST_REGISTERED_PAYLOAD, JSON.stringify(full));
|
||||||
|
// 同时写入旧 key,便于兼容老逻辑/快速排查
|
||||||
|
await setLastRegisteredPushToken(payload.pushToken);
|
||||||
|
}
|
||||||
|
|
||||||
export type RecoFeedCacheItem = {
|
export type RecoFeedCacheItem = {
|
||||||
content_id: number;
|
content_id: number;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -188,7 +231,7 @@ export async function setReaction(contentId: string, reaction: Reaction): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type FavoriteItem = {
|
export type FavoriteItem = {
|
||||||
favId: string; // 唯一标识,支持重复点赞同一文案
|
favId: string; // 唯一标识
|
||||||
id: string;
|
id: string;
|
||||||
/**
|
/**
|
||||||
* 收藏时的文案快照(强烈建议写入,避免后续 cache 覆盖导致无法还原文案)
|
* 收藏时的文案快照(强烈建议写入,避免后续 cache 覆盖导致无法还原文案)
|
||||||
@@ -200,13 +243,28 @@ export type FavoriteItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getFavorites(): Promise<FavoriteItem[]> {
|
export async function getFavorites(): Promise<FavoriteItem[]> {
|
||||||
return await getJson<FavoriteItem[]>(KEY_FAVORITES_ITEMS, []);
|
const raw = await getJson<FavoriteItem[]>(KEY_FAVORITES_ITEMS, []);
|
||||||
|
// 去重:同一条文案只保留最新的一条,防止重复点击喜欢导致弹窗重复展示
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped: FavoriteItem[] = [];
|
||||||
|
for (const item of raw) {
|
||||||
|
const key = String(item.id);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
deduped.push(item);
|
||||||
|
}
|
||||||
|
// 若发现历史数据有重复,顺便写回清理后的版本
|
||||||
|
if (deduped.length !== raw.length) {
|
||||||
|
await setJson(KEY_FAVORITES_ITEMS, deduped);
|
||||||
|
}
|
||||||
|
return deduped;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addFavorite(item: FavoriteItem): Promise<void> {
|
export async function addFavorite(item: FavoriteItem): Promise<void> {
|
||||||
const list = await getFavorites();
|
const list = await getFavorites();
|
||||||
// 允许重复点赞,不再根据 id 去重
|
// 幂等写入:同一条文案只保留一条(最新),避免重复点击喜欢产生重复项
|
||||||
const newList = [item, ...list];
|
const filtered = list.filter(x => String(x.id) !== String(item.id));
|
||||||
|
const newList = [item, ...filtered];
|
||||||
console.log('Adding to favorites:', JSON.stringify(item));
|
console.log('Adding to favorites:', JSON.stringify(item));
|
||||||
await setJson(KEY_FAVORITES_ITEMS, newList);
|
await setJson(KEY_FAVORITES_ITEMS, newList);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ async def get_privacy_policy(request: Request) -> HTMLResponse:
|
|||||||
accept_language = request.headers.get("accept-language")
|
accept_language = request.headers.get("accept-language")
|
||||||
lang = _resolve_lang(accept_language)
|
lang = _resolve_lang(accept_language)
|
||||||
content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang)
|
content, resolved = choose_content_by_lang(PRIVACY_POLICY_MD, lang)
|
||||||
title = "Hey Mama | Privacy Policy" if resolved == "en" else "Hey Mama|隱私權政策"
|
title = "Dear Mama | Privacy Policy" if resolved == "en" else "Dear Mama|隱私權政策"
|
||||||
page = render_as_simple_html(title=title, content=content)
|
page = render_as_simple_html(title=title, content=content)
|
||||||
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
|
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ async def get_terms_of_use(request: Request) -> HTMLResponse:
|
|||||||
accept_language = request.headers.get("accept-language")
|
accept_language = request.headers.get("accept-language")
|
||||||
lang = _resolve_lang(accept_language)
|
lang = _resolve_lang(accept_language)
|
||||||
content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang)
|
content, resolved = choose_content_by_lang(TERMS_OF_USE_MD, lang)
|
||||||
title = "Hey Mama – Terms of Use" if resolved == "en" else "Hey Mama 使用條款"
|
title = "Dear Mama – Terms of Use" if resolved == "en" else "Dear Mama 使用條款"
|
||||||
page = render_as_simple_html(title=title, content=content)
|
page = render_as_simple_html(title=title, content=content)
|
||||||
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
|
return HTMLResponse(content=page, headers={"Content-Language": "en" if resolved == "en" else "zh-Hant"})
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,20 @@ from datetime import datetime, timezone
|
|||||||
from typing import Any, Literal, Optional
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import redis
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.limits import rate_limit_push_by_ip
|
from app.api.limits import rate_limit_push_by_ip
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.db.models.push_preference import PushPreference
|
from app.db.models.push_preference import PushPreference
|
||||||
from app.db.models.push_token import PushToken
|
from app.db.models.push_token import PushToken
|
||||||
|
from app.db.models.push_send_log import PushSendLog
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
from app.features.user_profile_scoring.types import UserProfileV1_2
|
from app.features.user_profile_scoring.types import UserProfileV1_2
|
||||||
|
from app.worker import celery_app
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(
|
router = APIRouter(
|
||||||
@@ -191,9 +194,10 @@ async def put_preferences(req: PushPreferencesRequest, db: AsyncSession = Depend
|
|||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
# 返回更新时间(从 ORM 读取到的可能不包含 server_onupdate,这里用 now 兜底)
|
# 返回更新时间:
|
||||||
updated_at = getattr(pref, "updated_at", None)
|
# - 某些运行环境/驱动组合下,commit 后访问 ORM 字段可能触发隐式 IO,导致 async 下报 MissingGreenlet。
|
||||||
updated_at_iso = updated_at.isoformat() if isinstance(updated_at, datetime) else None
|
# - 这里直接用当前时间兜底(字段本身为可选,仅用于前端展示)。
|
||||||
|
updated_at_iso = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
return PushPreferencesResponse(
|
return PushPreferencesResponse(
|
||||||
client_user_id=req.client_user_id,
|
client_user_id=req.client_user_id,
|
||||||
@@ -253,10 +257,123 @@ async def test_push(
|
|||||||
_ = UserProfileV1_2.model_validate(pref.user_profile_json)
|
_ = UserProfileV1_2.model_validate(pref.user_profile_json)
|
||||||
|
|
||||||
# V1:先发固定测试文案;后续在定时任务中替换为推荐模块的 push 场景模板
|
# V1:先发固定测试文案;后续在定时任务中替换为推荐模块的 push 场景模板
|
||||||
title = req.title or "Hey Mama"
|
title = req.title or "Dear Mama"
|
||||||
body = req.body or "这是一条测试推送(dev)。"
|
body = req.body or "这是一条测试推送(dev)。"
|
||||||
|
|
||||||
expo_res = await _send_expo_push(to=token.push_token, title=title, body=body, data={"client_user_id": req.client_user_id})
|
expo_res = await _send_expo_push(to=token.push_token, title=title, body=body, data={"client_user_id": req.client_user_id})
|
||||||
_ = accept_language
|
_ = accept_language
|
||||||
return {"status": "ok", "expo": expo_res}
|
return {"status": "ok", "expo": expo_res}
|
||||||
|
|
||||||
|
|
||||||
|
def _env_prefix(app_env: str) -> str:
|
||||||
|
"""
|
||||||
|
根据环境生成前缀:
|
||||||
|
- dev -> dev
|
||||||
|
- prod -> pro
|
||||||
|
"""
|
||||||
|
|
||||||
|
return "dev" if str(app_env) == "dev" else "pro"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scheduler/health")
|
||||||
|
async def scheduler_health(db: AsyncSession = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
推送“定时服务”健康检查(用于容器内验证)。
|
||||||
|
|
||||||
|
返回内容(尽量不暴露敏感信息):
|
||||||
|
- Redis:是否可连通
|
||||||
|
- Worker:是否至少有一个 worker 在线(inspect ping)
|
||||||
|
- Beat:是否在跑(beat 心跳 key 是否在持续刷新)
|
||||||
|
- DB:是否可查询到 push_send_log 的最新时间(辅助定位排程是否生成)
|
||||||
|
"""
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
prefix = _env_prefix(settings.app_env)
|
||||||
|
beat_key = f"{prefix}:beat:heartbeat"
|
||||||
|
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"env": settings.app_env,
|
||||||
|
"redis": {"ok": False},
|
||||||
|
"worker": {"ok": False, "worker_count": 0},
|
||||||
|
"beat": {"ok": False, "last_heartbeat_at": None, "age_seconds": None},
|
||||||
|
"db": {"ok": False, "push_send_log_latest_created_at": None},
|
||||||
|
"db_push_tokens": {"ok": False, "count": None, "latest": None},
|
||||||
|
"now_utc": datetime.now(timezone.utc).isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# 1) Redis 连通性 + 读取 beat 心跳
|
||||||
|
try:
|
||||||
|
r = redis.Redis.from_url(settings.celery_broker_url, decode_responses=True)
|
||||||
|
r.ping()
|
||||||
|
out["redis"]["ok"] = True
|
||||||
|
|
||||||
|
hb = r.get(beat_key)
|
||||||
|
if hb:
|
||||||
|
out["beat"]["last_heartbeat_at"] = hb
|
||||||
|
try:
|
||||||
|
# Python 3.11+ 支持解析 ISO8601(含 +00:00)
|
||||||
|
hb_dt = datetime.fromisoformat(hb.replace("Z", "+00:00"))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
age = int((now - hb_dt.astimezone(timezone.utc)).total_seconds())
|
||||||
|
out["beat"]["age_seconds"] = age
|
||||||
|
# 2 分钟内认为健康(beat 每分钟刷新一次)
|
||||||
|
out["beat"]["ok"] = age <= 120
|
||||||
|
except Exception:
|
||||||
|
# 解析失败:至少说明 key 存在,但时间格式异常
|
||||||
|
out["beat"]["ok"] = False
|
||||||
|
except Exception as e:
|
||||||
|
out["redis"]["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
# 2) Worker 在线性(inspect ping)
|
||||||
|
try:
|
||||||
|
insp = celery_app.control.inspect(timeout=1.0)
|
||||||
|
pings = insp.ping() or {}
|
||||||
|
if isinstance(pings, dict):
|
||||||
|
out["worker"]["worker_count"] = len(pings)
|
||||||
|
out["worker"]["ok"] = len(pings) > 0
|
||||||
|
except Exception as e:
|
||||||
|
out["worker"]["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
# 3) DB:查询 push_send_log 最新创建时间(用于判断排程是否有生成)
|
||||||
|
try:
|
||||||
|
q = select(PushSendLog.created_at).order_by(PushSendLog.created_at.desc()).limit(1)
|
||||||
|
row = await db.execute(q)
|
||||||
|
latest = row.scalar_one_or_none()
|
||||||
|
out["db"]["ok"] = True
|
||||||
|
out["db"]["push_send_log_latest_created_at"] = latest.isoformat() if latest else None
|
||||||
|
except Exception as e:
|
||||||
|
out["db"]["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
# 4) DB:查询 push_tokens 计数与最近一条(用于确认 /v1/push/register 是否真正落库)
|
||||||
|
try:
|
||||||
|
qcount = select(func.count()).select_from(PushToken)
|
||||||
|
rcount = await db.execute(qcount)
|
||||||
|
cnt = int(rcount.scalar_one() or 0)
|
||||||
|
|
||||||
|
qlatest = select(PushToken).order_by(PushToken.last_seen_at.desc()).limit(1)
|
||||||
|
rlatest = await db.execute(qlatest)
|
||||||
|
t = rlatest.scalar_one_or_none()
|
||||||
|
|
||||||
|
latest_obj = None
|
||||||
|
if t is not None:
|
||||||
|
tok = str(t.push_token or "")
|
||||||
|
masked = tok[:10] + "***" + tok[-6:] if len(tok) > 20 else (tok[:6] + "***" if tok else "")
|
||||||
|
latest_obj = {
|
||||||
|
"id": int(getattr(t, "id", 0) or 0),
|
||||||
|
"client_user_id": str(t.client_user_id),
|
||||||
|
"env": str(t.env),
|
||||||
|
"app_id": str(t.app_id),
|
||||||
|
"platform": str(t.platform),
|
||||||
|
"is_active": bool(t.is_active),
|
||||||
|
"last_seen_at": t.last_seen_at.isoformat() if t.last_seen_at else None,
|
||||||
|
"push_token_masked": masked,
|
||||||
|
}
|
||||||
|
|
||||||
|
out["db_push_tokens"]["ok"] = True
|
||||||
|
out["db_push_tokens"]["count"] = cnt
|
||||||
|
out["db_push_tokens"]["latest"] = latest_obj
|
||||||
|
except Exception as e:
|
||||||
|
out["db_push_tokens"]["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -11,21 +11,21 @@ ResolvedLang = Literal["en", "tc"]
|
|||||||
# 协议原文(直接来自仓库中的 Markdown 文档)。
|
# 协议原文(直接来自仓库中的 Markdown 文档)。
|
||||||
# 说明:为了保证“点击一定有内容”,这里在后端内置了一份可展示的协议文本。
|
# 说明:为了保证“点击一定有内容”,这里在后端内置了一份可展示的协议文本。
|
||||||
# 线上若你们有官网/静态站点托管页面,可通过环境变量 LEGAL_* 覆盖为外部链接。
|
# 线上若你们有官网/静态站点托管页面,可通过环境变量 LEGAL_* 覆盖为外部链接。
|
||||||
PRIVACY_POLICY_MD = """Hey Mama | Privacy Policy
|
PRIVACY_POLICY_MD = """Dear Mama | Privacy Policy
|
||||||
Last updated: February 2026
|
Last updated: February 2026
|
||||||
|
|
||||||
1. Introduction
|
1. Introduction
|
||||||
Welcome to Hey Mama (“the App,” “we,” “us”).
|
Welcome to Dear Mama (“the App,” “we,” “us”).
|
||||||
We respect your privacy and are committed to protecting your personal information. This Privacy Policy explains how we collect, use, store, and protect information when you use the App.
|
We respect your privacy and are committed to protecting your personal information. This Privacy Policy explains how we collect, use, store, and protect information when you use the App.
|
||||||
By downloading, accessing, or using the App, you acknowledge that you have read, understood, and agreed to this Privacy Policy.
|
By downloading, accessing, or using the App, you acknowledge that you have read, understood, and agreed to this Privacy Policy.
|
||||||
|
|
||||||
2. Data Controller and Scope
|
2. Data Controller and Scope
|
||||||
The App is operated and maintained by the Hey Mama team.
|
The App is operated and maintained by the Dear Mama team.
|
||||||
This Privacy Policy applies to information processing activities related to your use of the App.
|
This Privacy Policy applies to information processing activities related to your use of the App.
|
||||||
|
|
||||||
3. Information We Collect
|
3. Information We Collect
|
||||||
3.1 Information You Provide
|
3.1 Information You Provide
|
||||||
Hey Mama does not require account registration and does not require you to provide personally identifiable information.
|
Dear Mama does not require account registration and does not require you to provide personally identifiable information.
|
||||||
During your use of the App, you may optionally provide or generate the following information:
|
During your use of the App, you may optionally provide or generate the following information:
|
||||||
- Reminder settings (e.g., reminder frequency)
|
- Reminder settings (e.g., reminder frequency)
|
||||||
- Text content you view, save as favorites, or create within the App (if available)
|
- Text content you view, save as favorites, or create within the App (if available)
|
||||||
@@ -59,7 +59,7 @@ We do not:
|
|||||||
- Use your data for third-party advertising purposes
|
- Use your data for third-party advertising purposes
|
||||||
|
|
||||||
7. Third-Party Services
|
7. Third-Party Services
|
||||||
Hey Mama currently does not integrate third-party advertising or marketing services.
|
Dear Mama currently does not integrate third-party advertising or marketing services.
|
||||||
The App may rely on necessary operating system and app store services to provide functionality (for example, push notification delivery mechanisms).
|
The App may rely on necessary operating system and app store services to provide functionality (for example, push notification delivery mechanisms).
|
||||||
If we later integrate third-party analytics or technical services, we will update this Privacy Policy accordingly.
|
If we later integrate third-party analytics or technical services, we will update this Privacy Policy accordingly.
|
||||||
|
|
||||||
@@ -67,7 +67,7 @@ If we later integrate third-party analytics or technical services, we will updat
|
|||||||
We retain information only for as long as necessary to achieve the purposes described above. We implement reasonable technical and organizational measures to protect information against unauthorized access, disclosure, alteration, or loss.
|
We retain information only for as long as necessary to achieve the purposes described above. We implement reasonable technical and organizational measures to protect information against unauthorized access, disclosure, alteration, or loss.
|
||||||
|
|
||||||
9. Minors
|
9. Minors
|
||||||
Hey Mama is not designed for children, and we do not knowingly collect personal information from users under the age of 13.
|
Dear Mama is not designed for children, and we do not knowingly collect personal information from users under the age of 13.
|
||||||
If you are a minor, please use the App with the consent and supervision of a parent or guardian.
|
If you are a minor, please use the App with the consent and supervision of a parent or guardian.
|
||||||
|
|
||||||
10. Changes to This Privacy Policy
|
10. Changes to This Privacy Policy
|
||||||
@@ -75,17 +75,17 @@ We may update this Privacy Policy from time to time. The updated version will be
|
|||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
Hey Mama|隱私權政策
|
Dear Mama|隱私權政策
|
||||||
最後更新日期:2026 年 2 月
|
最後更新日期:2026 年 2 月
|
||||||
|
|
||||||
一、前言
|
一、前言
|
||||||
歡迎使用 Hey Mama(以下簡稱「本 App」、「我們」)。
|
歡迎使用 Dear Mama(以下簡稱「本 App」、「我們」)。
|
||||||
我們重視您的隱私,並致力於保護您的個人資料安全。本隱私權政策說明您在使用 Hey Mama 時,我們如何收集、使用、保存與保護相關資訊。
|
我們重視您的隱私,並致力於保護您的個人資料安全。本隱私權政策說明您在使用 Dear Mama 時,我們如何收集、使用、保存與保護相關資訊。
|
||||||
當您下載、存取或使用本 App,即表示您已閱讀、理解並同意本隱私權政策之內容。
|
當您下載、存取或使用本 App,即表示您已閱讀、理解並同意本隱私權政策之內容。
|
||||||
|
|
||||||
二、我們收集的資訊
|
二、我們收集的資訊
|
||||||
1. 使用者主動提供的資訊
|
1. 使用者主動提供的資訊
|
||||||
Hey Mama 不要求建立帳號,亦不強制使用者提供可識別個人身分的資料。
|
Dear Mama 不要求建立帳號,亦不強制使用者提供可識別個人身分的資料。
|
||||||
在使用過程中,您可能會選擇性提供或產生以下資訊:
|
在使用過程中,您可能會選擇性提供或產生以下資訊:
|
||||||
- 提醒設定(例如提醒頻率)
|
- 提醒設定(例如提醒頻率)
|
||||||
- 使用者在 App 內閱讀、收藏或建立的文字內容(如有)
|
- 使用者在 App 內閱讀、收藏或建立的文字內容(如有)
|
||||||
@@ -99,7 +99,7 @@ Hey Mama 不要求建立帳號,亦不強制使用者提供可識別個人身
|
|||||||
這些資訊無法直接識別您的身分,僅用於維持 App 穩定性與改善使用體驗。
|
這些資訊無法直接識別您的身分,僅用於維持 App 穩定性與改善使用體驗。
|
||||||
|
|
||||||
三、推送通知
|
三、推送通知
|
||||||
在取得您同意後,Hey Mama 可能會向您發送提醒推送,例如每日肯定語提示。
|
在取得您同意後,Dear Mama 可能會向您發送提醒推送,例如每日肯定語提示。
|
||||||
- 推送內容僅包含一般文字資訊
|
- 推送內容僅包含一般文字資訊
|
||||||
- 不包含任何敏感個人資料
|
- 不包含任何敏感個人資料
|
||||||
- 您可隨時於裝置系統設定中關閉通知功能
|
- 您可隨時於裝置系統設定中關閉通知功能
|
||||||
@@ -117,14 +117,14 @@ Hey Mama 不要求建立帳號,亦不強制使用者提供可識別個人身
|
|||||||
- 將資料用於第三方廣告投放
|
- 將資料用於第三方廣告投放
|
||||||
|
|
||||||
六、第三方服務
|
六、第三方服務
|
||||||
目前 Hey Mama 未整合第三方廣告或行銷服務。
|
目前 Dear Mama 未整合第三方廣告或行銷服務。
|
||||||
如未來整合第三方分析或技術服務,我們將於本政策中另行說明並更新。
|
如未來整合第三方分析或技術服務,我們將於本政策中另行說明並更新。
|
||||||
|
|
||||||
七、資料保存與安全
|
七、資料保存與安全
|
||||||
我們僅在達成上述目的所需期間內保存相關資訊,並採取合理的技術與管理措施,以防止資料遭未經授權存取、洩漏、竄改或遺失。
|
我們僅在達成上述目的所需期間內保存相關資訊,並採取合理的技術與管理措施,以防止資料遭未經授權存取、洩漏、竄改或遺失。
|
||||||
|
|
||||||
八、未成年人說明
|
八、未成年人說明
|
||||||
Hey Mama 並非專為兒童設計,亦不刻意收集未滿 13 歲使用者的個人資料。
|
Dear Mama 並非專為兒童設計,亦不刻意收集未滿 13 歲使用者的個人資料。
|
||||||
若您為未成年人,請在監護人同意與陪同下使用本 App。
|
若您為未成年人,請在監護人同意與陪同下使用本 App。
|
||||||
|
|
||||||
九、隱私權政策的變更
|
九、隱私權政策的變更
|
||||||
@@ -132,17 +132,17 @@ Hey Mama 並非專為兒童設計,亦不刻意收集未滿 13 歲使用者的
|
|||||||
更新後的版本將公布於 App 內或相關頁面,您於政策更新後繼續使用本 App,即視為同意更新內容。
|
更新後的版本將公布於 App 內或相關頁面,您於政策更新後繼續使用本 App,即視為同意更新內容。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
TERMS_OF_USE_MD = """Hey Mama – Terms of Use
|
TERMS_OF_USE_MD = """Dear Mama – Terms of Use
|
||||||
Last updated: February 2026
|
Last updated: February 2026
|
||||||
Welcome to Hey Mama (“the App,” “we,” or “us”).
|
Welcome to Dear Mama (“the App,” “we,” or “us”).
|
||||||
Please read these Terms of Use carefully before downloading, accessing, or using the App. By using the App, you agree to be bound by these Terms.
|
Please read these Terms of Use carefully before downloading, accessing, or using the App. By using the App, you agree to be bound by these Terms.
|
||||||
|
|
||||||
1. Intended Audience
|
1. Intended Audience
|
||||||
Hey Mama is intended for adults only.
|
Dear Mama is intended for adults only.
|
||||||
The App is not designed for children, and users must ensure they have the legal capacity to use the App under applicable laws.
|
The App is not designed for children, and users must ensure they have the legal capacity to use the App under applicable laws.
|
||||||
|
|
||||||
2. Services Provided
|
2. Services Provided
|
||||||
Hey Mama provides text-based content and features, including but not limited to:
|
Dear Mama provides text-based content and features, including but not limited to:
|
||||||
- Daily affirmations and mindfulness text
|
- Daily affirmations and mindfulness text
|
||||||
- User-configured reminders and push notifications
|
- User-configured reminders and push notifications
|
||||||
- Home screen widgets displaying affirmation text
|
- Home screen widgets displaying affirmation text
|
||||||
@@ -184,17 +184,17 @@ Updated versions will be made available within the App or related pages. Continu
|
|||||||
These Terms shall be governed by and construed in accordance with the applicable laws of our operating jurisdiction.
|
These Terms shall be governed by and construed in accordance with the applicable laws of our operating jurisdiction.
|
||||||
|
|
||||||
---
|
---
|
||||||
Hey Mama 使用條款
|
Dear Mama 使用條款
|
||||||
最後更新日期:2026 年 2 月
|
最後更新日期:2026 年 2 月
|
||||||
歡迎使用 Hey Mama(以下簡稱「本 App」、「我們」)。
|
歡迎使用 Dear Mama(以下簡稱「本 App」、「我們」)。
|
||||||
在下載、存取或使用本 App 前,請您仔細閱讀本使用條款。當您開始使用本 App,即表示您已閱讀、理解並同意遵守本條款。
|
在下載、存取或使用本 App 前,請您仔細閱讀本使用條款。當您開始使用本 App,即表示您已閱讀、理解並同意遵守本條款。
|
||||||
|
|
||||||
1. 服務對象與使用資格
|
1. 服務對象與使用資格
|
||||||
Hey Mama 僅供成年人使用(intended for adults)。
|
Dear Mama 僅供成年人使用(intended for adults)。
|
||||||
本 App 並非為兒童設計,使用者應確認自己具備依所在地法律使用本服務的完全行為能力。
|
本 App 並非為兒童設計,使用者應確認自己具備依所在地法律使用本服務的完全行為能力。
|
||||||
|
|
||||||
2. 服務內容
|
2. 服務內容
|
||||||
Hey Mama 提供以文字形式為主的內容與功能,包括但不限於:
|
Dear Mama 提供以文字形式為主的內容與功能,包括但不限於:
|
||||||
- 每日肯定語與正念文字內容
|
- 每日肯定語與正念文字內容
|
||||||
- 使用者設定的提醒與推送通知
|
- 使用者設定的提醒與推送通知
|
||||||
- 桌面小組件顯示肯定語文字
|
- 桌面小組件顯示肯定語文字
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ Celery 任务集合。
|
|||||||
from app.tasks import ping as _ping # noqa: F401
|
from app.tasks import ping as _ping # noqa: F401
|
||||||
from app.tasks import reco as _reco # noqa: F401
|
from app.tasks import reco as _reco # noqa: F401
|
||||||
from app.tasks import push as _push # noqa: F401
|
from app.tasks import push as _push # noqa: F401
|
||||||
|
from app.tasks import ops as _ops # noqa: F401
|
||||||
|
|
||||||
|
|||||||
41
server/app/tasks/ops.py
Normal file
41
server/app/tasks/ops.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import redis
|
||||||
|
from celery import shared_task
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def _env_prefix(app_env: str) -> str:
|
||||||
|
"""
|
||||||
|
根据环境生成前缀:
|
||||||
|
- dev -> dev
|
||||||
|
- prod -> pro
|
||||||
|
"""
|
||||||
|
|
||||||
|
return "dev" if str(app_env) == "dev" else "pro"
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(name="tasks.ops.beat_heartbeat")
|
||||||
|
def beat_heartbeat() -> dict[str, str]:
|
||||||
|
"""
|
||||||
|
Beat 心跳任务(用于健康检查)。
|
||||||
|
|
||||||
|
作用:
|
||||||
|
- 由 Celery Beat 每分钟触发一次
|
||||||
|
- 写入 Redis 心跳 key,并设置 TTL
|
||||||
|
- API 侧读取该 key,可判断 beat 是否在运行
|
||||||
|
"""
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
prefix = _env_prefix(settings.app_env)
|
||||||
|
key = f"{prefix}:beat:heartbeat"
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
r = redis.Redis.from_url(settings.celery_broker_url, decode_responses=True)
|
||||||
|
# TTL 设短一些:一旦 beat 挂了,很快就能从“过期/缺失”判断出来
|
||||||
|
r.set(key, now, ex=180)
|
||||||
|
return {"status": "ok", "key": key, "at": now}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user