refactor(web): consume api v1 contracts
This commit is contained in:
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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<ApiSession> {
|
||||
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<ApiSession
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
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<T>(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
|
||||
|
||||
@@ -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<BackendInboxItem>(`/api/projects/${projectId}/inbox`, {
|
||||
export async function captureProjectInbox(session: ApiSession, projectId: string, input: CaptureInboxInput) {
|
||||
return apiRequest<InboxItemDTO>(`/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<AnalyzeInboxResponseDTO>(`/api/v1/inbox/${inboxId}/analyze`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
})
|
||||
}
|
||||
|
||||
export async function confirmInboxItem(session: ApiSession, inboxId: string | number, suggestions: InboxSuggestion[]) {
|
||||
return apiRequest<void>(`/api/inbox/${inboxId}/confirm`, {
|
||||
export async function confirmInboxItem(session: ApiSession, inboxId: string, suggestions: InboxSuggestionDTO[]) {
|
||||
return apiRequest<void>(`/api/v1/inbox/${inboxId}/confirm`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: { suggestions },
|
||||
|
||||
@@ -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 <IconHome />
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<BackendProject[]>('/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<BackendProjectWorkspace>(`/api/projects/${projectId}/workspace`, { token: session.token })
|
||||
}
|
||||
|
||||
export async function fetchProjectTags(session: ApiSession, projectId: string | number) {
|
||||
return apiRequest<BackendTag[]>(`/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<CreateProjectInput>
|
||||
|
||||
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<ProjectDTO[]>('/api/v1/projects', { token: session.token })
|
||||
}
|
||||
|
||||
export async function fetchProjectWorkspace(session: ApiSession, projectId: string) {
|
||||
return apiRequest<ProjectWorkspaceDTO>(`/api/v1/projects/${projectId}/workspace`, { token: session.token })
|
||||
}
|
||||
|
||||
export async function fetchProjectTags(session: ApiSession, projectId: string) {
|
||||
return apiRequest<WorkspaceTagDTO[]>(`/api/v1/projects/${projectId}/tags`, { token: session.token })
|
||||
}
|
||||
|
||||
export async function createProject(session: ApiSession, input: CreateProjectInput) {
|
||||
return apiRequest<BackendProject>('/api/projects', {
|
||||
return apiRequest<ProjectDTO>('/api/v1/projects', {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: input,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createTask(session: ApiSession, projectId: string | number, input: CreateTaskInput) {
|
||||
return apiRequest<BackendTask>(`/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<BackendTask>(`/api/projects/${projectId}/tasks/${taskId}`, {
|
||||
export async function updateProject(session: ApiSession, projectId: string, input: UpdateProjectInput) {
|
||||
return apiRequest<ProjectDTO>(`/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<TaskDTO>(`/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<TaskDTO>(`/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<BackendNoteSource>(`/api/projects/${projectId}/sources`, {
|
||||
return apiRequest<SourceDTO>(`/api/v1/projects/${projectId}/sources`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export async function createCronPlan(session: ApiSession, projectId: string | number, input: CreateCronPlanInput) {
|
||||
return apiRequest<BackendCronPlan>(`/api/projects/${projectId}/cron-plans`, {
|
||||
export async function createCronPlan(session: ApiSession, projectId: string, input: CreateCronPlanInput) {
|
||||
return apiRequest<CronPlanDTO>(`/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<BackendTag>(`/api/projects/${projectId}/tags`, {
|
||||
export async function createProjectTag(session: ApiSession, projectId: string, input: CreateProjectTagInput) {
|
||||
return apiRequest<WorkspaceTagDTO>(`/api/v1/projects/${projectId}/tags`, {
|
||||
method: 'POST',
|
||||
token: session.token,
|
||||
body: input,
|
||||
|
||||
20
apps/web_v1/src/api/search.ts
Normal file
20
apps/web_v1/src/api/search.ts
Normal file
@@ -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<SearchResponseDTO>(`${searchPath}?${search.toString()}`, { token: session.token })
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user