import type { AdminStats, Course, CourseStatus, CourseTemplate, DigitalInstructor, Lead, LeadStatus, Organization, PointsTransaction, User } from './types' const token = () => localStorage.getItem('xinghen_token') async function request(path: string, init: RequestInit = {}): Promise { const headers = new Headers(init.headers) if (token()) headers.set('Authorization', `Bearer ${token()}`) if (!headers.has('X-Workspace-Id')) headers.set('X-Workspace-Id', localStorage.getItem('xinghen_workspace') || 'personal') const response = await fetch(`/api${path}`, { ...init, headers }) if (!response.ok) { const body = await response.json().catch(() => ({ message: '请求失败,请稍后重试' })) if (response.status === 401) { localStorage.removeItem('xinghen_token') window.dispatchEvent(new Event('auth-expired')) } throw new Error(body.message || '请求失败,请稍后重试') } const result = await response.json() as T if (init.method && init.method !== 'GET') window.dispatchEvent(new Event('workspace-updated')) return result } export async function downloadPrivateFile(url: string, filename: string) { const response = await fetch(url.replace('/uploads/', '/api/files/'), { headers: { Authorization: `Bearer ${token()}` } }) if (!response.ok) throw new Error('文件下载失败') const objectUrl = URL.createObjectURL(await response.blob()) const link = document.createElement('a') link.href = objectUrl link.download = filename link.click() setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000) } export async function privateImageUrl(url: string) { const response = await fetch(url.replace('/uploads/', '/api/files/'), { headers: { Authorization: `Bearer ${token()}` } }) if (!response.ok) throw new Error('图片不可访问') return URL.createObjectURL(await response.blob()) } export async function fetchPrivateAsset(url: string) { const response = await fetch(url.replace('/uploads/', '/api/files/'), { headers: { Authorization: `Bearer ${token()}` } }) if (!response.ok) throw new Error('素材读取失败,请确认登录状态和课程权限') return response.blob() } export async function fetchOfficePreview(courseId: string, assetId: string) { const response = await fetch(`/api/courses/${encodeURIComponent(courseId)}/assets/${encodeURIComponent(assetId)}/preview`, { headers: { Authorization: `Bearer ${token()}`, 'X-Workspace-Id': localStorage.getItem('xinghen_workspace') || 'personal' } }) if (!response.ok) { const body = await response.json().catch(() => null) throw new Error(body?.message || '预览生成失败') } return response.blob() } export const api = { digitalInstructors: { list: (workspaceId?: string) => request<{ instructors: DigitalInstructor[] }>('/digital-instructors', workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}), get: (id: string, workspaceId?: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}`, workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}), usage: (id: string, workspaceId?: string) => request<{ courses: { id: string; name: string; status: string }[] }>(`/digital-instructors/${id}/usage`, workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}), create: (data: { name: string; note: string; images: File[]; audio?: File | null; coverNewIndex?: number }) => { const form = new FormData() form.append('name', data.name) form.append('note', data.note) if (data.coverNewIndex !== undefined) form.append('coverNewIndex', String(data.coverNewIndex)) data.images.forEach(file => form.append('images', file)) if (data.audio) form.append('audio', data.audio) return request<{ instructor: DigitalInstructor }>('/digital-instructors', { method: 'POST', body: form }) }, update: (id: string, data: Pick, workspaceId?: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...(workspaceId ? { 'X-Workspace-Id': workspaceId } : {}) }, body: JSON.stringify(data) }), manage: (id: string, data: { name: string; note: string; ownerId?: string; images: File[]; audio: File | null; removeImageIds: string[]; coverImageId?: string; coverNewIndex?: number }, workspaceId?: string) => { const form = new FormData() form.append('name', data.name); form.append('note', data.note) if (data.ownerId) form.append('ownerId', data.ownerId) form.append('removeImageIds', JSON.stringify(data.removeImageIds)) if (data.coverImageId) form.append('coverImageId', data.coverImageId) if (data.coverNewIndex !== undefined) form.append('coverNewIndex', String(data.coverNewIndex)) data.images.forEach(file => form.append('images', file)) if (data.audio) form.append('audio', data.audio) return request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}/manage`, { method: 'POST', body: form, ...(workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}) }) }, owner: (id: string, ownerId: string, workspaceId?: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}/owner`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...(workspaceId ? { 'X-Workspace-Id': workspaceId } : {}) }, body: JSON.stringify({ ownerId }) }), copy: (id: string, organizationId: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}/copy-to-organization`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ organizationId }) }), duplicate: (id: string, workspaceId?: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}/duplicate`, { method: 'POST', ...(workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}) }), delete: (id: string, workspaceId?: string) => request<{ ok: true }>(`/digital-instructors/${id}`, { method: 'DELETE', ...(workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}) }) }, organizations: { mine: () => request<{ organizations: Organization[] }>('/me/organizations'), publicInfo: (slug: string) => request<{ organization: { id: string; name: string; isActive: boolean } | null }>(`/organizations/${slug}`), content: (id: string) => request(`/organizations/${id}/content`), apply: (slug: string) => request<{ ok: boolean }>(`/organizations/${slug}/apply`, { method: 'POST' }), join: (inviteToken: string) => request<{ organizationId: string }>(`/organizations/join/${inviteToken}`, { method: 'POST' }), manage: (id: string) => request(`/organizations/${id}/manage`), review: (id: string, applicationId: string, approve: boolean) => request<{ ok: boolean }>(`/organizations/${id}/applications/${applicationId}/review`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ approve }) }), createUser: (id: string, phone: string, contactName: string) => request<{ user: User; initialPassword: string }>(`/organizations/${id}/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone, contactName }) }), updateMemberName: (id: string, userId: string, contactName: string) => request<{ user: User }>(`/organizations/${id}/members/${userId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contactName }) }), removeMember: (id: string, userId: string) => request<{ ok: boolean }>(`/organizations/${id}/members/${userId}`, { method: 'DELETE' }), rotateInvite: (id: string) => request<{ token: string }>(`/organizations/${id}/invite`, { method: 'POST' }), disableInvite: (id: string) => request<{ ok: boolean }>(`/organizations/${id}/invite`, { method: 'DELETE' }), points: async (id: string) => { const result = await request<{ balance: number; transactions: any[] }>(`/organizations/${id}/points`) return { balance: result.balance, transactions: result.transactions.map((row) => ({ id: row.id, courseId: row.course_id, courseName: row.course_name || row.reason, points: row.amount, createdAt: row.created_at })) as PointsTransaction[] } } }, // 用户鉴权与信息 register: (phone: string, password: string, contactName: string, inviteToken?: string) => request<{ token: string; user: User }>('/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone, password, contactName, inviteToken }) }), login: (phone: string, password: string) => request<{ token: string; user: User }>('/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone, password }) }), logout: () => request<{ ok: boolean }>('/auth/logout', { method: 'POST' }), me: () => request<{ user: User }>('/me'), getPointsTransactions: () => request<{ balance: number; transactions: PointsTransaction[] }>('/me/points-transactions'), updateMe: (body: Pick) => request<{ user: User }>('/me', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }), changePassword: (currentPassword: string, newPassword: string) => request<{ ok: boolean }>('/me/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPassword, newPassword }) }), // 课程模板 getTemplates: () => request<{ templates: CourseTemplate[] }>('/course-templates'), createFromTemplate: (templateId?: string) => request<{ course: Course }>('/courses/template', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ templateId }) }), // 普通用户课程操作 listCourses: () => request<{ courses: Course[] }>('/courses'), createCourse: (body: Pick & { digitalInstructorId?: string | null }) => request<{ course: Course }>('/courses', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }), getCourse: (id: string) => request<{ course: Course }>(`/courses/${id}`), updateCourse: (id: string, body: Partial> & { digitalInstructorId?: string | null }) => request<{ course: Course }>(`/courses/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }), deleteCourse: (id: string) => request<{ ok: boolean; message?: string }>(`/courses/${id}`, { method: 'DELETE' }), uploadAssets: (id: string, files: File[], episodeId?: string) => { const form = new FormData() files.forEach((f) => form.append('assets', f)) if (episodeId) form.append('episodeId', episodeId) return request<{ course: Course; notesImported: number }>(`/courses/${id}/assets`, { method: 'POST', body: form }) }, deleteAsset: (id: string, assetId: string) => request<{ course: Course }>(`/courses/${id}/assets/${assetId}`, { method: 'DELETE' }), // 课程分集操作 createEpisode: ( courseId: string, data: { title: string; episodeNumber?: number; summary?: string; lectureNotes?: string } ) => request<{ course: Course }>(`/courses/${courseId}/episodes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), batchCreateEpisodes: ( courseId: string, data: { count?: number; episodes?: Array<{ title: string; episodeNumber?: number; summary?: string; lectureNotes?: string }> } ) => request<{ course: Course }>(`/courses/${courseId}/episodes/batch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), updateEpisode: ( courseId: string, episodeId: string, data: { title?: string; episodeNumber?: number; summary?: string; lectureNotes?: string } ) => request<{ course: Course }>(`/courses/${courseId}/episodes/${episodeId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), deleteEpisode: (courseId: string, episodeId: string) => request<{ course: Course }>(`/courses/${courseId}/episodes/${episodeId}`, { method: 'DELETE' }), addInstructor: ( id: string, data: { name: string; organization: string; introduction: string; images?: File[] } ) => { const f = new FormData() f.append('name', data.name) f.append('organization', data.organization) f.append('introduction', data.introduction) data.images?.forEach((image) => f.append('images', image)) return request<{ course: Course }>(`/courses/${id}/instructors`, { method: 'POST', body: f }) }, deleteInstructor: (id: string, instructorId: string) => request<{ course: Course }>(`/courses/${id}/instructors/${instructorId}`, { method: 'DELETE' }), submitCourse: (id: string) => request<{ course: Course }>(`/courses/${id}/submit`, { method: 'POST' }), cancelCourseSubmission: (id: string) => request<{ course: Course }>(`/courses/${id}/cancel-submission`, { method: 'POST' }), // 管理员后台专区 API admin: { organizations: () => request<{ organizations: any[] }>('/admin/organizations'), createOrganization: (name: string, slug: string) => request('/admin/organizations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, slug }) }), organization: (id: string) => request(`/admin/organizations/${id}`), renameOrganization: (id: string, name: string) => request(`/admin/organizations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }), updateOrganizationSlug: (id: string, slug: string) => request(`/admin/organizations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug }) }), setOrganizationActive: (id: string, isActive: boolean) => request(`/admin/organizations/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isActive }) }), setOrganizationMember: (id: string, userId: string, role: 'MEMBER' | 'MANAGER') => request(`/admin/organizations/${id}/members/${userId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role }) }), removeOrganizationMember: (id: string, userId: string) => request(`/admin/organizations/${id}/members/${userId}`, { method: 'DELETE' }), reviewOrganizationApplication: (id: string, applicationId: string, approve: boolean) => request(`/admin/organizations/${id}/applications/${applicationId}/review`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ approve }) }), adjustOrganizationPoints: (id: string, amount: number, reason: string) => request(`/admin/organizations/${id}/points`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amount, reason }) }), login: (phone: string, password: string) => request<{ token: string; user: User }>('/admin/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone, password }) }), getStats: () => request('/admin/stats'), listCourses: (params?: { status?: string; keyword?: string }) => { const q = new URLSearchParams() if (params?.status) q.set('status', params.status) if (params?.keyword) q.set('keyword', params.keyword) const qs = q.toString() ? `?${q.toString()}` : '' return request<{ courses: Course[]; total: number }>(`/admin/courses${qs}`) }, getCourse: (id: string) => request<{ course: Course }>(`/admin/courses/${id}`), updateStatus: (id: string, status: CourseStatus) => request<{ course: Course }>(`/admin/courses/${id}/status`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) }), updatePoints: (id: string, points: number, reason: string) => request<{ course: Course }>(`/admin/courses/${id}/points`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ points, reason }) }), uploadDeliverables: (id: string, files: File[], episodeId?: string) => { const form = new FormData() files.forEach((f) => form.append('deliverables', f)) if (episodeId) form.append('episodeId', episodeId) return request<{ course: Course }>(`/admin/courses/${id}/deliverables`, { method: 'POST', body: form }) }, deleteDeliverable: (id: string, delivId: string) => request<{ course: Course }>(`/admin/courses/${id}/deliverables/${delivId}`, { method: 'DELETE' }), completeCourse: (id: string, productionNotes: string) => request<{ course: Course }>(`/admin/courses/${id}/complete`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ productionNotes }) }), listUsers: (keyword?: string) => { const qs = keyword ? `?keyword=${encodeURIComponent(keyword)}` : '' return request<{ users: User[] }>(`/admin/users${qs}`) }, createUser: (body: { phone: string; password: string; contactName?: string }) => request<{ user: User }>('/admin/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }), resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/admin/users/${id}/password`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }), // 广告落地页线索 listLeads: (params?: { status?: LeadStatus | ''; keyword?: string }) => { const q = new URLSearchParams() if (params?.status) q.set('status', params.status) if (params?.keyword) q.set('keyword', params.keyword) const qs = q.toString() ? `?${q.toString()}` : '' return request<{ leads: Lead[] }>(`/admin/leads${qs}`) }, updateLead: (id: string, body: { status?: LeadStatus; note?: string }) => request<{ lead: Lead }>(`/admin/leads/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) } }