api.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336
  1. import type { AdminStats, Course, CourseStatus, CourseTemplate, DigitalInstructor, Lead, LeadStatus, Organization, PointsTransaction, User } from './types'
  2. const token = () => localStorage.getItem('xinghen_token')
  3. async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
  4. const headers = new Headers(init.headers)
  5. if (token()) headers.set('Authorization', `Bearer ${token()}`)
  6. if (!headers.has('X-Workspace-Id')) headers.set('X-Workspace-Id', localStorage.getItem('xinghen_workspace') || 'personal')
  7. const response = await fetch(`/api${path}`, { ...init, headers })
  8. if (!response.ok) {
  9. const body = await response.json().catch(() => ({ message: '请求失败,请稍后重试' }))
  10. if (response.status === 401) {
  11. localStorage.removeItem('xinghen_token')
  12. window.dispatchEvent(new Event('auth-expired'))
  13. }
  14. throw new Error(body.message || '请求失败,请稍后重试')
  15. }
  16. const result = await response.json() as T
  17. if (init.method && init.method !== 'GET') window.dispatchEvent(new Event('workspace-updated'))
  18. return result
  19. }
  20. export async function downloadPrivateFile(url: string, filename: string) {
  21. const response = await fetch(url.replace('/uploads/', '/api/files/'), {
  22. headers: { Authorization: `Bearer ${token()}` }
  23. })
  24. if (!response.ok) throw new Error('文件下载失败')
  25. const objectUrl = URL.createObjectURL(await response.blob())
  26. const link = document.createElement('a')
  27. link.href = objectUrl
  28. link.download = filename
  29. link.click()
  30. setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
  31. }
  32. export async function privateImageUrl(url: string) {
  33. const response = await fetch(url.replace('/uploads/', '/api/files/'), {
  34. headers: { Authorization: `Bearer ${token()}` }
  35. })
  36. if (!response.ok) throw new Error('图片不可访问')
  37. return URL.createObjectURL(await response.blob())
  38. }
  39. export async function fetchPrivateAsset(url: string) {
  40. const response = await fetch(url.replace('/uploads/', '/api/files/'), {
  41. headers: { Authorization: `Bearer ${token()}` }
  42. })
  43. if (!response.ok) throw new Error('素材读取失败,请确认登录状态和课程权限')
  44. return response.blob()
  45. }
  46. export async function fetchOfficePreview(courseId: string, assetId: string) {
  47. const response = await fetch(`/api/courses/${encodeURIComponent(courseId)}/assets/${encodeURIComponent(assetId)}/preview`, {
  48. headers: {
  49. Authorization: `Bearer ${token()}`,
  50. 'X-Workspace-Id': localStorage.getItem('xinghen_workspace') || 'personal'
  51. }
  52. })
  53. if (!response.ok) {
  54. const body = await response.json().catch(() => null)
  55. throw new Error(body?.message || '预览生成失败')
  56. }
  57. return response.blob()
  58. }
  59. export const api = {
  60. digitalInstructors: {
  61. list: (workspaceId?: string) => request<{ instructors: DigitalInstructor[] }>('/digital-instructors', workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}),
  62. get: (id: string, workspaceId?: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}`, workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}),
  63. usage: (id: string, workspaceId?: string) => request<{ courses: { id: string; name: string; status: string }[] }>(`/digital-instructors/${id}/usage`, workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}),
  64. create: (data: { name: string; note: string; images: File[]; audio?: File | null; coverNewIndex?: number }) => {
  65. const form = new FormData()
  66. form.append('name', data.name)
  67. form.append('note', data.note)
  68. if (data.coverNewIndex !== undefined) form.append('coverNewIndex', String(data.coverNewIndex))
  69. data.images.forEach(file => form.append('images', file))
  70. if (data.audio) form.append('audio', data.audio)
  71. return request<{ instructor: DigitalInstructor }>('/digital-instructors', { method: 'POST', body: form })
  72. },
  73. update: (id: string, data: Pick<DigitalInstructor, 'name' | 'note'>, workspaceId?: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', ...(workspaceId ? { 'X-Workspace-Id': workspaceId } : {}) }, body: JSON.stringify(data) }),
  74. manage: (id: string, data: { name: string; note: string; ownerId?: string; images: File[]; audio: File | null; removeImageIds: string[]; coverImageId?: string; coverNewIndex?: number }, workspaceId?: string) => {
  75. const form = new FormData()
  76. form.append('name', data.name); form.append('note', data.note)
  77. if (data.ownerId) form.append('ownerId', data.ownerId)
  78. form.append('removeImageIds', JSON.stringify(data.removeImageIds))
  79. if (data.coverImageId) form.append('coverImageId', data.coverImageId)
  80. if (data.coverNewIndex !== undefined) form.append('coverNewIndex', String(data.coverNewIndex))
  81. data.images.forEach(file => form.append('images', file))
  82. if (data.audio) form.append('audio', data.audio)
  83. return request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}/manage`, { method: 'POST', body: form, ...(workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}) })
  84. },
  85. 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 }) }),
  86. 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 }) }),
  87. duplicate: (id: string, workspaceId?: string) => request<{ instructor: DigitalInstructor }>(`/digital-instructors/${id}/duplicate`, { method: 'POST', ...(workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}) }),
  88. delete: (id: string, workspaceId?: string) => request<{ ok: true }>(`/digital-instructors/${id}`, { method: 'DELETE', ...(workspaceId ? { headers: { 'X-Workspace-Id': workspaceId } } : {}) })
  89. },
  90. organizations: {
  91. mine: () => request<{ organizations: Organization[] }>('/me/organizations'),
  92. publicInfo: (slug: string) => request<{ organization: { id: string; name: string; isActive: boolean } | null }>(`/organizations/${slug}`),
  93. content: (id: string) => request<any>(`/organizations/${id}/content`),
  94. apply: (slug: string) => request<{ ok: boolean }>(`/organizations/${slug}/apply`, { method: 'POST' }),
  95. join: (inviteToken: string) => request<{ organizationId: string }>(`/organizations/join/${inviteToken}`, { method: 'POST' }),
  96. manage: (id: string) => request<any>(`/organizations/${id}/manage`),
  97. review: (id: string, applicationId: string, approve: boolean) => request<{ ok: boolean }>(`/organizations/${id}/applications/${applicationId}/review`, {
  98. method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ approve })
  99. }),
  100. createUser: (id: string, phone: string, contactName: string) => request<{ user: User; initialPassword: string }>(`/organizations/${id}/users`, {
  101. method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone, contactName })
  102. }),
  103. updateMemberName: (id: string, userId: string, contactName: string) => request<{ user: User }>(`/organizations/${id}/members/${userId}`, {
  104. method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contactName })
  105. }),
  106. removeMember: (id: string, userId: string) => request<{ ok: boolean }>(`/organizations/${id}/members/${userId}`, {
  107. method: 'DELETE'
  108. }),
  109. rotateInvite: (id: string) => request<{ token: string }>(`/organizations/${id}/invite`, { method: 'POST' }),
  110. disableInvite: (id: string) => request<{ ok: boolean }>(`/organizations/${id}/invite`, { method: 'DELETE' }),
  111. points: async (id: string) => {
  112. const result = await request<{ balance: number; transactions: any[] }>(`/organizations/${id}/points`)
  113. return { balance: result.balance, transactions: result.transactions.map((row) => ({
  114. id: row.id, courseId: row.course_id, courseName: row.course_name || row.reason, points: row.amount, createdAt: row.created_at
  115. })) as PointsTransaction[] }
  116. }
  117. },
  118. // 用户鉴权与信息
  119. register: (phone: string, password: string, contactName: string, inviteToken?: string) =>
  120. request<{ token: string; user: User }>('/auth/register', {
  121. method: 'POST',
  122. headers: { 'Content-Type': 'application/json' },
  123. body: JSON.stringify({ phone, password, contactName, inviteToken })
  124. }),
  125. login: (phone: string, password: string) =>
  126. request<{ token: string; user: User }>('/auth/login', {
  127. method: 'POST',
  128. headers: { 'Content-Type': 'application/json' },
  129. body: JSON.stringify({ phone, password })
  130. }),
  131. logout: () => request<{ ok: boolean }>('/auth/logout', { method: 'POST' }),
  132. me: () => request<{ user: User }>('/me'),
  133. getPointsTransactions: () =>
  134. request<{ balance: number; transactions: PointsTransaction[] }>('/me/points-transactions'),
  135. updateMe: (body: Pick<User, 'wechat' | 'contactName' | 'bio'>) =>
  136. request<{ user: User }>('/me', {
  137. method: 'PATCH',
  138. headers: { 'Content-Type': 'application/json' },
  139. body: JSON.stringify(body)
  140. }),
  141. changePassword: (currentPassword: string, newPassword: string) =>
  142. request<{ ok: boolean }>('/me/password', {
  143. method: 'POST',
  144. headers: { 'Content-Type': 'application/json' },
  145. body: JSON.stringify({ currentPassword, newPassword })
  146. }),
  147. // 课程模板
  148. getTemplates: () => request<{ templates: CourseTemplate[] }>('/course-templates'),
  149. createFromTemplate: (templateId?: string) =>
  150. request<{ course: Course }>('/courses/template', {
  151. method: 'POST',
  152. headers: { 'Content-Type': 'application/json' },
  153. body: JSON.stringify({ templateId })
  154. }),
  155. // 普通用户课程操作
  156. listCourses: () => request<{ courses: Course[] }>('/courses'),
  157. createCourse: (body: Pick<Course, 'name' | 'description'> & { digitalInstructorId?: string | null }) =>
  158. request<{ course: Course }>('/courses', {
  159. method: 'POST',
  160. headers: { 'Content-Type': 'application/json' },
  161. body: JSON.stringify(body)
  162. }),
  163. getCourse: (id: string) => request<{ course: Course }>(`/courses/${id}`),
  164. updateCourse: (id: string, body: Partial<Pick<Course, 'name' | 'description' | 'assetNotes'>> & { digitalInstructorId?: string | null }) =>
  165. request<{ course: Course }>(`/courses/${id}`, {
  166. method: 'PATCH',
  167. headers: { 'Content-Type': 'application/json' },
  168. body: JSON.stringify(body)
  169. }),
  170. deleteCourse: (id: string) => request<{ ok: boolean; message?: string }>(`/courses/${id}`, { method: 'DELETE' }),
  171. uploadAssets: (id: string, files: File[], episodeId?: string) => {
  172. const form = new FormData()
  173. files.forEach((f) => form.append('assets', f))
  174. if (episodeId) form.append('episodeId', episodeId)
  175. return request<{ course: Course; notesImported: number }>(`/courses/${id}/assets`, { method: 'POST', body: form })
  176. },
  177. deleteAsset: (id: string, assetId: string) =>
  178. request<{ course: Course }>(`/courses/${id}/assets/${assetId}`, { method: 'DELETE' }),
  179. // 课程分集操作
  180. createEpisode: (
  181. courseId: string,
  182. data: { title: string; episodeNumber?: number; summary?: string; lectureNotes?: string }
  183. ) =>
  184. request<{ course: Course }>(`/courses/${courseId}/episodes`, {
  185. method: 'POST',
  186. headers: { 'Content-Type': 'application/json' },
  187. body: JSON.stringify(data)
  188. }),
  189. batchCreateEpisodes: (
  190. courseId: string,
  191. data: { count?: number; episodes?: Array<{ title: string; episodeNumber?: number; summary?: string; lectureNotes?: string }> }
  192. ) =>
  193. request<{ course: Course }>(`/courses/${courseId}/episodes/batch`, {
  194. method: 'POST',
  195. headers: { 'Content-Type': 'application/json' },
  196. body: JSON.stringify(data)
  197. }),
  198. updateEpisode: (
  199. courseId: string,
  200. episodeId: string,
  201. data: { title?: string; episodeNumber?: number; summary?: string; lectureNotes?: string }
  202. ) =>
  203. request<{ course: Course }>(`/courses/${courseId}/episodes/${episodeId}`, {
  204. method: 'PATCH',
  205. headers: { 'Content-Type': 'application/json' },
  206. body: JSON.stringify(data)
  207. }),
  208. deleteEpisode: (courseId: string, episodeId: string) =>
  209. request<{ course: Course }>(`/courses/${courseId}/episodes/${episodeId}`, { method: 'DELETE' }),
  210. addInstructor: (
  211. id: string,
  212. data: { name: string; organization: string; introduction: string; images?: File[] }
  213. ) => {
  214. const f = new FormData()
  215. f.append('name', data.name)
  216. f.append('organization', data.organization)
  217. f.append('introduction', data.introduction)
  218. data.images?.forEach((image) => f.append('images', image))
  219. return request<{ course: Course }>(`/courses/${id}/instructors`, { method: 'POST', body: f })
  220. },
  221. deleteInstructor: (id: string, instructorId: string) =>
  222. request<{ course: Course }>(`/courses/${id}/instructors/${instructorId}`, { method: 'DELETE' }),
  223. submitCourse: (id: string) => request<{ course: Course }>(`/courses/${id}/submit`, { method: 'POST' }),
  224. cancelCourseSubmission: (id: string) =>
  225. request<{ course: Course }>(`/courses/${id}/cancel-submission`, { method: 'POST' }),
  226. // 管理员后台专区 API
  227. admin: {
  228. organizations: () => request<{ organizations: any[] }>('/admin/organizations'),
  229. createOrganization: (name: string, slug: string) => request<any>('/admin/organizations', {
  230. method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, slug })
  231. }),
  232. organization: (id: string) => request<any>(`/admin/organizations/${id}`),
  233. renameOrganization: (id: string, name: string) => request<any>(`/admin/organizations/${id}`, {
  234. method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name })
  235. }),
  236. updateOrganizationSlug: (id: string, slug: string) => request<any>(`/admin/organizations/${id}`, {
  237. method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ slug })
  238. }),
  239. setOrganizationActive: (id: string, isActive: boolean) => request<any>(`/admin/organizations/${id}`, {
  240. method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isActive })
  241. }),
  242. setOrganizationMember: (id: string, userId: string, role: 'MEMBER' | 'MANAGER') => request<any>(`/admin/organizations/${id}/members/${userId}`, {
  243. method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ role })
  244. }),
  245. removeOrganizationMember: (id: string, userId: string) => request<any>(`/admin/organizations/${id}/members/${userId}`, { method: 'DELETE' }),
  246. reviewOrganizationApplication: (id: string, applicationId: string, approve: boolean) => request<any>(`/admin/organizations/${id}/applications/${applicationId}/review`, {
  247. method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ approve })
  248. }),
  249. adjustOrganizationPoints: (id: string, amount: number, reason: string) => request<any>(`/admin/organizations/${id}/points`, {
  250. method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amount, reason })
  251. }),
  252. login: (phone: string, password: string) =>
  253. request<{ token: string; user: User }>('/admin/auth/login', {
  254. method: 'POST',
  255. headers: { 'Content-Type': 'application/json' },
  256. body: JSON.stringify({ phone, password })
  257. }),
  258. getStats: () => request<AdminStats>('/admin/stats'),
  259. listCourses: (params?: { status?: string; keyword?: string }) => {
  260. const q = new URLSearchParams()
  261. if (params?.status) q.set('status', params.status)
  262. if (params?.keyword) q.set('keyword', params.keyword)
  263. const qs = q.toString() ? `?${q.toString()}` : ''
  264. return request<{ courses: Course[]; total: number }>(`/admin/courses${qs}`)
  265. },
  266. getCourse: (id: string) => request<{ course: Course }>(`/admin/courses/${id}`),
  267. updateStatus: (id: string, status: CourseStatus) =>
  268. request<{ course: Course }>(`/admin/courses/${id}/status`, {
  269. method: 'POST',
  270. headers: { 'Content-Type': 'application/json' },
  271. body: JSON.stringify({ status })
  272. }),
  273. updatePoints: (id: string, points: number, reason: string) =>
  274. request<{ course: Course }>(`/admin/courses/${id}/points`, {
  275. method: 'PATCH',
  276. headers: { 'Content-Type': 'application/json' },
  277. body: JSON.stringify({ points, reason })
  278. }),
  279. uploadDeliverables: (id: string, files: File[], episodeId?: string) => {
  280. const form = new FormData()
  281. files.forEach((f) => form.append('deliverables', f))
  282. if (episodeId) form.append('episodeId', episodeId)
  283. return request<{ course: Course }>(`/admin/courses/${id}/deliverables`, { method: 'POST', body: form })
  284. },
  285. deleteDeliverable: (id: string, delivId: string) =>
  286. request<{ course: Course }>(`/admin/courses/${id}/deliverables/${delivId}`, { method: 'DELETE' }),
  287. completeCourse: (id: string, productionNotes: string) =>
  288. request<{ course: Course }>(`/admin/courses/${id}/complete`, {
  289. method: 'POST',
  290. headers: { 'Content-Type': 'application/json' },
  291. body: JSON.stringify({ productionNotes })
  292. }),
  293. listUsers: (keyword?: string) => {
  294. const qs = keyword ? `?keyword=${encodeURIComponent(keyword)}` : ''
  295. return request<{ users: User[] }>(`/admin/users${qs}`)
  296. },
  297. createUser: (body: { phone: string; password: string; contactName?: string }) =>
  298. request<{ user: User }>('/admin/users', {
  299. method: 'POST',
  300. headers: { 'Content-Type': 'application/json' },
  301. body: JSON.stringify(body)
  302. }),
  303. resetUserPassword: (id: string, password: string) =>
  304. request<{ ok: boolean }>(`/admin/users/${id}/password`, {
  305. method: 'PATCH',
  306. headers: { 'Content-Type': 'application/json' },
  307. body: JSON.stringify({ password })
  308. }),
  309. // 广告落地页线索
  310. listLeads: (params?: { status?: LeadStatus | ''; keyword?: string }) => {
  311. const q = new URLSearchParams()
  312. if (params?.status) q.set('status', params.status)
  313. if (params?.keyword) q.set('keyword', params.keyword)
  314. const qs = q.toString() ? `?${q.toString()}` : ''
  315. return request<{ leads: Lead[] }>(`/admin/leads${qs}`)
  316. },
  317. updateLead: (id: string, body: { status?: LeadStatus; note?: string }) =>
  318. request<{ lead: Lead }>(`/admin/leads/${id}`, {
  319. method: 'PATCH',
  320. headers: { 'Content-Type': 'application/json' },
  321. body: JSON.stringify(body)
  322. })
  323. }
  324. }