diff --git a/frontend/.env.example b/frontend/.env.example
index 591fdf0..e646a25 100644
--- a/frontend/.env.example
+++ b/frontend/.env.example
@@ -1,4 +1,12 @@
-# Android emulator: 10.0.2.2 reaches the host machine.
+# Android emulator talks to the host machine via 10.0.2.2
EXPO_PUBLIC_API_URL=http://10.0.2.2:8000/api/v1
-# Baidu Maps browser-side AK with JavaScript API v4 enabled.
+
+# Baidu Maps browser-side AK with JavaScript API v4 enabled
EXPO_PUBLIC_BAIDU_MAP_AK=your-baidu-map-browser-ak
+
+# Real backend WebSocket URL. Leave empty to use the in-process fake transport in development.
+# Example: ws://10.0.2.2:8000/ws/v1
+EXPO_PUBLIC_WS_URL=
+
+# Force the fake WebSocket even if EXPO_PUBLIC_WS_URL is set (dev only).
+# EXPO_PUBLIC_USE_FAKE_WS=true
\ No newline at end of file
diff --git a/frontend/__tests__/app/session/SessionProvider.test.tsx b/frontend/__tests__/app/session/SessionProvider.test.tsx
new file mode 100644
index 0000000..7217def
--- /dev/null
+++ b/frontend/__tests__/app/session/SessionProvider.test.tsx
@@ -0,0 +1,95 @@
+import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
+import { act, render } from '@testing-library/react-native';
+import { View } from 'react-native';
+
+import { SessionProvider } from '@/app/session/SessionProvider';
+import type { DeviceIdStore } from '@/infrastructure/storage/deviceIdStore';
+
+class TestWebSocket {
+ static readonly OPEN = 1;
+ static instances: TestWebSocket[] = [];
+
+ binaryType = '';
+ readyState = 0;
+ onopen: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ onclose: (() => void) | null = null;
+ onmessage: ((event: { data: unknown }) => void) | null = null;
+
+ constructor(_url: string) {
+ TestWebSocket.instances.push(this);
+ }
+
+ send(_data: string) {}
+
+ close() {
+ this.readyState = 3;
+ this.onclose?.();
+ }
+
+ open() {
+ this.readyState = TestWebSocket.OPEN;
+ this.onopen?.();
+ }
+}
+
+const deviceIdStore: DeviceIdStore = {
+ get: async () => 'device_test',
+ set: async () => undefined,
+};
+
+describe('SessionProvider reconnect lifecycle', () => {
+ const originalWebSocket = globalThis.WebSocket;
+ const originalUrl = process.env.EXPO_PUBLIC_WS_URL;
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ TestWebSocket.instances = [];
+ process.env.EXPO_PUBLIC_WS_URL = 'ws://test.invalid/ws';
+ Object.defineProperty(globalThis, 'WebSocket', {
+ configurable: true,
+ value: TestWebSocket,
+ writable: true,
+ });
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ if (originalUrl === undefined) delete process.env.EXPO_PUBLIC_WS_URL;
+ else process.env.EXPO_PUBLIC_WS_URL = originalUrl;
+ Object.defineProperty(globalThis, 'WebSocket', {
+ configurable: true,
+ value: originalWebSocket,
+ writable: true,
+ });
+ });
+
+ it('schedules only one first retry after the session handshake times out', async () => {
+ const view = render(
+
+
+ ,
+ );
+
+ await act(async () => undefined);
+ expect(TestWebSocket.instances).toHaveLength(1);
+
+ await act(async () => {
+ TestWebSocket.instances[0]?.open();
+ await Promise.resolve();
+ });
+
+ act(() => {
+ jest.advanceTimersByTime(10_000);
+ jest.advanceTimersByTime(999);
+ });
+ expect(TestWebSocket.instances).toHaveLength(1);
+
+ act(() => {
+ jest.advanceTimersByTime(1);
+ });
+ expect(TestWebSocket.instances).toHaveLength(2);
+
+ view.unmount();
+ });
+});
diff --git a/frontend/__tests__/app/session/sessionEndpoint.test.ts b/frontend/__tests__/app/session/sessionEndpoint.test.ts
new file mode 100644
index 0000000..457c936
--- /dev/null
+++ b/frontend/__tests__/app/session/sessionEndpoint.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from '@jest/globals';
+
+import { buildSessionWebSocketUrl, resolveSessionUserId } from '@/app/session/sessionEndpoint';
+
+describe('session endpoint compatibility', () => {
+ it('adds the persisted device id to the backend WebSocket URL', () => {
+ expect(buildSessionWebSocketUrl('ws://127.0.0.1:8000/ws', 'device 1')).toBe(
+ 'ws://127.0.0.1:8000/ws?device_id=device+1',
+ );
+ });
+
+ it('replaces a stale device id while preserving other query parameters', () => {
+ expect(
+ buildSessionWebSocketUrl('wss://api.example.com/ws?token=test&device_id=stale', 'current'),
+ ).toBe('wss://api.example.com/ws?token=test&device_id=current');
+ });
+
+ it('rejects non-WebSocket URLs', () => {
+ expect(() => buildSessionWebSocketUrl('http://127.0.0.1:8000/ws', 'device_1')).toThrow(
+ '必须使用 ws:// 或 wss://',
+ );
+ });
+
+ it('uses the MVP backend user when session.ready omits user_id', () => {
+ expect(resolveSessionUserId(undefined)).toBe('default_user');
+ expect(resolveSessionUserId(' user_1 ')).toBe('user_1');
+ });
+});
diff --git a/frontend/__tests__/dev/fakes/schedule/scheduleConflicts.test.ts b/frontend/__tests__/dev/fakes/schedule/scheduleConflicts.test.ts
new file mode 100644
index 0000000..1a66b56
--- /dev/null
+++ b/frontend/__tests__/dev/fakes/schedule/scheduleConflicts.test.ts
@@ -0,0 +1,135 @@
+import { describe, expect, it } from '@jest/globals';
+
+import type { Schedule, ScheduleUpsertCommand } from '@/contracts';
+
+import { upsertSchedule } from '@/dev/fakes/schedule/scheduleConflicts';
+
+function makeSchedule(overrides: Partial = {}): Schedule {
+ return {
+ id: 'schedule_existing',
+ user_id: 'default_user',
+ source_mode: 'manual',
+ schedule_type: 'time',
+ status: 'scheduled',
+ title: '已有日程',
+ notes: null,
+ start_time: new Date(2026, 6, 29, 9, 0).toISOString(),
+ end_time: new Date(2026, 6, 29, 10, 0).toISOString(),
+ timezone: 'Asia/Shanghai',
+ location_name: null,
+ location_address: null,
+ latitude: null,
+ longitude: null,
+ geofence_radius_meters: 100,
+ geofence_armed: false,
+ time_remind_offset_minutes: 15,
+ time_triggered_at: null,
+ geo_triggered_at: null,
+ system_schedule_ref_id: null,
+ system_alarm_ref_id: null,
+ created_at: new Date(2026, 6, 20, 10, 0).toISOString(),
+ updated_at: new Date(2026, 6, 20, 10, 0).toISOString(),
+ ...overrides,
+ };
+}
+
+function makeCommand(startHour: number, endHour: number | null): ScheduleUpsertCommand {
+ return {
+ type: 'schedule.upsert.command',
+ request_id: 'req_test',
+ payload: {
+ source_mode: 'manual',
+ schedule_type: 'time',
+ title: '新日程',
+ start_time: new Date(2026, 6, 29, startHour, 0).toISOString(),
+ end_time: endHour === null ? null : new Date(2026, 6, 29, endHour, 0).toISOString(),
+ },
+ };
+}
+
+describe('upsertSchedule conflict detection', () => {
+ const existing = [makeSchedule()];
+
+ it('flags a schedule that overlaps an existing one', () => {
+ const result = upsertSchedule(makeCommand(9, 10), existing, 'schedule_new');
+ expect(result.payload.conflicts.map((conflict) => conflict.schedule_id)).toEqual([
+ 'schedule_existing',
+ ]);
+ });
+
+ it('flags a partial overlap', () => {
+ const result = upsertSchedule(makeCommand(9, 11), existing, 'schedule_new');
+ expect(result.payload.conflicts).toHaveLength(1);
+ });
+
+ it('treats touching boundaries as a conflict', () => {
+ const result = upsertSchedule(makeCommand(10, 11), existing, 'schedule_new');
+ expect(result.payload.conflicts).toHaveLength(1);
+ });
+
+ it('reports nothing for a non-overlapping slot', () => {
+ const result = upsertSchedule(makeCommand(11, 12), existing, 'schedule_new');
+ expect(result.payload.conflicts).toHaveLength(0);
+ });
+
+ it('does not conflict a schedule with itself while editing', () => {
+ const result = upsertSchedule(makeCommand(9, 10), existing, 'schedule_existing');
+ expect(result.payload.conflicts).toHaveLength(0);
+ });
+
+ it('ignores deleted schedules', () => {
+ const deleted = [makeSchedule({ status: 'deleted' })];
+ const result = upsertSchedule(makeCommand(9, 10), deleted, 'schedule_new');
+ expect(result.payload.conflicts).toHaveLength(0);
+ });
+
+ it('reports nothing when the new schedule has no start time', () => {
+ const command: ScheduleUpsertCommand = {
+ type: 'schedule.upsert.command',
+ request_id: 'req_test',
+ payload: {
+ source_mode: 'manual',
+ schedule_type: 'location',
+ title: '地点日程',
+ start_time: null,
+ },
+ };
+ const result = upsertSchedule(command, existing, 'schedule_new');
+ expect(result.payload.conflicts).toHaveLength(0);
+ });
+
+ it('echoes the request id and schedule id back', () => {
+ const result = upsertSchedule(makeCommand(14, 15), existing, 'schedule_new');
+ expect(result.request_id).toBe('req_test');
+ expect(result.payload.schedule_id).toBe('schedule_new');
+ });
+
+ it('ignores unparseable start times on the new command', () => {
+ const command: ScheduleUpsertCommand = {
+ type: 'schedule.upsert.command',
+ request_id: 'req_test',
+ payload: {
+ source_mode: 'manual',
+ schedule_type: 'time',
+ title: '坏时间',
+ start_time: 'not-a-date',
+ end_time: null,
+ },
+ };
+ expect(upsertSchedule(command, existing, 'schedule_new').payload.conflicts).toHaveLength(0);
+ });
+
+ it('ignores existing items whose times do not parse', () => {
+ const broken = [makeSchedule({ id: 'broken', start_time: 'bad', end_time: 'also-bad' })];
+ expect(
+ upsertSchedule(makeCommand(9, 10), broken, 'schedule_new').payload.conflicts,
+ ).toHaveLength(0);
+ });
+
+ it('inherits geofence_armed from the existing schedule when omitted', () => {
+ const armed = [makeSchedule({ id: 'schedule_existing', geofence_armed: true })];
+ const command = makeCommand(14, 15);
+ delete (command.payload as { geofence_armed?: boolean }).geofence_armed;
+ expect(upsertSchedule(command, armed, 'schedule_existing').payload.geofence_armed).toBe(true);
+ });
+});
diff --git a/frontend/__tests__/fixtures.ts b/frontend/__tests__/fixtures.ts
new file mode 100644
index 0000000..4195d90
--- /dev/null
+++ b/frontend/__tests__/fixtures.ts
@@ -0,0 +1,30 @@
+import type { Schedule } from '@/contracts';
+
+export function makeSchedule(overrides: Partial = {}): Schedule {
+ return {
+ id: 'schedule_test',
+ user_id: 'default_user',
+ source_mode: 'manual',
+ schedule_type: 'time',
+ status: 'scheduled',
+ title: '测试日程',
+ notes: null,
+ start_time: new Date(2026, 6, 29, 9, 5).toISOString(),
+ end_time: null,
+ timezone: 'Asia/Shanghai',
+ location_name: null,
+ location_address: null,
+ latitude: null,
+ longitude: null,
+ geofence_radius_meters: 100,
+ geofence_armed: false,
+ time_remind_offset_minutes: 15,
+ time_triggered_at: null,
+ geo_triggered_at: null,
+ system_schedule_ref_id: null,
+ system_alarm_ref_id: null,
+ created_at: new Date(2026, 6, 20, 10, 0).toISOString(),
+ updated_at: new Date(2026, 6, 20, 10, 0).toISOString(),
+ ...overrides,
+ };
+}
diff --git a/frontend/__tests__/infrastructure/storage/deviceIdStore.test.ts b/frontend/__tests__/infrastructure/storage/deviceIdStore.test.ts
new file mode 100644
index 0000000..6da89a4
--- /dev/null
+++ b/frontend/__tests__/infrastructure/storage/deviceIdStore.test.ts
@@ -0,0 +1,41 @@
+import { beforeEach, describe, expect, it, jest } from '@jest/globals';
+
+const mockGetInfoAsync = jest.fn(async () => ({ exists: true }));
+const mockReadAsStringAsync = jest.fn(async () => ' device_existing ');
+const mockWriteAsStringAsync = jest.fn(async () => undefined);
+const mockFileSystem = {
+ documentDirectory: 'file:///documents/',
+ getInfoAsync: mockGetInfoAsync,
+ readAsStringAsync: mockReadAsStringAsync,
+ writeAsStringAsync: mockWriteAsStringAsync,
+};
+
+jest.mock('react-native', () => ({
+ Platform: { OS: 'android' },
+}));
+
+jest.mock('expo', () => ({
+ requireOptionalNativeModule: jest.fn(() => mockFileSystem),
+}));
+
+import { createDeviceIdStore } from '@/infrastructure/storage/deviceIdStore';
+
+describe('native deviceIdStore', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockGetInfoAsync.mockResolvedValue({ exists: true });
+ mockReadAsStringAsync.mockResolvedValue(' device_existing ');
+ });
+
+ it('passes the options arguments required by Expo SDK 57 native methods', async () => {
+ const store = await createDeviceIdStore();
+
+ await expect(store.get()).resolves.toBe('device_existing');
+ await store.set('device_updated');
+
+ const path = 'file:///documents/.timeflow-device-id';
+ expect(mockGetInfoAsync).toHaveBeenCalledWith(path, {});
+ expect(mockReadAsStringAsync).toHaveBeenCalledWith(path, {});
+ expect(mockWriteAsStringAsync).toHaveBeenCalledWith(path, 'device_updated', {});
+ });
+});
diff --git a/frontend/__tests__/infrastructure/ws/WsClient.test.ts b/frontend/__tests__/infrastructure/ws/WsClient.test.ts
new file mode 100644
index 0000000..0de8916
--- /dev/null
+++ b/frontend/__tests__/infrastructure/ws/WsClient.test.ts
@@ -0,0 +1,190 @@
+import { describe, expect, it } from '@jest/globals';
+
+import { FakeWsServer } from '@/dev/fakes/FakeWsServer';
+import { WsClient } from '@/infrastructure/ws/WsClient';
+import { makeSchedule } from '@test/fixtures';
+
+describe('WsClient + FakeWsServer', () => {
+ it('completes session hello and lists schedules', async () => {
+ const server = new FakeWsServer({ userId: 'user_test' });
+ const client = new WsClient({ fakeHandler: server.handleMessage });
+ server.attach(client);
+ await client.connect();
+
+ const ready = new Promise((resolve) => {
+ client.onMessage((message) => {
+ if (!(message instanceof ArrayBuffer) && message.type === 'session.ready') {
+ resolve();
+ }
+ });
+ });
+ client.sendJson({
+ type: 'session.hello',
+ device_id: 'device_1',
+ app_version: '1.0.0',
+ });
+ await ready;
+
+ const list = await client.request({
+ type: 'schedule.list.query',
+ request_id: 'req_list_1',
+ payload: { status: null, include_deleted: false },
+ });
+ expect(list.type).toBe('schedule.list.result');
+ expect(list.ok).toBe(true);
+ client.close();
+ });
+
+ it('upserts a schedule through fake WS', async () => {
+ const server = new FakeWsServer({ userId: 'user_test' });
+ const client = new WsClient({ fakeHandler: server.handleMessage });
+ server.attach(client);
+ await client.connect();
+
+ const response = await client.request({
+ type: 'schedule.upsert.command',
+ request_id: 'req_up_1',
+ payload: {
+ source_mode: 'manual',
+ schedule_type: 'time',
+ title: '测试',
+ start_time: new Date(Date.now() + 3_600_000).toISOString(),
+ },
+ });
+ expect(response.ok).toBe(true);
+ expect(server.getSchedules()).toHaveLength(1);
+ client.close();
+ });
+
+ it('acks delete without losing synchronous fake replies', async () => {
+ const server = new FakeWsServer({
+ userId: 'user_test',
+ seedSchedules: [makeSchedule({ id: 'del_sync', user_id: 'user_test' })],
+ });
+ const client = new WsClient({ fakeHandler: server.handleMessage });
+ server.attach(client);
+ await client.connect();
+
+ const ack = await client.request({
+ type: 'schedule.deleted',
+ request_id: 'req_del_sync',
+ schedule_id: 'del_sync',
+ deleted: true,
+ timestamp: new Date().toISOString(),
+ });
+ expect(ack.type).toBe('schedule.deleted.ack');
+ expect(ack.ok).toBe(true);
+ expect(server.getSchedules()[0]?.status).toBe('deleted');
+ client.close();
+ });
+
+ it('updates status to done without marking deleted', async () => {
+ const server = new FakeWsServer({
+ userId: 'user_test',
+ seedSchedules: [makeSchedule({ id: 'status_1', user_id: 'user_test' })],
+ });
+ const client = new WsClient({ fakeHandler: server.handleMessage });
+ server.attach(client);
+ await client.connect();
+
+ const response = await client.request({
+ type: 'schedule.status.command',
+ request_id: 'req_status_1',
+ payload: { schedule_id: 'status_1', status: 'done' },
+ });
+ expect(response.ok).toBe(true);
+ expect(server.getSchedules()[0]?.status).toBe('done');
+ client.close();
+ });
+
+ it('uses the production location report and uncorrelated ack shapes', async () => {
+ const server = new FakeWsServer({ userId: 'user_test' });
+ const client = new WsClient({ fakeHandler: server.handleMessage });
+ server.attach(client);
+ await client.connect();
+
+ const ack = new Promise>((resolve) => {
+ client.onMessage((message) => {
+ if (!(message instanceof ArrayBuffer) && message.type === 'location.report.ack') {
+ resolve(message);
+ }
+ });
+ });
+ client.sendJson({
+ type: 'location.report',
+ schedule_scope: 'current',
+ latitude: 31.236305,
+ longitude: 121.480237,
+ accuracy: 12,
+ timestamp: '2026-07-31T10:00:00Z',
+ });
+
+ await expect(ack).resolves.toEqual({ type: 'location.report.ack', ok: true });
+ client.close();
+ });
+
+ it('rejects pending requests immediately when the remote socket closes unexpectedly', async () => {
+ class TestWebSocket {
+ static readonly OPEN = 1;
+ static instance: TestWebSocket | null = null;
+
+ binaryType = '';
+ readyState = 0;
+ onopen: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ onclose: (() => void) | null = null;
+ onmessage: ((event: { data: unknown }) => void) | null = null;
+
+ constructor(_url: string) {
+ TestWebSocket.instance = this;
+ }
+
+ send(_data: string | ArrayBuffer) {}
+
+ close() {
+ this.readyState = 3;
+ this.onclose?.();
+ }
+
+ open() {
+ this.readyState = TestWebSocket.OPEN;
+ this.onopen?.();
+ }
+
+ closeUnexpectedly() {
+ this.readyState = 3;
+ this.onclose?.();
+ }
+ }
+
+ const originalWebSocket = globalThis.WebSocket;
+ Object.defineProperty(globalThis, 'WebSocket', {
+ configurable: true,
+ value: TestWebSocket,
+ writable: true,
+ });
+
+ try {
+ const client = new WsClient({ url: 'ws://test.invalid', requestTimeoutMs: 60_000 });
+ const connecting = client.connect();
+ TestWebSocket.instance?.open();
+ await connecting;
+
+ const pending = client.request({
+ type: 'schedule.list.query',
+ request_id: 'req_disconnect',
+ payload: { status: null, include_deleted: false },
+ });
+ TestWebSocket.instance?.closeUnexpectedly();
+
+ await expect(pending).rejects.toThrow('WebSocket closed unexpectedly');
+ expect(client.getConnectionStatus()).toBe('closed');
+ } finally {
+ Object.defineProperty(globalThis, 'WebSocket', {
+ configurable: true,
+ value: originalWebSocket,
+ writable: true,
+ });
+ }
+ });
+});
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index 5220552..d9e20b2 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -8,18 +8,148 @@ module.exports = defineConfig([
expoConfig,
prettierConfig,
{
- ignores: ['dist/**', '.expo/**', 'web-build/**', 'node_modules/**'],
+ ignores: [
+ 'dist/**',
+ '.expo/**',
+ 'web-build/**',
+ 'node_modules/**',
+ '_backup_*/**',
+ '_shots/**',
+ '**/*.apk',
+ 'android/**',
+ 'ios/**',
+ 'modules/**',
+ ],
},
{
rules: {
'no-console': ['warn', { allow: ['warn', 'error'] }],
},
},
+ {
+ files: ['src/features/**/*.ts', 'src/features/**/*.tsx'],
+ rules: {
+ 'no-restricted-imports': [
+ 'error',
+ {
+ patterns: [
+ {
+ group: [
+ '@/app',
+ '@/app/*',
+ '@/features/*',
+ '@/features/*/*',
+ '@/infrastructure',
+ '@/infrastructure/*',
+ ],
+ message:
+ 'Features may depend only on contracts/shared and their own relative modules. Compose adapters in app.',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ {
+ files: [
+ 'src/contracts/**/*.ts',
+ 'src/contracts/**/*.tsx',
+ 'src/shared/**/*.ts',
+ 'src/shared/**/*.tsx',
+ ],
+ rules: {
+ 'no-restricted-imports': [
+ 'error',
+ {
+ patterns: [
+ {
+ group: [
+ '@/app',
+ '@/app/*',
+ '@/dev',
+ '@/dev/*',
+ '@/features/*',
+ '@/features/*/*',
+ '@/infrastructure',
+ '@/infrastructure/*',
+ ],
+ message: 'Contracts/shared must not depend on app, dev, features, or infrastructure.',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ {
+ files: ['src/infrastructure/**/*.ts', 'src/infrastructure/**/*.tsx'],
+ rules: {
+ 'no-restricted-imports': [
+ 'error',
+ {
+ patterns: [
+ {
+ group: ['@/app', '@/app/*', '@/dev', '@/dev/*', '@/features/*', '@/features/*/*'],
+ message: 'Infrastructure must not depend on app, dev, or feature implementations.',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ {
+ files: ['src/dev/**/*.ts', 'src/dev/**/*.tsx'],
+ rules: {
+ 'no-restricted-imports': [
+ 'error',
+ {
+ patterns: [
+ {
+ group: ['@/features/*', '@/features/*/*'],
+ message: 'Development fakes must not depend on feature-private implementations.',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ {
+ files: ['src/app/**/*.ts', 'src/app/**/*.tsx', 'App.tsx'],
+ rules: {
+ 'no-restricted-imports': [
+ 'error',
+ {
+ patterns: [
+ {
+ group: [
+ '@/features/*/hooks/*',
+ '@/features/*/components/*',
+ '@/features/*/data/*',
+ '@/features/*/domain/*',
+ '@/features/*/application/*',
+ '@/features/*/calendar/*',
+ '@/features/*/editor/*',
+ '@/features/*/detail/*',
+ '@/features/*/screens/*',
+ '@/features/*/model/*',
+ '@/features/*/location/*',
+ '@/features/*/native/*',
+ '@/features/*/presentation/*',
+ '@/features/*/services/*',
+ '@/features/*/utils/*',
+ ],
+ message: 'Import from @/features/ public entry instead of deep paths.',
+ },
+ ],
+ },
+ ],
+ },
+ },
{
files: ['__tests__/**/*.ts', '__tests__/**/*.tsx'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
'import/first': 'off',
+ 'no-restricted-imports': 'off',
},
},
{
@@ -30,4 +160,12 @@ module.exports = defineConfig([
},
},
},
+ {
+ files: ['react-native.config.js'],
+ languageOptions: {
+ globals: {
+ __dirname: 'readonly',
+ },
+ },
+ },
]);
diff --git a/frontend/src/app/session/SessionProvider.tsx b/frontend/src/app/session/SessionProvider.tsx
new file mode 100644
index 0000000..027ed40
--- /dev/null
+++ b/frontend/src/app/session/SessionProvider.tsx
@@ -0,0 +1,288 @@
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+ type ReactNode,
+} from 'react';
+
+import type { ConnectionStatus, SessionHello, SessionReady, WsJsonMessage } from '@/contracts';
+
+import { FakeWsServer } from '@/dev/fakes/FakeWsServer';
+import { getOrCreateDeviceId, type DeviceIdStore } from '@/infrastructure/storage/deviceIdStore';
+import { WsClient } from '@/infrastructure/ws/WsClient';
+
+import { buildSessionWebSocketUrl, resolveSessionUserId } from './sessionEndpoint';
+
+export type SessionTransportMode = 'remote' | 'fake' | 'unavailable';
+
+export type SessionContextValue = {
+ deviceId: string | null;
+ userId: string | null;
+ connectionStatus: ConnectionStatus;
+ transportMode: SessionTransportMode;
+ /** 每次成功 session.ready 递增,供 schedule 重连后 resync。 */
+ sessionEpoch: number;
+ client: WsClient | null;
+ fakeServer: FakeWsServer | null;
+ connectionError: string | null;
+};
+
+const SessionContext = createContext(null);
+
+function resolveWsUrl(): string | null {
+ const fromEnv =
+ typeof process !== 'undefined' ? process.env.EXPO_PUBLIC_WS_URL?.trim() : undefined;
+ return fromEnv || null;
+}
+
+function resolveAllowFake(): boolean {
+ const flag =
+ typeof process !== 'undefined' ? process.env.EXPO_PUBLIC_USE_FAKE_WS?.trim() : undefined;
+ const isDevBuild = typeof __DEV__ !== 'undefined' && __DEV__;
+ // Fake 只能进入开发/调试构建;release 即使误带变量也必须拒绝。
+ if (!isDevBuild) return false;
+ if (flag === '0' || flag === 'false') return false;
+ return flag === '1' || flag === 'true' || flag == null;
+}
+
+const RECONNECT_BASE_MS = 1000;
+const RECONNECT_MAX_MS = 30_000;
+const SESSION_READY_TIMEOUT_MS = 10_000;
+const UNAVAILABLE_CONNECTION_ERROR =
+ '缺少 EXPO_PUBLIC_WS_URL。开发环境可设置 EXPO_PUBLIC_USE_FAKE_WS=true 使用进程内 Fake。';
+
+function isSessionReady(message: WsJsonMessage, deviceId: string): message is SessionReady {
+ return (
+ message.type === 'session.ready' &&
+ message.device_id === deviceId &&
+ (message.user_id == null ||
+ (typeof message.user_id === 'string' && message.user_id.trim().length > 0)) &&
+ typeof message.server_time === 'string'
+ );
+}
+
+export function SessionProvider({
+ children,
+ deviceIdStore,
+}: {
+ children: ReactNode;
+ deviceIdStore?: DeviceIdStore;
+}) {
+ const [deviceId, setDeviceId] = useState(null);
+ const [userId, setUserId] = useState(null);
+ const [connectionStatus, setConnectionStatus] = useState('idle');
+ const [sessionEpoch, setSessionEpoch] = useState(0);
+ const [connectionError, setConnectionError] = useState(null);
+
+ const url = useMemo(() => resolveWsUrl(), []);
+ const allowFake = useMemo(() => resolveAllowFake(), []);
+ const transportMode: SessionTransportMode = url ? 'remote' : allowFake ? 'fake' : 'unavailable';
+
+ const remoteEndpoint = useMemo(() => {
+ if (transportMode !== 'remote' || !url || !deviceId) {
+ return { url: null, error: null };
+ }
+ try {
+ return { url: buildSessionWebSocketUrl(url, deviceId), error: null };
+ } catch (error) {
+ return {
+ url: null,
+ error: error instanceof Error ? error.message : 'WebSocket 地址不合法',
+ };
+ }
+ }, [deviceId, transportMode, url]);
+
+ const { client, fakeServer } = useMemo(() => {
+ if (transportMode === 'unavailable') {
+ return { client: null as WsClient | null, fakeServer: null as FakeWsServer | null };
+ }
+ if (transportMode === 'remote' && !remoteEndpoint.url) {
+ return { client: null as WsClient | null, fakeServer: null as FakeWsServer | null };
+ }
+ const server = transportMode === 'fake' ? new FakeWsServer() : null;
+ const ws = new WsClient({
+ url: transportMode === 'remote' ? remoteEndpoint.url : null,
+ fakeHandler: server ? server.handleMessage : undefined,
+ });
+ server?.attach(ws);
+ return { client: ws, fakeServer: server };
+ }, [remoteEndpoint.url, transportMode]);
+
+ const reconnectAttempt = useRef(0);
+ const reconnectTimer = useRef | null>(null);
+
+ useEffect(() => {
+ let cancelled = false;
+ void getOrCreateDeviceId(deviceIdStore)
+ .then((id) => {
+ if (!cancelled) setDeviceId(id);
+ })
+ .catch((error: unknown) => {
+ if (cancelled) return;
+ setConnectionStatus('error');
+ setConnectionError(
+ error instanceof Error ? error.message : '无法初始化设备身份,请检查原生存储配置',
+ );
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [deviceIdStore]);
+
+ useEffect(() => {
+ if (!client) return;
+ if (!deviceId) return;
+
+ let cancelled = false;
+ let sessionReadyTimer: ReturnType | null = null;
+
+ const clearReconnect = () => {
+ if (reconnectTimer.current) {
+ clearTimeout(reconnectTimer.current);
+ reconnectTimer.current = null;
+ }
+ };
+
+ const clearSessionReadyTimer = () => {
+ if (!sessionReadyTimer) return;
+ clearTimeout(sessionReadyTimer);
+ sessionReadyTimer = null;
+ };
+
+ const sendHello = () => {
+ const hello: SessionHello = {
+ type: 'session.hello',
+ device_id: deviceId,
+ app_version: '1.0.0',
+ };
+ clearSessionReadyTimer();
+ sessionReadyTimer = setTimeout(() => {
+ if (cancelled) return;
+ setConnectionStatus('error');
+ setConnectionError('会话握手超时,请检查服务连接');
+ client.close();
+ }, SESSION_READY_TIMEOUT_MS);
+ client.sendJson(hello);
+ };
+
+ const connectOnce = async () => {
+ try {
+ setConnectionError(null);
+ await client.connect();
+ if (cancelled) return;
+ reconnectAttempt.current = 0;
+ sendHello();
+ } catch (error) {
+ if (cancelled) return;
+ clearSessionReadyTimer();
+ client.close();
+ setConnectionStatus('error');
+ setConnectionError(error instanceof Error ? error.message : 'WebSocket 连接失败');
+ }
+ };
+
+ const scheduleReconnect = () => {
+ if (cancelled || transportMode === 'fake') return;
+ clearReconnect();
+ const attempt = reconnectAttempt.current;
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS);
+ reconnectAttempt.current = attempt + 1;
+ setConnectionStatus('reconnecting');
+ reconnectTimer.current = setTimeout(() => {
+ void connectOnce();
+ }, delay);
+ };
+
+ const unsubscribeStatus = client.onStatus((status) => {
+ if (cancelled) return;
+ // WebSocket open 只代表 socket 可用;session.ready 才代表身份握手完成。
+ setConnectionStatus(status === 'ready' ? 'connecting' : status);
+ if (status === 'closed') {
+ clearSessionReadyTimer();
+ scheduleReconnect();
+ }
+ });
+
+ const unsubscribeMessage = client.onMessage((message) => {
+ if (message instanceof ArrayBuffer) return;
+ if (isSessionReady(message, deviceId)) {
+ clearSessionReadyTimer();
+ setUserId(resolveSessionUserId(message.user_id));
+ setSessionEpoch((value) => value + 1);
+ setConnectionStatus('ready');
+ setConnectionError(null);
+ return;
+ }
+ if (message.type === 'session.error') {
+ clearSessionReadyTimer();
+ setConnectionStatus('error');
+ setConnectionError(
+ typeof message.error === 'object' &&
+ message.error !== null &&
+ 'message' in message.error &&
+ typeof message.error.message === 'string'
+ ? message.error.message
+ : '会话握手失败',
+ );
+ }
+ });
+
+ void connectOnce();
+
+ return () => {
+ cancelled = true;
+ clearReconnect();
+ clearSessionReadyTimer();
+ unsubscribeStatus();
+ unsubscribeMessage();
+ client.close();
+ };
+ }, [client, deviceId, transportMode]);
+
+ const effectiveConnectionStatus: ConnectionStatus =
+ transportMode === 'unavailable' || remoteEndpoint.error || (connectionError && !client)
+ ? 'error'
+ : client
+ ? connectionStatus
+ : 'connecting';
+ const effectiveConnectionError =
+ transportMode === 'unavailable'
+ ? UNAVAILABLE_CONNECTION_ERROR
+ : (remoteEndpoint.error ?? connectionError);
+
+ const value = useMemo(
+ () => ({
+ deviceId,
+ userId,
+ connectionStatus: effectiveConnectionStatus,
+ transportMode,
+ sessionEpoch,
+ client,
+ fakeServer,
+ connectionError: effectiveConnectionError,
+ }),
+ [
+ client,
+ deviceId,
+ effectiveConnectionError,
+ effectiveConnectionStatus,
+ fakeServer,
+ sessionEpoch,
+ transportMode,
+ userId,
+ ],
+ );
+
+ return {children};
+}
+
+export function useSession(): SessionContextValue {
+ const value = useContext(SessionContext);
+ if (!value) {
+ throw new Error('useSession must be used within SessionProvider');
+ }
+ return value;
+}
diff --git a/frontend/src/app/session/sessionEndpoint.ts b/frontend/src/app/session/sessionEndpoint.ts
new file mode 100644
index 0000000..6b49fd8
--- /dev/null
+++ b/frontend/src/app/session/sessionEndpoint.ts
@@ -0,0 +1,15 @@
+const LEGACY_BACKEND_USER_ID = 'default_user';
+
+export function buildSessionWebSocketUrl(baseUrl: string, deviceId: string): string {
+ const url = new URL(baseUrl);
+ if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
+ throw new Error('EXPO_PUBLIC_WS_URL 必须使用 ws:// 或 wss://');
+ }
+ url.searchParams.set('device_id', deviceId);
+ return url.toString();
+}
+
+/** Current MVP backend owns a single default user but omits it from session.ready. */
+export function resolveSessionUserId(userId: unknown): string {
+ return typeof userId === 'string' && userId.trim() ? userId.trim() : LEGACY_BACKEND_USER_ID;
+}
diff --git a/frontend/src/contracts/envelope.ts b/frontend/src/contracts/envelope.ts
new file mode 100644
index 0000000..f7ff041
--- /dev/null
+++ b/frontend/src/contracts/envelope.ts
@@ -0,0 +1,25 @@
+export type ApiError = {
+ code: string;
+ message: string;
+ details: Record | null;
+};
+
+export type WsRequest = {
+ type: TType;
+ request_id: string;
+ payload: TPayload;
+};
+
+export type WsSuccess = {
+ type: TType;
+ request_id: string;
+ ok: true;
+ payload: TPayload;
+};
+
+export type WsFailure = {
+ type: TType;
+ request_id: string;
+ ok: false;
+ error: ApiError;
+};
diff --git a/frontend/src/contracts/index.ts b/frontend/src/contracts/index.ts
new file mode 100644
index 0000000..5b1d678
--- /dev/null
+++ b/frontend/src/contracts/index.ts
@@ -0,0 +1,6 @@
+export type * from './envelope';
+export type * from './reminder';
+export type * from './schedule';
+export type * from './session';
+export type * from './transport';
+export type * from './voice';
diff --git a/frontend/src/contracts/reminder.ts b/frontend/src/contracts/reminder.ts
new file mode 100644
index 0000000..7936d25
--- /dev/null
+++ b/frontend/src/contracts/reminder.ts
@@ -0,0 +1,48 @@
+import type { ApiError } from './envelope';
+
+/**
+ * 提醒通道协议(预留)。
+ * 当前前端尚未接入 WS 提醒控制 / TTS 音频流;类型仅作与后端对齐的权威契约文档。
+ * 接入客户端前请勿在业务层依赖这些消息。
+ */
+
+/** 服务端下发提醒控制;具体展示通道由客户端根据前后台自行决定。 */
+export type ReminderControl = {
+ type: 'reminder.control';
+ schedule_id: string;
+ reason: string;
+ action: 'show';
+};
+
+export type ReminderControlAck =
+ | { type: 'reminder.control.ack'; schedule_id: string; ok: true }
+ | { type: 'reminder.control.ack'; schedule_id: string; ok: false; error: ApiError };
+
+/** 提醒 TTS 音频流开始;随后通过同一 WebSocket 连接发送 Binary Frame。 */
+export type ReminderAudioStart = {
+ type: 'reminder.audio.start';
+ schedule_id: string;
+ stream_id: string;
+ audio_format: 'mp3';
+};
+
+export type ReminderAudioEnd = {
+ type: 'reminder.audio.end';
+ schedule_id: string;
+ stream_id: string;
+};
+
+export type ReminderAudioAck =
+ | {
+ type: 'reminder.audio.ack';
+ schedule_id: string;
+ stream_id: string;
+ ok: true;
+ }
+ | {
+ type: 'reminder.audio.ack';
+ schedule_id: string;
+ stream_id: string;
+ ok: false;
+ error: ApiError;
+ };
diff --git a/frontend/src/contracts/schedule.ts b/frontend/src/contracts/schedule.ts
new file mode 100644
index 0000000..9978407
--- /dev/null
+++ b/frontend/src/contracts/schedule.ts
@@ -0,0 +1,134 @@
+import type { ApiError, WsFailure, WsRequest, WsSuccess } from './envelope';
+
+export type ScheduleSourceMode = 'manual' | 'voice';
+export type ScheduleType = 'time' | 'location';
+export type ScheduleStatus = 'scheduled' | 'done' | 'deleted';
+
+export type Schedule = {
+ id: string;
+ user_id: string;
+ source_mode: ScheduleSourceMode;
+ schedule_type: ScheduleType;
+ status: ScheduleStatus;
+ title: string;
+ notes: string | null;
+ start_time: string | null;
+ end_time: string | null;
+ timezone: string | null;
+ location_name: string | null;
+ location_address: string | null;
+ latitude: number | null;
+ longitude: number | null;
+ geofence_radius_meters: number;
+ geofence_armed: boolean;
+ time_remind_offset_minutes: number;
+ time_triggered_at: string | null;
+ geo_triggered_at: string | null;
+ system_schedule_ref_id: string | null;
+ system_alarm_ref_id: string | null;
+ created_at: string;
+ updated_at: string;
+};
+
+export type ScheduleListQueryPayload = {
+ status: ScheduleStatus | null;
+ include_deleted: boolean;
+};
+
+export type ScheduleListQuery = WsRequest<'schedule.list.query', ScheduleListQueryPayload>;
+
+export type ScheduleListResultPayload = {
+ schedules: Schedule[];
+};
+
+export type ScheduleListResult = WsSuccess<'schedule.list.result', ScheduleListResultPayload>;
+
+export type ScheduleListError = WsFailure<'schedule.list.error'>;
+export type ScheduleListResponse = ScheduleListResult | ScheduleListError;
+
+export type ScheduleConflict = {
+ schedule_id: string;
+ title: string;
+ start_time: string;
+ end_time: string | null;
+};
+
+/** 草稿业务字段(创建/语音解析共用,不含 schedule_id / source_mode)。 */
+export type ScheduleDraftFields = {
+ schedule_type: ScheduleType;
+ title: string;
+ notes?: string | null;
+ start_time?: string | null;
+ end_time?: string | null;
+ timezone?: string | null;
+ location_name?: string | null;
+ location_address?: string | null;
+ latitude?: number | null;
+ longitude?: number | null;
+ geofence_radius_meters?: number | null;
+ geofence_armed?: boolean | null;
+ time_remind_offset_minutes?: number | null;
+};
+
+export type ScheduleUpsertPayload = ScheduleDraftFields & {
+ schedule_id?: string | null;
+ source_mode: ScheduleSourceMode;
+};
+
+export type ScheduleUpsertCommand = WsRequest<'schedule.upsert.command', ScheduleUpsertPayload>;
+
+export type ScheduleUpsertResultPayload = {
+ schedule_id: string;
+ schedule_type: ScheduleType;
+ status: ScheduleStatus;
+ conflicts: ScheduleConflict[];
+ geofence_armed: boolean;
+};
+
+export type ScheduleUpsertResult = WsSuccess<'schedule.upsert.result', ScheduleUpsertResultPayload>;
+
+export type ScheduleUpsertError = WsFailure<'schedule.upsert.error'>;
+export type ScheduleUpsertResponse = ScheduleUpsertResult | ScheduleUpsertError;
+
+/** 完成 / 恢复为已安排;与删除语义分离。 */
+export type ScheduleStatusUpdatePayload = {
+ schedule_id: string;
+ status: Extract;
+};
+
+export type ScheduleStatusUpdateCommand = WsRequest<
+ 'schedule.status.command',
+ ScheduleStatusUpdatePayload
+>;
+
+export type ScheduleStatusUpdateResultPayload = {
+ schedule_id: string;
+ status: ScheduleStatus;
+};
+
+export type ScheduleStatusUpdateResult = WsSuccess<
+ 'schedule.status.result',
+ ScheduleStatusUpdateResultPayload
+>;
+
+export type ScheduleStatusUpdateError = WsFailure<'schedule.status.error'>;
+export type ScheduleStatusUpdateResponse = ScheduleStatusUpdateResult | ScheduleStatusUpdateError;
+
+/** 客户端确认删除后,通知服务端取消监听与提醒(仅删除,不含完成)。 */
+export type ScheduleDeleted = {
+ type: 'schedule.deleted';
+ request_id: string;
+ schedule_id: string;
+ deleted: true;
+ timestamp: string;
+};
+
+export type ScheduleDeletedAck =
+ | { type: 'schedule.deleted.ack'; request_id?: string; schedule_id: string; ok: true }
+ | {
+ type: 'schedule.deleted.ack';
+ request_id?: string;
+ schedule_id: string;
+ ok: false;
+ error: ApiError;
+ };
diff --git a/frontend/src/contracts/session.ts b/frontend/src/contracts/session.ts
new file mode 100644
index 0000000..98bdf44
--- /dev/null
+++ b/frontend/src/contracts/session.ts
@@ -0,0 +1,34 @@
+import type { ApiError } from './envelope';
+
+export type SessionHello = {
+ type: 'session.hello';
+ device_id: string;
+ app_version: string;
+};
+
+export type SessionReady = {
+ type: 'session.ready';
+ device_id: string;
+ /** Newer servers return this; the current single-user MVP server omits it. */
+ user_id?: string;
+ server_time: string;
+};
+
+export type SessionError = {
+ type: 'session.error';
+ ok: false;
+ error: ApiError;
+};
+
+export type LocationReport = {
+ type: 'location.report';
+ schedule_scope: 'current';
+ latitude: number;
+ longitude: number;
+ accuracy: number;
+ timestamp: string;
+};
+
+export type LocationReportAck =
+ | { type: 'location.report.ack'; ok: true }
+ | { type: 'location.report.ack'; ok: false; error: ApiError };
diff --git a/frontend/src/contracts/transport.ts b/frontend/src/contracts/transport.ts
new file mode 100644
index 0000000..9530433
--- /dev/null
+++ b/frontend/src/contracts/transport.ts
@@ -0,0 +1,8 @@
+export type ConnectionStatus =
+ 'idle' | 'connecting' | 'ready' | 'reconnecting' | 'closed' | 'error';
+
+export type WsJsonMessage = {
+ type: string;
+ request_id?: string;
+ [key: string]: unknown;
+};
diff --git a/frontend/src/contracts/voice.ts b/frontend/src/contracts/voice.ts
new file mode 100644
index 0000000..07c8ad1
--- /dev/null
+++ b/frontend/src/contracts/voice.ts
@@ -0,0 +1,54 @@
+import type { ApiError, WsFailure, WsRequest, WsSuccess } from './envelope';
+import type { ScheduleDraftFields } from './schedule';
+
+export type VoiceStreamStartPayload = {
+ audio_format: 'pcm_s16le';
+ sample_rate_hz: number;
+ channels: number;
+};
+
+export type VoiceStreamStartCommand = WsRequest<'voice.stream.start', VoiceStreamStartPayload>;
+
+export type VoiceStreamEndPayload = {
+ stream_id: string;
+};
+
+export type VoiceStreamEndCommand = WsRequest<'voice.stream.end', VoiceStreamEndPayload>;
+
+export type VoiceStreamError = WsFailure<'voice.stream.error'>;
+
+export type VoiceStreamStarted = WsSuccess<
+ 'voice.stream.started',
+ { stream_id: string; job_id: string }
+>;
+
+export type VoiceStreamEnded = WsSuccess<
+ 'voice.stream.ended',
+ { stream_id: string; job_id: string; status: 'processing' }
+>;
+
+export type VoiceStreamStartResponse = VoiceStreamStarted | VoiceStreamError;
+export type VoiceStreamEndResponse = VoiceStreamEnded | VoiceStreamError;
+
+export type VoiceParseDraft = Omit;
+
+export type VoiceParseReadyResult = {
+ type: 'voice.parse.result';
+ request_id: string;
+ job_id: string;
+ status: 'ready_for_confirmation';
+ draft: VoiceParseDraft;
+ missing_fields: string[];
+ ambiguous_fields: string[];
+ needs_confirmation: true;
+};
+
+export type VoiceParseFailedResult = {
+ type: 'voice.parse.result';
+ request_id: string;
+ job_id: string;
+ status: 'failed';
+ error: ApiError;
+};
+
+export type VoiceParseResultMessage = VoiceParseReadyResult | VoiceParseFailedResult;
diff --git a/frontend/src/dev/fakes/FakeWsServer.ts b/frontend/src/dev/fakes/FakeWsServer.ts
new file mode 100644
index 0000000..f3d1c85
--- /dev/null
+++ b/frontend/src/dev/fakes/FakeWsServer.ts
@@ -0,0 +1,255 @@
+import type {
+ LocationReport,
+ LocationReportAck,
+ Schedule,
+ ScheduleDeleted,
+ ScheduleDeletedAck,
+ ScheduleListQuery,
+ ScheduleListResponse,
+ ScheduleStatusUpdateCommand,
+ ScheduleStatusUpdateResponse,
+ ScheduleUpsertCommand,
+ ScheduleUpsertResponse,
+ SessionHello,
+ SessionReady,
+ VoiceParseResultMessage,
+ VoiceStreamEndCommand,
+ VoiceStreamStartCommand,
+ VoiceStreamStartResponse,
+ VoiceStreamEndResponse,
+ WsJsonMessage,
+} from '@/contracts';
+import type { WsClient } from '@/infrastructure/ws/WsClient';
+
+import { upsertSchedule } from './schedule/scheduleConflicts';
+import { createFakeSchedule } from './schedule/scheduleFactory';
+
+type FakeWsServerOptions = {
+ userId?: string;
+ seedSchedules?: Schedule[];
+};
+
+/**
+ * 进程内 Fake WS:只依赖 contracts + WsClient,供本地与测试使用。
+ */
+export class FakeWsServer {
+ private readonly schedules = new Map();
+ private readonly userId: string;
+ private client: WsClient | null = null;
+ private voiceJobCounter = 0;
+
+ constructor(options: FakeWsServerOptions = {}) {
+ this.userId = options.userId ?? 'user_fake_1';
+ for (const schedule of options.seedSchedules ?? []) {
+ this.schedules.set(schedule.id, schedule);
+ }
+ }
+
+ attach(client: WsClient): void {
+ this.client = client;
+ }
+
+ getUserId(): string {
+ return this.userId;
+ }
+
+ getSchedules(): Schedule[] {
+ return [...this.schedules.values()];
+ }
+
+ handleMessage = async (message: WsJsonMessage | ArrayBuffer): Promise => {
+ if (message instanceof ArrayBuffer) {
+ return;
+ }
+
+ switch (message.type) {
+ case 'session.hello':
+ this.handleSessionHello(message as SessionHello);
+ return;
+ case 'schedule.list.query':
+ this.handleList(message as ScheduleListQuery);
+ return;
+ case 'schedule.upsert.command':
+ this.handleUpsert(message as ScheduleUpsertCommand);
+ return;
+ case 'schedule.status.command':
+ this.handleStatusUpdate(message as ScheduleStatusUpdateCommand);
+ return;
+ case 'schedule.deleted':
+ this.handleDeleted(message as ScheduleDeleted);
+ return;
+ case 'location.report':
+ this.handleLocationReport(message as LocationReport);
+ return;
+ case 'voice.stream.start':
+ this.handleVoiceStart(message as VoiceStreamStartCommand);
+ return;
+ case 'voice.stream.end':
+ this.handleVoiceEnd(message as VoiceStreamEndCommand);
+ return;
+ default:
+ return;
+ }
+ };
+
+ private reply(message: WsJsonMessage): void {
+ this.client?.emitFromServer(message);
+ }
+
+ private handleSessionHello(message: SessionHello): void {
+ const ready: SessionReady = {
+ type: 'session.ready',
+ device_id: message.device_id,
+ user_id: this.userId,
+ server_time: new Date().toISOString(),
+ };
+ this.reply(ready);
+ }
+
+ private handleList(message: ScheduleListQuery): void {
+ const includeDeleted = message.payload.include_deleted;
+ const statusFilter = message.payload.status;
+ const schedules = [...this.schedules.values()].filter((item) => {
+ if (!includeDeleted && item.status === 'deleted') return false;
+ if (statusFilter && item.status !== statusFilter) return false;
+ return true;
+ });
+ const response: ScheduleListResponse = {
+ type: 'schedule.list.result',
+ request_id: message.request_id,
+ ok: true,
+ payload: { schedules },
+ };
+ this.reply(response);
+ }
+
+ private handleUpsert(message: ScheduleUpsertCommand): void {
+ const scheduleId = message.payload.schedule_id ?? `schedule_${Date.now()}`;
+ const current = [...this.schedules.values()];
+ const existing = this.schedules.get(scheduleId) ?? null;
+ const result = upsertSchedule(message, current, scheduleId);
+ const entity = createFakeSchedule({
+ draft: { ...message.payload, schedule_id: scheduleId },
+ scheduleId,
+ userId: this.userId,
+ status: result.payload.status,
+ geofenceArmed: result.payload.geofence_armed,
+ existing,
+ });
+ this.schedules.set(scheduleId, entity);
+ const response: ScheduleUpsertResponse = result;
+ this.reply(response);
+ this.reply({
+ type: 'schedule.updated',
+ schedule: entity,
+ });
+ }
+
+ private handleStatusUpdate(message: ScheduleStatusUpdateCommand): void {
+ const existing = this.schedules.get(message.payload.schedule_id);
+ if (!existing || existing.status === 'deleted') {
+ const response: ScheduleStatusUpdateResponse = {
+ type: 'schedule.status.error',
+ request_id: message.request_id,
+ ok: false,
+ error: {
+ code: 'schedule_not_found',
+ message: '日程不存在或已删除',
+ details: null,
+ },
+ };
+ this.reply(response);
+ return;
+ }
+
+ const next: Schedule = {
+ ...existing,
+ status: message.payload.status,
+ updated_at: new Date().toISOString(),
+ };
+ this.schedules.set(next.id, next);
+ const response: ScheduleStatusUpdateResponse = {
+ type: 'schedule.status.result',
+ request_id: message.request_id,
+ ok: true,
+ payload: { schedule_id: next.id, status: next.status },
+ };
+ this.reply(response);
+ this.reply({ type: 'schedule.updated', schedule: next });
+ }
+
+ private handleDeleted(message: ScheduleDeleted): void {
+ const existing = this.schedules.get(message.schedule_id);
+ if (existing) {
+ const next: Schedule = {
+ ...existing,
+ status: 'deleted',
+ updated_at: new Date().toISOString(),
+ };
+ this.schedules.set(message.schedule_id, next);
+ this.reply({ type: 'schedule.updated', schedule: next });
+ }
+ const ack: ScheduleDeletedAck = {
+ type: 'schedule.deleted.ack',
+ request_id: message.request_id,
+ schedule_id: message.schedule_id,
+ ok: true,
+ };
+ this.reply(ack);
+ }
+
+ private handleLocationReport(_message: LocationReport): void {
+ const ack: LocationReportAck = {
+ type: 'location.report.ack',
+ ok: true,
+ };
+ this.reply(ack);
+ }
+
+ private handleVoiceStart(message: VoiceStreamStartCommand): void {
+ this.voiceJobCounter += 1;
+ const streamId = `stream_${this.voiceJobCounter}`;
+ const jobId = `job_${this.voiceJobCounter}`;
+ const response: VoiceStreamStartResponse = {
+ type: 'voice.stream.started',
+ request_id: message.request_id,
+ ok: true,
+ payload: { stream_id: streamId, job_id: jobId },
+ };
+ this.reply(response);
+ }
+
+ private handleVoiceEnd(message: VoiceStreamEndCommand): void {
+ const jobId = `job_${this.voiceJobCounter || 1}`;
+ const response: VoiceStreamEndResponse = {
+ type: 'voice.stream.ended',
+ request_id: message.request_id,
+ ok: true,
+ payload: {
+ stream_id: message.payload.stream_id,
+ job_id: jobId,
+ status: 'processing',
+ },
+ };
+ this.reply(response);
+
+ const parseResult: VoiceParseResultMessage = {
+ type: 'voice.parse.result',
+ request_id: message.request_id,
+ job_id: jobId,
+ status: 'ready_for_confirmation',
+ draft: {
+ schedule_type: 'time',
+ title: '语音创建的日程',
+ start_time: new Date(Date.now() + 3_600_000).toISOString(),
+ end_time: null,
+ timezone: 'Asia/Shanghai',
+ time_remind_offset_minutes: 0,
+ },
+ missing_fields: [],
+ ambiguous_fields: [],
+ needs_confirmation: true,
+ };
+ setTimeout(() => this.reply(parseResult), 0);
+ }
+}
diff --git a/frontend/src/dev/fakes/schedule/scheduleConflicts.ts b/frontend/src/dev/fakes/schedule/scheduleConflicts.ts
new file mode 100644
index 0000000..d2612b5
--- /dev/null
+++ b/frontend/src/dev/fakes/schedule/scheduleConflicts.ts
@@ -0,0 +1,61 @@
+import type {
+ Schedule,
+ ScheduleConflict,
+ ScheduleUpsertCommand,
+ ScheduleUpsertResult,
+} from '@/contracts';
+
+/** 检测与现有日程的时间重叠冲突(排除自身与已删除项)。 */
+function findScheduleConflicts(
+ command: ScheduleUpsertCommand,
+ schedules: Schedule[],
+ currentScheduleId: string,
+): ScheduleConflict[] {
+ const { end_time: endTime, start_time: startTime } = command.payload;
+ if (!startTime) return [];
+
+ const start = new Date(startTime).getTime();
+ const end = endTime ? new Date(endTime).getTime() : start;
+ if (!Number.isFinite(start) || !Number.isFinite(end)) return [];
+
+ return schedules
+ .filter((item) => item.id !== currentScheduleId && item.status !== 'deleted' && item.start_time)
+ .filter((item) => {
+ const itemStart = new Date(item.start_time!).getTime();
+ const itemEnd = item.end_time ? new Date(item.end_time).getTime() : itemStart;
+ return (
+ Number.isFinite(itemStart) &&
+ Number.isFinite(itemEnd) &&
+ start <= itemEnd &&
+ itemStart <= end
+ );
+ })
+ .map((item) => ({
+ schedule_id: item.id,
+ title: item.title,
+ start_time: item.start_time!,
+ end_time: item.end_time,
+ }));
+}
+
+/** 拼装本地 upsert 结果(含冲突列表与 geofence 默认值)。 */
+export function upsertSchedule(
+ command: ScheduleUpsertCommand,
+ current: Schedule[],
+ scheduleId: string,
+): ScheduleUpsertResult {
+ const existingSchedule = current.find((item) => item.id === scheduleId);
+
+ return {
+ type: 'schedule.upsert.result',
+ request_id: command.request_id,
+ ok: true,
+ payload: {
+ schedule_id: scheduleId,
+ schedule_type: command.payload.schedule_type,
+ status: 'scheduled',
+ conflicts: findScheduleConflicts(command, current, scheduleId),
+ geofence_armed: command.payload.geofence_armed ?? existingSchedule?.geofence_armed ?? true,
+ },
+ };
+}
diff --git a/frontend/src/dev/fakes/schedule/scheduleFactory.ts b/frontend/src/dev/fakes/schedule/scheduleFactory.ts
new file mode 100644
index 0000000..89863e8
--- /dev/null
+++ b/frontend/src/dev/fakes/schedule/scheduleFactory.ts
@@ -0,0 +1,39 @@
+import type { Schedule, ScheduleStatus, ScheduleUpsertPayload } from '@/contracts';
+
+export function createFakeSchedule(input: {
+ draft: ScheduleUpsertPayload;
+ scheduleId: string;
+ userId: string;
+ status: ScheduleStatus;
+ geofenceArmed: boolean;
+ existing?: Schedule | null;
+}): Schedule {
+ const { draft, existing } = input;
+ const now = new Date().toISOString();
+
+ return {
+ id: input.scheduleId,
+ user_id: existing?.user_id ?? input.userId,
+ source_mode: draft.source_mode,
+ schedule_type: draft.schedule_type,
+ status: input.status,
+ title: draft.title,
+ notes: draft.notes ?? null,
+ start_time: draft.start_time ?? null,
+ end_time: draft.end_time ?? null,
+ timezone: draft.timezone ?? null,
+ location_name: draft.location_name ?? null,
+ location_address: draft.location_address ?? null,
+ latitude: draft.latitude ?? null,
+ longitude: draft.longitude ?? null,
+ geofence_radius_meters: draft.geofence_radius_meters ?? existing?.geofence_radius_meters ?? 100,
+ geofence_armed: input.geofenceArmed,
+ time_remind_offset_minutes: draft.time_remind_offset_minutes ?? 0,
+ time_triggered_at: existing?.time_triggered_at ?? null,
+ geo_triggered_at: existing?.geo_triggered_at ?? null,
+ system_schedule_ref_id: existing?.system_schedule_ref_id ?? null,
+ system_alarm_ref_id: existing?.system_alarm_ref_id ?? null,
+ created_at: existing?.created_at ?? now,
+ updated_at: now,
+ };
+}
diff --git a/frontend/src/infrastructure/storage/deviceIdStore.ts b/frontend/src/infrastructure/storage/deviceIdStore.ts
new file mode 100644
index 0000000..21d998e
--- /dev/null
+++ b/frontend/src/infrastructure/storage/deviceIdStore.ts
@@ -0,0 +1,150 @@
+import { Platform } from 'react-native';
+import { requireOptionalNativeModule } from 'expo';
+
+const DEVICE_ID_KEY = 'timeflow.device_id';
+
+export type DeviceIdStore = {
+ get(): Promise;
+ set(value: string): Promise;
+};
+
+type ExpoFileSystemLike = {
+ documentDirectory: string | null;
+ getInfoAsync: (path: string, options: Record) => Promise<{ exists: boolean }>;
+ readAsStringAsync: (path: string, options: Record) => Promise;
+ writeAsStringAsync: (
+ path: string,
+ contents: string,
+ options: Record,
+ ) => Promise;
+};
+
+/**
+ * Native storage is deliberately a hard dependency at runtime. Falling back
+ * to an in-process map makes the device identity change on every cold start,
+ * which is worse than refusing to establish a session.
+ */
+export class DeviceIdPersistenceUnavailableError extends Error {
+ constructor(message = '原生设备存储不可用,无法持久化 device_id') {
+ super(message);
+ this.name = 'DeviceIdPersistenceUnavailableError';
+ }
+}
+
+function webStore(): DeviceIdStore {
+ return {
+ async get() {
+ if (typeof localStorage === 'undefined') {
+ throw new DeviceIdPersistenceUnavailableError('浏览器 localStorage 不可用');
+ }
+ return localStorage.getItem(DEVICE_ID_KEY);
+ },
+ async set(value) {
+ if (typeof localStorage === 'undefined') {
+ throw new DeviceIdPersistenceUnavailableError('浏览器 localStorage 不可用');
+ }
+ localStorage.setItem(DEVICE_ID_KEY, value);
+ },
+ };
+}
+
+/** Test/host adapter. Production code must inject a persistent implementation. */
+export function memoryStore(seed: Map = new Map()): DeviceIdStore {
+ return {
+ async get() {
+ return seed.get(DEVICE_ID_KEY) ?? null;
+ },
+ async set(value) {
+ seed.set(DEVICE_ID_KEY, value);
+ },
+ };
+}
+
+let nativeFileStore: DeviceIdStore | null = null;
+let nativeFileStorePromise: Promise | null = null;
+
+function loadExpoFileSystem(): ExpoFileSystemLike | null {
+ // Expo Go and a custom Expo runtime expose the legacy module under this
+ // name. The lookup is static and Metro-visible; there is no hidden import
+ // or optional JS package that can silently disappear from a release bundle.
+ return requireOptionalNativeModule('ExponentFileSystem');
+}
+
+function createNativeFileStore(FileSystem = loadExpoFileSystem()): DeviceIdStore {
+ const base = FileSystem?.documentDirectory;
+ if (
+ !FileSystem ||
+ !base ||
+ typeof FileSystem.getInfoAsync !== 'function' ||
+ typeof FileSystem.readAsStringAsync !== 'function' ||
+ typeof FileSystem.writeAsStringAsync !== 'function'
+ ) {
+ throw new DeviceIdPersistenceUnavailableError(
+ 'ExpoFileSystem 原生模块未链接;请在构建中声明 expo-file-system 或注入 DeviceIdStore',
+ );
+ }
+ const path = `${base}.timeflow-device-id`;
+ return {
+ async get() {
+ let info: { exists: boolean };
+ try {
+ info = await FileSystem.getInfoAsync(path, {});
+ } catch (error) {
+ throw new DeviceIdPersistenceUnavailableError(
+ `读取 device_id 失败: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ if (!info.exists) return null;
+ try {
+ const value = await FileSystem.readAsStringAsync(path, {});
+ return value.trim() || null;
+ } catch (error) {
+ throw new DeviceIdPersistenceUnavailableError(
+ `读取 device_id 失败: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ },
+ async set(value) {
+ try {
+ await FileSystem.writeAsStringAsync(path, value, {});
+ } catch (error) {
+ throw new DeviceIdPersistenceUnavailableError(
+ `写入 device_id 失败: ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ },
+ };
+}
+
+export async function createDeviceIdStore(): Promise {
+ if (Platform.OS === 'web') {
+ return webStore();
+ }
+ if (!nativeFileStore) {
+ nativeFileStorePromise ??= Promise.resolve()
+ .then(() => createNativeFileStore())
+ .catch((error) => {
+ // A transient host/module setup failure should not poison all later
+ // attempts during the same app lifetime.
+ nativeFileStorePromise = null;
+ throw error;
+ });
+ nativeFileStore = await nativeFileStorePromise;
+ }
+ return nativeFileStore;
+}
+
+export async function getOrCreateDeviceId(store?: DeviceIdStore): Promise {
+ const backend = store ?? (await createDeviceIdStore());
+ const existing = await backend.get();
+ if (existing) return existing;
+ const next = `device_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+ await backend.set(next);
+ // Read back once so a best-effort native implementation cannot report a
+ // successful write while losing the value (for example, a denied file URI).
+ const persisted = await backend.get();
+ if (persisted !== next) {
+ throw new DeviceIdPersistenceUnavailableError('device_id 写入后校验失败');
+ }
+ return next;
+}
diff --git a/frontend/src/infrastructure/ws/WsClient.ts b/frontend/src/infrastructure/ws/WsClient.ts
new file mode 100644
index 0000000..45c9b88
--- /dev/null
+++ b/frontend/src/infrastructure/ws/WsClient.ts
@@ -0,0 +1,250 @@
+import type { ConnectionStatus, WsJsonMessage } from '@/contracts';
+
+export type { ConnectionStatus, WsJsonMessage } from '@/contracts';
+
+export type WsClientOptions = {
+ /** 真实后端地址;为空则走 fakeHandler 进程内通道。 */
+ url?: string | null;
+ /** 无 URL 时的进程内消息处理器(由 SessionProvider 在显式 Fake 模式下注入)。 */
+ fakeHandler?: (message: WsJsonMessage | ArrayBuffer) => void | Promise;
+ requestTimeoutMs?: number;
+};
+
+type PendingRequest = {
+ resolve: (value: WsJsonMessage) => void;
+ reject: (error: Error) => void;
+ timer: ReturnType;
+ isMatch: (response: WsJsonMessage) => boolean;
+};
+
+/**
+ * 契约对齐的 WS 客户端:按 request_id 等待响应,支持订阅推送与二进制帧。
+ * 无 URL 时走进程内 Fake 通道,便于本地与单测。
+ */
+export class WsClient {
+ private socket: WebSocket | null = null;
+ private readonly pending = new Map();
+ private readonly listeners = new Set<(message: WsJsonMessage | ArrayBuffer) => void>();
+ private readonly statusListeners = new Set<(status: ConnectionStatus) => void>();
+ private status: ConnectionStatus = 'idle';
+ private readonly requestTimeoutMs: number;
+ private readonly url: string | null;
+ private readonly fakeHandler?: (message: WsJsonMessage | ArrayBuffer) => void | Promise;
+ private fakeReply: ((message: WsJsonMessage | ArrayBuffer) => void) | null = null;
+ private intentionallyClosed = false;
+ /** Invalidates callbacks belonging to a socket that has been replaced. */
+ private socketGeneration = 0;
+ private connecting: { generation: number; reject: (error: Error) => void } | null = null;
+
+ constructor(options: WsClientOptions = {}) {
+ this.url = options.url?.trim() || null;
+ this.fakeHandler = options.fakeHandler;
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
+ }
+
+ getConnectionStatus(): ConnectionStatus {
+ return this.status;
+ }
+
+ onStatus(listener: (status: ConnectionStatus) => void): () => void {
+ this.statusListeners.add(listener);
+ listener(this.status);
+ return () => this.statusListeners.delete(listener);
+ }
+
+ onMessage(listener: (message: WsJsonMessage | ArrayBuffer) => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ async connect(): Promise {
+ this.intentionallyClosed = false;
+ if (!this.url) {
+ this.setStatus('connecting');
+ this.fakeReply = (message) => this.dispatch(message);
+ this.setStatus('ready');
+ return;
+ }
+
+ this.setStatus(this.status === 'ready' ? 'reconnecting' : 'connecting');
+ await new Promise((resolve, reject) => {
+ const generation = ++this.socketGeneration;
+ const socket = new WebSocket(this.url!);
+ this.socket = socket;
+ this.connecting = { generation, reject };
+ socket.binaryType = 'arraybuffer';
+ socket.onopen = () => {
+ if (this.socket !== socket || this.socketGeneration !== generation) return;
+ this.connecting = null;
+ this.setStatus('ready');
+ resolve();
+ };
+ socket.onerror = () => {
+ if (this.socket !== socket || this.socketGeneration !== generation) return;
+ const error = new Error('WebSocket connection failed');
+ this.setStatus('error');
+ this.rejectPending(error);
+ this.rejectConnecting(generation, error);
+ };
+ socket.onclose = () => {
+ const isCurrent = this.socket === socket && this.socketGeneration === generation;
+ if (!isCurrent) return;
+ this.socket = null;
+ if (this.intentionallyClosed) return;
+ const error = new Error('WebSocket closed unexpectedly');
+ this.rejectPending(error);
+ this.setStatus('closed');
+ this.rejectConnecting(generation, new Error('WebSocket closed before becoming ready'));
+ };
+ socket.onmessage = (event) => {
+ if (this.socket !== socket || this.socketGeneration !== generation) return;
+ if (typeof event.data === 'string') {
+ try {
+ this.dispatch(JSON.parse(event.data) as WsJsonMessage);
+ } catch {
+ // ignore malformed JSON
+ }
+ return;
+ }
+ if (event.data instanceof ArrayBuffer) {
+ this.dispatch(event.data);
+ }
+ };
+ });
+ }
+
+ close(): void {
+ this.intentionallyClosed = true;
+ this.rejectConnecting(this.socketGeneration, new Error('WebSocket closed'));
+ this.socketGeneration += 1;
+ const socket = this.socket;
+ this.socket = null;
+ socket?.close();
+ this.fakeReply = null;
+ this.rejectPending(new Error('WebSocket closed'));
+ this.setStatus('closed');
+ }
+
+ sendJson(message: WsJsonMessage): void {
+ if (!this.url) {
+ void Promise.resolve(this.fakeHandler?.(message)).catch((error) => {
+ if (message.request_id) {
+ this.rejectRequest(
+ message.request_id,
+ error instanceof Error ? error : new Error(String(error)),
+ );
+ }
+ });
+ return;
+ }
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
+ throw new Error('WebSocket is not open');
+ }
+ this.socket.send(JSON.stringify(message));
+ }
+
+ sendBinary(data: ArrayBuffer): void {
+ if (!this.url) {
+ void Promise.resolve(this.fakeHandler?.(data)).catch(() => undefined);
+ return;
+ }
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
+ throw new Error('WebSocket is not open');
+ }
+ this.socket.send(data);
+ }
+
+ /** Fake 服务端向客户端推送。 */
+ emitFromServer(message: WsJsonMessage | ArrayBuffer): void {
+ this.fakeReply?.(message);
+ }
+
+ request(
+ message: WsJsonMessage & { request_id: string },
+ isMatch: (response: WsJsonMessage) => boolean = (response) =>
+ response.request_id === message.request_id,
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ if (!message.request_id) {
+ reject(new Error(`Request id is required: ${message.type}`));
+ return;
+ }
+ if (this.pending.has(message.request_id)) {
+ reject(new Error(`Duplicate request id: ${message.request_id}`));
+ return;
+ }
+ const timer = setTimeout(() => {
+ this.pending.delete(message.request_id);
+ reject(new Error(`Request timed out: ${message.type}`));
+ }, this.requestTimeoutMs);
+
+ this.pending.set(message.request_id, {
+ isMatch,
+ resolve: (value) => {
+ clearTimeout(timer);
+ this.pending.delete(message.request_id);
+ resolve(value as T);
+ },
+ reject: (error) => {
+ clearTimeout(timer);
+ this.pending.delete(message.request_id);
+ reject(error);
+ },
+ timer,
+ });
+
+ try {
+ this.sendJson(message);
+ } catch (error) {
+ clearTimeout(timer);
+ this.pending.delete(message.request_id);
+ reject(error instanceof Error ? error : new Error(String(error)));
+ }
+ });
+ }
+
+ private dispatch(message: WsJsonMessage | ArrayBuffer): void {
+ if (!(message instanceof ArrayBuffer)) {
+ for (const pending of this.pending.values()) {
+ if (pending.isMatch(message)) {
+ pending.resolve(message);
+ break;
+ }
+ }
+ }
+ for (const listener of this.listeners) {
+ listener(message);
+ }
+ }
+
+ private rejectPending(error: Error): void {
+ const pending = [...this.pending.values()];
+ this.pending.clear();
+ for (const request of pending) {
+ clearTimeout(request.timer);
+ request.reject(error);
+ }
+ }
+
+ private rejectRequest(requestId: string, error: Error): void {
+ const request = this.pending.get(requestId);
+ if (!request) return;
+ clearTimeout(request.timer);
+ this.pending.delete(requestId);
+ request.reject(error);
+ }
+
+ private rejectConnecting(generation: number, error: Error): void {
+ const connecting = this.connecting;
+ if (!connecting || connecting.generation !== generation) return;
+ this.connecting = null;
+ connecting.reject(error);
+ }
+
+ private setStatus(status: ConnectionStatus): void {
+ this.status = status;
+ for (const listener of this.statusListeners) {
+ listener(status);
+ }
+ }
+}
diff --git a/frontend/src/shared/types/geo.ts b/frontend/src/shared/types/geo.ts
new file mode 100644
index 0000000..9bb1e6e
--- /dev/null
+++ b/frontend/src/shared/types/geo.ts
@@ -0,0 +1,7 @@
+/** 中立地理点类型:feature 与地图 adapter 共用,不归属具体供应商。 */
+export type MapLocation = {
+ address: string;
+ latitude: number;
+ longitude: number;
+ name?: string;
+};
diff --git a/frontend/src/shared/utils/requestId.ts b/frontend/src/shared/utils/requestId.ts
new file mode 100644
index 0000000..f5aca22
--- /dev/null
+++ b/frontend/src/shared/utils/requestId.ts
@@ -0,0 +1,4 @@
+/** 生成 WS 请求 ID:`prefix_timestamp_random`。 */
+export function nextRequestId(prefix: string): string {
+ return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+}