diff --git a/apps/cli/src/http/routes/web/index.ts b/apps/cli/src/http/routes/web/index.ts index 2fb491c9..58be6790 100644 --- a/apps/cli/src/http/routes/web/index.ts +++ b/apps/cli/src/http/routes/web/index.ts @@ -45,7 +45,7 @@ import { MergeSessionCache, type SemanticIndexRuntime, } from '@openchatlab/node-runtime' -import { annualSummaryNodePlugin, registerSharedRoutes } from '@openchatlab/http-routes' +import { annualSummaryNodePlugin, registerSharedRoutes, timeInvestmentNodePlugin } from '@openchatlab/http-routes' import type { HttpRouteContext } from '@openchatlab/http-routes' import { registerImportRoutes } from './import' import { openDirectoryPath, showPathInFolder } from './cache' @@ -177,7 +177,7 @@ export function registerWebRoutes( }), }, { - nodePlugins: [annualSummaryNodePlugin], + nodePlugins: [annualSummaryNodePlugin, timeInvestmentNodePlugin], ...(ai && { requireAi: true }), } ) diff --git a/apps/desktop/main/internal-api/server.ts b/apps/desktop/main/internal-api/server.ts index b36088fd..7d87ca3a 100644 --- a/apps/desktop/main/internal-api/server.ts +++ b/apps/desktop/main/internal-api/server.ts @@ -29,7 +29,12 @@ import { import type { StreamImportDeps, SemanticIndexRuntime } from '@openchatlab/node-runtime' import { getLoadablePath as getSqliteVecLoadablePath } from 'sqlite-vec' import multipart from '@fastify/multipart' -import { annualSummaryNodePlugin, registerSharedRoutes, registerAutomationRoutes } from '@openchatlab/http-routes' +import { + annualSummaryNodePlugin, + registerSharedRoutes, + registerAutomationRoutes, + timeInvestmentNodePlugin, +} from '@openchatlab/http-routes' import { createApiServer } from '@openchatlab/http-routes/server' import type { HttpRouteContext } from '@openchatlab/http-routes' import { reloadTimer, stopTimer, type DataSourceManager, type PullEngine } from '@openchatlab/sync' @@ -208,7 +213,7 @@ export async function startInternalServer( registerSharedRoutes(newServer, ctx, { requireAi: true, - nodePlugins: [annualSummaryNodePlugin], + nodePlugins: [annualSummaryNodePlugin, timeInvestmentNodePlugin], }) registerAutomationRoutes(newServer, ctx) diff --git a/apps/web-wasm/src/main.ts b/apps/web-wasm/src/main.ts index 0bcdc86f..ef44dae0 100644 --- a/apps/web-wasm/src/main.ts +++ b/apps/web-wasm/src/main.ts @@ -18,7 +18,8 @@ import { WebWasmSessionSync } from './session-sync' import { useSessionStore } from '@/stores/session' import { initializeWebWasmLocale } from './locale-bootstrap' import { installInsightPluginRuntime } from '@/plugins/insight-vue' -import { webWasmInsightRuntime, webWasmUiHost } from '@/plugins/web-wasm' +import { webWasmInsightBuiltins, webWasmInsightRuntime, webWasmUiHost, webWasmUiServices } from '@/plugins/web-wasm' +import { installStaticInsightPluginUiServices } from '@/plugins/static-insight' import '@/assets/styles/main.css' initializeWebWasmLocale() @@ -52,6 +53,7 @@ async function start(): Promise { app.use(router) app.use(ui) app.use(i18n) + await installStaticInsightPluginUiServices(webWasmInsightBuiltins, webWasmInsightRuntime, webWasmUiServices) installInsightPluginRuntime(app, webWasmInsightRuntime) const sessionStore = useSessionStore(pinia) diff --git a/apps/web-wasm/src/routes.ts b/apps/web-wasm/src/routes.ts index 89fe9f45..1d4180ff 100644 --- a/apps/web-wasm/src/routes.ts +++ b/apps/web-wasm/src/routes.ts @@ -1,4 +1,9 @@ import type { RouteRecordRaw } from 'vue-router' +import { createVueInsightRouteRecords } from '@/plugins/insight-vue' +import { webWasmInsightRuntime } from '@/plugins/web-wasm' + +const defaultInsightRoute = + webWasmInsightRuntime.getDefaultPage()?.routeName ?? webWasmInsightRuntime.listPages()[0]?.routeName export const webWasmRoutes: RouteRecordRaw[] = [ { @@ -19,14 +24,7 @@ export const webWasmRoutes: RouteRecordRaw[] = [ { path: '/insight', component: () => import('@/pages/insight/index.vue'), - redirect: { name: 'insight-time-investment' }, - children: [ - { - path: 'time-investment', - name: 'insight-time-investment', - component: () => import('@/pages/insight/time-investment/index.vue'), - meta: { insightPageId: 'time-investment' }, - }, - ], + redirect: defaultInsightRoute ? { name: defaultInsightRoute } : undefined, + children: createVueInsightRouteRecords(webWasmInsightRuntime), }, ] diff --git a/packages/http-routes/src/index.ts b/packages/http-routes/src/index.ts index 9d240e5e..2f737748 100644 --- a/packages/http-routes/src/index.ts +++ b/packages/http-routes/src/index.ts @@ -9,6 +9,7 @@ export type { export { registerSharedRoutes } from './register' export type { SharedRouteOptions } from './register' export { annualSummaryNodePlugin } from './plugins/builtin/annual-summary' +export { timeInvestmentNodePlugin } from './plugins/builtin/time-investment' export { registerNodePlugins } from './plugins/node' export type { NodePluginContext, NodePluginDescriptor } from './plugins/node' export { executeRegistryTool } from './ai/tool-executor' diff --git a/packages/http-routes/src/routes/web/time-investment.test.ts b/packages/http-routes/src/plugins/builtin/time-investment/index.test.ts similarity index 74% rename from packages/http-routes/src/routes/web/time-investment.test.ts rename to packages/http-routes/src/plugins/builtin/time-investment/index.test.ts index 6211db91..27387dff 100644 --- a/packages/http-routes/src/routes/web/time-investment.test.ts +++ b/packages/http-routes/src/plugins/builtin/time-investment/index.test.ts @@ -8,9 +8,8 @@ import type { } from '@openchatlab/node-runtime' import type { TimeInvestmentResponse } from '@openchatlab/shared-types' import type { PathProvider } from '@openchatlab/core' -import { registerTimeInvestmentRoutes } from './time-investment' - -type TimeInvestmentRouteContext = Parameters[1] +import { registerNodePlugins, type NodePluginContext } from '../../node' +import { timeInvestmentNodePlugin } from '.' function emptyTimeInvestmentResponse(): TimeInvestmentResponse { return { @@ -58,7 +57,7 @@ class FakeTimeInvestmentService implements TimeInvestmentService { } } -function timeInvestmentContext(service: TimeInvestmentService): TimeInvestmentRouteContext { +function context(service: TimeInvestmentService): NodePluginContext { return { timeInvestmentService: service, sessionAdapter: {} as SessionRuntimeAdapter, @@ -66,10 +65,10 @@ function timeInvestmentContext(service: TimeInvestmentService): TimeInvestmentRo } } -test('time investment routes forward range, stale, recompute, and close the service', async () => { +test('time investment plugin forwards range, stale, recompute, and owns its service lifecycle', async () => { const timeInvestmentService = new FakeTimeInvestmentService() const app = Fastify() - registerTimeInvestmentRoutes(app, timeInvestmentContext(timeInvestmentService)) + registerNodePlugins(app, context(timeInvestmentService), [timeInvestmentNodePlugin]) await app.ready() const getResponse = await app.inject({ @@ -88,3 +87,17 @@ test('time investment routes forward range, stale, recompute, and close the serv await app.close() assert.equal(timeInvestmentService.closeCalls, 1) }) + +test('omitting the time investment plugin leaves its API unavailable', async (t) => { + const service = new FakeTimeInvestmentService() + const app = Fastify() + t.after(() => app.close()) + registerNodePlugins(app, context(service), []) + await app.ready() + + const response = await app.inject({ method: 'GET', url: '/_web/global-insight/time-investment' }) + + assert.equal(response.statusCode, 404) + assert.equal(service.getCalls.length, 0) + assert.equal(service.closeCalls, 0) +}) diff --git a/packages/http-routes/src/plugins/builtin/time-investment/index.ts b/packages/http-routes/src/plugins/builtin/time-investment/index.ts new file mode 100644 index 00000000..6563dff9 --- /dev/null +++ b/packages/http-routes/src/plugins/builtin/time-investment/index.ts @@ -0,0 +1,62 @@ +import { createTimeInvestmentService, PreferencesManager } from '@openchatlab/node-runtime' +import { TIME_INVESTMENT_PLUGIN_ID } from '@openchatlab/shared-types' +import type { NodePluginDescriptor } from '../../node' + +interface TimeInvestmentQuery { + mode?: string + year?: string + days?: string + acceptStale?: string +} + +export const timeInvestmentNodePlugin = { + id: TIME_INVESTMENT_PLUGIN_ID, + registerHttpRoutes(server, context) { + let preferences = context.preferencesManager + const getExcludedSessionIds = () => { + preferences ??= new PreferencesManager(context.pathProvider.getSystemDir()) + return preferences.load().ownerExcludedSessionIds + } + const service = + context.timeInvestmentService ?? + createTimeInvestmentService({ + adapter: context.sessionAdapter, + pathProvider: context.pathProvider, + runtimeIdentity: context.runtimeIdentity, + nativeBinding: context.nativeBinding, + getExcludedSessionIds, + }) + server.addHook('onClose', () => service.close()) + + server.get<{ Querystring: TimeInvestmentQuery }>('/_web/global-insight/time-investment', async (request) => + service.getTimeInvestment({ + ...parseRange(request.query), + acceptStale: isTruthy(request.query.acceptStale), + }) + ) + + server.post<{ Querystring: TimeInvestmentQuery }>( + '/_web/global-insight/time-investment/recompute', + async (request) => service.startRecompute(parseRange(request.query)) + ) + }, +} satisfies NodePluginDescriptor + +function parseRange(query: TimeInvestmentQuery) { + const mode = query.mode === 'recent' ? ('recent' as const) : ('year' as const) + return { + mode, + year: parseInteger(query.year), + days: query.days === '365' ? (365 as const) : undefined, + } +} + +function parseInteger(value: string | undefined): number | undefined { + if (!value) return undefined + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : undefined +} + +function isTruthy(value: string | undefined): boolean { + return value === '1' || value === 'true' || value === 'yes' +} diff --git a/packages/http-routes/src/routes/shared-routes.test.ts b/packages/http-routes/src/routes/shared-routes.test.ts index 2037aee3..9a63ec9c 100644 --- a/packages/http-routes/src/routes/shared-routes.test.ts +++ b/packages/http-routes/src/routes/shared-routes.test.ts @@ -231,7 +231,7 @@ describe('registerSharedRoutes smoke tests', () => { }) it('registers host insights without implicitly enabling optional Node plugins', () => { - assert.equal(app.hasRoute({ method: 'GET', url: '/_web/global-insight/time-investment' }), true) + assert.equal(app.hasRoute({ method: 'GET', url: '/_web/global-insight/time-investment' }), false) assert.equal(app.hasRoute({ method: 'GET', url: '/_web/global-insight/annual-summary' }), false) }) diff --git a/packages/http-routes/src/routes/web/register.ts b/packages/http-routes/src/routes/web/register.ts index b6ca9b40..3d27b7de 100644 --- a/packages/http-routes/src/routes/web/register.ts +++ b/packages/http-routes/src/routes/web/register.ts @@ -11,7 +11,6 @@ import { registerAnalyticsRoutes } from './analytics' import { registerCacheRoutes } from './cache' import { registerContactsRoutes } from './contacts' import { registerExportRoutes } from './export' -import { registerTimeInvestmentRoutes } from './time-investment' import { registerLogRoutes } from './logs' import { registerMemberRoutes } from './members' import { registerMergeRoutes } from './merge' @@ -45,7 +44,6 @@ export function registerWebRoutes(server: FastifyInstance, ctx: WebRoutesContext registerMemberRoutes(server, resolvedCtx) registerContactsRoutes(server, resolvedCtx) registerPeopleRelationshipsRoutes(server, resolvedCtx) - registerTimeInvestmentRoutes(server, resolvedCtx) registerNodePlugins(server, resolvedCtx, options?.nodePlugins ?? []) registerPreferencesRoutes(server, resolvedCtx) registerAnalyticsRoutes(server, resolvedCtx) diff --git a/packages/http-routes/src/routes/web/time-investment.ts b/packages/http-routes/src/routes/web/time-investment.ts deleted file mode 100644 index faf8b936..00000000 --- a/packages/http-routes/src/routes/web/time-investment.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { FastifyInstance } from 'fastify' -import { createTimeInvestmentService, PreferencesManager } from '@openchatlab/node-runtime' -import type { RuntimeRouteContext } from '../../context/runtime' -import type { ServiceRouteContext } from '../../context/services' - -interface TimeInvestmentQuery { - mode?: string - year?: string - days?: string - acceptStale?: string -} - -type TimeInvestmentRouteContext = Pick< - RuntimeRouteContext, - 'sessionAdapter' | 'pathProvider' | 'runtimeIdentity' | 'nativeBinding' -> & - Pick - -export function registerTimeInvestmentRoutes(server: FastifyInstance, ctx: TimeInvestmentRouteContext): void { - let preferences = ctx.preferencesManager - const getExcludedSessionIds = () => { - preferences ??= new PreferencesManager(ctx.pathProvider.getSystemDir()) - return preferences.load().ownerExcludedSessionIds - } - const service = - ctx.timeInvestmentService ?? - createTimeInvestmentService({ - adapter: ctx.sessionAdapter, - pathProvider: ctx.pathProvider, - runtimeIdentity: ctx.runtimeIdentity, - nativeBinding: ctx.nativeBinding, - getExcludedSessionIds, - }) - server.addHook('onClose', () => service.close()) - - server.get<{ Querystring: TimeInvestmentQuery }>('/_web/global-insight/time-investment', async (request) => - service.getTimeInvestment({ - ...parseRange(request.query), - acceptStale: isTruthy(request.query.acceptStale), - }) - ) - - server.post<{ Querystring: TimeInvestmentQuery }>('/_web/global-insight/time-investment/recompute', async (request) => - service.startRecompute(parseRange(request.query)) - ) -} - -function parseRange(query: TimeInvestmentQuery) { - const mode = query.mode === 'recent' ? ('recent' as const) : ('year' as const) - return { - mode, - year: parseInteger(query.year), - days: query.days === '365' ? (365 as const) : undefined, - } -} - -function parseInteger(value: string | undefined): number | undefined { - if (!value) return undefined - const parsed = Number(value) - return Number.isInteger(parsed) ? parsed : undefined -} - -function isTruthy(value: string | undefined): boolean { - return value === '1' || value === 'true' || value === 'yes' -} diff --git a/packages/shared-types/index.ts b/packages/shared-types/index.ts index 1085d537..542dfb14 100644 --- a/packages/shared-types/index.ts +++ b/packages/shared-types/index.ts @@ -7,6 +7,7 @@ /** Stable identity shared by the UI and Node facets of the built-in plugin. */ export const ANNUAL_SUMMARY_PLUGIN_ID = 'chatlab.insight.annual-summary' +export const TIME_INVESTMENT_PLUGIN_ID = 'chatlab.insight.time-investment' // ==================== 时间筛选 ==================== diff --git a/src/i18n/locales/en-US/insight.json b/src/i18n/locales/en-US/insight.json index b9f13d44..cd4ea6cb 100644 --- a/src/i18n/locales/en-US/insight.json +++ b/src/i18n/locales/en-US/insight.json @@ -3,63 +3,6 @@ "reserved": "This view will be available in a future release", "tabs": { "nav": "Footprint views", - "timeInvestment": "Time Investment", "relationshipChanges": "Relationship Changes" - }, - "monthLabel": "Month {month}", - "noData": "No analyzable data in this time range", - "overviewCard": { - "keyMetrics": "Key Metrics" - }, - "timeInvestment": { - "yearTitle": "Time invested in {year}", - "recentTitle": "Time invested in the last year", - "estimateNote": "Estimated from message timing with overlapping conversations merged. It shows engagement trends, not exact screen time.", - "narrative": { - "totalPrefix": "Over the year, your estimated chat investment was", - "totalSuffix": ".", - "daysPrefix": "It was spread across", - "daysMiddle": "active days, averaging", - "daysSuffix": "per active day." - }, - "loading": "Loading time investment…", - "failed": "Time investment calculation failed. You can retry it.", - "privateChat": "Private chats", - "groupChat": "Group chats", - "shareOfTotal": "{share}% of total investment", - "peakDay": "Highest-investment day", - "peakWeekday": "Highest daily-average weekday", - "chatTypeTitle": "Conversation types", - "monthlyTitle": "Monthly trend", - "monthlyDescription": "Estimated chat investment by month, in hours", - "rhythmTitle": "Investment rhythm", - "rhythmDescription": "Average daily investment by weekday", - "topDaysTitle": "Highest-investment days", - "rankingTitle": "Where the time went", - "rankingDescription": "Shows the 10 private and group chats with the most invested time; overlapping time is divided evenly between conversations", - "rankingCount": "{count} chats", - "kpis": { - "estimated": "Estimated chat investment", - "activeDays": "Investment days", - "dailyAverage": "Average per active day" - }, - "duration": { - "lessThanMinute": "Less than 1 min", - "minutes": "{count} min", - "hours": "{count} hr", - "hoursMinutes": "{hours} hr {minutes} min" - } - }, - "status": { - "computingProgress": "Analyzing conversations {processed}/{total}…", - "updating": "Data changed; this result is being refreshed in the background", - "coverage": "Analyzed {analyzed} of {total} conversations", - "ownerIssues": "{count} conversations still need a valid “me” identity", - "noAnalyzableOwner": "No conversation has a usable identity yet. Set who “me” is in a conversation first.", - "noDataWithLatest": "No analyzable data for {year}; the latest available year is {latestYear}" - }, - "actions": { - "retry": "Retry", - "switchYear": "View {year}" } } diff --git a/src/i18n/locales/ja-JP/insight.json b/src/i18n/locales/ja-JP/insight.json index a5c74f3f..c15e25cd 100644 --- a/src/i18n/locales/ja-JP/insight.json +++ b/src/i18n/locales/ja-JP/insight.json @@ -3,63 +3,6 @@ "reserved": "このビューは今後のバージョンで公開予定です", "tabs": { "nav": "足跡ビュー", - "timeInvestment": "時間の投入", "relationshipChanges": "関係の変化" - }, - "monthLabel": "{month}月", - "noData": "この期間には分析可能なデータがありません", - "overviewCard": { - "keyMetrics": "主要指標" - }, - "timeInvestment": { - "yearTitle": "{year} 年の時間投入", - "recentTitle": "直近1年間の時間投入", - "estimateNote": "メッセージの時間間隔から推定し、複数の会話で重なる時間帯を統合しています。正確な画面使用時間ではなく、会話への投入傾向を示します。", - "narrative": { - "totalPrefix": "この一年の推定チャット投入時間は", - "totalSuffix": "でした。", - "daysPrefix": "投入は", - "daysMiddle": "日間にわたり、アクティブ日の平均は", - "daysSuffix": "でした。" - }, - "loading": "時間投入を読み込んでいます…", - "failed": "時間投入の計算に失敗しました。再試行できます。", - "privateChat": "個別チャット", - "groupChat": "グループチャット", - "shareOfTotal": "全体の {share}%", - "peakDay": "投入時間が最も長い日", - "peakWeekday": "1日平均が最も長い曜日", - "chatTypeTitle": "会話タイプ", - "monthlyTitle": "月別推移", - "monthlyDescription": "月ごとの推定チャット投入時間。縦軸は時間です", - "rhythmTitle": "投入のリズム", - "rhythmDescription": "曜日別の1日平均投入時間", - "topDaysTitle": "投入時間が長い日", - "rankingTitle": "時間を使った会話", - "rankingDescription": "個別チャットとグループチャットで投入時間の多い上位10件を表示します。重なる時間は会話間で均等に配分されます", - "rankingCount": "{count} 件", - "kpis": { - "estimated": "推定チャット投入時間", - "activeDays": "投入日数", - "dailyAverage": "活動日あたりの平均" - }, - "duration": { - "lessThanMinute": "1分未満", - "minutes": "{count}分", - "hours": "{count}時間", - "hoursMinutes": "{hours}時間{minutes}分" - } - }, - "status": { - "computingProgress": "会話を分析しています {processed}/{total}…", - "updating": "データが変更されたため、バックグラウンドで更新しています", - "coverage": "{total} 件中 {analyzed} 件の会話を分析済み", - "ownerIssues": "{count} 件の会話で「自分」の設定が必要です", - "noAnalyzableOwner": "足跡の分析に使用できる本人情報がありません。先に会話で「自分」を設定してください。", - "noDataWithLatest": "{year} 年の分析データはありません。最新の有効年は {latestYear} 年です" - }, - "actions": { - "retry": "再試行", - "switchYear": "{year} 年を見る" } } diff --git a/src/i18n/locales/zh-CN/insight.json b/src/i18n/locales/zh-CN/insight.json index 073c121d..169c18d7 100644 --- a/src/i18n/locales/zh-CN/insight.json +++ b/src/i18n/locales/zh-CN/insight.json @@ -3,63 +3,6 @@ "reserved": "该视图将在后续版本开放", "tabs": { "nav": "足迹视图", - "timeInvestment": "时间投入", "relationshipChanges": "关系变化" - }, - "monthLabel": "{month}月", - "noData": "当前时间范围内暂无可分析数据", - "overviewCard": { - "keyMetrics": "关键指标" - }, - "timeInvestment": { - "yearTitle": "{year} 年的时间投入", - "recentTitle": "最近一年的时间投入", - "estimateNote": "根据消息时间间隔估算,并合并多个会话的重叠时段;它反映聊天投入趋势,不是精确的屏幕使用时间。", - "narrative": { - "totalPrefix": "这一年,你预估在聊天中投入了", - "totalSuffix": "。", - "daysPrefix": "这些时间分布在", - "daysMiddle": "个活跃日,活跃日平均投入", - "daysSuffix": "。" - }, - "loading": "正在读取时间投入…", - "failed": "时间投入计算失败,可以重试", - "privateChat": "私聊", - "groupChat": "群聊", - "shareOfTotal": "占全部投入 {share}%", - "peakDay": "最高投入日", - "peakWeekday": "日均投入最高星期", - "chatTypeTitle": "对话类型", - "monthlyTitle": "月度趋势", - "monthlyDescription": "每月预估聊天投入,纵轴单位为小时", - "rhythmTitle": "投入节奏", - "rhythmDescription": "比较一周中各天的平均投入", - "topDaysTitle": "高投入日", - "rankingTitle": "时间都去了哪里", - "rankingDescription": "私聊与群聊分别展示投入时间最多的 10 个会话;重叠时间会在会话之间平均分配", - "rankingCount": "{count} 个会话", - "kpis": { - "estimated": "预估聊天投入", - "activeDays": "投入天数", - "dailyAverage": "活跃日均投入" - }, - "duration": { - "lessThanMinute": "不足 1 分钟", - "minutes": "{count} 分钟", - "hours": "{count} 小时", - "hoursMinutes": "{hours} 小时 {minutes} 分" - } - }, - "status": { - "computingProgress": "正在分析会话 {processed}/{total}…", - "updating": "数据发生变化,正在后台更新当前结果", - "coverage": "已分析 {analyzed}/{total} 个会话", - "ownerIssues": "{count} 个会话尚未正确设置“我是谁”", - "noAnalyzableOwner": "还没有可用于足迹分析的身份,请先在会话中设置“我是谁”", - "noDataWithLatest": "{year} 年暂无可分析数据,最近有效数据为 {latestYear} 年" - }, - "actions": { - "retry": "重试", - "switchYear": "查看 {year} 年" } } diff --git a/src/i18n/locales/zh-TW/insight.json b/src/i18n/locales/zh-TW/insight.json index 0760f7b2..c98e18d0 100644 --- a/src/i18n/locales/zh-TW/insight.json +++ b/src/i18n/locales/zh-TW/insight.json @@ -3,63 +3,6 @@ "reserved": "此檢視將於後續版本開放", "tabs": { "nav": "足跡檢視", - "timeInvestment": "時間投入", "relationshipChanges": "關係變化" - }, - "monthLabel": "{month}月", - "noData": "目前時間範圍內沒有可分析資料", - "overviewCard": { - "keyMetrics": "關鍵指標" - }, - "timeInvestment": { - "yearTitle": "{year} 年的時間投入", - "recentTitle": "最近一年的時間投入", - "estimateNote": "根據訊息時間間隔估算,並合併多個會話的重疊時段;它反映聊天投入趨勢,不是精確的螢幕使用時間。", - "narrative": { - "totalPrefix": "這一年,你預估在聊天中投入了", - "totalSuffix": "。", - "daysPrefix": "這些時間分佈在", - "daysMiddle": "個活躍日,活躍日平均投入", - "daysSuffix": "。" - }, - "loading": "正在讀取時間投入…", - "failed": "時間投入計算失敗,可以重試", - "privateChat": "私聊", - "groupChat": "群聊", - "shareOfTotal": "佔全部投入 {share}%", - "peakDay": "最高投入日", - "peakWeekday": "日均投入最高星期", - "chatTypeTitle": "對話類型", - "monthlyTitle": "月度趨勢", - "monthlyDescription": "每月預估聊天投入,縱軸單位為小時", - "rhythmTitle": "投入節奏", - "rhythmDescription": "比較一週中每天的平均投入", - "topDaysTitle": "高投入日", - "rankingTitle": "時間都去了哪裡", - "rankingDescription": "私聊與群聊分別展示投入時間最多的 10 個會話;重疊時間會在會話之間平均分配", - "rankingCount": "{count} 個會話", - "kpis": { - "estimated": "預估聊天投入", - "activeDays": "投入天數", - "dailyAverage": "活躍日均投入" - }, - "duration": { - "lessThanMinute": "不足 1 分鐘", - "minutes": "{count} 分鐘", - "hours": "{count} 小時", - "hoursMinutes": "{hours} 小時 {minutes} 分" - } - }, - "status": { - "computingProgress": "正在分析會話 {processed}/{total}…", - "updating": "資料已變更,正在背景更新目前結果", - "coverage": "已分析 {analyzed}/{total} 個會話", - "ownerIssues": "{count} 個會話尚未正確設定「我是誰」", - "noAnalyzableOwner": "尚無可用於足跡分析的身分,請先在會話中設定「我是誰」", - "noDataWithLatest": "{year} 年沒有可分析資料,最近有效資料為 {latestYear} 年" - }, - "actions": { - "retry": "重試", - "switchYear": "查看 {year} 年" } } diff --git a/src/plugins/builtin/time-investment/constants.ts b/src/plugins/builtin/time-investment/constants.ts new file mode 100644 index 00000000..ebb61d57 --- /dev/null +++ b/src/plugins/builtin/time-investment/constants.ts @@ -0,0 +1,4 @@ +import { TIME_INVESTMENT_PLUGIN_ID } from '@openchatlab/shared-types' + +export { TIME_INVESTMENT_PLUGIN_ID } +export const TIME_INVESTMENT_LOCALE_NAMESPACE = `plugins.${TIME_INVESTMENT_PLUGIN_ID}` diff --git a/src/plugins/builtin/time-investment/host.ts b/src/plugins/builtin/time-investment/host.ts new file mode 100644 index 00000000..84cc2106 --- /dev/null +++ b/src/plugins/builtin/time-investment/host.ts @@ -0,0 +1,28 @@ +import { watch } from 'vue' +import { useDataService } from '@/services' +import { useLayoutStore } from '@/stores/layout' +import { PLATFORM_CAPABILITIES } from '@/utils/platform-capabilities' +import type { UiServiceRegistrar } from '../../ui-host' +import { TIME_INVESTMENT_UI_SERVICE, type TimeInvestmentUiService } from './service' + +export function createTimeInvestmentUiService(): TimeInvestmentUiService { + return { + canConfigureOwner: !PLATFORM_CAPABILITIES.usesBrowserRuntime, + get: (options) => useDataService().getTimeInvestment(options), + recompute: (options) => useDataService().recomputeTimeInvestment(options), + openOwnerSettings: () => useLayoutStore().openSettings('data', 'missing-owner'), + subscribeOwnerSettingsClosed: (listener) => { + const layoutStore = useLayoutStore() + return watch( + () => layoutStore.showSettings, + (visible, wasVisible) => { + if (wasVisible && !visible) listener() + } + ) + }, + } +} + +export function registerTimeInvestmentUiService(services: UiServiceRegistrar): void { + services.register(TIME_INVESTMENT_UI_SERVICE, createTimeInvestmentUiService()) +} diff --git a/src/plugins/builtin/time-investment/index.ts b/src/plugins/builtin/time-investment/index.ts new file mode 100644 index 00000000..b85dfe6c --- /dev/null +++ b/src/plugins/builtin/time-investment/index.ts @@ -0,0 +1 @@ +export { timeInvestmentBuiltin } from './plugin' diff --git a/src/plugins/builtin/time-investment/locales.ts b/src/plugins/builtin/time-investment/locales.ts new file mode 100644 index 00000000..4f09baae --- /dev/null +++ b/src/plugins/builtin/time-investment/locales.ts @@ -0,0 +1,183 @@ +import type { PluginLocaleDictionary, PluginLocaleMessages, PluginTranslate } from '../../locale' + +const enUS = { + title: 'Time Investment', + yearTitle: 'Time invested in {year}', + recentTitle: 'Time invested in the last year', + estimateNote: + 'Estimated from message timing with overlapping conversations merged. It shows engagement trends, not exact screen time.', + 'narrative.totalPrefix': 'Over the year, your estimated chat investment was', + 'narrative.totalSuffix': '.', + 'narrative.daysPrefix': 'It was spread across', + 'narrative.daysMiddle': 'active days, averaging', + 'narrative.daysSuffix': 'per active day.', + loading: 'Loading time investment…', + failed: 'Time investment calculation failed. You can retry it.', + privateChat: 'Private chats', + groupChat: 'Group chats', + shareOfTotal: '{share}% of total investment', + peakDay: 'Highest-investment day', + peakWeekday: 'Highest daily-average weekday', + monthlyTitle: 'Monthly trend', + monthlyDescription: 'Estimated chat investment by month, in hours', + rhythmTitle: 'Investment rhythm', + rhythmDescription: 'Average daily investment by weekday', + topDaysTitle: 'Highest-investment days', + rankingTitle: 'Where the time went', + rankingDescription: + 'Shows the 10 private and group chats with the most invested time; overlapping time is divided evenly between conversations', + rankingCount: '{count} chats', + 'duration.lessThanMinute': 'Less than 1 min', + 'duration.minutes': '{count} min', + 'duration.hours': '{count} hr', + 'duration.hoursMinutes': '{hours} hr {minutes} min', + monthLabel: 'Month {month}', + noData: 'No analyzable data in this time range', + keyMetrics: 'Key Metrics', + 'status.computingProgress': 'Analyzing conversations {processed}/{total}…', + 'status.updating': 'Data changed; this result is being refreshed in the background', + 'status.coverage': 'Analyzed {analyzed} of {total} conversations', + 'status.ownerIssues': '{count} conversations still need a valid “me” identity', + 'status.noAnalyzableOwner': 'No conversation has a usable identity yet. Set who “me” is in a conversation first.', + 'status.noDataWithLatest': 'No analyzable data for {year}; the latest available year is {latestYear}', + 'actions.retry': 'Retry', + 'actions.switchYear': 'View {year}', +} as const + +export type TimeInvestmentLocaleKey = keyof typeof enUS +export type TimeInvestmentTranslate = PluginTranslate + +const zhCN = { + title: '时间投入', + yearTitle: '{year} 年的时间投入', + recentTitle: '最近一年的时间投入', + estimateNote: '根据消息时间间隔估算,并合并多个会话的重叠时段;它反映聊天投入趋势,不是精确的屏幕使用时间。', + 'narrative.totalPrefix': '这一年,你预估在聊天中投入了', + 'narrative.totalSuffix': '。', + 'narrative.daysPrefix': '这些时间分布在', + 'narrative.daysMiddle': '个活跃日,活跃日平均投入', + 'narrative.daysSuffix': '。', + loading: '正在读取时间投入…', + failed: '时间投入计算失败,可以重试', + privateChat: '私聊', + groupChat: '群聊', + shareOfTotal: '占全部投入 {share}%', + peakDay: '最高投入日', + peakWeekday: '日均投入最高星期', + monthlyTitle: '月度趋势', + monthlyDescription: '每月预估聊天投入,纵轴单位为小时', + rhythmTitle: '投入节奏', + rhythmDescription: '比较一周中各天的平均投入', + topDaysTitle: '高投入日', + rankingTitle: '时间都去了哪里', + rankingDescription: '私聊与群聊分别展示投入时间最多的 10 个会话;重叠时间会在会话之间平均分配', + rankingCount: '{count} 个会话', + 'duration.lessThanMinute': '不足 1 分钟', + 'duration.minutes': '{count} 分钟', + 'duration.hours': '{count} 小时', + 'duration.hoursMinutes': '{hours} 小时 {minutes} 分', + monthLabel: '{month}月', + noData: '当前时间范围内暂无可分析数据', + keyMetrics: '关键指标', + 'status.computingProgress': '正在分析会话 {processed}/{total}…', + 'status.updating': '数据发生变化,正在后台更新当前结果', + 'status.coverage': '已分析 {analyzed}/{total} 个会话', + 'status.ownerIssues': '{count} 个会话尚未正确设置“我是谁”', + 'status.noAnalyzableOwner': '还没有可用于足迹分析的身份,请先在会话中设置“我是谁”', + 'status.noDataWithLatest': '{year} 年暂无可分析数据,最近有效数据为 {latestYear} 年', + 'actions.retry': '重试', + 'actions.switchYear': '查看 {year} 年', +} as const satisfies PluginLocaleDictionary + +const zhTW = { + title: '時間投入', + yearTitle: '{year} 年的時間投入', + recentTitle: '最近一年的時間投入', + estimateNote: '根據訊息時間間隔估算,並合併多個會話的重疊時段;它反映聊天投入趨勢,不是精確的螢幕使用時間。', + 'narrative.totalPrefix': '這一年,你預估在聊天中投入了', + 'narrative.totalSuffix': '。', + 'narrative.daysPrefix': '這些時間分佈在', + 'narrative.daysMiddle': '個活躍日,活躍日平均投入', + 'narrative.daysSuffix': '。', + loading: '正在讀取時間投入…', + failed: '時間投入計算失敗,可以重試', + privateChat: '私聊', + groupChat: '群聊', + shareOfTotal: '佔全部投入 {share}%', + peakDay: '最高投入日', + peakWeekday: '日均投入最高星期', + monthlyTitle: '月度趨勢', + monthlyDescription: '每月預估聊天投入,縱軸單位為小時', + rhythmTitle: '投入節奏', + rhythmDescription: '比較一週中每天的平均投入', + topDaysTitle: '高投入日', + rankingTitle: '時間都去了哪裡', + rankingDescription: '私聊與群聊分別展示投入時間最多的 10 個會話;重疊時間會在會話之間平均分配', + rankingCount: '{count} 個會話', + 'duration.lessThanMinute': '不足 1 分鐘', + 'duration.minutes': '{count} 分鐘', + 'duration.hours': '{count} 小時', + 'duration.hoursMinutes': '{hours} 小時 {minutes} 分', + monthLabel: '{month}月', + noData: '目前時間範圍內沒有可分析資料', + keyMetrics: '關鍵指標', + 'status.computingProgress': '正在分析會話 {processed}/{total}…', + 'status.updating': '資料已變更,正在背景更新目前結果', + 'status.coverage': '已分析 {analyzed}/{total} 個會話', + 'status.ownerIssues': '{count} 個會話尚未正確設定「我是誰」', + 'status.noAnalyzableOwner': '尚無可用於足跡分析的身分,請先在會話中設定「我是誰」', + 'status.noDataWithLatest': '{year} 年沒有可分析資料,最近有效資料為 {latestYear} 年', + 'actions.retry': '重試', + 'actions.switchYear': '查看 {year} 年', +} as const satisfies PluginLocaleDictionary + +const jaJP = { + title: '時間の投入', + yearTitle: '{year} 年の時間投入', + recentTitle: '直近1年間の時間投入', + estimateNote: + 'メッセージの時間間隔から推定し、複数の会話で重なる時間帯を統合しています。正確な画面使用時間ではなく、会話への投入傾向を示します。', + 'narrative.totalPrefix': 'この一年の推定チャット投入時間は', + 'narrative.totalSuffix': 'でした。', + 'narrative.daysPrefix': '投入は', + 'narrative.daysMiddle': '日間にわたり、アクティブ日の平均は', + 'narrative.daysSuffix': 'でした。', + loading: '時間投入を読み込んでいます…', + failed: '時間投入の計算に失敗しました。再試行できます。', + privateChat: '個別チャット', + groupChat: 'グループチャット', + shareOfTotal: '全体の {share}%', + peakDay: '投入時間が最も長い日', + peakWeekday: '1日平均が最も長い曜日', + monthlyTitle: '月別推移', + monthlyDescription: '月ごとの推定チャット投入時間。縦軸は時間です', + rhythmTitle: '投入のリズム', + rhythmDescription: '曜日別の1日平均投入時間', + topDaysTitle: '投入時間が長い日', + rankingTitle: '時間を使った会話', + rankingDescription: + '個別チャットとグループチャットで投入時間の多い上位10件を表示します。重なる時間は会話間で均等に配分されます', + rankingCount: '{count} 件', + 'duration.lessThanMinute': '1分未満', + 'duration.minutes': '{count}分', + 'duration.hours': '{count}時間', + 'duration.hoursMinutes': '{hours}時間{minutes}分', + monthLabel: '{month}月', + noData: 'この期間には分析可能なデータがありません', + keyMetrics: '主要指標', + 'status.computingProgress': '会話を分析しています {processed}/{total}…', + 'status.updating': 'データが変更されたため、バックグラウンドで更新しています', + 'status.coverage': '{total} 件中 {analyzed} 件の会話を分析済み', + 'status.ownerIssues': '{count} 件の会話で「自分」の設定が必要です', + 'status.noAnalyzableOwner': '足跡の分析に使用できる本人情報がありません。先に会話で「自分」を設定してください。', + 'status.noDataWithLatest': '{year} 年の分析データはありません。最新の有効年は {latestYear} 年です', + 'actions.retry': '再試行', + 'actions.switchYear': '{year} 年を見る', +} as const satisfies PluginLocaleDictionary + +export const timeInvestmentLocaleMessages = { + 'en-US': enUS, + 'zh-CN': zhCN, + 'zh-TW': zhTW, + 'ja-JP': jaJP, +} satisfies PluginLocaleMessages diff --git a/src/plugins/builtin/time-investment/plugin.ts b/src/plugins/builtin/time-investment/plugin.ts new file mode 100644 index 00000000..07e4544a --- /dev/null +++ b/src/plugins/builtin/time-investment/plugin.ts @@ -0,0 +1,42 @@ +import type { InsightPlugin } from '../../insight' +import type { StaticInsightPluginDescriptor } from '../../static-insight' +import { TIME_INVESTMENT_LOCALE_NAMESPACE, TIME_INVESTMENT_PLUGIN_ID } from './constants' +import { timeInvestmentLocaleMessages } from './locales' + +export const timeInvestmentPlugin: InsightPlugin = { + id: TIME_INVESTMENT_PLUGIN_ID, + platforms: ['electron', 'cli-web', 'web-wasm'], + activate(context) { + context.locale.register(TIME_INVESTMENT_LOCALE_NAMESPACE, timeInvestmentLocaleMessages) + context.pages.register({ + id: 'time-investment', + path: 'time-investment', + routeName: 'insight-time-investment', + title: { namespace: TIME_INVESTMENT_LOCALE_NAMESPACE, key: 'title' }, + icon: 'i-lucide-clock-3', + filters: { + time: { + allowedModes: ['recent', 'year'], + allowedRecentDays: [365], + defaultMode: 'year', + }, + }, + view: { + load: () => import('./ui/index.vue'), + }, + }) + context.navigation.register({ + id: 'insight.time-investment', + pageId: 'time-investment', + order: 20, + }) + }, +} + +export const timeInvestmentBuiltin: StaticInsightPluginDescriptor = { + plugin: timeInvestmentPlugin, + installUiServices: async (services) => { + const { registerTimeInvestmentUiService } = await import('./host') + registerTimeInvestmentUiService(services) + }, +} diff --git a/src/plugins/builtin/time-investment/service.ts b/src/plugins/builtin/time-investment/service.ts new file mode 100644 index 00000000..b82bb8f9 --- /dev/null +++ b/src/plugins/builtin/time-investment/service.ts @@ -0,0 +1,16 @@ +import type { TimeInvestmentResponse } from '@openchatlab/shared-types' +import type { AnnualSummaryFetchOptions } from '@/services/data/types' +import type { Disposer } from '../../core' +import { createUiServiceKey } from '../../ui-host' + +export interface TimeInvestmentUiService { + readonly canConfigureOwner: boolean + get(options: AnnualSummaryFetchOptions): Promise + recompute(options: AnnualSummaryFetchOptions): Promise + openOwnerSettings(): void + subscribeOwnerSettingsClosed(listener: () => void): Disposer +} + +export const TIME_INVESTMENT_UI_SERVICE = createUiServiceKey( + 'insight.time-investment.ui-service' +) diff --git a/src/pages/insight/time-investment/index.vue b/src/plugins/builtin/time-investment/ui/index.vue similarity index 81% rename from src/pages/insight/time-investment/index.vue rename to src/plugins/builtin/time-investment/ui/index.vue index 1ed1cbb6..19661154 100644 --- a/src/pages/insight/time-investment/index.vue +++ b/src/plugins/builtin/time-investment/ui/index.vue @@ -1,35 +1,34 @@ @@ -271,13 +269,13 @@ function chatTypeLabel(type: ChatType): string {
@@ -286,16 +284,16 @@ function chatTypeLabel(type: ChatType): string { class="flex items-center gap-2 rounded-xl border border-amber-100 bg-amber-50/50 px-4 py-2.5 text-xs text-amber-800 dark:border-amber-950/40 dark:bg-amber-950/20 dark:text-amber-300" > - {{ t('insight.status.updating') }} + {{ t('status.updating') }}
- {{ t('insight.timeInvestment.failed') }} + {{ t('failed') }} - {{ t('insight.actions.retry') }} + {{ t('actions.retry') }}
@@ -304,11 +302,11 @@ function chatTypeLabel(type: ChatType): string { height="min(52vh, 420px)" :text=" response?.task.status === 'running' - ? t('insight.status.computingProgress', { + ? t('status.computingProgress', { processed: response.task.processedSessions, total: response.task.totalSessions, }) - : t('insight.timeInvestment.loading') + : t('loading') " /> @@ -317,9 +315,9 @@ function chatTypeLabel(type: ChatType): string { v-if="isZeroData" class="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-gray-200 bg-white p-4 text-xs text-gray-600 dark:border-zinc-800 dark:bg-zinc-900/50 dark:text-zinc-300" > - {{ t('insight.status.noAnalyzableOwner') }} - {{ t('insight.status.noDataWithLatest', latestYearSuggestion) }} - {{ t('insight.noData') }} + {{ t('status.noAnalyzableOwner') }} + {{ t('status.noDataWithLatest', latestYearSuggestion) }} + {{ t('noData') }} - {{ t('insight.actions.switchYear', { year: latestYearSuggestion.latestYear }) }} + {{ t('actions.switchYear', { year: latestYearSuggestion.latestYear }) }} @@ -340,31 +338,31 @@ function chatTypeLabel(type: ChatType): string {

- {{ t('insight.timeInvestment.narrative.totalPrefix') }} + {{ t('narrative.totalPrefix') }} {{ formatDuration(response.metrics.estimatedSeconds) }} - {{ t('insight.timeInvestment.narrative.totalSuffix') }} + {{ t('narrative.totalSuffix') }}

- {{ t('insight.timeInvestment.narrative.daysPrefix') }} + {{ t('narrative.daysPrefix') }} - {{ response.metrics.activeDayCount.toLocaleString() }} + {{ formatNumber(response.metrics.activeDayCount) }} - {{ t('insight.timeInvestment.narrative.daysMiddle') }} + {{ t('narrative.daysMiddle') }} {{ formatDuration(response.metrics.averagePerActiveDaySeconds) }} - {{ t('insight.timeInvestment.narrative.daysSuffix') }} + {{ t('narrative.daysSuffix') }}

- {{ t('insight.overviewCard.keyMetrics') }} + {{ t('keyMetrics') }}

- {{ t('insight.timeInvestment.monthlyTitle') }} + {{ t('monthlyTitle') }}

- {{ t('insight.timeInvestment.monthlyDescription') }} + {{ t('monthlyDescription') }}

@@ -410,7 +408,7 @@ function chatTypeLabel(type: ChatType): string { />

- {{ t('insight.timeInvestment.estimateNote') }} + {{ t('estimateNote') }}

@@ -420,7 +418,7 @@ function chatTypeLabel(type: ChatType): string { {{ timeRangeText }} {{ - t('insight.status.coverage', { + t('status.coverage', { analyzed: response.coverage.analyzedSessions, total: response.coverage.totalSessions, }) @@ -436,10 +434,10 @@ function chatTypeLabel(type: ChatType): string { class="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-zinc-300" > - {{ t('insight.timeInvestment.rhythmTitle') }} + {{ t('rhythmTitle') }}

- {{ t('insight.timeInvestment.rhythmDescription') }} + {{ t('rhythmDescription') }}

@@ -464,14 +462,14 @@ function chatTypeLabel(type: ChatType): string {
-

{{ t('insight.noData') }}

+

{{ t('noData') }}

- {{ t('insight.timeInvestment.topDaysTitle') }} + {{ t('topDaysTitle') }}

-

{{ t('insight.noData') }}

+

{{ t('noData') }}

@@ -510,10 +508,10 @@ function chatTypeLabel(type: ChatType): string {

- {{ t('insight.timeInvestment.rankingTitle') }} + {{ t('rankingTitle') }}

- {{ t('insight.timeInvestment.rankingDescription') }} + {{ t('rankingDescription') }}

@@ -544,7 +542,7 @@ function chatTypeLabel(type: ChatType): string { - {{ t('insight.timeInvestment.rankingCount', { count: ranking.items.length }) }} + {{ t('rankingCount', { count: ranking.items.length }) }} @@ -595,10 +593,10 @@ function chatTypeLabel(type: ChatType): string { -

{{ t('insight.noData') }}

+

{{ t('noData') }}

-

{{ t('insight.noData') }}

+

{{ t('noData') }}

diff --git a/src/plugins/desktop-cli-web.ts b/src/plugins/desktop-cli-web.ts index 3f237775..754e120d 100644 --- a/src/plugins/desktop-cli-web.ts +++ b/src/plugins/desktop-cli-web.ts @@ -1,12 +1,13 @@ import { PLATFORM_CAPABILITIES } from '@/utils/platform-capabilities' import { annualSummaryBuiltin } from './builtin/annual-summary' +import { timeInvestmentBuiltin } from './builtin/time-investment' import { getLegacyInsightPages } from './insight-catalog' import { createStaticInsightPluginRuntime } from './static-insight' import { UiServiceRegistry } from './ui-host' import { createVueUiHostContext } from './vue-ui-host' const platform = PLATFORM_CAPABILITIES.platform -export const desktopCliWebInsightBuiltins = [annualSummaryBuiltin] as const +export const desktopCliWebInsightBuiltins = [annualSummaryBuiltin, timeInvestmentBuiltin] as const export const desktopCliWebUiServices = new UiServiceRegistry() export const desktopCliWebUiHost = createVueUiHostContext({ services: desktopCliWebUiServices }) export const desktopCliWebInsightRuntime = createStaticInsightPluginRuntime( diff --git a/src/plugins/insight-catalog.ts b/src/plugins/insight-catalog.ts index 18a6ce88..22acd1c9 100644 --- a/src/plugins/insight-catalog.ts +++ b/src/plugins/insight-catalog.ts @@ -7,22 +7,6 @@ export interface LegacyInsightPage extends InsightPageDefinition { } export const LEGACY_INSIGHT_PAGES: readonly LegacyInsightPage[] = [ - { - id: 'time-investment', - path: 'time-investment', - routeName: 'insight-time-investment', - title: { key: 'insight.tabs.timeInvestment' }, - icon: 'i-lucide-clock-3', - platforms: ['electron', 'cli-web', 'web-wasm'], - order: 20, - filters: { - time: { - allowedModes: ['recent', 'year'], - allowedRecentDays: [365], - defaultMode: 'year', - }, - }, - }, { id: 'relationship-changes', path: 'relationship-changes', diff --git a/src/plugins/insight.test.ts b/src/plugins/insight.test.ts index a88b0c55..a51340dd 100644 --- a/src/plugins/insight.test.ts +++ b/src/plugins/insight.test.ts @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { annualSummaryBuiltin } from './builtin/annual-summary' +import { timeInvestmentBuiltin } from './builtin/time-investment' import { getLegacyInsightPages, listInsightShellPages } from './insight-catalog' import { createInsightPluginRuntime, type InsightPlugin } from './insight' import { InsightScopeController } from './insight-scope' @@ -8,6 +9,7 @@ import { PluginLocaleHost } from './locale' import { UiServiceRegistry, type UiHostContext } from './ui-host' const annualSummaryPlugin = annualSummaryBuiltin.plugin +const timeInvestmentPlugin = timeInvestmentBuiltin.plugin function createTestHost(): { localeHost: PluginLocaleHost; uiHost: UiHostContext } { const localeHost = new PluginLocaleHost({ @@ -51,19 +53,44 @@ test('registers annual summary as a removable Desktop and CLI Web contribution', test('keeps the annual summary plugin out of Web WASM', () => { const { uiHost, localeHost } = createTestHost() - const runtime = createInsightPluginRuntime('web-wasm', uiHost, localeHost, [annualSummaryPlugin]) + const runtime = createInsightPluginRuntime('web-wasm', uiHost, localeHost, [ + annualSummaryPlugin, + timeInvestmentPlugin, + ]) assert.equal(runtime.isActive(annualSummaryPlugin.id), false) - assert.deepEqual(runtime.listPages(), []) + assert.equal(runtime.isActive(timeInvestmentPlugin.id), true) assert.deepEqual( listInsightShellPages(runtime).map((page) => page.id), ['time-investment'] ) }) +test('registers time investment on every platform and removes all of its contributions', () => { + for (const platform of ['electron', 'cli-web', 'web-wasm'] as const) { + const { uiHost, localeHost } = createTestHost() + const runtime = createInsightPluginRuntime(platform, uiHost, localeHost, [timeInvestmentPlugin]) + const title = runtime.getPage('time-investment')!.title + + assert.equal(runtime.isActive(timeInvestmentPlugin.id), true) + assert.equal(uiHost.locale.translate(title), 'Time Investment') + assert.deepEqual( + runtime.listNavigation().map(({ page }) => page.id), + ['time-investment'] + ) + + runtime.dispose(timeInvestmentPlugin.id) + assert.equal(runtime.getPage('time-investment'), undefined) + assert.equal(uiHost.locale.translate(title), 'title') + } +}) + test('combines plugin and legacy Insight pages by navigation order', () => { const { uiHost, localeHost } = createTestHost() - const runtime = createInsightPluginRuntime('electron', uiHost, localeHost, [annualSummaryPlugin]) + const runtime = createInsightPluginRuntime('electron', uiHost, localeHost, [ + annualSummaryPlugin, + timeInvestmentPlugin, + ]) assert.deepEqual( listInsightShellPages(runtime).map((page) => page.id), @@ -158,7 +185,7 @@ test('rejects plugin pages that conflict with host-owned legacy pages', () => { platforms: ['cli-web'], activate(context) { context.pages.register({ - id: 'time-investment', + id: 'relationship-changes', path: 'another-path', routeName: 'another-route', title: { namespace: 'plugins.conflicting-page', key: 'another.title' }, diff --git a/src/plugins/web-wasm.ts b/src/plugins/web-wasm.ts index a5cf1637..bf206bc5 100644 --- a/src/plugins/web-wasm.ts +++ b/src/plugins/web-wasm.ts @@ -1,12 +1,16 @@ -import { createInsightPluginRuntime } from './insight' +import { timeInvestmentBuiltin } from './builtin/time-investment' import { getLegacyInsightPages } from './insight-catalog' +import { createStaticInsightPluginRuntime } from './static-insight' +import { UiServiceRegistry } from './ui-host' import { createVueUiHostContext } from './vue-ui-host' -export const webWasmUiHost = createVueUiHostContext() -export const webWasmInsightRuntime = createInsightPluginRuntime( +export const webWasmInsightBuiltins = [timeInvestmentBuiltin] as const +export const webWasmUiServices = new UiServiceRegistry() +export const webWasmUiHost = createVueUiHostContext({ services: webWasmUiServices }) +export const webWasmInsightRuntime = createStaticInsightPluginRuntime( 'web-wasm', webWasmUiHost, webWasmUiHost.locale, - [], + webWasmInsightBuiltins, getLegacyInsightPages('web-wasm') ) diff --git a/src/routes/routes.test.ts b/src/routes/routes.test.ts index f3af86e1..18b8c7f9 100644 --- a/src/routes/routes.test.ts +++ b/src/routes/routes.test.ts @@ -50,7 +50,7 @@ test('registers annual summary as the default insight child route', () => { ) }) -test('removes the annual summary route and changes the default when its plugin is absent', () => { +test('removes optional plugin routes and falls back to the remaining Insight page', () => { const localeHost = new PluginLocaleHost({ getLocale: () => 'en-US', subscribe: () => () => {}, @@ -74,16 +74,20 @@ test('removes the annual summary route and changes the default when its plugin i assert.ok(insightRoute) assert.deepEqual( listInsightShellPages(runtimeWithoutPlugins).map((page) => page.id), - ['time-investment', 'relationship-changes'] + ['relationship-changes'] ) - assert.deepEqual(insightRoute.redirect, { name: 'insight-time-investment' }) + assert.deepEqual(insightRoute.redirect, { name: 'insight-relationship-changes' }) assert.equal( insightRoute.children?.some((route) => route.name === 'insight-annual-summary'), false ) + assert.equal( + insightRoute.children?.some((route) => route.name === 'insight-time-investment'), + false + ) }) -test('reserves time investment and relationship change insight routes', () => { +test('registers plugin and reserved Insight routes', () => { const insightRoute = findRoute('/insight') assert.ok(insightRoute) diff --git a/src/routes/routes.ts b/src/routes/routes.ts index 18b2ef2d..d644f011 100644 --- a/src/routes/routes.ts +++ b/src/routes/routes.ts @@ -1,6 +1,7 @@ import type { RouteRecordRaw } from 'vue-router' import { desktopCliWebInsightRuntime } from '@/plugins/desktop-cli-web' import type { InsightPluginRuntime } from '@/plugins/insight' +import { listInsightShellPages } from '@/plugins/insight-catalog' import { createVueInsightRouteRecords } from '@/plugins/insight-vue' /** 开发模式按需加载页面,避免失败的预加载请求污染后续动态路由导航。 */ @@ -9,7 +10,8 @@ export function shouldPreloadCriticalRoutes(isProduction: boolean): boolean { } export function createAppRoutes(insightRuntime: InsightPluginRuntime = desktopCliWebInsightRuntime): RouteRecordRaw[] { - const defaultInsightRoute = insightRuntime.getDefaultPage()?.routeName ?? 'insight-time-investment' + const defaultInsightRoute = + insightRuntime.getDefaultPage()?.routeName ?? listInsightShellPages(insightRuntime)[0]?.routeName return [ { @@ -36,15 +38,9 @@ export function createAppRoutes(insightRuntime: InsightPluginRuntime = desktopCl { path: '/insight', component: () => import('@/pages/insight/index.vue'), - redirect: { name: defaultInsightRoute }, + redirect: defaultInsightRoute ? { name: defaultInsightRoute } : undefined, children: [ ...createVueInsightRouteRecords(insightRuntime), - { - path: 'time-investment', - name: 'insight-time-investment', - component: () => import('@/pages/insight/time-investment/index.vue'), - meta: { insightPageId: 'time-investment' }, - }, { path: 'relationship-changes', name: 'insight-relationship-changes',