From c56ada3708d3e1c8630f9f8f4a5231763f73b110 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Tue, 21 Jul 2026 16:29:42 +0800 Subject: [PATCH] refactor(web): consume api v1 contracts --- apps/web_v1/scripts/structure-check.mjs | 39 +++++ apps/web_v1/src/api/client.ts | 86 ++++++++-- apps/web_v1/src/api/inbox.ts | 49 +++--- apps/web_v1/src/api/mappers.tsx | 56 +++---- apps/web_v1/src/api/projects.ts | 198 +++++++++++++++--------- apps/web_v1/src/api/search.ts | 20 +++ apps/web_v1/src/app/App.tsx | 6 +- apps/web_v1/src/pages/login.tsx | 6 +- 8 files changed, 311 insertions(+), 149 deletions(-) create mode 100644 apps/web_v1/src/api/search.ts diff --git a/apps/web_v1/scripts/structure-check.mjs b/apps/web_v1/scripts/structure-check.mjs index 40beb3e..58e40db 100644 --- a/apps/web_v1/scripts/structure-check.mjs +++ b/apps/web_v1/scripts/structure-check.mjs @@ -21,6 +21,8 @@ const requiredFiles = [ 'src/api/client.ts', 'src/api/projects.ts', 'src/api/mappers.tsx', + 'src/api/search.ts', + 'src/api/inbox.ts', ] const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`) @@ -47,6 +49,43 @@ if (!projectRailSource.includes('projectNameLabel(project.name)')) { failures.push('project rail labels must derive from the project name') } +const apiFiles = ['client.ts', 'projects.ts', 'mappers.tsx', 'search.ts', 'inbox.ts'].map((name) => `src/api/${name}`) +const apiSource = apiFiles.map((file) => readFileSync(file, 'utf8')).join('\n') +for (const forbidden of [ + { pattern: /\bID\s*\?\s*:/, label: 'optional PascalCase ID compatibility field' }, + { pattern: /\bName\s*\?\s*:/, label: 'optional PascalCase Name compatibility field' }, + { pattern: /['"`]\/api\/projects/, label: 'legacy /api/projects path' }, + { + pattern: /\b(?:project|task|inbox)Id\s*\??\s*:\s*(?:string\s*\|\s*)?number\b/i, + label: 'numeric project/task/inbox identity', + }, +]) { + if (forbidden.pattern.test(apiSource)) failures.push(`src/api contains ${forbidden.label}`) +} + +const clientSource = readFileSync('src/api/client.ts', 'utf8') +if (!clientSource.includes("'/api/v1/auth/login'")) failures.push('login must use /api/v1/auth/login') +if (!clientSource.includes('export class ApiError extends Error')) failures.push('client must export ApiError') + +const projectsSource = readFileSync('src/api/projects.ts', 'utf8') +if (!projectsSource.includes("'/api/v1/projects'")) failures.push('project API must use /api/v1/projects') +if (/['"`]\/api\/(?!v1\/)/.test(apiSource)) failures.push('src/api paths must use the /api/v1 prefix') + +for (const match of apiSource.matchAll(/export type (\w+DTO)\s*=\s*\{([\s\S]*?)\n\}/g)) { + const [, name, body] = match + if (/^\s*\w+\s*\?\s*:/m.test(body)) failures.push(`${name} fields must be required`) + if (/^\s*[A-Z]\w*\s*:/m.test(body)) failures.push(`${name} fields must use camelCase`) + if (/^\s*(?:id|\w+Id)\s*:\s*number\b/im.test(body)) failures.push(`${name} identities must be strings`) +} + +const searchSource = readFileSync('src/api/search.ts', 'utf8') +if (!searchSource.includes("'/api/v1/search'")) failures.push('search API must use /api/v1/search') + +const inboxSource = readFileSync('src/api/inbox.ts', 'utf8') +for (const path of ['/api/v1/projects/', '/api/v1/inbox/']) { + if (!inboxSource.includes(path)) failures.push(`inbox API must use ${path}`) +} + if (existsSync('src/App.tsx')) { failures.push('legacy src/App.tsx should be removed after page split') } diff --git a/apps/web_v1/src/api/client.ts b/apps/web_v1/src/api/client.ts index 1bcb760..b0ff3d5 100644 --- a/apps/web_v1/src/api/client.ts +++ b/apps/web_v1/src/api/client.ts @@ -9,6 +9,25 @@ type RequestOptions = { token?: string } +type ErrorEnvelope = { + error: { + code: string + message: string + } +} + +export class ApiError extends Error { + status: number + code: string + + constructor(status: number, code: string, message: string) { + super(message) + this.name = 'ApiError' + this.status = status + this.code = code + } +} + let configuredBaseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:18080') export function setApiBaseUrl(baseUrl: string) { @@ -20,7 +39,7 @@ export function getApiBaseUrl() { } export async function login(email: string, password: string): Promise { - const response = await apiRequest<{ token: string }>('/api/auth/login', { + const response = await apiRequest<{ token: string }>('/api/v1/auth/login', { method: 'POST', body: { email, password }, }) @@ -30,24 +49,30 @@ export async function login(email: string, password: string): Promise(path: string, options: RequestOptions = {}): Promise { const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData const requestBody = options.body === undefined ? undefined : isFormData ? options.body : JSON.stringify(options.body) - const response = await fetch(`${configuredBaseUrl}${path}`, { - method: options.method ?? 'GET', - headers: { - ...(isFormData ? {} : { 'Content-Type': 'application/json' }), - ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}), - }, - body: requestBody as BodyInit | undefined, - }) + let response: Response + try { + response = await fetch(`${configuredBaseUrl}${path}`, { + method: options.method ?? 'GET', + headers: { + ...(isFormData ? {} : { 'Content-Type': 'application/json' }), + ...(options.token ? { Authorization: `Bearer ${options.token}` } : {}), + }, + body: requestBody as BodyInit | undefined, + }) + } catch { + throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试') + } if (!response.ok) { - let message = `请求失败:${response.status}` + let envelope: ErrorEnvelope | null = null try { - const payload = await response.json() - if (typeof payload.error === 'string') message = payload.error + const payload: unknown = await response.json() + if (isErrorEnvelope(payload)) envelope = payload } catch { - // Keep the status-based message when the server does not return JSON. + // 非 JSON 响应统一使用状态码文案,避免把服务端内部信息展示给用户。 } - throw new Error(message) + if (envelope) throw new ApiError(response.status, envelope.error.code, envelope.error.message) + throw new ApiError(response.status, fallbackErrorCode(response.status), fallbackErrorMessage(response.status)) } if (response.status === 204) { @@ -61,6 +86,39 @@ export async function apiRequest(path: string, options: RequestOptions = {}): return JSON.parse(text) as T } +function isErrorEnvelope(value: unknown): value is ErrorEnvelope { + if (!value || typeof value !== 'object' || !('error' in value)) return false + const error = value.error + return ( + !!error && + typeof error === 'object' && + 'code' in error && + typeof error.code === 'string' && + error.code.trim() !== '' && + 'message' in error && + typeof error.message === 'string' && + error.message.trim() !== '' + ) +} + +function fallbackErrorCode(status: number) { + if (status === 401) return 'unauthorized' + if (status === 403) return 'forbidden' + if (status === 404) return 'not_found' + if (status === 409) return 'conflict' + if (status >= 500) return 'internal_error' + return 'request_failed' +} + +function fallbackErrorMessage(status: number) { + if (status === 401) return '登录已失效,请重新登录' + if (status === 403) return '没有权限执行此操作' + if (status === 404) return '请求的内容不存在' + if (status === 409) return '操作冲突,请刷新后重试' + if (status >= 500) return '服务器暂时无法处理请求,请稍后重试' + return '请求失败,请稍后重试' +} + function normalizeBaseUrl(value: string) { const trimmed = value.trim() return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed diff --git a/apps/web_v1/src/api/inbox.ts b/apps/web_v1/src/api/inbox.ts index f09e413..6421d32 100644 --- a/apps/web_v1/src/api/inbox.ts +++ b/apps/web_v1/src/api/inbox.ts @@ -1,21 +1,24 @@ import { apiRequest, type ApiSession } from './client' -export type BackendInboxItem = { - id?: number - ID?: number - project_id?: number - projectID?: number - source_type?: string - sourceType?: string - title?: string - body?: string - status?: string -} - -export type InboxSuggestion = { - type: string +export type InboxItemDTO = { + id: string + projectId: string + sourceType: string title: string body: string + status: string + createdAt: string + updatedAt: string +} + +export type InboxSuggestionDTO = { + kind: 'task' | 'note' | 'source' + title: string + body: string +} + +export type AnalyzeInboxResponseDTO = { + suggestions: InboxSuggestionDTO[] } export type CaptureInboxInput = { @@ -24,27 +27,23 @@ export type CaptureInboxInput = { body: string } -export async function captureProjectInbox(session: ApiSession, projectId: string | number, input: CaptureInboxInput) { - return apiRequest(`/api/projects/${projectId}/inbox`, { +export async function captureProjectInbox(session: ApiSession, projectId: string, input: CaptureInboxInput) { + return apiRequest(`/api/v1/projects/${projectId}/inbox`, { method: 'POST', token: session.token, - body: { - source_type: input.sourceType, - title: input.title, - body: input.body, - }, + body: input, }) } -export async function analyzeInboxItem(session: ApiSession, inboxId: string | number) { - return apiRequest<{ suggestions: InboxSuggestion[] }>(`/api/inbox/${inboxId}/analyze`, { +export async function analyzeInboxItem(session: ApiSession, inboxId: string) { + return apiRequest(`/api/v1/inbox/${inboxId}/analyze`, { method: 'POST', token: session.token, }) } -export async function confirmInboxItem(session: ApiSession, inboxId: string | number, suggestions: InboxSuggestion[]) { - return apiRequest(`/api/inbox/${inboxId}/confirm`, { +export async function confirmInboxItem(session: ApiSession, inboxId: string, suggestions: InboxSuggestionDTO[]) { + return apiRequest(`/api/v1/inbox/${inboxId}/confirm`, { method: 'POST', token: session.token, body: { suggestions }, diff --git a/apps/web_v1/src/api/mappers.tsx b/apps/web_v1/src/api/mappers.tsx index b6ee34e..81972ab 100644 --- a/apps/web_v1/src/api/mappers.tsx +++ b/apps/web_v1/src/api/mappers.tsx @@ -1,20 +1,20 @@ import { IconClockCircle, IconEmail, IconFile, IconHome, IconLink, IconList, IconRobot, IconUserGroup } from '@arco-design/web-react/icon' import type { - BackendAISession, - BackendChannel, - BackendCronPlan, - BackendInboxMessage, - BackendNoteSource, - BackendProjectWorkspace, - BackendTask, + ProjectWorkspaceDTO, + WorkspaceAISessionDTO, + WorkspaceChannelDTO, + WorkspaceCronPlanDTO, + WorkspaceInboxDTO, + WorkspaceNoteSourceDTO, + WorkspaceTaskDTO, } from './projects' import type { AISession, ChannelKey, CronJob, InboxItem, NoteSource, Project, ProjectChannel, ProjectWorkspace, TaskItem } from '../pages/projects/project-types' const projectColors = ['#165DFF', '#00B42A', '#722ED1', '#FF7D00', '#14C9C9', '#F53F3F'] -export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): ProjectWorkspace { +export function mapWorkspace(payload: ProjectWorkspaceDTO, index = 0): ProjectWorkspace { const project: Project = { - id: String(payload.project.id), + id: payload.project.id, name: payload.project.name, identifier: payload.project.identifier ?? '', icon: payload.project.icon ?? '', @@ -29,7 +29,7 @@ export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): Proje return { project, channels: payload.channels.filter((channel) => channel.type !== 'inbox').map(mapChannel), - tags: payload.tags.map((tag) => (tag === 'all' ? '全部' : tag)), + tags: payload.tags.map((tag) => tag.name), recentSessions: payload.recentSessions.map(mapAISession), inbox: payload.inbox.map(mapInbox), tasks: payload.tasks.map(mapTask), @@ -55,7 +55,7 @@ function projectColor(background: string, index: number) { return projectColors[index % projectColors.length] } -function mapChannel(channel: BackendChannel): ProjectChannel { +function mapChannel(channel: WorkspaceChannelDTO): ProjectChannel { return { id: channel.id, key: mapChannelKey(channel.type, channel.id), @@ -86,7 +86,7 @@ function mapChannelKey(type: string, id = ''): ChannelKey { } } -function channelLabel(channel: BackendChannel) { +function channelLabel(channel: WorkspaceChannelDTO) { switch (channel.type) { case 'overview': return '概况' @@ -105,7 +105,7 @@ function channelLabel(channel: BackendChannel) { } } -function channelIcon(channel: BackendChannel) { +function channelIcon(channel: WorkspaceChannelDTO) { switch (channel.type) { case 'overview': return @@ -124,36 +124,36 @@ function channelIcon(channel: BackendChannel) { } } -function mapInbox(item: BackendInboxMessage): InboxItem { +function mapInbox(item: WorkspaceInboxDTO): InboxItem { return { id: item.id, title: item.title, meta: `来源:${item.source}`, owner: '系统', - time: item.time, + time: formatBackendDate(item.time), tag: item.tag || item.status, summary: item.summary, status: item.status, } } -function mapTask(task: BackendTask): TaskItem { +function mapTask(task: WorkspaceTaskDTO): TaskItem { return { id: task.id, title: task.title, owner: task.owner, - progress: task.completed ? '100%' : '60%', - due: task.due || '未设置', - createdAt: formatCreatedAt(task.createdAt), - completedAt: task.completedAt ? formatCreatedAt(task.completedAt) : '', - duration: task.duration || '', + progress: task.completed ? '100%' : '', + due: task.due ? formatBackendDate(task.due) : '未设置', + createdAt: formatBackendDate(task.createdAt), + completedAt: task.completedAt ? formatBackendDate(task.completedAt) : '', + duration: '', tag: task.tag || '', summary: task.summary, completed: task.completed, } } -function formatCreatedAt(value: string) { +function formatBackendDate(value: string) { const date = parseBackendDate(value) if (!date) return '未知时间' @@ -184,35 +184,35 @@ function pad(value: number) { return String(value).padStart(2, '0') } -function mapAISession(session: BackendAISession): AISession { +function mapAISession(session: WorkspaceAISessionDTO): AISession { return { id: session.id, title: session.title, summary: session.summary, owner: 'AI', - time: session.updatedAt, + time: formatBackendDate(session.updatedAt), } } -function mapNoteSource(note: BackendNoteSource): NoteSource { +function mapNoteSource(note: WorkspaceNoteSourceDTO): NoteSource { return { id: note.id, title: note.title, type: note.kind === 'note' ? '笔记' : note.source, - updated: note.updatedAt, + updated: formatBackendDate(note.updatedAt), owner: note.source, source: note.source, } } -function mapCronPlan(plan: BackendCronPlan): CronJob { +function mapCronPlan(plan: WorkspaceCronPlanDTO): CronJob { return { id: plan.id, name: plan.title, expr: plan.schedule, status: plan.enabled ? '运行中' : '暂停', lastRun: plan.lastResult, - nextRun: plan.nextRun || '暂停中', + nextRun: plan.nextRun ? formatBackendDate(plan.nextRun) : '暂停中', owner: plan.owner, enabled: plan.enabled, } diff --git a/apps/web_v1/src/api/projects.ts b/apps/web_v1/src/api/projects.ts index 7217a7f..500cc10 100644 --- a/apps/web_v1/src/api/projects.ts +++ b/apps/web_v1/src/api/projects.ts @@ -1,44 +1,44 @@ import { apiRequest, type ApiSession } from './client' -export type BackendProject = { - ID?: number - id?: number - Name?: string - name?: string - Identifier?: string - identifier?: string - Icon?: string - icon?: string - Background?: string - background?: string - Description?: string - description?: string +export type ProjectDTO = { + id: string + name: string + identifier: string + icon: string + background: string + description: string } -export type BackendWorkspaceProject = { - id: number +export type WorkspaceProjectDTO = { + id: string name: string - identifier?: string - icon?: string - background?: string + identifier: string + icon: string + background: string description: string initials: string unreadCount: number } -export type BackendChannel = { +export type WorkspaceChannelDTO = { id: string - projectID: number + projectId: string type: string title: string icon: string - count?: number - url?: string + count: number | undefined + url: string | undefined sortOrder: number } -export type BackendInboxMessage = { +export type WorkspaceTagDTO = { id: string + name: string +} + +export type WorkspaceInboxDTO = { + id: string + projectId: string source: string title: string summary: string @@ -47,29 +47,32 @@ export type BackendInboxMessage = { time: string } -export type BackendTask = { +export type WorkspaceTaskDTO = { id: string + projectId: string title: string summary: string completed: boolean owner: string - due: string + due: string | null createdAt: string - completedAt: string - duration: string + completedAt: string | null + tagId: string | null tag: string } -export type BackendAISession = { +export type WorkspaceAISessionDTO = { id: string + projectId: string title: string summary: string updatedAt: string references: string[] } -export type BackendNoteSource = { +export type WorkspaceNoteSourceDTO = { id: string + projectId: string kind: string title: string updatedAt: string @@ -77,55 +80,77 @@ export type BackendNoteSource = { source: string } -export type BackendCronPlan = { +export type WorkspaceCronPlanDTO = { id: string + projectId: string title: string schedule: string - nextRun: string + nextRun: string | null enabled: boolean lastResult: string owner: string } -export type BackendProjectWorkspace = { - project: BackendWorkspaceProject - channels: BackendChannel[] - tags: string[] - recentSessions: BackendAISession[] - inbox: BackendInboxMessage[] - tasks: BackendTask[] - aiSessions: BackendAISession[] - notesSources: BackendNoteSource[] - cronPlans: BackendCronPlan[] +export type ProjectWorkspaceDTO = { + project: WorkspaceProjectDTO + channels: WorkspaceChannelDTO[] + tags: WorkspaceTagDTO[] + recentSessions: WorkspaceAISessionDTO[] + inbox: WorkspaceInboxDTO[] + tasks: WorkspaceTaskDTO[] + aiSessions: WorkspaceAISessionDTO[] + notesSources: WorkspaceNoteSourceDTO[] + cronPlans: WorkspaceCronPlanDTO[] } -export type BackendTag = { - ID?: number - id?: number - Name?: string - name?: string +export type TaskDTO = { + id: string + projectId: string + title: string + description: string + status: string + completed: boolean + dueAt: string | null + assigneeId: string | null + tagId: string | null + tag: string + sourceInboxItemId: string | null + createdAt: string + updatedAt: string } -export async function fetchProjects(session: ApiSession) { - return apiRequest('/api/projects', { token: session.token }) +export type SourceDTO = { + id: string + projectId: string + kind: string + title: string + filePath: string + createdAt: string + updatedAt: string } -export async function fetchProjectWorkspace(session: ApiSession, projectId: string | number) { - return apiRequest(`/api/projects/${projectId}/workspace`, { token: session.token }) -} - -export async function fetchProjectTags(session: ApiSession, projectId: string | number) { - return apiRequest(`/api/projects/${projectId}/tags`, { token: session.token }) +export type CronPlanDTO = { + id: string + projectId: string + title: string + schedule: string + nextRunAt: string | null + enabled: boolean + lastResult: string + createdAt: string + updatedAt: string } export type CreateProjectInput = { name: string - identifier?: string - icon?: string - background?: string - description?: string + identifier: string + icon: string + background: string + description: string } +export type UpdateProjectInput = Partial + export type CreateTaskInput = { title: string description?: string @@ -137,8 +162,9 @@ export type CreateTaskInput = { export type UpdateTaskInput = { title: string description?: string + status?: string completed: boolean - nextProjectId?: number + nextProjectId?: string tag?: string } @@ -158,51 +184,71 @@ export type CreateProjectTagInput = { name: string } +export async function fetchProjects(session: ApiSession) { + return apiRequest('/api/v1/projects', { token: session.token }) +} + +export async function fetchProjectWorkspace(session: ApiSession, projectId: string) { + return apiRequest(`/api/v1/projects/${projectId}/workspace`, { token: session.token }) +} + +export async function fetchProjectTags(session: ApiSession, projectId: string) { + return apiRequest(`/api/v1/projects/${projectId}/tags`, { token: session.token }) +} + export async function createProject(session: ApiSession, input: CreateProjectInput) { - return apiRequest('/api/projects', { + return apiRequest('/api/v1/projects', { method: 'POST', token: session.token, body: input, }) } -export async function createTask(session: ApiSession, projectId: string | number, input: CreateTaskInput) { - return apiRequest(`/api/projects/${projectId}/tasks`, { - method: 'POST', - token: session.token, - body: input, - }) -} - -export async function updateTask(session: ApiSession, projectId: string | number, taskId: string | number, input: UpdateTaskInput) { - return apiRequest(`/api/projects/${projectId}/tasks/${taskId}`, { +export async function updateProject(session: ApiSession, projectId: string, input: UpdateProjectInput) { + return apiRequest(`/api/v1/projects/${projectId}`, { method: 'PATCH', token: session.token, body: input, }) } -export async function uploadSource(session: ApiSession, projectId: string | number, input: UploadSourceInput) { +export async function createTask(session: ApiSession, projectId: string, input: CreateTaskInput) { + return apiRequest(`/api/v1/projects/${projectId}/tasks`, { + method: 'POST', + token: session.token, + body: input, + }) +} + +export async function updateTask(session: ApiSession, projectId: string, taskId: string, input: UpdateTaskInput) { + return apiRequest(`/api/v1/projects/${projectId}/tasks/${taskId}`, { + method: 'PATCH', + token: session.token, + body: input, + }) +} + +export async function uploadSource(session: ApiSession, projectId: string, input: UploadSourceInput) { const body = new FormData() body.append('file', input.file) if (input.title) body.append('title', input.title) - return apiRequest(`/api/projects/${projectId}/sources`, { + return apiRequest(`/api/v1/projects/${projectId}/sources`, { method: 'POST', token: session.token, body, }) } -export async function createCronPlan(session: ApiSession, projectId: string | number, input: CreateCronPlanInput) { - return apiRequest(`/api/projects/${projectId}/cron-plans`, { +export async function createCronPlan(session: ApiSession, projectId: string, input: CreateCronPlanInput) { + return apiRequest(`/api/v1/projects/${projectId}/cron-plans`, { method: 'POST', token: session.token, body: input, }) } -export async function createProjectTag(session: ApiSession, projectId: string | number, input: CreateProjectTagInput) { - return apiRequest(`/api/projects/${projectId}/tags`, { +export async function createProjectTag(session: ApiSession, projectId: string, input: CreateProjectTagInput) { + return apiRequest(`/api/v1/projects/${projectId}/tags`, { method: 'POST', token: session.token, body: input, diff --git a/apps/web_v1/src/api/search.ts b/apps/web_v1/src/api/search.ts new file mode 100644 index 0000000..2aa402f --- /dev/null +++ b/apps/web_v1/src/api/search.ts @@ -0,0 +1,20 @@ +import { apiRequest, type ApiSession } from './client' + +export type SearchResultDTO = { + type: 'project' | 'task' | 'note' | 'source' | 'inbox' | 'ai_session' + id: string + projectId: string + title: string + snippet: string +} + +export type SearchResponseDTO = { + items: SearchResultDTO[] +} + +const searchPath = '/api/v1/search' + +export async function searchWorkspace(session: ApiSession, query: string) { + const search = new URLSearchParams({ q: query }) + return apiRequest(`${searchPath}?${search.toString()}`, { token: session.token }) +} diff --git a/apps/web_v1/src/app/App.tsx b/apps/web_v1/src/app/App.tsx index 9bda1a9..d9a7f5d 100644 --- a/apps/web_v1/src/app/App.tsx +++ b/apps/web_v1/src/app/App.tsx @@ -42,7 +42,7 @@ function App() { async function loadWorkspaces(nextSession: ApiSession, preferredProjectID = activeProjectID) { const backendProjects = await fetchProjects(nextSession) const backendWorkspaces = await Promise.all( - backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.ID ?? project.id ?? '').then((workspace) => mapWorkspace(workspace, index))), + backendProjects.map((project, index) => fetchProjectWorkspace(nextSession, project.id).then((workspace) => mapWorkspace(workspace, index))), ) setWorkspaces(backendWorkspaces) const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === preferredProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? '' @@ -114,7 +114,7 @@ function App() { background: draft.background.trim(), description: draft.description.trim(), }) - const nextProjectID = String(created.ID ?? created.id ?? '') + const nextProjectID = created.id await refreshAfterAction(nextProjectID) if (nextProjectID) { setActiveProjectID(nextProjectID) @@ -203,7 +203,7 @@ function App() { title: update.title, description: update.summary, completed: update.completed, - nextProjectId: Number(update.nextProjectId), + nextProjectId: update.nextProjectId, tag: update.tag, }) await refreshAfterAction(update.nextProjectId) diff --git a/apps/web_v1/src/pages/login.tsx b/apps/web_v1/src/pages/login.tsx index 5ef1d51..e2f5d4e 100644 --- a/apps/web_v1/src/pages/login.tsx +++ b/apps/web_v1/src/pages/login.tsx @@ -118,10 +118,10 @@ async function checkServerStatus( setStatus('checking') try { - const response = await fetch(`${baseUrl}/api/status`, { signal }) + const response = await fetch(`${baseUrl}/api/v1/status`, { signal }) if (!response.ok) throw new Error(`HTTP ${response.status}`) - const payload = await response.json() as { timestamp_ms?: number } - if (typeof payload.timestamp_ms !== 'number') throw new Error('invalid status payload') + const payload = await response.json() as { timestamp?: string } + if (typeof payload.timestamp !== 'string') throw new Error('invalid status payload') setStatus('online') } catch (error) { if (error instanceof DOMException && error.name === 'AbortError') return