fix:
1. 修复登录界面文字被遮挡问题 2. 大字模式关闭后显示异常问题 3. 重新调整大字模式是否开启时的字体显示效果
This commit is contained in:
@@ -88,7 +88,11 @@ def _is_emotion_heavy(text: str) -> bool:
|
||||
return False
|
||||
if any(m in t for m in _EMOTION_MARKERS):
|
||||
return True
|
||||
if len(t) >= 40 and ("!" in t or "!" in t) and (".." in t or "…" in t or "唉" in t):
|
||||
if (
|
||||
len(t) >= 40
|
||||
and ("!" in t or "!" in t)
|
||||
and (".." in t or "…" in t or "唉" in t)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -366,7 +366,11 @@ def get_guided_conversation_prompt(
|
||||
current_stage, empty_slots
|
||||
)
|
||||
|
||||
_prefix = f"{turn_directive_block.rstrip()}\n\n" if (turn_directive_block or "").strip() else ""
|
||||
_prefix = (
|
||||
f"{turn_directive_block.rstrip()}\n\n"
|
||||
if (turn_directive_block or "").strip()
|
||||
else ""
|
||||
)
|
||||
|
||||
return f"""{_prefix}你是「岁月知己」——**主持式知己**:语气像最懂我的老朋友,**职责是帮用户把人生故事口述清楚**。{tone_line}
|
||||
|
||||
|
||||
@@ -124,12 +124,8 @@ def _run_batch_phase1_prep_chunk_with_bisect(
|
||||
mid = len(segments) // 2
|
||||
if mid < 1:
|
||||
raise
|
||||
left = _run_batch_phase1_prep_chunk_with_bisect(
|
||||
segments[:mid], state, llm
|
||||
)
|
||||
right = _run_batch_phase1_prep_chunk_with_bisect(
|
||||
segments[mid:], state, llm
|
||||
)
|
||||
left = _run_batch_phase1_prep_chunk_with_bisect(segments[:mid], state, llm)
|
||||
right = _run_batch_phase1_prep_chunk_with_bisect(segments[mid:], state, llm)
|
||||
merged = {**left, **right}
|
||||
expected = {str(s.id) for s in segments}
|
||||
if merged.keys() != expected:
|
||||
@@ -181,7 +177,5 @@ def run_batch_phase1_prep_chunked(
|
||||
missing,
|
||||
extra,
|
||||
)
|
||||
raise ValueError(
|
||||
"batch phase1 chunked: merged segment ids do not match input"
|
||||
)
|
||||
raise ValueError("batch phase1 chunked: merged segment ids do not match input")
|
||||
return merged
|
||||
|
||||
@@ -83,7 +83,9 @@ def _merge_phase2_list(
|
||||
return list(by_tid.values())
|
||||
|
||||
|
||||
def _fanout_list_merge_key(items: list[dict], patch_items: list[dict], id_key: str) -> None:
|
||||
def _fanout_list_merge_key(
|
||||
items: list[dict], patch_items: list[dict], id_key: str
|
||||
) -> None:
|
||||
by_id: dict[str, dict[str, Any]] = {}
|
||||
for x in items:
|
||||
k = str(x.get(id_key) or "").strip()
|
||||
@@ -104,9 +106,11 @@ def _fanout_list_merge_key(items: list[dict], patch_items: list[dict], id_key: s
|
||||
def _merge_fanout(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
||||
out = dict(base)
|
||||
for k, v in patch.items():
|
||||
if k in ("story_images", "recompose_chapters", "memory_enrichment") and isinstance(
|
||||
v, list
|
||||
):
|
||||
if k in (
|
||||
"story_images",
|
||||
"recompose_chapters",
|
||||
"memory_enrichment",
|
||||
) and isinstance(v, list):
|
||||
id_key = (
|
||||
"story_id"
|
||||
if k == "story_images"
|
||||
@@ -132,14 +136,14 @@ def _merge_doc(base: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
||||
if k == "phase2" and isinstance(v, list):
|
||||
out["phase2"] = _merge_phase2_list(list(out.get("phase2") or []), v)
|
||||
elif k == "fanout" and isinstance(v, dict):
|
||||
out["fanout"] = _merge_fanout(
|
||||
dict(out.get("fanout") or _empty_fanout()), v
|
||||
)
|
||||
out["fanout"] = _merge_fanout(dict(out.get("fanout") or _empty_fanout()), v)
|
||||
elif k == "phase1" and isinstance(v, dict):
|
||||
cur = dict(out.get("phase1") or {})
|
||||
for pk, pv in v.items():
|
||||
if pk == "detail" and isinstance(pv, dict) and isinstance(
|
||||
cur.get("detail"), dict
|
||||
if (
|
||||
pk == "detail"
|
||||
and isinstance(pv, dict)
|
||||
and isinstance(cur.get("detail"), dict)
|
||||
):
|
||||
cur["detail"] = {**cur["detail"], **pv}
|
||||
else:
|
||||
|
||||
@@ -712,7 +712,9 @@ class EvalJudgeManualService:
|
||||
|
||||
def _nonempty_chapters(cols: list[Any]) -> int:
|
||||
return sum(
|
||||
1 for x in cols if (getattr(x, "canonical_markdown", None) or "").strip()
|
||||
1
|
||||
for x in cols
|
||||
if (getattr(x, "canonical_markdown", None) or "").strip()
|
||||
)
|
||||
|
||||
conc = max(1, min(32, int(settings.eval_judge_memoir_chapter_concurrency)))
|
||||
@@ -849,9 +851,7 @@ class EvalJudgeManualService:
|
||||
judged = await asyncio.gather(*[_judge_one(p) for p in prepared])
|
||||
judged.sort(
|
||||
key=lambda r: (
|
||||
r["order_index"]
|
||||
if r["order_index"] is not None
|
||||
else 10**9,
|
||||
r["order_index"] if r["order_index"] is not None else 10**9,
|
||||
r["enum_idx"],
|
||||
)
|
||||
)
|
||||
@@ -900,7 +900,11 @@ class EvalJudgeManualService:
|
||||
"""Streaming SSE: one event per chapter judge result, concurrent LLM calls."""
|
||||
uid = (user_id or "").strip()
|
||||
if not uid:
|
||||
yield {"event": "error", "phase": "validate", "message": "user_id is required"}
|
||||
yield {
|
||||
"event": "error",
|
||||
"phase": "validate",
|
||||
"message": "user_id is required",
|
||||
}
|
||||
return
|
||||
|
||||
judge, resolved_model = _make_eval_judge(judge_provider, judge_model)
|
||||
@@ -912,7 +916,10 @@ class EvalJudgeManualService:
|
||||
trace_svc = EvalTraceService(self._db)
|
||||
|
||||
def _chapter_evidence_notes(
|
||||
lineage_tier: str, evidence_summary: str, truncated: bool, dropped: list[str]
|
||||
lineage_tier: str,
|
||||
evidence_summary: str,
|
||||
truncated: bool,
|
||||
dropped: list[str],
|
||||
) -> str:
|
||||
drops = ",".join(dropped[:12]) if dropped else ""
|
||||
return (
|
||||
@@ -926,8 +933,14 @@ class EvalJudgeManualService:
|
||||
uid, self._db, active_only=True, is_new_only=None
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("manual memoir stream: chapter list failed user_id={}", uid)
|
||||
yield {"event": "error", "phase": "load", "message": f"加载章节列表失败:{e}"}
|
||||
logger.exception(
|
||||
"manual memoir stream: chapter list failed user_id={}", uid
|
||||
)
|
||||
yield {
|
||||
"event": "error",
|
||||
"phase": "load",
|
||||
"message": f"加载章节列表失败:{e}",
|
||||
}
|
||||
return
|
||||
|
||||
yield {
|
||||
@@ -957,15 +970,22 @@ class EvalJudgeManualService:
|
||||
cb = await trace_svc.build_chapter_bundle(uid, ch)
|
||||
formatted, cb2 = await trace_svc.format_chapter_bundle(cb)
|
||||
fm = formatted.format_meta
|
||||
prepared.append({
|
||||
"ch": ch, "bl": bl, "md": md,
|
||||
prepared.append(
|
||||
{
|
||||
"ch": ch,
|
||||
"bl": bl,
|
||||
"md": md,
|
||||
"baseline_excerpt": baseline_excerpt,
|
||||
"formatted": formatted, "cb2": cb2, "fm": fm,
|
||||
})
|
||||
"formatted": formatted,
|
||||
"cb2": cb2,
|
||||
"fm": fm,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"manual memoir stream: chapter prepare failed user_id={} chapter_id={}",
|
||||
uid, ch.id,
|
||||
uid,
|
||||
ch.id,
|
||||
)
|
||||
yield {
|
||||
"event": "chapter_error",
|
||||
@@ -1025,7 +1045,8 @@ class EvalJudgeManualService:
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"memoir stream: baseline judge failed ch={} err={}",
|
||||
ch.id, exc,
|
||||
ch.id,
|
||||
exc,
|
||||
)
|
||||
baseline_error = str(exc)
|
||||
|
||||
@@ -1040,14 +1061,17 @@ class EvalJudgeManualService:
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"manual memoir stream: chapter judge failed user_id={} chapter_id={}",
|
||||
uid, ch.id,
|
||||
uid,
|
||||
ch.id,
|
||||
)
|
||||
await result_queue.put({
|
||||
await result_queue.put(
|
||||
{
|
||||
"event": "chapter_error",
|
||||
"chapter_id": ch.id,
|
||||
"title": ch.title,
|
||||
"message": f"评审失败:{e}",
|
||||
})
|
||||
}
|
||||
)
|
||||
return
|
||||
cj = cj_res.output
|
||||
compare_summary = build_memoir_compare_summary(
|
||||
@@ -1073,12 +1097,14 @@ class EvalJudgeManualService:
|
||||
row["judge_error"] = cj_res.error
|
||||
if not cj and not cj_res.error:
|
||||
row["judge_error"] = "empty_output"
|
||||
await result_queue.put({
|
||||
await result_queue.put(
|
||||
{
|
||||
"event": "chapter_judge",
|
||||
"index": idx,
|
||||
"chapter": row,
|
||||
"ok": cj is not None,
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
tasks = [asyncio.create_task(_judge_one(i, p)) for i, p in enumerate(prepared)]
|
||||
|
||||
|
||||
@@ -229,8 +229,12 @@ class MemoirJudgeOutput(BaseModel):
|
||||
|
||||
# 细项:校验放宽到 0–100;真实满分仍以 rubric 为准,由 after 钳制
|
||||
mem_fidelity: float = Field(default=0, ge=0, le=100, description="记忆忠实度")
|
||||
mem_factual_accuracy: float = Field(default=0, ge=0, le=100, description="事实准确性")
|
||||
mem_factual_coverage: float = Field(default=0, ge=0, le=100, description="事实覆盖率")
|
||||
mem_factual_accuracy: float = Field(
|
||||
default=0, ge=0, le=100, description="事实准确性"
|
||||
)
|
||||
mem_factual_coverage: float = Field(
|
||||
default=0, ge=0, le=100, description="事实覆盖率"
|
||||
)
|
||||
mem_traceability: float = Field(default=0, ge=0, le=100, description="记忆可追溯性")
|
||||
|
||||
info_slot_coverage: float = Field(default=0, ge=0, le=100, description="槽位覆盖度")
|
||||
@@ -290,7 +294,9 @@ class MemoirJudgeOutput(BaseModel):
|
||||
def _coerce_memoir_judge_input(cls, data: Any) -> Any:
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
data["rationale"] = "" if data.get("rationale") is None else str(data["rationale"])
|
||||
data["rationale"] = (
|
||||
"" if data.get("rationale") is None else str(data["rationale"])
|
||||
)
|
||||
for key in ("major_strengths", "major_issues", "insufficient_evidence"):
|
||||
data[key] = _coerce_judge_str_list(data.get(key))
|
||||
raw_refs = data.get("evidence_refs")
|
||||
|
||||
@@ -492,7 +492,9 @@ class EvalJudgeService:
|
||||
self._llm,
|
||||
prompt,
|
||||
MemoirJudgeOutput,
|
||||
max_tokens=max(512, int(settings.eval_judge_memoir_completion_max_tokens)),
|
||||
max_tokens=max(
|
||||
512, int(settings.eval_judge_memoir_completion_max_tokens)
|
||||
),
|
||||
agent="EvalJudgeService.judge_memoir",
|
||||
)
|
||||
return JudgeCallResult(output=out)
|
||||
|
||||
@@ -106,9 +106,7 @@ def build_memoir_compare_summary(
|
||||
key_regressions = [
|
||||
v["label"] for v in leaf_deltas.values() if float(v["delta"]) <= -0.5
|
||||
]
|
||||
key_gains = [
|
||||
v["label"] for v in leaf_deltas.values() if float(v["delta"]) >= 0.5
|
||||
]
|
||||
key_gains = [v["label"] for v in leaf_deltas.values() if float(v["delta"]) >= 0.5]
|
||||
|
||||
parity_passed = total_delta >= -2.0 and len(key_regressions) <= 3
|
||||
surpass_passed = total_delta >= 2.0 and len(key_regressions) <= 1
|
||||
|
||||
@@ -363,9 +363,7 @@ def ingest_transcripts_batch_sync(
|
||||
if not text:
|
||||
continue
|
||||
primary_mid = (
|
||||
primary_user_message_id_from_lineage(lineage_json)
|
||||
if lineage_json
|
||||
else None
|
||||
primary_user_message_id_from_lineage(lineage_json) if lineage_json else None
|
||||
)
|
||||
source = create_source_sync(
|
||||
session,
|
||||
@@ -419,9 +417,7 @@ def ingest_transcripts_batch_sync(
|
||||
)
|
||||
|
||||
emb_ok = (
|
||||
embedding_provider.is_available()
|
||||
if embedding_provider is not None
|
||||
else False
|
||||
embedding_provider.is_available() if embedding_provider is not None else False
|
||||
)
|
||||
logger.info(
|
||||
"event=memory_ingest_batch_done user_id={} sources={} chunks={} "
|
||||
|
||||
@@ -128,7 +128,9 @@ def _log_task_prerun(
|
||||
|
||||
|
||||
@task_success.connect
|
||||
def _log_task_success(sender: object | None = None, result: object | None = None, **_: object) -> None:
|
||||
def _log_task_success(
|
||||
sender: object | None = None, result: object | None = None, **_: object
|
||||
) -> None:
|
||||
"""仅成功路径;失败见 ``task_failure``(避免 ``task_postrun`` 在异常态仍触发)。"""
|
||||
name = getattr(sender, "name", None) if sender is not None else None
|
||||
name = name or "?"
|
||||
|
||||
@@ -185,7 +185,9 @@ def _update_task_status_sync(
|
||||
|
||||
logger.debug("任务状态已更新: task_id={} status={}", task_id, status)
|
||||
except Exception as e:
|
||||
logger.error("event=memoir_task_status_update_failed msg=更新任务状态失败 exc={}", e)
|
||||
logger.error(
|
||||
"event=memoir_task_status_update_failed msg=更新任务状态失败 exc={}", e
|
||||
)
|
||||
|
||||
|
||||
def _merge_chapter_image_assets(
|
||||
|
||||
@@ -151,9 +151,7 @@ def _claim_story_image_intent_sync(db, story_id: str, claim_token: str):
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=30)
|
||||
def generate_story_image(
|
||||
self, story_id: str, memoir_correlation_id: str | None = None
|
||||
):
|
||||
def generate_story_image(self, story_id: str, memoir_correlation_id: str | None = None):
|
||||
"""
|
||||
为 story 生成主插图。
|
||||
从 story_image_intents 原子认领 primary intent,生成后写入 assets 并更新 intent。
|
||||
|
||||
@@ -47,9 +47,7 @@ def test_run_batch_phase1_prep_chunked_splits_95_into_four_calls(
|
||||
"app.agents.memoir.batch_phase1_prep.run_batch_phase1_prep",
|
||||
fake_prep,
|
||||
)
|
||||
segments = [
|
||||
SimpleNamespace(id=f"s{i}", user_input_text="hello") for i in range(95)
|
||||
]
|
||||
segments = [SimpleNamespace(id=f"s{i}", user_input_text="hello") for i in range(95)]
|
||||
by_id = run_batch_phase1_prep_chunked(
|
||||
segments,
|
||||
_state(),
|
||||
@@ -85,9 +83,7 @@ def test_chunked_bisect_on_value_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"app.agents.memoir.batch_phase1_prep.run_batch_phase1_prep",
|
||||
fake_prep,
|
||||
)
|
||||
segments = [
|
||||
SimpleNamespace(id=f"b{i}", user_input_text="x") for i in range(4)
|
||||
]
|
||||
segments = [SimpleNamespace(id=f"b{i}", user_input_text="x") for i in range(4)]
|
||||
by_id = run_batch_phase1_prep_chunked(
|
||||
segments,
|
||||
_state(),
|
||||
|
||||
@@ -83,23 +83,15 @@ def test_merge_fanout_lists_merge_by_id(fake_redis: _FakeRedis) -> None:
|
||||
|
||||
|
||||
def test_init_and_index_resolve(fake_redis: _FakeRedis) -> None:
|
||||
mpp.init_pipeline_run_from_phase1(
|
||||
"user-a", "cid-4", "p1tid", segment_count=3
|
||||
)
|
||||
mpp.init_pipeline_run_from_phase1("user-a", "cid-4", "p1tid", segment_count=3)
|
||||
cid = mpp.resolve_correlation_id_for_phase1_task("p1tid")
|
||||
assert cid == "cid-4"
|
||||
snap = mpp.get_pipeline_run_for_eval(
|
||||
"user-a", phase1_task_id="p1tid"
|
||||
)
|
||||
snap = mpp.get_pipeline_run_for_eval("user-a", phase1_task_id="p1tid")
|
||||
assert snap is not None
|
||||
assert snap["user_id"] == "user-a"
|
||||
assert snap["phase1"]["task_id"] == "p1tid"
|
||||
|
||||
|
||||
def test_get_pipeline_run_for_eval_user_mismatch(fake_redis: _FakeRedis) -> None:
|
||||
mpp.init_pipeline_run_from_phase1(
|
||||
"user-a", "cid-5", "p1b", segment_count=1
|
||||
)
|
||||
assert (
|
||||
mpp.get_pipeline_run_for_eval("other", phase1_task_id="p1b") is None
|
||||
)
|
||||
mpp.init_pipeline_run_from_phase1("user-a", "cid-5", "p1b", segment_count=1)
|
||||
assert mpp.get_pipeline_run_for_eval("other", phase1_task_id="p1b") is None
|
||||
|
||||
@@ -26,7 +26,9 @@ def test_enrichment_payload_roundtrip() -> None:
|
||||
assert p.facts[0].subject == "王伟"
|
||||
|
||||
|
||||
def test_enrich_memory_after_ingest_sync_single_llm_call(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_enrich_memory_after_ingest_sync_single_llm_call(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from app.features.memory import enrichment as mod
|
||||
|
||||
monkeypatch.setattr("app.core.config.settings.memory_enrichment_enabled", True)
|
||||
@@ -81,7 +83,9 @@ def test_enrich_memory_after_ingest_sync_single_llm_call(monkeypatch: pytest.Mon
|
||||
assert facts[0]["status"] == "confirmed"
|
||||
|
||||
|
||||
def test_enrich_memory_skips_when_parse_returns_none(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_enrich_memory_skips_when_parse_returns_none(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from app.features.memory import enrichment as mod
|
||||
|
||||
monkeypatch.setattr("app.core.config.settings.memory_enrichment_enabled", True)
|
||||
|
||||
@@ -46,7 +46,9 @@ def test_retrieve_evidence_bundle_sync_uses_vector_search(
|
||||
}
|
||||
|
||||
monkeypatch.setattr(evidence_mod, "search_chunks_vector_sync", fake_search)
|
||||
monkeypatch.setattr(evidence_mod, "fetch_evidence_metadata_parallel_sync", fake_meta)
|
||||
monkeypatch.setattr(
|
||||
evidence_mod, "fetch_evidence_metadata_parallel_sync", fake_meta
|
||||
)
|
||||
|
||||
out = retrieve_evidence_bundle_sync(
|
||||
session=object(),
|
||||
|
||||
@@ -200,6 +200,26 @@
|
||||
"lineHeightTight": 24,
|
||||
"lineHeightLoose": 29,
|
||||
"lineHeightXLoose": 34
|
||||
},
|
||||
"xlarge": {
|
||||
"headingLarge": 44,
|
||||
"headingMedium": 33,
|
||||
"headingSmall": 28,
|
||||
"titleLarge": 28,
|
||||
"titleMedium": 25,
|
||||
"titleSmall": 21,
|
||||
"bodyLarge": 28,
|
||||
"bodyMedium": 26,
|
||||
"bodySmall": 18,
|
||||
"captionLarge": 18,
|
||||
"captionMedium": 16,
|
||||
"captionSmall": 15,
|
||||
"sectionTitle": 16,
|
||||
"badge": 13,
|
||||
"lineHeightNormal": 30,
|
||||
"lineHeightTight": 28,
|
||||
"lineHeightLoose": 34,
|
||||
"lineHeightXLoose": 40
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -960,13 +960,14 @@ export default function ConversationScreen() {
|
||||
/** 大字模式:朗读图标与触控区与气泡字号同档放大 */
|
||||
const chatReadAloudIconSize = largeText ? 24 : 20;
|
||||
const chatReadAloudButtonSize = largeText ? 52 : 44;
|
||||
const chatVoiceDurationStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.headingSmall : typography.titleMedium,
|
||||
const chatVoiceDurationStyle = useMemo(() => {
|
||||
const fs = largeText ? typography.headingSmall : typography.titleMedium;
|
||||
return {
|
||||
fontSize: fs,
|
||||
lineHeight: Math.ceil(fs * 1.25),
|
||||
fontWeight: '500' as const,
|
||||
}),
|
||||
[typography, largeText],
|
||||
);
|
||||
};
|
||||
}, [typography, largeText]);
|
||||
const chatTypingLabelStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.bodySmall : typography.captionLarge,
|
||||
@@ -977,6 +978,9 @@ export default function ConversationScreen() {
|
||||
const headerTitleFontStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.headingMedium : typography.headingSmall,
|
||||
lineHeight: largeText
|
||||
? Math.ceil(typography.headingMedium * 1.4)
|
||||
: Math.ceil(typography.headingSmall * 1.28),
|
||||
}),
|
||||
[typography, largeText],
|
||||
);
|
||||
@@ -992,13 +996,14 @@ export default function ConversationScreen() {
|
||||
}),
|
||||
[typography, inputLineHeight, largeText],
|
||||
);
|
||||
const connectionNoticeTitleStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.titleSmall : typography.captionLarge,
|
||||
const connectionNoticeTitleStyle = useMemo(() => {
|
||||
const fs = largeText ? typography.titleSmall : typography.captionLarge;
|
||||
return {
|
||||
fontSize: fs,
|
||||
lineHeight: Math.ceil(fs * 1.28),
|
||||
fontWeight: '700' as const,
|
||||
}),
|
||||
[typography, largeText],
|
||||
);
|
||||
};
|
||||
}, [typography, largeText]);
|
||||
const connectionNoticeBodyStyle = useMemo(
|
||||
() => ({
|
||||
fontSize: largeText ? typography.bodySmall : typography.captionLarge,
|
||||
@@ -1401,7 +1406,10 @@ export default function ConversationScreen() {
|
||||
variant="chat"
|
||||
title={
|
||||
<View style={styles.headerTitleBlock}>
|
||||
<Text style={[styles.headerTitle, headerTitleFontStyle]}>
|
||||
<Text
|
||||
style={[styles.headerTitle, headerTitleFontStyle]}
|
||||
includeFontPadding={Platform.OS === 'android' ? false : undefined}
|
||||
>
|
||||
{tApp('name')}
|
||||
</Text>
|
||||
{showConnectionBadge ? (
|
||||
@@ -1757,6 +1765,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
voiceDurationText: {
|
||||
fontSize: 18,
|
||||
lineHeight: 24,
|
||||
fontWeight: '500',
|
||||
},
|
||||
voiceDurationTextUser: {
|
||||
@@ -1879,15 +1888,13 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
voiceRecordDurationWrap: {
|
||||
minWidth: 40,
|
||||
height: 20,
|
||||
minHeight: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 1,
|
||||
},
|
||||
/** 字号/行高由 voiceRecordDurationStyle 提供,避免大字模式仍锁 20px 高裁切 */
|
||||
voiceRecordDuration: {
|
||||
fontSize: 12,
|
||||
/** 与容器等高,避免 Android/iOS 数字相对胶囊上下偏移 */
|
||||
height: 20,
|
||||
lineHeight: 20,
|
||||
paddingVertical: 0,
|
||||
marginVertical: 0,
|
||||
color: 'rgba(27, 27, 31, 0.86)',
|
||||
@@ -1905,7 +1912,7 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
},
|
||||
sendButton: {
|
||||
height: 44,
|
||||
minHeight: 44,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 11,
|
||||
borderRadius: 22,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { TabBarIcon } from '@/components/tab-bar-icon';
|
||||
import { useTypography } from '@/core/typography-context';
|
||||
import { useSession } from '@/features/auth/hooks';
|
||||
|
||||
// Life-Echo bottom nav colors (from HTML reference)
|
||||
@@ -27,6 +28,7 @@ const TAB_BAR_PADDING_HORIZONTAL = 12;
|
||||
|
||||
export default function TabsLayout() {
|
||||
const { status } = useSession();
|
||||
const typography = useTypography();
|
||||
const { colorScheme } = useColorScheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const isDark = colorScheme === 'dark';
|
||||
@@ -75,7 +77,8 @@ export default function TabsLayout() {
|
||||
marginBottom: 0,
|
||||
},
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 11,
|
||||
fontSize: typography.captionLarge,
|
||||
lineHeight: typography.lineHeightTight,
|
||||
fontWeight: '600',
|
||||
textTransform: 'uppercase',
|
||||
marginTop: 4,
|
||||
|
||||
@@ -154,14 +154,16 @@ function ConversationCard({
|
||||
{item.title}
|
||||
</Text>
|
||||
<Text
|
||||
className="shrink-0 text-sm font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
variant="captionMedium"
|
||||
className="shrink-0 font-semibold uppercase tracking-wider text-muted-foreground"
|
||||
style={{ fontVariant: ['tabular-nums'] }}
|
||||
>
|
||||
{formatRelativeConversationListTime(item.latestMessageTime, t)}
|
||||
</Text>
|
||||
</View>
|
||||
<Text
|
||||
className="min-w-0 self-stretch text-base font-semibold text-muted-foreground"
|
||||
variant="bodyMedium"
|
||||
className="min-w-0 self-stretch font-semibold text-muted-foreground"
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
@@ -202,7 +204,8 @@ function SwipeableConversationCard({
|
||||
>
|
||||
<Icon as={Trash2} className="text-destructive-foreground" size={24} />
|
||||
<Text
|
||||
className="mt-1 text-xs font-semibold text-destructive-foreground"
|
||||
variant="captionSmall"
|
||||
className="mt-1 font-semibold text-destructive-foreground"
|
||||
selectable={false}
|
||||
>
|
||||
{t('delete')}
|
||||
@@ -405,11 +408,13 @@ export default function ConversationsScreen() {
|
||||
<Text
|
||||
variant="h2"
|
||||
className="text-center font-display text-primary"
|
||||
style={{ borderWidth: 0, fontSize: 28, lineHeight: 38 }}
|
||||
>
|
||||
{t('greetingTitle')}
|
||||
</Text>
|
||||
<Text className="text-center text-base font-medium leading-6 text-muted-foreground">
|
||||
<Text
|
||||
variant="bodyLarge"
|
||||
className="text-center font-medium text-muted-foreground"
|
||||
>
|
||||
{t('emptyGreetingSubtitle')}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -429,13 +434,15 @@ export default function ConversationsScreen() {
|
||||
<Text
|
||||
variant="h2"
|
||||
className="text-center font-display text-primary"
|
||||
style={{ borderWidth: 0, fontSize: 28, lineHeight: 38 }}
|
||||
>
|
||||
{showResumeEntry
|
||||
? t('resumeChatTitle')
|
||||
: t('greetingTitle')}
|
||||
</Text>
|
||||
<Text className="text-center text-base font-medium leading-6 text-muted-foreground">
|
||||
<Text
|
||||
variant="bodyLarge"
|
||||
className="text-center font-medium text-muted-foreground"
|
||||
>
|
||||
{showResumeEntry
|
||||
? t('resumeChatSubtitle')
|
||||
: t('emptyGreetingSubtitle')}
|
||||
@@ -449,10 +456,14 @@ export default function ConversationsScreen() {
|
||||
{t('recentChats')}
|
||||
</Text>
|
||||
<Pressable
|
||||
className="min-h-11 min-w-11 items-center justify-center active:opacity-70"
|
||||
className="min-h-11 items-center justify-center px-2 active:opacity-70"
|
||||
hitSlop={{ top: 6, bottom: 6, left: 4, right: 4 }}
|
||||
onPress={() => {}}
|
||||
>
|
||||
<Text className="text-sm font-semibold text-primary">
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
className="font-semibold text-primary"
|
||||
>
|
||||
{t('viewAll')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Icon } from '@/components/ui/icon';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { ScreenGutter } from '@/constants/layout';
|
||||
import { useTypography } from '@/core/typography-context';
|
||||
import { useCreateConversation } from '@/features/conversation/hooks';
|
||||
import {
|
||||
buildFrameworkChapterPlaceholders,
|
||||
@@ -77,9 +78,18 @@ function ChapterCard({
|
||||
onReadPress: () => void;
|
||||
onContinuePress: () => void;
|
||||
}) {
|
||||
const typography = useTypography();
|
||||
const { width } = useWindowDimensions();
|
||||
const contentWidth = Math.min(width - ScreenGutter * 2, 672);
|
||||
const gutter = ScreenGutter;
|
||||
const completedTitleLineHeight = Math.max(
|
||||
typography.lineHeightTight + 6,
|
||||
typography.headingMedium + 6,
|
||||
);
|
||||
const draftingTitleLineHeight = Math.max(
|
||||
typography.lineHeightTight + 4,
|
||||
typography.titleLarge + 4,
|
||||
);
|
||||
|
||||
const chapterIndex = item.orderIndex + 1;
|
||||
const chapterLabel = t('chapterLabel').replace(
|
||||
@@ -142,7 +152,8 @@ function ChapterCard({
|
||||
<View style={{ padding: gutter, gap: 12 }}>
|
||||
<View style={{ gap: 4, minHeight: 72 }}>
|
||||
<Text
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
variant="captionMedium"
|
||||
className="font-medium text-muted-foreground"
|
||||
selectable
|
||||
>
|
||||
{chapterLabel}
|
||||
@@ -150,7 +161,7 @@ function ChapterCard({
|
||||
<Text
|
||||
variant="h2"
|
||||
className="text-foreground"
|
||||
style={{ lineHeight: 34 }}
|
||||
style={{ lineHeight: completedTitleLineHeight }}
|
||||
numberOfLines={2}
|
||||
selectable
|
||||
>
|
||||
@@ -161,7 +172,8 @@ function ChapterCard({
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Icon as={FileText} className="text-muted-foreground" size={16} />
|
||||
<Text
|
||||
className="text-sm font-medium text-muted-foreground"
|
||||
variant="bodySmall"
|
||||
className="font-medium text-muted-foreground"
|
||||
style={{ fontVariant: ['tabular-nums'] }}
|
||||
selectable
|
||||
>
|
||||
@@ -174,7 +186,10 @@ function ChapterCard({
|
||||
style={{ borderCurve: 'continuous' }}
|
||||
onPress={onReadPress}
|
||||
>
|
||||
<Text className="text-base font-semibold text-primary-foreground">
|
||||
<Text
|
||||
variant="bodyLarge"
|
||||
className="font-semibold text-primary-foreground"
|
||||
>
|
||||
{t('readMemory')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -197,7 +212,8 @@ function ChapterCard({
|
||||
<View className="gap-1">
|
||||
<View>
|
||||
<Text
|
||||
className="text-xs font-medium text-muted-foreground"
|
||||
variant="captionMedium"
|
||||
className="font-medium text-muted-foreground"
|
||||
selectable
|
||||
>
|
||||
{chapterLabel}
|
||||
@@ -205,7 +221,7 @@ function ChapterCard({
|
||||
<Text
|
||||
variant="h4"
|
||||
className="mt-0.5 text-foreground"
|
||||
style={{ lineHeight: 28 }}
|
||||
style={{ lineHeight: draftingTitleLineHeight }}
|
||||
numberOfLines={2}
|
||||
selectable
|
||||
>
|
||||
@@ -213,7 +229,11 @@ function ChapterCard({
|
||||
</Text>
|
||||
</View>
|
||||
<View className="mt-2 flex-row items-center gap-2">
|
||||
<Text className="text-sm font-medium text-secondary" selectable>
|
||||
<Text
|
||||
variant="bodySmall"
|
||||
className="font-medium text-secondary"
|
||||
selectable
|
||||
>
|
||||
{t('statusDrafting')}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -223,7 +243,7 @@ function ChapterCard({
|
||||
style={{ borderCurve: 'continuous' }}
|
||||
onPress={onContinuePress}
|
||||
>
|
||||
<Text className="text-base font-semibold text-secondary">
|
||||
<Text variant="bodyLarge" className="font-semibold text-secondary">
|
||||
{t('continueWriting')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
@@ -237,7 +257,7 @@ function MemoirLoadError({ onRetry }: { onRetry: () => void }) {
|
||||
const { t } = useTranslation('memoir');
|
||||
return (
|
||||
<View className="items-center gap-4 rounded-2xl border border-dashed border-border bg-muted/20 p-10">
|
||||
<Text className="text-center text-base text-destructive">
|
||||
<Text variant="bodyLarge" className="text-center text-destructive">
|
||||
{t('loadErrorMessage')}
|
||||
</Text>
|
||||
<Pressable
|
||||
@@ -245,7 +265,10 @@ function MemoirLoadError({ onRetry }: { onRetry: () => void }) {
|
||||
style={{ borderCurve: 'continuous' }}
|
||||
onPress={onRetry}
|
||||
>
|
||||
<Text className="font-semibold text-primary-foreground">
|
||||
<Text
|
||||
variant="bodyMedium"
|
||||
className="font-semibold text-primary-foreground"
|
||||
>
|
||||
{t('loadErrorRetry')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -90,11 +90,15 @@ function LanguageRow({
|
||||
<Icon as={Globe} className="text-muted-foreground" size={20} />
|
||||
<View>
|
||||
<Text className="font-medium text-foreground">{label}</Text>
|
||||
<Text className="text-sm text-muted-foreground">{description}</Text>
|
||||
<Text variant="bodySmall" className="text-muted-foreground">
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Text className="text-sm text-muted-foreground">{currentLabel}</Text>
|
||||
<Text variant="bodySmall" className="text-muted-foreground">
|
||||
{currentLabel}
|
||||
</Text>
|
||||
<Icon as={ChevronRight} className="text-muted-foreground" size={20} />
|
||||
</View>
|
||||
</Pressable>
|
||||
@@ -120,7 +124,9 @@ function SettingRow({
|
||||
<Icon as={SettingIcon} className="text-muted-foreground" size={20} />
|
||||
<View>
|
||||
<Text className="font-medium text-foreground">{label}</Text>
|
||||
<Text className="text-sm text-muted-foreground">{description}</Text>
|
||||
<Text variant="bodySmall" className="text-muted-foreground">
|
||||
{description}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Switch checked={value} onCheckedChange={onValueChange} />
|
||||
@@ -203,7 +209,7 @@ export default function ProfileScreen() {
|
||||
<Text variant="h3" className="text-foreground">
|
||||
{user?.nickname ?? t('userNamePlaceholder')}
|
||||
</Text>
|
||||
<Text className="text-sm text-muted-foreground">
|
||||
<Text variant="bodySmall" className="text-muted-foreground">
|
||||
{t('userTier', { tier: tierLabel })}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { router } from 'expo-router';
|
||||
import { ArrowLeft } from 'lucide-react-native';
|
||||
import React from 'react';
|
||||
import { Pressable, View } from 'react-native';
|
||||
import { Platform, Pressable, Text as RNText, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
import { Icon } from '@/components/ui/icon';
|
||||
import { Text } from '@/components/ui/text';
|
||||
import { useTypography } from '@/core/typography-context';
|
||||
import { ScreenGutter } from '@/constants/layout';
|
||||
import { useAppSettings } from '@/hooks/use-app-settings';
|
||||
|
||||
/** 默认最小触控目标 48dp;大字模式下与标题字号匹配,略向左扩展便于够到边缘 */
|
||||
const BACK_HIT_MIN = 48;
|
||||
const BACK_HIT_MIN_LARGE = 56;
|
||||
const BACK_ICON_SIZE = 24;
|
||||
const BACK_ICON_SIZE_LARGE = 30;
|
||||
const BACK_EXTRA_HIT_LEFT = 4;
|
||||
|
||||
export type ScreenHeaderVariant = 'default' | 'chat' | 'reading';
|
||||
|
||||
@@ -64,21 +72,44 @@ export function ScreenHeader({
|
||||
useSafeArea = true,
|
||||
}: ScreenHeaderProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
const { largeText } = useAppSettings();
|
||||
const typography = useTypography();
|
||||
const colors = VARIANT_COLORS[variant];
|
||||
|
||||
const backTouchMin = largeText ? BACK_HIT_MIN_LARGE : BACK_HIT_MIN;
|
||||
const backIconSize = largeText ? BACK_ICON_SIZE_LARGE : BACK_ICON_SIZE;
|
||||
|
||||
const handleBack = onBack ?? (() => router.back());
|
||||
const bgColor = backgroundColor ?? colors.background;
|
||||
const titleColor = colors.title;
|
||||
const iconColor = colors.icon ?? colors.iconSecondary;
|
||||
|
||||
/**
|
||||
* 不要用外层 minHeight「框死」整块栏:paddingTop 含安全区时会把内容区压扁。
|
||||
* 标题行最小高度随当前排版 token 计算(关大字也要留够,避免裁字 / Android font padding)。
|
||||
*/
|
||||
const barPaddingBottom = largeText ? 18 : 16;
|
||||
const titleRowPaddingV = largeText ? 8 : 4;
|
||||
const titleFontSize =
|
||||
variant === 'chat'
|
||||
? largeText
|
||||
? typography.headingMedium
|
||||
: typography.headingSmall
|
||||
: Math.max(typography.titleLarge, typography.headingSmall);
|
||||
const titleLineMin =
|
||||
variant === 'chat'
|
||||
? Math.ceil(titleFontSize * (largeText ? 1.45 : 1.38)) +
|
||||
(largeText ? 14 : 10)
|
||||
: Math.ceil(titleFontSize * 1.32) + (largeText ? 10 : 8);
|
||||
const titleRowMinHeight = Math.max(backTouchMin, titleLineMin);
|
||||
|
||||
const containerStyle = {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'space-between' as const,
|
||||
paddingHorizontal: Math.max(ScreenGutter, 16),
|
||||
paddingTop: useSafeArea ? Math.max(insets.top, 12) : 12,
|
||||
paddingBottom: 16,
|
||||
minHeight: 56,
|
||||
paddingBottom: barPaddingBottom,
|
||||
...(bgColor && { backgroundColor: bgColor }),
|
||||
...(absolute && {
|
||||
position: 'absolute' as const,
|
||||
@@ -105,41 +136,74 @@ export function ScreenHeader({
|
||||
gap: 12,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
paddingVertical: titleRowPaddingV,
|
||||
minHeight: titleRowMinHeight,
|
||||
}}
|
||||
>
|
||||
{showBack && (
|
||||
<Pressable
|
||||
onPress={handleBack}
|
||||
hitSlop={
|
||||
Platform.OS === 'android'
|
||||
? {
|
||||
top: largeText ? 10 : 6,
|
||||
bottom: largeText ? 10 : 6,
|
||||
right: 8,
|
||||
left: BACK_EXTRA_HIT_LEFT,
|
||||
}
|
||||
: {
|
||||
top: largeText ? 8 : 4,
|
||||
bottom: largeText ? 8 : 4,
|
||||
left: 2,
|
||||
right: 4,
|
||||
}
|
||||
}
|
||||
android_ripple={
|
||||
Platform.OS === 'android'
|
||||
? { borderless: true, radius: backTouchMin / 2 }
|
||||
: undefined
|
||||
}
|
||||
style={({ pressed }) => ({
|
||||
padding: 8,
|
||||
minWidth: backTouchMin,
|
||||
minHeight: backTouchMin,
|
||||
marginLeft: -BACK_EXTRA_HIT_LEFT,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 9999,
|
||||
opacity: pressed ? 0.7 : 1,
|
||||
opacity: Platform.OS === 'android' ? 1 : pressed ? 0.7 : 1,
|
||||
})}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={backAccessibilityLabel}
|
||||
>
|
||||
{iconColor ? (
|
||||
<Icon as={ArrowLeft} size={24} color={iconColor} />
|
||||
<Icon as={ArrowLeft} size={backIconSize} color={iconColor} />
|
||||
) : (
|
||||
<Icon as={ArrowLeft} size={24} className="text-foreground" />
|
||||
<Icon
|
||||
as={ArrowLeft}
|
||||
size={backIconSize}
|
||||
className="text-foreground"
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
)}
|
||||
{typeof title === 'string' ? (
|
||||
<Text
|
||||
<RNText
|
||||
numberOfLines={1}
|
||||
selectable
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: variant === 'chat' ? 24 : 18,
|
||||
fontSize: variant === 'chat' ? titleFontSize : 18,
|
||||
lineHeight:
|
||||
variant === 'chat'
|
||||
? Math.ceil(titleFontSize * 1.35)
|
||||
: Math.ceil(18 * 1.32),
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.5,
|
||||
...(titleColor && { color: titleColor }),
|
||||
}}
|
||||
className={!titleColor ? 'text-foreground' : undefined}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</RNText>
|
||||
) : (
|
||||
<View style={{ flex: 1, minWidth: 0 }}>{title}</View>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { buttonTextVariants, buttonVariants } from '@/components/ui/button';
|
||||
import { NativeOnlyAnimatedView } from '@/components/ui/native-only-animated-view';
|
||||
import { TextClassContext } from '@/components/ui/text';
|
||||
import { useTypography } from '@/core/typography-context';
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as AlertDialogPrimitive from '@rn-primitives/alert-dialog';
|
||||
import * as React from 'react';
|
||||
@@ -97,11 +98,20 @@ function AlertDialogFooter({ className, ...props }: ViewProps) {
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
style,
|
||||
...props
|
||||
}: AlertDialogPrimitive.TitleProps & React.RefAttributes<AlertDialogPrimitive.TitleRef>) {
|
||||
const typography = useTypography();
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
className={cn('min-w-0 text-foreground text-lg font-semibold', className)}
|
||||
className={cn('min-w-0 text-foreground font-semibold', className)}
|
||||
style={[
|
||||
{
|
||||
fontSize: typography.titleLarge,
|
||||
lineHeight: typography.lineHeightTight,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -113,15 +123,20 @@ function AlertDialogDescription({
|
||||
...props
|
||||
}: AlertDialogPrimitive.DescriptionProps &
|
||||
React.RefAttributes<AlertDialogPrimitive.DescriptionRef>) {
|
||||
const typography = useTypography();
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
className={cn(
|
||||
// shrink: RN 中 Text 在 flex 内需 flexShrink 才能按父宽换行
|
||||
'w-full min-w-0 shrink text-muted-foreground text-sm',
|
||||
'w-full min-w-0 shrink text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
style={[
|
||||
Platform.OS === 'web' ? undefined : { flexShrink: 1 },
|
||||
{
|
||||
fontSize: typography.bodyMedium,
|
||||
lineHeight: typography.lineHeightLoose,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
{...props}
|
||||
@@ -135,7 +150,15 @@ function AlertDialogAction({
|
||||
}: AlertDialogPrimitive.ActionProps & React.RefAttributes<AlertDialogPrimitive.ActionRef>) {
|
||||
return (
|
||||
<TextClassContext.Provider value={buttonTextVariants({ className })}>
|
||||
<AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(
|
||||
buttonVariants(),
|
||||
// 大字模式下文与行高变大,固定 h-10 会裁切标签(如「知道了」)
|
||||
'h-auto min-h-14 py-3.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -147,7 +170,11 @@ function AlertDialogCancel({
|
||||
return (
|
||||
<TextClassContext.Provider value={buttonTextVariants({ className, variant: 'outline' })}>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: 'outline' }), className)}
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'outline' }),
|
||||
'h-auto min-h-14 py-3.5',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TextClassContext.Provider>
|
||||
|
||||
@@ -3,7 +3,13 @@ import { cn } from '@/lib/utils';
|
||||
import * as Slot from '@rn-primitives/slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import * as React from 'react';
|
||||
import { Platform, Text as RNText, type Role } from 'react-native';
|
||||
import {
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text as RNText,
|
||||
type Role,
|
||||
type TextStyle,
|
||||
} from 'react-native';
|
||||
|
||||
/**
|
||||
* Maps Text variant to design-token keys for fontSize and lineHeight.
|
||||
@@ -122,6 +128,22 @@ const ARIA_LEVEL: Partial<Record<TextVariant, string>> = {
|
||||
|
||||
const TextClassContext = React.createContext<string | undefined>(undefined);
|
||||
|
||||
/** Android 上 lineHeight < fontSize 会裁切字形(如标题配 lineHeightTight) */
|
||||
const MIN_LINE_HEIGHT_RATIO = 1.2;
|
||||
|
||||
function ensureMinimumLineHeight(style: TextStyle): TextStyle {
|
||||
const fs = style.fontSize;
|
||||
const lh = style.lineHeight;
|
||||
if (typeof fs !== 'number' || typeof lh !== 'number') {
|
||||
return style;
|
||||
}
|
||||
const minLh = Math.ceil(fs * MIN_LINE_HEIGHT_RATIO);
|
||||
if (lh < minLh) {
|
||||
return { ...style, lineHeight: minLh };
|
||||
}
|
||||
return style;
|
||||
}
|
||||
|
||||
function Text({
|
||||
className,
|
||||
asChild = false,
|
||||
@@ -151,12 +173,19 @@ function Text({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const flatStyle = StyleSheet.flatten([
|
||||
{ textAlign: 'left' as const },
|
||||
typographyStyle,
|
||||
style,
|
||||
]) as TextStyle;
|
||||
const resolvedStyle = ensureMinimumLineHeight(flatStyle);
|
||||
|
||||
return (
|
||||
<Component
|
||||
className={cn(textVariants({ variant }), textClass, className)}
|
||||
role={variant ? ROLE[variant] : undefined}
|
||||
aria-level={variant ? ARIA_LEVEL[variant] : undefined}
|
||||
style={[{ textAlign: 'left' }, typographyStyle, style]}
|
||||
style={resolvedStyle}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Typography context for large text mode (大字模式).
|
||||
* Global typography from design tokens.
|
||||
*
|
||||
* - **关大字模式** → `typography.large`(舒适基准,不用过小的 `normal` 档)
|
||||
* - **开大字模式** → `typography.xlarge`(一级页、正文等整体再放大一档)
|
||||
*
|
||||
* 对话气泡等仍可在页面内用 `largeText` 做更大一级的 token 选择。
|
||||
*
|
||||
* Uses React Context instead of NativeWind vars() because vars() returns empty
|
||||
* objects on native (iOS/Android), so CSS variables never cascade to children.
|
||||
* See: https://github.com/nativewind/nativewind/issues/1113
|
||||
*
|
||||
* NativeWind vars() docs (works on web only):
|
||||
* https://www.nativewind.dev/docs/api/vars
|
||||
* objects on native (iOS/Android). See:
|
||||
* https://github.com/nativewind/nativewind/issues/1113
|
||||
*/
|
||||
import React, {
|
||||
createContext,
|
||||
@@ -19,18 +21,23 @@ import { useAppSettings } from '@/hooks/use-app-settings';
|
||||
|
||||
import tokens from '../../design-tokens.json';
|
||||
|
||||
export type TypographyMode = 'normal' | 'large';
|
||||
export type TypographyScale = 'large' | 'xlarge';
|
||||
|
||||
export type TypographyTokens = Record<string, number>;
|
||||
|
||||
const TypographyContext = createContext<TypographyTokens | null>(null);
|
||||
|
||||
const SCALE: Record<TypographyScale, TypographyTokens> = {
|
||||
large: tokens.typography.large as TypographyTokens,
|
||||
xlarge: tokens.typography.xlarge as TypographyTokens,
|
||||
};
|
||||
|
||||
export function TypographyProvider({ children }: PropsWithChildren) {
|
||||
const { largeText } = useAppSettings();
|
||||
const typography = useMemo(() => {
|
||||
const mode: TypographyMode = largeText ? 'large' : 'normal';
|
||||
return tokens.typography[mode] as TypographyTokens;
|
||||
}, [largeText]);
|
||||
const typography = useMemo(
|
||||
() => SCALE[largeText ? 'xlarge' : 'large'],
|
||||
[largeText],
|
||||
);
|
||||
|
||||
return (
|
||||
<TypographyContext.Provider value={typography}>
|
||||
|
||||
@@ -25,7 +25,9 @@ const typographyVars = (mode) =>
|
||||
]),
|
||||
);
|
||||
|
||||
// Use default theme for Tailwind base (runtime theme switch via ThemeProvider)
|
||||
// Use default theme for Tailwind base (runtime theme switch via ThemeProvider).
|
||||
// Typography vars use the same comfortable scale as TypographyProvider (large),
|
||||
// not the compact `normal` tier — see typography-context.tsx
|
||||
const defaultLight = tokens.colors.default.light;
|
||||
const defaultDark = tokens.colors.default.dark;
|
||||
|
||||
@@ -35,7 +37,7 @@ const rootVariables = Object.fromEntries([
|
||||
value,
|
||||
]),
|
||||
['--radius', px(tokens.radius.default)],
|
||||
...Object.entries(typographyVars('normal')),
|
||||
...Object.entries(typographyVars('large')),
|
||||
]);
|
||||
|
||||
const darkVariables = Object.fromEntries(
|
||||
|
||||
Reference in New Issue
Block a user