refactor(web): consume api v1 contracts
This commit is contained in:
@@ -21,6 +21,8 @@ const requiredFiles = [
|
|||||||
'src/api/client.ts',
|
'src/api/client.ts',
|
||||||
'src/api/projects.ts',
|
'src/api/projects.ts',
|
||||||
'src/api/mappers.tsx',
|
'src/api/mappers.tsx',
|
||||||
|
'src/api/search.ts',
|
||||||
|
'src/api/inbox.ts',
|
||||||
]
|
]
|
||||||
|
|
||||||
const failures = requiredFiles.filter((file) => !existsSync(file)).map((file) => `missing ${file}`)
|
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')
|
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')) {
|
if (existsSync('src/App.tsx')) {
|
||||||
failures.push('legacy src/App.tsx should be removed after page split')
|
failures.push('legacy src/App.tsx should be removed after page split')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,25 @@ type RequestOptions = {
|
|||||||
token?: string
|
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')
|
let configuredBaseUrl = normalizeBaseUrl(import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:18080')
|
||||||
|
|
||||||
export function setApiBaseUrl(baseUrl: string) {
|
export function setApiBaseUrl(baseUrl: string) {
|
||||||
@@ -20,7 +39,7 @@ export function getApiBaseUrl() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function login(email: string, password: string): Promise<ApiSession> {
|
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',
|
method: 'POST',
|
||||||
body: { email, password },
|
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> {
|
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
|
const isFormData = typeof FormData !== 'undefined' && options.body instanceof FormData
|
||||||
const requestBody = options.body === undefined ? undefined : isFormData ? options.body : JSON.stringify(options.body)
|
const requestBody = options.body === undefined ? undefined : isFormData ? options.body : JSON.stringify(options.body)
|
||||||
const response = await fetch(`${configuredBaseUrl}${path}`, {
|
let response: Response
|
||||||
method: options.method ?? 'GET',
|
try {
|
||||||
headers: {
|
response = await fetch(`${configuredBaseUrl}${path}`, {
|
||||||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
method: options.method ?? 'GET',
|
||||||
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
headers: {
|
||||||
},
|
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
body: requestBody as BodyInit | undefined,
|
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||||
})
|
},
|
||||||
|
body: requestBody as BodyInit | undefined,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(0, 'network_error', '无法连接服务器,请检查地址和网络后重试')
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
let message = `请求失败:${response.status}`
|
let envelope: ErrorEnvelope | null = null
|
||||||
try {
|
try {
|
||||||
const payload = await response.json()
|
const payload: unknown = await response.json()
|
||||||
if (typeof payload.error === 'string') message = payload.error
|
if (isErrorEnvelope(payload)) envelope = payload
|
||||||
} catch {
|
} 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) {
|
if (response.status === 204) {
|
||||||
@@ -61,6 +86,39 @@ export async function apiRequest<T>(path: string, options: RequestOptions = {}):
|
|||||||
return JSON.parse(text) as T
|
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) {
|
function normalizeBaseUrl(value: string) {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
|
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import { apiRequest, type ApiSession } from './client'
|
import { apiRequest, type ApiSession } from './client'
|
||||||
|
|
||||||
export type BackendInboxItem = {
|
export type InboxItemDTO = {
|
||||||
id?: number
|
id: string
|
||||||
ID?: number
|
projectId: string
|
||||||
project_id?: number
|
sourceType: string
|
||||||
projectID?: number
|
|
||||||
source_type?: string
|
|
||||||
sourceType?: string
|
|
||||||
title?: string
|
|
||||||
body?: string
|
|
||||||
status?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type InboxSuggestion = {
|
|
||||||
type: string
|
|
||||||
title: string
|
title: string
|
||||||
body: 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 = {
|
export type CaptureInboxInput = {
|
||||||
@@ -24,27 +27,23 @@ export type CaptureInboxInput = {
|
|||||||
body: string
|
body: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function captureProjectInbox(session: ApiSession, projectId: string | number, input: CaptureInboxInput) {
|
export async function captureProjectInbox(session: ApiSession, projectId: string, input: CaptureInboxInput) {
|
||||||
return apiRequest<BackendInboxItem>(`/api/projects/${projectId}/inbox`, {
|
return apiRequest<InboxItemDTO>(`/api/v1/projects/${projectId}/inbox`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: session.token,
|
token: session.token,
|
||||||
body: {
|
body: input,
|
||||||
source_type: input.sourceType,
|
|
||||||
title: input.title,
|
|
||||||
body: input.body,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function analyzeInboxItem(session: ApiSession, inboxId: string | number) {
|
export async function analyzeInboxItem(session: ApiSession, inboxId: string) {
|
||||||
return apiRequest<{ suggestions: InboxSuggestion[] }>(`/api/inbox/${inboxId}/analyze`, {
|
return apiRequest<AnalyzeInboxResponseDTO>(`/api/v1/inbox/${inboxId}/analyze`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: session.token,
|
token: session.token,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function confirmInboxItem(session: ApiSession, inboxId: string | number, suggestions: InboxSuggestion[]) {
|
export async function confirmInboxItem(session: ApiSession, inboxId: string, suggestions: InboxSuggestionDTO[]) {
|
||||||
return apiRequest<void>(`/api/inbox/${inboxId}/confirm`, {
|
return apiRequest<void>(`/api/v1/inbox/${inboxId}/confirm`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: session.token,
|
token: session.token,
|
||||||
body: { suggestions },
|
body: { suggestions },
|
||||||
|
|||||||
@@ -1,20 +1,20 @@
|
|||||||
import { IconClockCircle, IconEmail, IconFile, IconHome, IconLink, IconList, IconRobot, IconUserGroup } from '@arco-design/web-react/icon'
|
import { IconClockCircle, IconEmail, IconFile, IconHome, IconLink, IconList, IconRobot, IconUserGroup } from '@arco-design/web-react/icon'
|
||||||
import type {
|
import type {
|
||||||
BackendAISession,
|
ProjectWorkspaceDTO,
|
||||||
BackendChannel,
|
WorkspaceAISessionDTO,
|
||||||
BackendCronPlan,
|
WorkspaceChannelDTO,
|
||||||
BackendInboxMessage,
|
WorkspaceCronPlanDTO,
|
||||||
BackendNoteSource,
|
WorkspaceInboxDTO,
|
||||||
BackendProjectWorkspace,
|
WorkspaceNoteSourceDTO,
|
||||||
BackendTask,
|
WorkspaceTaskDTO,
|
||||||
} from './projects'
|
} from './projects'
|
||||||
import type { AISession, ChannelKey, CronJob, InboxItem, NoteSource, Project, ProjectChannel, ProjectWorkspace, TaskItem } from '../pages/projects/project-types'
|
import type { AISession, ChannelKey, CronJob, InboxItem, NoteSource, Project, ProjectChannel, ProjectWorkspace, TaskItem } from '../pages/projects/project-types'
|
||||||
|
|
||||||
const projectColors = ['#165DFF', '#00B42A', '#722ED1', '#FF7D00', '#14C9C9', '#F53F3F']
|
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 = {
|
const project: Project = {
|
||||||
id: String(payload.project.id),
|
id: payload.project.id,
|
||||||
name: payload.project.name,
|
name: payload.project.name,
|
||||||
identifier: payload.project.identifier ?? '',
|
identifier: payload.project.identifier ?? '',
|
||||||
icon: payload.project.icon ?? '',
|
icon: payload.project.icon ?? '',
|
||||||
@@ -29,7 +29,7 @@ export function mapWorkspace(payload: BackendProjectWorkspace, index = 0): Proje
|
|||||||
return {
|
return {
|
||||||
project,
|
project,
|
||||||
channels: payload.channels.filter((channel) => channel.type !== 'inbox').map(mapChannel),
|
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),
|
recentSessions: payload.recentSessions.map(mapAISession),
|
||||||
inbox: payload.inbox.map(mapInbox),
|
inbox: payload.inbox.map(mapInbox),
|
||||||
tasks: payload.tasks.map(mapTask),
|
tasks: payload.tasks.map(mapTask),
|
||||||
@@ -55,7 +55,7 @@ function projectColor(background: string, index: number) {
|
|||||||
return projectColors[index % projectColors.length]
|
return projectColors[index % projectColors.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapChannel(channel: BackendChannel): ProjectChannel {
|
function mapChannel(channel: WorkspaceChannelDTO): ProjectChannel {
|
||||||
return {
|
return {
|
||||||
id: channel.id,
|
id: channel.id,
|
||||||
key: mapChannelKey(channel.type, 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) {
|
switch (channel.type) {
|
||||||
case 'overview':
|
case 'overview':
|
||||||
return '概况'
|
return '概况'
|
||||||
@@ -105,7 +105,7 @@ function channelLabel(channel: BackendChannel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function channelIcon(channel: BackendChannel) {
|
function channelIcon(channel: WorkspaceChannelDTO) {
|
||||||
switch (channel.type) {
|
switch (channel.type) {
|
||||||
case 'overview':
|
case 'overview':
|
||||||
return <IconHome />
|
return <IconHome />
|
||||||
@@ -124,36 +124,36 @@ function channelIcon(channel: BackendChannel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapInbox(item: BackendInboxMessage): InboxItem {
|
function mapInbox(item: WorkspaceInboxDTO): InboxItem {
|
||||||
return {
|
return {
|
||||||
id: item.id,
|
id: item.id,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
meta: `来源:${item.source}`,
|
meta: `来源:${item.source}`,
|
||||||
owner: '系统',
|
owner: '系统',
|
||||||
time: item.time,
|
time: formatBackendDate(item.time),
|
||||||
tag: item.tag || item.status,
|
tag: item.tag || item.status,
|
||||||
summary: item.summary,
|
summary: item.summary,
|
||||||
status: item.status,
|
status: item.status,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapTask(task: BackendTask): TaskItem {
|
function mapTask(task: WorkspaceTaskDTO): TaskItem {
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
title: task.title,
|
title: task.title,
|
||||||
owner: task.owner,
|
owner: task.owner,
|
||||||
progress: task.completed ? '100%' : '60%',
|
progress: task.completed ? '100%' : '',
|
||||||
due: task.due || '未设置',
|
due: task.due ? formatBackendDate(task.due) : '未设置',
|
||||||
createdAt: formatCreatedAt(task.createdAt),
|
createdAt: formatBackendDate(task.createdAt),
|
||||||
completedAt: task.completedAt ? formatCreatedAt(task.completedAt) : '',
|
completedAt: task.completedAt ? formatBackendDate(task.completedAt) : '',
|
||||||
duration: task.duration || '',
|
duration: '',
|
||||||
tag: task.tag || '',
|
tag: task.tag || '',
|
||||||
summary: task.summary,
|
summary: task.summary,
|
||||||
completed: task.completed,
|
completed: task.completed,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatCreatedAt(value: string) {
|
function formatBackendDate(value: string) {
|
||||||
const date = parseBackendDate(value)
|
const date = parseBackendDate(value)
|
||||||
if (!date) return '未知时间'
|
if (!date) return '未知时间'
|
||||||
|
|
||||||
@@ -184,35 +184,35 @@ function pad(value: number) {
|
|||||||
return String(value).padStart(2, '0')
|
return String(value).padStart(2, '0')
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapAISession(session: BackendAISession): AISession {
|
function mapAISession(session: WorkspaceAISessionDTO): AISession {
|
||||||
return {
|
return {
|
||||||
id: session.id,
|
id: session.id,
|
||||||
title: session.title,
|
title: session.title,
|
||||||
summary: session.summary,
|
summary: session.summary,
|
||||||
owner: 'AI',
|
owner: 'AI',
|
||||||
time: session.updatedAt,
|
time: formatBackendDate(session.updatedAt),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapNoteSource(note: BackendNoteSource): NoteSource {
|
function mapNoteSource(note: WorkspaceNoteSourceDTO): NoteSource {
|
||||||
return {
|
return {
|
||||||
id: note.id,
|
id: note.id,
|
||||||
title: note.title,
|
title: note.title,
|
||||||
type: note.kind === 'note' ? '笔记' : note.source,
|
type: note.kind === 'note' ? '笔记' : note.source,
|
||||||
updated: note.updatedAt,
|
updated: formatBackendDate(note.updatedAt),
|
||||||
owner: note.source,
|
owner: note.source,
|
||||||
source: note.source,
|
source: note.source,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapCronPlan(plan: BackendCronPlan): CronJob {
|
function mapCronPlan(plan: WorkspaceCronPlanDTO): CronJob {
|
||||||
return {
|
return {
|
||||||
id: plan.id,
|
id: plan.id,
|
||||||
name: plan.title,
|
name: plan.title,
|
||||||
expr: plan.schedule,
|
expr: plan.schedule,
|
||||||
status: plan.enabled ? '运行中' : '暂停',
|
status: plan.enabled ? '运行中' : '暂停',
|
||||||
lastRun: plan.lastResult,
|
lastRun: plan.lastResult,
|
||||||
nextRun: plan.nextRun || '暂停中',
|
nextRun: plan.nextRun ? formatBackendDate(plan.nextRun) : '暂停中',
|
||||||
owner: plan.owner,
|
owner: plan.owner,
|
||||||
enabled: plan.enabled,
|
enabled: plan.enabled,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,44 +1,44 @@
|
|||||||
import { apiRequest, type ApiSession } from './client'
|
import { apiRequest, type ApiSession } from './client'
|
||||||
|
|
||||||
export type BackendProject = {
|
export type ProjectDTO = {
|
||||||
ID?: number
|
id: string
|
||||||
id?: number
|
name: string
|
||||||
Name?: string
|
identifier: string
|
||||||
name?: string
|
icon: string
|
||||||
Identifier?: string
|
background: string
|
||||||
identifier?: string
|
description: string
|
||||||
Icon?: string
|
|
||||||
icon?: string
|
|
||||||
Background?: string
|
|
||||||
background?: string
|
|
||||||
Description?: string
|
|
||||||
description?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackendWorkspaceProject = {
|
export type WorkspaceProjectDTO = {
|
||||||
id: number
|
id: string
|
||||||
name: string
|
name: string
|
||||||
identifier?: string
|
identifier: string
|
||||||
icon?: string
|
icon: string
|
||||||
background?: string
|
background: string
|
||||||
description: string
|
description: string
|
||||||
initials: string
|
initials: string
|
||||||
unreadCount: number
|
unreadCount: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackendChannel = {
|
export type WorkspaceChannelDTO = {
|
||||||
id: string
|
id: string
|
||||||
projectID: number
|
projectId: string
|
||||||
type: string
|
type: string
|
||||||
title: string
|
title: string
|
||||||
icon: string
|
icon: string
|
||||||
count?: number
|
count: number | undefined
|
||||||
url?: string
|
url: string | undefined
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackendInboxMessage = {
|
export type WorkspaceTagDTO = {
|
||||||
id: string
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkspaceInboxDTO = {
|
||||||
|
id: string
|
||||||
|
projectId: string
|
||||||
source: string
|
source: string
|
||||||
title: string
|
title: string
|
||||||
summary: string
|
summary: string
|
||||||
@@ -47,29 +47,32 @@ export type BackendInboxMessage = {
|
|||||||
time: string
|
time: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackendTask = {
|
export type WorkspaceTaskDTO = {
|
||||||
id: string
|
id: string
|
||||||
|
projectId: string
|
||||||
title: string
|
title: string
|
||||||
summary: string
|
summary: string
|
||||||
completed: boolean
|
completed: boolean
|
||||||
owner: string
|
owner: string
|
||||||
due: string
|
due: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
completedAt: string
|
completedAt: string | null
|
||||||
duration: string
|
tagId: string | null
|
||||||
tag: string
|
tag: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackendAISession = {
|
export type WorkspaceAISessionDTO = {
|
||||||
id: string
|
id: string
|
||||||
|
projectId: string
|
||||||
title: string
|
title: string
|
||||||
summary: string
|
summary: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
references: string[]
|
references: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackendNoteSource = {
|
export type WorkspaceNoteSourceDTO = {
|
||||||
id: string
|
id: string
|
||||||
|
projectId: string
|
||||||
kind: string
|
kind: string
|
||||||
title: string
|
title: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
@@ -77,55 +80,77 @@ export type BackendNoteSource = {
|
|||||||
source: string
|
source: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackendCronPlan = {
|
export type WorkspaceCronPlanDTO = {
|
||||||
id: string
|
id: string
|
||||||
|
projectId: string
|
||||||
title: string
|
title: string
|
||||||
schedule: string
|
schedule: string
|
||||||
nextRun: string
|
nextRun: string | null
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
lastResult: string
|
lastResult: string
|
||||||
owner: string
|
owner: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackendProjectWorkspace = {
|
export type ProjectWorkspaceDTO = {
|
||||||
project: BackendWorkspaceProject
|
project: WorkspaceProjectDTO
|
||||||
channels: BackendChannel[]
|
channels: WorkspaceChannelDTO[]
|
||||||
tags: string[]
|
tags: WorkspaceTagDTO[]
|
||||||
recentSessions: BackendAISession[]
|
recentSessions: WorkspaceAISessionDTO[]
|
||||||
inbox: BackendInboxMessage[]
|
inbox: WorkspaceInboxDTO[]
|
||||||
tasks: BackendTask[]
|
tasks: WorkspaceTaskDTO[]
|
||||||
aiSessions: BackendAISession[]
|
aiSessions: WorkspaceAISessionDTO[]
|
||||||
notesSources: BackendNoteSource[]
|
notesSources: WorkspaceNoteSourceDTO[]
|
||||||
cronPlans: BackendCronPlan[]
|
cronPlans: WorkspaceCronPlanDTO[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BackendTag = {
|
export type TaskDTO = {
|
||||||
ID?: number
|
id: string
|
||||||
id?: number
|
projectId: string
|
||||||
Name?: string
|
title: string
|
||||||
name?: 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) {
|
export type SourceDTO = {
|
||||||
return apiRequest<BackendProject[]>('/api/projects', { token: session.token })
|
id: string
|
||||||
|
projectId: string
|
||||||
|
kind: string
|
||||||
|
title: string
|
||||||
|
filePath: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchProjectWorkspace(session: ApiSession, projectId: string | number) {
|
export type CronPlanDTO = {
|
||||||
return apiRequest<BackendProjectWorkspace>(`/api/projects/${projectId}/workspace`, { token: session.token })
|
id: string
|
||||||
}
|
projectId: string
|
||||||
|
title: string
|
||||||
export async function fetchProjectTags(session: ApiSession, projectId: string | number) {
|
schedule: string
|
||||||
return apiRequest<BackendTag[]>(`/api/projects/${projectId}/tags`, { token: session.token })
|
nextRunAt: string | null
|
||||||
|
enabled: boolean
|
||||||
|
lastResult: string
|
||||||
|
createdAt: string
|
||||||
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CreateProjectInput = {
|
export type CreateProjectInput = {
|
||||||
name: string
|
name: string
|
||||||
identifier?: string
|
identifier: string
|
||||||
icon?: string
|
icon: string
|
||||||
background?: string
|
background: string
|
||||||
description?: string
|
description: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type UpdateProjectInput = Partial<CreateProjectInput>
|
||||||
|
|
||||||
export type CreateTaskInput = {
|
export type CreateTaskInput = {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
@@ -137,8 +162,9 @@ export type CreateTaskInput = {
|
|||||||
export type UpdateTaskInput = {
|
export type UpdateTaskInput = {
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
|
status?: string
|
||||||
completed: boolean
|
completed: boolean
|
||||||
nextProjectId?: number
|
nextProjectId?: string
|
||||||
tag?: string
|
tag?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,51 +184,71 @@ export type CreateProjectTagInput = {
|
|||||||
name: string
|
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) {
|
export async function createProject(session: ApiSession, input: CreateProjectInput) {
|
||||||
return apiRequest<BackendProject>('/api/projects', {
|
return apiRequest<ProjectDTO>('/api/v1/projects', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: session.token,
|
token: session.token,
|
||||||
body: input,
|
body: input,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTask(session: ApiSession, projectId: string | number, input: CreateTaskInput) {
|
export async function updateProject(session: ApiSession, projectId: string, input: UpdateProjectInput) {
|
||||||
return apiRequest<BackendTask>(`/api/projects/${projectId}/tasks`, {
|
return apiRequest<ProjectDTO>(`/api/v1/projects/${projectId}`, {
|
||||||
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}`, {
|
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
token: session.token,
|
token: session.token,
|
||||||
body: input,
|
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()
|
const body = new FormData()
|
||||||
body.append('file', input.file)
|
body.append('file', input.file)
|
||||||
if (input.title) body.append('title', input.title)
|
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',
|
method: 'POST',
|
||||||
token: session.token,
|
token: session.token,
|
||||||
body,
|
body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createCronPlan(session: ApiSession, projectId: string | number, input: CreateCronPlanInput) {
|
export async function createCronPlan(session: ApiSession, projectId: string, input: CreateCronPlanInput) {
|
||||||
return apiRequest<BackendCronPlan>(`/api/projects/${projectId}/cron-plans`, {
|
return apiRequest<CronPlanDTO>(`/api/v1/projects/${projectId}/cron-plans`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: session.token,
|
token: session.token,
|
||||||
body: input,
|
body: input,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createProjectTag(session: ApiSession, projectId: string | number, input: CreateProjectTagInput) {
|
export async function createProjectTag(session: ApiSession, projectId: string, input: CreateProjectTagInput) {
|
||||||
return apiRequest<BackendTag>(`/api/projects/${projectId}/tags`, {
|
return apiRequest<WorkspaceTagDTO>(`/api/v1/projects/${projectId}/tags`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: session.token,
|
token: session.token,
|
||||||
body: input,
|
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) {
|
async function loadWorkspaces(nextSession: ApiSession, preferredProjectID = activeProjectID) {
|
||||||
const backendProjects = await fetchProjects(nextSession)
|
const backendProjects = await fetchProjects(nextSession)
|
||||||
const backendWorkspaces = await Promise.all(
|
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)
|
setWorkspaces(backendWorkspaces)
|
||||||
const nextProjectID = backendWorkspaces.find((workspace) => workspace.project.id === preferredProjectID)?.project.id ?? backendWorkspaces[0]?.project.id ?? ''
|
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(),
|
background: draft.background.trim(),
|
||||||
description: draft.description.trim(),
|
description: draft.description.trim(),
|
||||||
})
|
})
|
||||||
const nextProjectID = String(created.ID ?? created.id ?? '')
|
const nextProjectID = created.id
|
||||||
await refreshAfterAction(nextProjectID)
|
await refreshAfterAction(nextProjectID)
|
||||||
if (nextProjectID) {
|
if (nextProjectID) {
|
||||||
setActiveProjectID(nextProjectID)
|
setActiveProjectID(nextProjectID)
|
||||||
@@ -203,7 +203,7 @@ function App() {
|
|||||||
title: update.title,
|
title: update.title,
|
||||||
description: update.summary,
|
description: update.summary,
|
||||||
completed: update.completed,
|
completed: update.completed,
|
||||||
nextProjectId: Number(update.nextProjectId),
|
nextProjectId: update.nextProjectId,
|
||||||
tag: update.tag,
|
tag: update.tag,
|
||||||
})
|
})
|
||||||
await refreshAfterAction(update.nextProjectId)
|
await refreshAfterAction(update.nextProjectId)
|
||||||
|
|||||||
@@ -118,10 +118,10 @@ async function checkServerStatus(
|
|||||||
setStatus('checking')
|
setStatus('checking')
|
||||||
|
|
||||||
try {
|
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}`)
|
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||||
const payload = await response.json() as { timestamp_ms?: number }
|
const payload = await response.json() as { timestamp?: string }
|
||||||
if (typeof payload.timestamp_ms !== 'number') throw new Error('invalid status payload')
|
if (typeof payload.timestamp !== 'string') throw new Error('invalid status payload')
|
||||||
setStatus('online')
|
setStatus('online')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof DOMException && error.name === 'AbortError') return
|
if (error instanceof DOMException && error.name === 'AbortError') return
|
||||||
|
|||||||
Reference in New Issue
Block a user