feat(overview): 「近 7 天」脱离历史 200 条上限,并支持近 7/30 天 × 条数/字数/时长切换 (#909)
* feat(activity): 每日活动汇总记录字数与时长
activity.json 此前每天只存一个条数。概览页想给出「近 30 天说了多少字」
这类周期指标时,只能回头从 list_history() 现算 —— 而历史有 200 条硬上限,
日均上百次的用户两三天就把上周挤没了,算出来的字数与时长必然偏低。
把单日值从裸数字扩成 {count, chars, durationMs}:
- chars 口径 = 最终插入文本的 Unicode 字符数,与历史详情页的「N 字」一致;
- durationMs 口径 = 录音时长,不含识别/润色耗时,与详情页「录音 x.x 秒」同源。
两个口径都刻意跟已有 UI 对齐,避免同一个数在两处对不上。
磁盘格式用 untagged enum 兼容旧文件:老用户的 activity.json 全是裸数字,
读不回来的话年度热力图会一次性清空(会被当成数据丢失)。旧日期没有字数/
时长,读回 0 —— 诚实缺省,好过整天丢掉条数。四个单测钉住这个契约:纯旧
格式、纯新格式、新旧混排(升级当天必然出现)、以及缺字段的对象。
存储代价可忽略:只存聚合数字、不存文本,一天一行,两年上限 731 行。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(overview): 「近 7 天」改为周期 × 指标可切换,并脱离历史 200 条上限
两件事,同一张卡:
1. 修数据源。「近 7 天」此前从 list_history() 现算,而历史有 200 条硬上限。
实测(日均约 88 次听写的真实数据):history.json 正好卡在 200 条,只装得下
两天多,于是近 7 天里前 4 天全画成 0 —— 而同一页的年度热力图上那 4 天分别是
40 / 118 / 108 / 37,亮着的。同一个页面两块数据自相矛盾。改读 activity 后
两者同源,柱子和格子对得上。
2. 加周期与指标切换。卡片标题位变成「近 7 天 / 近 30 天」,右侧原来的「条数 /
天」变成「条数 / 字数 / 时长」。月度字数是用户实际想看的数(跟按月计费的
竞品比较时要用),此前完全看不到。
卡片顶部显示周期总计(大字)+ 日均:想知道「这个月总共说了多少字」是要一个数,
不是在 30 根柱子里目测求和。30 天模式下柱子只有几像素宽,逐柱数字会糊成一片,
改为 title 悬浮显示、横轴只标首/中/末三个日期。
聚合逻辑抽成 lib/activityMetrics.ts 并带单测:窗口长度恒定、缺失日期补 0、
跨月边界、老数据无 chars/durationMs 时不产生 NaN(NaN 会把柱状图的 max 算坏)。
日期键一律用本地年月日拼,不能用 toISOString() —— 后者按 UTC 切日,东八区凌晨
的会话会算到前一天,与后端 chrono::Local 写的键对不上。
顺带:活动数据的 IPC 不再按 mobile 跳过。热力图在移动端仍不渲染(issue #861),
但周期指标卡是要渲染的,跳过 IPC 会让它在移动端永远空。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* feat(overview): 概览页支持 ⌘R / Ctrl+R 刷新
历史页早就有这个键(History.tsx),概览页没有 —— 想看到新数据只能切到别的
页再切回来触发重挂载。⌘R 是「重新加载」的通用直觉,两个数据页应该一致。
一次刷新本页全部三份数据:历史、活动、凭据。preventDefault 拦掉 webview
默认的整页 reload,只重发 IPC,不让整个前端重挂载。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix(history): 详情页字数按 Unicode 码点统计,与概览页/后端口径一致
finalText.length 按 UTF-16 码元计数,emoji / CJK 扩展 B 等增补平面字符
会被双算;后端 activity 的 chars 用 polished.chars().count()(Unicode 标量),
概览页「字数」指标也以此为准。改用 Array.from(...).length 后三处同口径。
* fix(overview): 今日字数按 Unicode 码点统计,与历史详情/后端口径统一
概览页顶部「今日字数」此前用 finalText.length 按 UTF-16 码元计数,emoji / CJK 扩展 B 等增补平面字符会被双算;历史详情页(commit 70464a19)与后端 activity 的 chars 已是 Unicode 标量口径。抽成 lib/unicode.ts 的 countCodePoints 让两处前端共用,消除第三处口径漂移。
* fix(overview): 历史与活动刷新加请求序号防竞态
⌘R 一次触发历史、活动、凭据三份刷新,历史与活动此前没有 credentialsRequestSeq 那样的防竞态:快速连按可能让较早的慢响应后到并覆盖新数据。沿用同一模式,请求前自增序号、响应校验一致才 setState。
* style(overview): 分段切换器按钮光标改 pointer
SegmentedToggle 是可点击按钮,与 _atoms 中 Btn 的 cursor: pointer 交互约定保持一致(此前为 default,看起来像不可点)。
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
17 files changed
+737
-98
No files matched your search
@@ -16,15 +16,21 @@ pub fn clear_history(coord: CoordinatorState<'_>) -> Result<(), String> {
|
||||
coord.history().clear().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// 每日活动计数(日期升序),概览页年度热力图的数据源。与历史内容 / 保留策略解耦:
|
||||
/// 清空历史不影响它,全年格子照亮。
|
||||
/// 每日活动汇总(日期升序),概览页年度热力图与「近 7 天 / 近 30 天」指标的数据源。
|
||||
/// 与历史内容 / 保留策略解耦:清空历史不影响它,全年格子照亮,周期统计也不会被
|
||||
/// 历史 200 条上限截断。
|
||||
#[tauri::command]
|
||||
pub fn get_activity_stats(coord: CoordinatorState<'_>) -> Vec<ActivityDay> {
|
||||
coord
|
||||
.activity()
|
||||
.snapshot()
|
||||
.into_iter()
|
||||
.map(|(date, count)| ActivityDay { date, count })
|
||||
.map(|(date, stats)| ActivityDay {
|
||||
date,
|
||||
count: stats.count,
|
||||
chars: stats.chars,
|
||||
duration_ms: stats.duration_ms,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
||||
@@ -3934,12 +3934,16 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
|
||||
) {
|
||||
log::error!("[coord] history append failed: {e}");
|
||||
}
|
||||
// 活动计数(概览页热力图数据源):只有成功完成的听写才点亮格子——转录失败 /
|
||||
// 错误收尾的两处 append 不计。写失败不阻断主流程。
|
||||
if let Err(e) = inner
|
||||
.activity
|
||||
.bump(&chrono::Local::now().format("%Y-%m-%d").to_string())
|
||||
{
|
||||
// 活动汇总(概览页热力图 + 近 7 天 / 近 30 天指标的数据源):只有成功完成的听写
|
||||
// 才点亮格子——转录失败 / 错误收尾的两处 append 不计。写失败不阻断主流程。
|
||||
//
|
||||
// 字数口径与历史详情页的「N 字」一致(最终插入文本的 Unicode 字符数);时长口径
|
||||
// 是录音时长,不含识别/润色耗时——与详情页「录音 x.x 秒」同源,避免两处对不上。
|
||||
if let Err(e) = inner.activity.bump(
|
||||
&chrono::Local::now().format("%Y-%m-%d").to_string(),
|
||||
polished.chars().count() as u64,
|
||||
raw.duration_ms,
|
||||
) {
|
||||
log::warn!("[coord] activity bump failed: {e}");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,66 @@
|
||||
//! 每日听写活动计数(`date(YYYY-MM-DD) → count`),供概览页年度活动热力图使用。
|
||||
//! 每日听写活动汇总(`date(YYYY-MM-DD) → {count, chars, duration_ms}`),供概览页的
|
||||
//! 年度热力图与「近 7 天 / 近 30 天」统计使用。
|
||||
//!
|
||||
//! 与历史内容存储完全解耦:不含任何转写文本,也不受历史保留策略 / 条数上限影响
|
||||
//! —— 清理历史不会抹掉活动足迹,热力图因此能覆盖全年而无需放开历史上限
|
||||
//! (取代 PR #716 里「为热力图把历史改为无限保留」的方案)。
|
||||
//! 写入时按保留窗口(两年)裁剪最早的日期,文件天然有界。
|
||||
//!
|
||||
//! 只存聚合数字、不存文本,所以「多记两个字段」的隐私与体积代价可忽略:一天一行,
|
||||
//! 两年上限 731 行。
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::{atomic_write, data_dir, ensure_dir, read_or_default};
|
||||
|
||||
const ACTIVITY_FILE: &str = "activity.json";
|
||||
/// 保留最近两年(含闰年余量)的日计数,超窗的最早日期在写入时移除。
|
||||
/// 保留最近两年(含闰年余量)的日汇总,超窗的最早日期在写入时移除。
|
||||
const ACTIVITY_RETENTION_DAYS: usize = 731;
|
||||
|
||||
/// 单日汇总。字段都是纯计数,不含任何文本。
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DayStats {
|
||||
pub count: u32,
|
||||
#[serde(default)]
|
||||
pub chars: u64,
|
||||
#[serde(default)]
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// 磁盘表示。旧版本的 activity.json 每天只写一个裸数字(`{"2026-08-01": 5}`),
|
||||
/// 升级后必须原样读回来 —— 否则老用户的年度热力图会一次性清空。
|
||||
/// 旧格式没有字数/时长,读回后为 0:这些天在新指标里显示为 0 是诚实的(数据当时没记),
|
||||
/// 比整段丢掉条数要好。写入一律用新的对象格式。
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum StoredDay {
|
||||
/// 旧格式:只有条数。
|
||||
CountOnly(u32),
|
||||
/// 新格式。
|
||||
Full(DayStats),
|
||||
}
|
||||
|
||||
impl From<StoredDay> for DayStats {
|
||||
fn from(stored: StoredDay) -> Self {
|
||||
match stored {
|
||||
StoredDay::CountOnly(count) => DayStats {
|
||||
count,
|
||||
..Default::default()
|
||||
},
|
||||
StoredDay::Full(stats) => stats,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ActivityStore {
|
||||
path: PathBuf,
|
||||
cache: Mutex<BTreeMap<String, u32>>,
|
||||
cache: Mutex<BTreeMap<String, DayStats>>,
|
||||
}
|
||||
|
||||
impl ActivityStore {
|
||||
@@ -27,7 +68,11 @@ impl ActivityStore {
|
||||
let dir = data_dir()?;
|
||||
ensure_dir(&dir)?;
|
||||
let path = dir.join(ACTIVITY_FILE);
|
||||
let cache: BTreeMap<String, u32> = read_or_default(&path)?;
|
||||
let stored: BTreeMap<String, StoredDay> = read_or_default(&path)?;
|
||||
let cache = stored
|
||||
.into_iter()
|
||||
.map(|(date, day)| (date, day.into()))
|
||||
.collect();
|
||||
Ok(Self {
|
||||
path,
|
||||
cache: Mutex::new(cache),
|
||||
@@ -44,9 +89,14 @@ impl ActivityStore {
|
||||
}
|
||||
|
||||
/// 记录一次活动。`date` 为本地日期 `YYYY-MM-DD`(BTreeMap 按字典序即按日期序)。
|
||||
pub fn bump(&self, date: &str) -> Result<()> {
|
||||
/// `chars` = 本次最终插入文本的字符数,`duration_ms` = 本次录音时长。
|
||||
/// 累加用 saturating:单日理论上不可能溢出,但计数器溢出 panic 不值得赌。
|
||||
pub fn bump(&self, date: &str, chars: u64, duration_ms: u64) -> Result<()> {
|
||||
let mut cache = self.cache.lock();
|
||||
*cache.entry(date.to_string()).or_insert(0) += 1;
|
||||
let entry = cache.entry(date.to_string()).or_default();
|
||||
entry.count = entry.count.saturating_add(1);
|
||||
entry.chars = entry.chars.saturating_add(chars);
|
||||
entry.duration_ms = entry.duration_ms.saturating_add(duration_ms);
|
||||
while cache.len() > ACTIVITY_RETENTION_DAYS {
|
||||
let oldest = match cache.keys().next() {
|
||||
Some(key) => key.clone(),
|
||||
@@ -58,12 +108,86 @@ impl ActivityStore {
|
||||
atomic_write(&self.path, &bytes)
|
||||
}
|
||||
|
||||
/// 全量快照(日期升序),前端聚合成热力图。
|
||||
pub fn snapshot(&self) -> Vec<(String, u32)> {
|
||||
/// 全量快照(日期升序),前端聚合成热力图与周期指标。
|
||||
pub fn snapshot(&self) -> Vec<(String, DayStats)> {
|
||||
self.cache
|
||||
.lock()
|
||||
.iter()
|
||||
.map(|(date, count)| (date.clone(), *count))
|
||||
.map(|(date, stats)| (date.clone(), *stats))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{DayStats, StoredDay};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// 老用户升级后 activity.json 仍是「日期 → 裸数字」。必须原样读回条数,
|
||||
/// 否则年度热力图一次性清空(用户会当成数据丢失)。
|
||||
#[test]
|
||||
fn legacy_count_only_entries_survive_the_upgrade() {
|
||||
let json = br#"{"2026-08-01": 5, "2026-08-02": 12}"#;
|
||||
let stored: BTreeMap<String, StoredDay> = serde_json::from_slice(json).unwrap();
|
||||
let parsed: BTreeMap<String, DayStats> =
|
||||
stored.into_iter().map(|(k, v)| (k, v.into())).collect();
|
||||
|
||||
assert_eq!(parsed["2026-08-01"].count, 5);
|
||||
assert_eq!(parsed["2026-08-02"].count, 12);
|
||||
// 旧格式没记过字数/时长,读回 0 —— 诚实缺省,好过整天丢掉。
|
||||
assert_eq!(parsed["2026-08-01"].chars, 0);
|
||||
assert_eq!(parsed["2026-08-01"].duration_ms, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_object_entries_round_trip() {
|
||||
let original: BTreeMap<String, DayStats> = BTreeMap::from([(
|
||||
"2026-08-03".to_string(),
|
||||
DayStats {
|
||||
count: 7,
|
||||
chars: 4210,
|
||||
duration_ms: 96_000,
|
||||
},
|
||||
)]);
|
||||
let bytes = serde_json::to_vec(&original).unwrap();
|
||||
let stored: BTreeMap<String, StoredDay> = serde_json::from_slice(&bytes).unwrap();
|
||||
let parsed: BTreeMap<String, DayStats> =
|
||||
stored.into_iter().map(|(k, v)| (k, v.into())).collect();
|
||||
|
||||
assert_eq!(parsed, original);
|
||||
}
|
||||
|
||||
/// 两种格式混在同一个文件里也要能读:升级当天写入会把当天变成对象格式,
|
||||
/// 而更早的日期仍是裸数字。
|
||||
#[test]
|
||||
fn mixed_legacy_and_new_entries_parse_together() {
|
||||
let json = br#"{"2026-08-01": 5, "2026-08-02": {"count": 3, "chars": 900, "durationMs": 12000}}"#;
|
||||
let stored: BTreeMap<String, StoredDay> = serde_json::from_slice(json).unwrap();
|
||||
let parsed: BTreeMap<String, DayStats> =
|
||||
stored.into_iter().map(|(k, v)| (k, v.into())).collect();
|
||||
|
||||
assert_eq!(parsed["2026-08-01"].count, 5);
|
||||
assert_eq!(parsed["2026-08-01"].chars, 0);
|
||||
assert_eq!(parsed["2026-08-02"].count, 3);
|
||||
assert_eq!(parsed["2026-08-02"].chars, 900);
|
||||
assert_eq!(parsed["2026-08-02"].duration_ms, 12_000);
|
||||
}
|
||||
|
||||
/// 缺字段的对象(比如手工编辑过的文件)按 0 补齐,不整份读失败。
|
||||
#[test]
|
||||
fn object_entries_tolerate_missing_optional_fields() {
|
||||
let json = br#"{"2026-08-04": {"count": 2}}"#;
|
||||
let stored: BTreeMap<String, StoredDay> = serde_json::from_slice(json).unwrap();
|
||||
let parsed: BTreeMap<String, DayStats> =
|
||||
stored.into_iter().map(|(k, v)| (k, v.into())).collect();
|
||||
|
||||
assert_eq!(
|
||||
parsed["2026-08-04"],
|
||||
DayStats {
|
||||
count: 2,
|
||||
chars: 0,
|
||||
duration_ms: 0
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -145,12 +145,20 @@ pub enum SelectionPolishOutputMode {
|
||||
PreviewConfirm,
|
||||
}
|
||||
|
||||
/// 概览页年度活动热力图的单日计数(date = 本地日期 YYYY-MM-DD)。
|
||||
/// 概览页活动统计的单日汇总(date = 本地日期 YYYY-MM-DD)。
|
||||
///
|
||||
/// 年度热力图只用 `count`;`chars` / `duration_ms` 供「近 7 天 / 近 30 天」的
|
||||
/// 字数与时长指标使用——这两个指标此前从 `list_history()` 现算,会被历史 200 条
|
||||
/// 上限截断(说得多的用户几天就把上周挤没了)。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActivityDay {
|
||||
pub date: String,
|
||||
pub count: u32,
|
||||
/// 当日最终插入文本的总字符数(按 Unicode 字符计,与历史详情页的「N 字」同口径)。
|
||||
pub chars: u64,
|
||||
/// 当日录音总时长(毫秒)。口径 = 每次会话的录音时长,不含识别/润色耗时。
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -324,10 +324,23 @@ export const en: typeof zhCN = {
|
||||
historyLoadError: 'History load failed',
|
||||
metricTotal: 'Total records',
|
||||
metricTotalTrend: 'Local archive (max 200)',
|
||||
weekTitle: 'Last 7 days',
|
||||
activityTitle: 'Annual activity',
|
||||
activityCount: '{{count}} dictation(s)',
|
||||
weekUnit: 'count / day',
|
||||
activityLoadError: 'Activity data load failed',
|
||||
period: {
|
||||
ariaLabel: 'Reporting period',
|
||||
last7Days: 'Last 7 days',
|
||||
last30Days: 'Last 30 days',
|
||||
dailyAverage: '{{value}} / day',
|
||||
minutes: '{{value}} min',
|
||||
hoursMinutes: '{{hours}} h {{minutes}} min',
|
||||
},
|
||||
metricName: {
|
||||
ariaLabel: 'Metric',
|
||||
count: 'Count',
|
||||
chars: 'Characters',
|
||||
duration: 'Duration',
|
||||
},
|
||||
recentTitle: 'Recent transcripts',
|
||||
recentAll: 'View all →',
|
||||
recentEmpty: 'No records yet. Press {{trigger}} to start your first recording.',
|
||||
|
||||
@@ -326,10 +326,23 @@ export const ja: typeof zhCN = {
|
||||
historyLoadError: '履歴の読み込みに失敗',
|
||||
metricTotal: '累計記録',
|
||||
metricTotalTrend: 'ローカル保存(上限 200)',
|
||||
weekTitle: '直近 7 日',
|
||||
activityTitle: '年間アクティビティ',
|
||||
activityCount: '{{count}} 回の入力',
|
||||
weekUnit: '件 / 日',
|
||||
activityLoadError: 'アクティビティの読み込みに失敗',
|
||||
period: {
|
||||
ariaLabel: '集計期間',
|
||||
last7Days: '直近 7 日',
|
||||
last30Days: '直近 30 日',
|
||||
dailyAverage: '1 日平均 {{value}}',
|
||||
minutes: '{{value}} 分',
|
||||
hoursMinutes: '{{hours}} 時間 {{minutes}} 分',
|
||||
},
|
||||
metricName: {
|
||||
ariaLabel: '指標',
|
||||
count: '件数',
|
||||
chars: '文字数',
|
||||
duration: '時間',
|
||||
},
|
||||
recentTitle: '最近の認識',
|
||||
recentAll: 'すべて表示 →',
|
||||
recentEmpty: '記録がありません。{{trigger}} を押して最初の録音を始めましょう。',
|
||||
|
||||
@@ -326,10 +326,23 @@ export const ko: typeof zhCN = {
|
||||
historyLoadError: '기록 로드 실패',
|
||||
metricTotal: '누적 기록',
|
||||
metricTotalTrend: '로컬 보관(상한 200)',
|
||||
weekTitle: '최근 7일',
|
||||
activityTitle: '연간 활동',
|
||||
activityCount: '{{count}}회 받아쓰기',
|
||||
weekUnit: '건/일',
|
||||
activityLoadError: '활동 데이터 로드 실패',
|
||||
period: {
|
||||
ariaLabel: '집계 기간',
|
||||
last7Days: '최근 7일',
|
||||
last30Days: '최근 30일',
|
||||
dailyAverage: '일평균 {{value}}',
|
||||
minutes: '{{value}}분',
|
||||
hoursMinutes: '{{hours}}시간 {{minutes}}분',
|
||||
},
|
||||
metricName: {
|
||||
ariaLabel: '지표',
|
||||
count: '건수',
|
||||
chars: '글자 수',
|
||||
duration: '시간',
|
||||
},
|
||||
recentTitle: '최근 인식',
|
||||
recentAll: '전체 보기 →',
|
||||
recentEmpty: '아직 기록이 없습니다. {{trigger}} 를 눌러 첫 녹음을 시작하세요.',
|
||||
|
||||
@@ -322,10 +322,23 @@ export const zhCN = {
|
||||
historyLoadError: '历史读取失败',
|
||||
metricTotal: '累计记录',
|
||||
metricTotalTrend: '本机存档 (上限 200)',
|
||||
weekTitle: '近 7 天',
|
||||
activityTitle: '年度活动',
|
||||
activityCount: '{{count}} 次听写',
|
||||
weekUnit: '条数 / 天',
|
||||
activityLoadError: '活动数据读取失败',
|
||||
period: {
|
||||
ariaLabel: '统计周期',
|
||||
last7Days: '近 7 天',
|
||||
last30Days: '近 30 天',
|
||||
dailyAverage: '日均 {{value}}',
|
||||
minutes: '{{value}} 分钟',
|
||||
hoursMinutes: '{{hours}} 小时 {{minutes}} 分',
|
||||
},
|
||||
metricName: {
|
||||
ariaLabel: '统计指标',
|
||||
count: '条数',
|
||||
chars: '字数',
|
||||
duration: '时长',
|
||||
},
|
||||
recentTitle: '最近识别',
|
||||
recentAll: '全部记录 →',
|
||||
recentEmpty: '还没有记录。按 {{trigger}} 开始第一次录音。',
|
||||
|
||||
@@ -324,10 +324,23 @@ export const zhTW: typeof zhCN = {
|
||||
historyLoadError: '歷史讀取失敗',
|
||||
metricTotal: '累計記錄',
|
||||
metricTotalTrend: '本機存檔 (上限 200)',
|
||||
weekTitle: '近 7 天',
|
||||
activityTitle: '年度活動',
|
||||
activityCount: '{{count}} 次聽寫',
|
||||
weekUnit: '條數 / 天',
|
||||
activityLoadError: '活動數據讀取失敗',
|
||||
period: {
|
||||
ariaLabel: '統計週期',
|
||||
last7Days: '近 7 天',
|
||||
last30Days: '近 30 天',
|
||||
dailyAverage: '日均 {{value}}',
|
||||
minutes: '{{value}} 分鐘',
|
||||
hoursMinutes: '{{hours}} 小時 {{minutes}} 分',
|
||||
},
|
||||
metricName: {
|
||||
ariaLabel: '統計指標',
|
||||
count: '條數',
|
||||
chars: '字數',
|
||||
duration: '時長',
|
||||
},
|
||||
recentTitle: '最近識別',
|
||||
recentAll: '全部記錄 →',
|
||||
recentEmpty: '還沒有記錄。按 {{trigger}} 開始第一次錄音。',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { buildPeriodSeries, localDateKey } from './activityMetrics';
|
||||
import type { ActivityDay } from './types';
|
||||
|
||||
function assert(condition: boolean, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function day(date: string, count: number, chars: number, durationMs: number): ActivityDay {
|
||||
return { date, count, chars, durationMs };
|
||||
}
|
||||
|
||||
// 本地日期键必须按本地年月日拼。东八区凌晨用 toISOString() 会切到前一天,
|
||||
// 与后端 chrono::Local 写入的键对不上,整段数据会读成 0。
|
||||
const localMidnight = new Date(2026, 7, 4, 0, 30, 0);
|
||||
assert(
|
||||
localDateKey(localMidnight) === '2026-08-04',
|
||||
`local date key should follow local calendar day, got ${localDateKey(localMidnight)}`,
|
||||
);
|
||||
|
||||
const today = new Date(2026, 7, 4, 12, 0, 0); // 2026-08-04
|
||||
const activity: ActivityDay[] = [
|
||||
day('2026-07-29', 40, 4000, 400_000),
|
||||
day('2026-07-30', 118, 11_800, 1_180_000),
|
||||
day('2026-08-02', 44, 4400, 440_000),
|
||||
day('2026-08-04', 32, 3200, 320_000),
|
||||
];
|
||||
|
||||
// 7 天窗口:长度恒为 7、按日期升序、最后一个是今天、缺失日期补 0。
|
||||
const week = buildPeriodSeries(activity, 7, 'count', today);
|
||||
assert(week.buckets.length === 7, `7-day window should have 7 buckets, got ${week.buckets.length}`);
|
||||
assert(
|
||||
week.buckets[0].date === '2026-07-29' && week.buckets[6].date === '2026-08-04',
|
||||
`window should span 07-29..08-04, got ${week.buckets[0].date}..${week.buckets[6].date}`,
|
||||
);
|
||||
assert(
|
||||
week.buckets[2].date === '2026-07-31' && week.buckets[2].value === 0,
|
||||
'a date with no activity should be a zero bucket, not a gap',
|
||||
);
|
||||
assert(week.total === 40 + 118 + 44 + 32, `7-day count total wrong: ${week.total}`);
|
||||
assert(
|
||||
Math.abs(week.dailyAverage - 234 / 7) < 1e-9,
|
||||
`daily average should divide by the whole period, got ${week.dailyAverage}`,
|
||||
);
|
||||
|
||||
// 指标切换读的是不同字段,窗口逻辑不变。
|
||||
const weekChars = buildPeriodSeries(activity, 7, 'chars', today);
|
||||
assert(weekChars.total === 4000 + 11_800 + 4400 + 3200, `7-day chars total wrong: ${weekChars.total}`);
|
||||
const weekDuration = buildPeriodSeries(activity, 7, 'duration', today);
|
||||
assert(
|
||||
weekDuration.total === 400_000 + 1_180_000 + 440_000 + 320_000,
|
||||
`7-day duration total wrong: ${weekDuration.total}`,
|
||||
);
|
||||
|
||||
// 30 天窗口把更早的日期也纳进来(这里 07-29 起的都在窗口内),长度恒为 30。
|
||||
const month = buildPeriodSeries(activity, 30, 'count', today);
|
||||
assert(month.buckets.length === 30, `30-day window should have 30 buckets, got ${month.buckets.length}`);
|
||||
assert(
|
||||
month.buckets[29].date === '2026-08-04' && month.buckets[0].date === '2026-07-06',
|
||||
`30-day window should span 07-06..08-04, got ${month.buckets[0].date}..${month.buckets[29].date}`,
|
||||
);
|
||||
assert(month.total === 234, `30-day count total wrong: ${month.total}`);
|
||||
|
||||
// 升级前写入的老数据只有 count,没有 chars / durationMs。字数/时长按 0 读,
|
||||
// 不能 NaN —— NaN 会把整个柱状图的 max 算坏。
|
||||
const legacy: ActivityDay[] = [{ date: '2026-08-03', count: 156 } as ActivityDay];
|
||||
const legacyChars = buildPeriodSeries(legacy, 7, 'chars', today);
|
||||
assert(legacyChars.total === 0, `legacy entries should read as 0 chars, got ${legacyChars.total}`);
|
||||
assert(
|
||||
Number.isFinite(legacyChars.dailyAverage),
|
||||
'legacy entries must not produce NaN averages',
|
||||
);
|
||||
const legacyCount = buildPeriodSeries(legacy, 7, 'count', today);
|
||||
assert(legacyCount.total === 156, 'legacy entries should still report their count');
|
||||
|
||||
// 空数据集不炸,全 0。
|
||||
const empty = buildPeriodSeries([], 7, 'count', today);
|
||||
assert(empty.buckets.length === 7 && empty.total === 0 && empty.dailyAverage === 0, 'empty activity should yield a zeroed series');
|
||||
|
||||
// 跨月边界:窗口要正确回退到上个月,不能在 1 号截断。
|
||||
const firstOfMonth = new Date(2026, 7, 1, 9, 0, 0); // 2026-08-01
|
||||
const crossMonth = buildPeriodSeries(activity, 7, 'count', firstOfMonth);
|
||||
assert(
|
||||
crossMonth.buckets[0].date === '2026-07-26' && crossMonth.buckets[6].date === '2026-08-01',
|
||||
`window should cross the month boundary, got ${crossMonth.buckets[0].date}..${crossMonth.buckets[6].date}`,
|
||||
);
|
||||
assert(crossMonth.total === 40 + 118, `cross-month total wrong: ${crossMonth.total}`);
|
||||
@@ -0,0 +1,77 @@
|
||||
// 概览页「近 7 天 / 近 30 天」周期指标的聚合。
|
||||
//
|
||||
// 数据源是 activity 存储(date → {count, chars, durationMs}),**不是** listHistory():
|
||||
// 历史受 200 条上限约束,日均上百次的用户几天就把上周挤没了,按历史现算会把没数据的
|
||||
// 那几天画成 0(明明年度热力图上是亮的)。activity 保留两年且只存聚合数字。
|
||||
|
||||
import type { ActivityDay } from './types';
|
||||
|
||||
export const ACTIVITY_PERIODS = [7, 30] as const;
|
||||
export type ActivityPeriod = (typeof ACTIVITY_PERIODS)[number];
|
||||
|
||||
export const ACTIVITY_METRICS = ['count', 'chars', 'duration'] as const;
|
||||
export type ActivityMetric = (typeof ACTIVITY_METRICS)[number];
|
||||
|
||||
export interface ActivityBucket {
|
||||
/** 本地日期 YYYY-MM-DD,与后端 chrono::Local 写入的键同格式。 */
|
||||
date: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface PeriodSeries {
|
||||
/** 长度恒等于 days,按日期升序,最后一个是今天。缺数据的日期补 0。 */
|
||||
buckets: ActivityBucket[];
|
||||
total: number;
|
||||
/** 周期内日均值。分母是整个周期(含没说话的日子),不是「有记录的天数」。 */
|
||||
dailyAverage: number;
|
||||
}
|
||||
|
||||
/** 本地日期键。必须用本地年月日拼,不能用 toISOString()——后者按 UTC 切日,
|
||||
* 东八区凌晨的会话会被算到前一天,与后端 chrono::Local 的键对不上。 */
|
||||
export function localDateKey(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
function readMetric(day: ActivityDay, metric: ActivityMetric): number {
|
||||
switch (metric) {
|
||||
case 'count':
|
||||
return day.count;
|
||||
case 'chars':
|
||||
return day.chars ?? 0;
|
||||
case 'duration':
|
||||
return day.durationMs ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把活动快照裁成「今天往前数 days 天」的连续序列。
|
||||
*
|
||||
* 老数据(升级前写入的裸数字)没有 chars / durationMs,读回是 0:这些天在字数/时长
|
||||
* 指标里显示为 0 是诚实的——当时确实没记,不该凭历史现算去伪造一个受 200 条上限
|
||||
* 影响的数字。条数指标不受影响,全程可用。
|
||||
*/
|
||||
export function buildPeriodSeries(
|
||||
activity: readonly ActivityDay[],
|
||||
days: number,
|
||||
metric: ActivityMetric,
|
||||
today: Date = new Date(),
|
||||
): PeriodSeries {
|
||||
const byDate = new Map<string, ActivityDay>();
|
||||
for (const day of activity) byDate.set(day.date, day);
|
||||
|
||||
const buckets: ActivityBucket[] = [];
|
||||
let total = 0;
|
||||
for (let offset = days - 1; offset >= 0; offset--) {
|
||||
const date = new Date(today);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
date.setDate(date.getDate() - offset);
|
||||
const key = localDateKey(date);
|
||||
const day = byDate.get(key);
|
||||
const value = day ? readMetric(day, metric) : 0;
|
||||
total += value;
|
||||
buckets.push({ date: key, value });
|
||||
}
|
||||
|
||||
return { buckets, total, dailyAverage: days > 0 ? total / days : 0 };
|
||||
}
|
||||
@@ -787,7 +787,20 @@ export const mockActivityDays: ActivityDay[] = (() => {
|
||||
if (seed < 0.55) continue
|
||||
const count = Math.max(1, Math.round(seed * 22) - 8)
|
||||
const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`
|
||||
days.push({ date: iso, count })
|
||||
// 字数 / 时长按每条 ~120 字、~9 秒的量级派生,让周期指标卡在浏览器 dev 下
|
||||
// 也有可看的数据。最早的 30 天故意只给 count(不给 chars/durationMs),
|
||||
// 模拟升级前写入的老数据,验证「老日期在字数/时长指标里显示 0」不会崩。
|
||||
const legacy = i > 334
|
||||
days.push(
|
||||
legacy
|
||||
? { date: iso, count }
|
||||
: {
|
||||
date: iso,
|
||||
count,
|
||||
chars: count * (90 + Math.round(seed * 70)),
|
||||
durationMs: count * (6000 + Math.round(seed * 7000)),
|
||||
},
|
||||
)
|
||||
}
|
||||
return days
|
||||
})()
|
||||
@@ -30,6 +30,10 @@ export type InsertStatus = 'inserted' | 'pasteSent' | 'copiedFallback' | 'failed
|
||||
export interface ActivityDay {
|
||||
date: string;
|
||||
count: number;
|
||||
/** 当日最终插入文本的总字符数。升级前写入的日期没有这个字段(读作 0)。 */
|
||||
chars?: number;
|
||||
/** 当日录音总时长(毫秒)。升级前写入的日期没有这个字段(读作 0)。 */
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface DictationSession {
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { countCodePoints } from './unicode';
|
||||
|
||||
function assert(condition: boolean, message: string) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
// 口径与后端 Rust `polished.chars().count()`(Unicode 标量值)一致:
|
||||
// ASCII / CJK 按字计,emoji 与 CJK 扩展 B 等增补平面字符不得被 UTF-16 码元双算。
|
||||
assert(countCodePoints('') === 0, 'empty string should count 0');
|
||||
assert(countCodePoints('hello') === 5, 'ASCII code points');
|
||||
assert(countCodePoints('你好,世界') === 5, 'CJK code points');
|
||||
assert(countCodePoints('😀') === 1, 'emoji surrogate pair must count as 1, not 2');
|
||||
assert(countCodePoints('😀a') === 2, 'emoji + ASCII');
|
||||
assert(countCodePoints('𠮷') === 1, 'CJK Extension B (surrogate pair) must count as 1');
|
||||
assert(
|
||||
countCodePoints('e\u0301') === 2,
|
||||
'combining marks count per code point, matching Rust chars()',
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
// 按 Unicode 码点(标量值)计数字符数。
|
||||
//
|
||||
// `String.prototype.length` 按 UTF-16 码元计数,emoji / CJK 扩展 B 等增补平面字符
|
||||
// 会被双算;后端 Rust `polished.chars().count()` 按 Unicode 标量值计数,两者必须对齐,
|
||||
// 否则概览页「字数」指标、历史详情页「N 字」与后端 activity 聚合会各说各话。
|
||||
// `Array.from(text).length` 按码点切分(对合法 UTF-16 文本即等于标量值数),与 Rust
|
||||
// `chars()` 同口径。注意这是码点数、不是字素簇数——组合字符(如 e + U+0301)
|
||||
// 会计成 2 个,与后端一致。
|
||||
export function countCodePoints(text: string): number {
|
||||
return Array.from(text).length;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { formatComboLabel } from '../lib/hotkey';
|
||||
import { clearHistory, deleteHistoryEntry, listHistory, readAudioRecording, retranscribeRecording, isTauri } from '../lib/ipc';
|
||||
import { useMobileLayout } from '../lib/useMobileLayout';
|
||||
import type { DictationSession, PolishMode } from '../lib/types';
|
||||
import { countCodePoints } from '../lib/unicode';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
import { chipSelectedStyle } from './settings/shared';
|
||||
@@ -473,7 +474,9 @@ export function History() {
|
||||
<span>{t('history.stepInsert')}</span>
|
||||
<span style={{ color: 'var(--ol-ink-2)' }}>
|
||||
{item.appName && <><b>{item.appName}</b>{' · '}</>}
|
||||
{t('history.chars', { count: item.finalText.length })}
|
||||
{/* 按 Unicode 码点计(emoji / CJK 扩展 B 等增补平面字符不按 UTF-16 码元双算),
|
||||
与后端 `polished.chars().count()` 及概览页「字数」口径一致。 */}
|
||||
{t('history.chars', { count: countCodePoints(item.finalText) })}
|
||||
{item.dictionaryEntryCount != null && item.dictionaryEntryCount > 0 && (
|
||||
<>{' · '}{t('history.vocabHits', { count: item.dictionaryEntryCount })}</>
|
||||
)}
|
||||
|
||||
@@ -7,6 +7,14 @@ import { formatComboLabel } from '../lib/hotkey';
|
||||
import { getActivityStats, getCredentials, listHistory } from '../lib/ipc';
|
||||
import { Heatmap } from '../components/Heatmap';
|
||||
import { useMobileLayout } from '../lib/useMobileLayout';
|
||||
import { countCodePoints } from '../lib/unicode';
|
||||
import {
|
||||
ACTIVITY_METRICS,
|
||||
ACTIVITY_PERIODS,
|
||||
buildPeriodSeries,
|
||||
type ActivityMetric,
|
||||
type ActivityPeriod,
|
||||
} from '../lib/activityMetrics';
|
||||
import type { ActivityDay, CredentialsStatus, DictationSession, PolishMode } from '../lib/types';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
import { Btn, Card, PageHeader, Pill } from './_atoms';
|
||||
@@ -64,29 +72,51 @@ export function Overview({ onOpenHistory }: OverviewProps) {
|
||||
});
|
||||
const { prefs } = useHotkeySettings();
|
||||
const credentialsRequestSeq = useRef(0);
|
||||
const historyRequestSeq = useRef(0);
|
||||
const activityRequestSeq = useRef(0);
|
||||
|
||||
const refreshHistory = useCallback(() => {
|
||||
const requestSeq = historyRequestSeq.current + 1;
|
||||
historyRequestSeq.current = requestSeq;
|
||||
setHistoryError(false);
|
||||
listHistory()
|
||||
.then(setHistory)
|
||||
.then(entries => {
|
||||
if (requestSeq !== historyRequestSeq.current) return;
|
||||
setHistory(entries);
|
||||
})
|
||||
.catch(error => {
|
||||
if (requestSeq !== historyRequestSeq.current) return;
|
||||
console.error('[overview] failed to load history', error);
|
||||
setHistoryError(true);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 年度活动热力图数据(独立于历史内容存储,清空历史不影响)。加载失败仅隐藏卡片。
|
||||
// 移动端跳过 IPC 与渲染(issue #861):热力图横向宽度固定,窄屏易溢出并拖慢 WebView。
|
||||
// 活动数据(独立于历史内容存储,清空历史不影响):年度热力图 + 近 7/30 天指标共用。
|
||||
// 加载失败仅隐藏对应卡片。
|
||||
//
|
||||
// 热力图在移动端不渲染(issue #861:横向宽度固定,窄屏易溢出并拖慢 WebView),但
|
||||
// 周期指标卡是要渲染的,所以 IPC 不能再按 mobile 跳过 —— 否则移动端周期卡永远空。
|
||||
const [activity, setActivity] = useState<ActivityDay[] | null>(null);
|
||||
useEffect(() => {
|
||||
if (mobile) return;
|
||||
const [activityError, setActivityError] = useState(false);
|
||||
const refreshActivity = useCallback(() => {
|
||||
const requestSeq = activityRequestSeq.current + 1;
|
||||
activityRequestSeq.current = requestSeq;
|
||||
setActivityError(false);
|
||||
getActivityStats()
|
||||
.then(setActivity)
|
||||
.then(stats => {
|
||||
if (requestSeq !== activityRequestSeq.current) return;
|
||||
setActivity(stats);
|
||||
})
|
||||
.catch(error => {
|
||||
if (requestSeq !== activityRequestSeq.current) return;
|
||||
console.error('[overview] failed to load activity stats', error);
|
||||
setActivity(null);
|
||||
setActivityError(true);
|
||||
});
|
||||
}, [mobile]);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
refreshActivity();
|
||||
}, [refreshActivity]);
|
||||
|
||||
const refreshCredentials = useCallback(() => {
|
||||
const requestSeq = credentialsRequestSeq.current + 1;
|
||||
@@ -111,7 +141,27 @@ export function Overview({ onOpenHistory }: OverviewProps) {
|
||||
|
||||
useEffect(() => {
|
||||
refreshCredentials();
|
||||
}, [refreshCredentials, prefs?.activeAsrProvider, prefs?.activeLlmProvider]);
|
||||
}, [refreshCredentials, prefs?.activeLlmProvider, prefs?.activeAsrProvider]);
|
||||
|
||||
// ⌘R / Ctrl+R 重新拉取本页的三份数据(历史、活动、凭据),与历史页同键同语义。
|
||||
// preventDefault 拦掉 webview 默认的整页 reload,避免整个前端重挂载。
|
||||
// 此前概览页没有刷新入口,用户只能切到别的页再切回来才能看到新数据。
|
||||
const refreshAll = useCallback(() => {
|
||||
refreshHistory();
|
||||
refreshActivity();
|
||||
refreshCredentials();
|
||||
}, [refreshHistory, refreshActivity, refreshCredentials]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && (e.key === 'r' || e.key === 'R')) {
|
||||
e.preventDefault();
|
||||
refreshAll();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [refreshAll]);
|
||||
|
||||
// 凭据被保存后重新拉取状态(issue #532 / #573:在 Settings 中填写/更新凭据
|
||||
// 但不切换提供商时,上面的 useEffect 不会重跑,导致概览页的状态仍停留在「未配置」)。
|
||||
@@ -145,27 +195,24 @@ export function Overview({ onOpenHistory }: OverviewProps) {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const todays = history.filter(s => new Date(s.createdAt) >= today);
|
||||
const charsToday = todays.reduce((acc, s) => acc + s.finalText.length, 0);
|
||||
const charsToday = todays.reduce((acc, s) => acc + countCodePoints(s.finalText), 0);
|
||||
const segmentsToday = todays.length;
|
||||
const totalDurationMs = todays.reduce((acc, s) => acc + (s.durationMs ?? 0), 0);
|
||||
const avgLatencyMs = segmentsToday > 0 ? totalDurationMs / segmentsToday : 0;
|
||||
return { charsToday, segmentsToday, totalDurationMs, avgLatencyMs };
|
||||
}, [history]);
|
||||
|
||||
// 周历:过去 7 天每天的条数
|
||||
const weekly = useMemo(() => {
|
||||
const buckets = Array(7).fill(0);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
history.forEach(s => {
|
||||
const d = new Date(s.createdAt);
|
||||
const diff = Math.floor((today.getTime() - d.setHours(0, 0, 0, 0)) / 86400000);
|
||||
if (diff >= 0 && diff < 7) {
|
||||
buckets[6 - diff] += 1;
|
||||
}
|
||||
});
|
||||
return buckets;
|
||||
}, [history]);
|
||||
// 周期指标:近 7 天 / 近 30 天 × 条数 / 字数 / 时长。
|
||||
//
|
||||
// 数据源必须是 activity 而不是 history —— history 有 200 条硬上限,日均上百次的用户
|
||||
// 两三天就把上周挤没了,按历史现算会把没数据的那几天画成 0(而同一页的年度热力图
|
||||
// 上那几天明明是亮的,两块数据自相矛盾)。
|
||||
const [period, setPeriod] = useState<ActivityPeriod>(7);
|
||||
const [metric, setMetric] = useState<ActivityMetric>('count');
|
||||
const series = useMemo(
|
||||
() => buildPeriodSeries(activity ?? [], period, metric),
|
||||
[activity, period, metric],
|
||||
);
|
||||
|
||||
const asrProviderId = creds.activeAsrProvider || 'volcengine';
|
||||
const llmProviderId = creds.activeLlmProvider || 'ark';
|
||||
@@ -221,22 +268,14 @@ export function Overview({ onOpenHistory }: OverviewProps) {
|
||||
<div style={{ display: 'grid', gridTemplateColumns: mobile ? '1fr' : '1fr 1.4fr', gap: 12, flex: mobile ? undefined : 1, minHeight: mobile ? undefined : 0 }}>
|
||||
{/* overflow:hidden:窗口过小时这一行 flex:1 会被压到比内容还矮,柱状图(固定高)
|
||||
原本会溢出卡片圆角外(issue #782)。裁进卡片内,与右侧「最近识别」卡片一致。 */}
|
||||
<Card padding={18} style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
|
||||
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--ol-ink-2)' }}>{t('overview.weekTitle')}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)' }}>{t('overview.weekUnit')}</span>
|
||||
</div>
|
||||
{historyError ? (
|
||||
<div style={{ height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)' }}>
|
||||
{t('overview.historyLoadError')}
|
||||
</div>
|
||||
) : (
|
||||
<WeekChart data={weekly} />
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: 'var(--ol-ink-4)', marginTop: 8 }}>
|
||||
{weekDayLabels(t('overview.weekDays', { returnObjects: true }) as string[]).map((d, i) => <span key={i}>{d}</span>)}
|
||||
</div>
|
||||
</Card>
|
||||
<PeriodMetricsCard
|
||||
series={series}
|
||||
period={period}
|
||||
metric={metric}
|
||||
onPeriodChange={setPeriod}
|
||||
onMetricChange={setMetric}
|
||||
loadError={activityError}
|
||||
/>
|
||||
|
||||
<Card padding={0} style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
|
||||
<div style={{ padding: '14px 18px', borderBottom: '0.5px solid var(--ol-line)', display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||
@@ -384,33 +423,223 @@ function Metric({ icon, label, value, trend, accent }: MetricProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function WeekChart({ data }: { data: number[] }) {
|
||||
const max = Math.max(...data, 1);
|
||||
/** 分段切换器(周期 / 指标共用)。窄,一行放得下两组。 */
|
||||
function SegmentedToggle<T extends string | number>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
ariaLabel,
|
||||
}: {
|
||||
value: T;
|
||||
options: Array<{ value: T; label: string }>;
|
||||
onChange: (next: T) => void;
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 8, height: 100 }}>
|
||||
{data.map((v, i) => {
|
||||
const isToday = i === 6;
|
||||
<div
|
||||
role="group"
|
||||
aria-label={ariaLabel}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
padding: 2,
|
||||
borderRadius: 8,
|
||||
background: 'var(--ol-surface-2)',
|
||||
border: '0.5px solid var(--ol-line)',
|
||||
}}
|
||||
>
|
||||
{options.map(option => {
|
||||
const selected = option.value === value;
|
||||
return (
|
||||
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
|
||||
<div style={{ fontSize: 9.5, color: isToday ? 'var(--ol-blue)' : 'var(--ol-ink-4)', fontWeight: isToday ? 600 : 400 }}>{v}</div>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: `${(v / max) * 80}px`,
|
||||
minHeight: 2,
|
||||
borderRadius: 4,
|
||||
background: isToday ? 'var(--ol-blue)' : 'var(--ol-ink-4)',
|
||||
opacity: v === 0 ? 0.15 : isToday ? 1 : 0.85,
|
||||
transition: 'height 0.18s var(--ol-motion-soft), opacity 0.18s var(--ol-motion-soft)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
key={String(option.value)}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => onChange(option.value)}
|
||||
style={{
|
||||
padding: '3px 9px',
|
||||
fontSize: 11,
|
||||
fontWeight: selected ? 600 : 500,
|
||||
border: 0,
|
||||
borderRadius: 6,
|
||||
background: selected ? 'var(--ol-blue)' : 'transparent',
|
||||
color: selected ? '#fff' : 'var(--ol-ink-3)',
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 周期指标卡:近 7 天 / 近 30 天 × 条数 / 字数 / 时长。
|
||||
*
|
||||
* 卡片顶部显示周期总计(大字)+ 日均,柱状图在下 —— 用户关心的「这个月总共说了多少字」
|
||||
* 是一个数,不是要在 30 根柱子里目测求和。
|
||||
*/
|
||||
function PeriodMetricsCard({
|
||||
series,
|
||||
period,
|
||||
metric,
|
||||
onPeriodChange,
|
||||
onMetricChange,
|
||||
loadError,
|
||||
}: {
|
||||
series: ReturnType<typeof buildPeriodSeries>;
|
||||
period: ActivityPeriod;
|
||||
metric: ActivityMetric;
|
||||
onPeriodChange: (next: ActivityPeriod) => void;
|
||||
onMetricChange: (next: ActivityMetric) => void;
|
||||
loadError: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const periodOptions = ACTIVITY_PERIODS.map(days => ({
|
||||
value: days,
|
||||
label: t(`overview.period.last${days}Days`),
|
||||
}));
|
||||
const metricOptions = ACTIVITY_METRICS.map(id => ({
|
||||
value: id,
|
||||
label: t(`overview.metricName.${id}`),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Card padding={18} style={{ display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
|
||||
{/* flexWrap:卡片在 1fr 列里较窄,两组切换器放不下时换行而不是压扁按钮。 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
<SegmentedToggle
|
||||
value={period}
|
||||
options={periodOptions}
|
||||
onChange={onPeriodChange}
|
||||
ariaLabel={t('overview.period.ariaLabel')}
|
||||
/>
|
||||
<SegmentedToggle
|
||||
value={metric}
|
||||
options={metricOptions}
|
||||
onChange={onMetricChange}
|
||||
ariaLabel={t('overview.metricName.ariaLabel')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loadError ? (
|
||||
<div style={{ height: 132, display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)' }}>
|
||||
{t('overview.activityLoadError')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 26, fontWeight: 600, letterSpacing: '-0.02em', color: 'var(--ol-ink)', lineHeight: 1.1 }}>
|
||||
{formatMetricValue(series.total, metric, t)}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 5 }}>
|
||||
{t('overview.period.dailyAverage', {
|
||||
value: formatMetricValue(series.dailyAverage, metric, t),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<PeriodChart series={series} metric={metric} />
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** 柱状图。7 天时每根柱子上标数值;30 天时柱子只有几像素宽,标了会糊成一片,
|
||||
* 改用 title 悬浮显示,并只在两端和中间标日期。 */
|
||||
function PeriodChart({
|
||||
series,
|
||||
metric,
|
||||
}: {
|
||||
series: ReturnType<typeof buildPeriodSeries>;
|
||||
metric: ActivityMetric;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { buckets } = series;
|
||||
const max = Math.max(...buckets.map(b => b.value), 1);
|
||||
const dense = buckets.length > 7;
|
||||
const lastIndex = buckets.length - 1;
|
||||
const midIndex = Math.floor(lastIndex / 2);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: dense ? 2 : 8, height: 100 }}>
|
||||
{buckets.map((bucket, i) => {
|
||||
const isToday = i === lastIndex;
|
||||
return (
|
||||
<div
|
||||
key={bucket.date}
|
||||
title={`${bucket.date} · ${formatMetricValue(bucket.value, metric, t)}`}
|
||||
style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}
|
||||
>
|
||||
{!dense && (
|
||||
<div style={{ fontSize: 9.5, color: isToday ? 'var(--ol-blue)' : 'var(--ol-ink-4)', fontWeight: isToday ? 600 : 400 }}>
|
||||
{formatMetricValue(bucket.value, metric, t)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: `${(bucket.value / max) * 80}px`,
|
||||
minHeight: 2,
|
||||
borderRadius: dense ? 2 : 4,
|
||||
background: isToday ? 'var(--ol-blue)' : 'var(--ol-ink-4)',
|
||||
opacity: bucket.value === 0 ? 0.15 : isToday ? 1 : 0.85,
|
||||
transition: 'height 0.18s var(--ol-motion-soft), opacity 0.18s var(--ol-motion-soft)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: 'var(--ol-ink-4)', marginTop: 8 }}>
|
||||
{dense
|
||||
? [0, midIndex, lastIndex].map(i => <span key={i}>{shortDateLabel(buckets[i].date)}</span>)
|
||||
: buckets.map(bucket => <span key={bucket.date}>{weekDayLabel(bucket.date, t('overview.weekDays', { returnObjects: true }) as string[])}</span>)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** `YYYY-MM-DD` → `M/D`。日期键是后端按本地日历写的,直接切字符串即可,
|
||||
* 不要 new Date(key) —— 那会按 UTC 解析再转回本地,跨时区会差一天。 */
|
||||
function shortDateLabel(dateKey: string): string {
|
||||
const [, month, day] = dateKey.split('-');
|
||||
return `${Number(month)}/${Number(day)}`;
|
||||
}
|
||||
|
||||
function weekDayLabel(dateKey: string, names: string[]): string {
|
||||
const [year, month, day] = dateKey.split('-').map(Number);
|
||||
return names[new Date(year, month - 1, day).getDay()];
|
||||
}
|
||||
|
||||
/** 条数/字数按整数千分位显示;时长转成人类可读的时/分/秒。 */
|
||||
function formatMetricValue(
|
||||
value: number,
|
||||
metric: ActivityMetric,
|
||||
t: ReturnType<typeof useTranslation>['t'],
|
||||
): string {
|
||||
if (metric === 'duration') return formatLongDuration(value, t);
|
||||
return Math.round(value).toLocaleString();
|
||||
}
|
||||
|
||||
/** 周期总时长可能是几十小时,不能沿用只处理秒/分的 formatDuration。 */
|
||||
function formatLongDuration(ms: number, t: ReturnType<typeof useTranslation>['t']): string {
|
||||
if (ms <= 0) return '0';
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
if (totalSeconds < 60) return t('common.durationSeconds', { value: totalSeconds });
|
||||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||
if (totalMinutes < 60) return t('overview.period.minutes', { value: totalMinutes });
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
return t('overview.period.hoursMinutes', { hours, minutes });
|
||||
}
|
||||
|
||||
function RecentRow({ session, modeLabel }: { session: DictationSession; modeLabel: Record<PolishMode, string> }) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -473,12 +702,3 @@ function formatDuration(ms: number, t: ReturnType<typeof useTranslation>['t']):
|
||||
if (sec < 60) return t('common.durationSeconds', { value: sec.toFixed(1) });
|
||||
return `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function weekDayLabels(names: string[]): string[] {
|
||||
const today = new Date().getDay();
|
||||
const out: string[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
out.push(names[(today - i + 7) % 7]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
Reference in new issue
Block a user