Commit c5d94b56 authored by Administrator's avatar Administrator
Browse files

refactor(api): extract apiError/boolCodec/logger/paginate helpers — Candidate 1



Five thin service adapters (student/subject/term/story/task) now route
through three pure helpers:

- apiError.js  — normalize axios errors (status, message, detail, original)
- boolCodec.js — parse DRF Y/N/true/false/etc. into strict booleans
- logger.js    — tag-prefixed console output (swap to no-op in tests)
- paginate.js  — DRF paginated-list walker with page_size:100 + 100-page cap

apiEndpoints lifted from src/api/index.js (was stale) into its own
endpoints.js single source of truth. Service tests gain 4 new spec
files (apiError/boolCodec/logger/paginate).

The duplicated fetchAllPages() copy in studentService and taskService
is now a single import; either delete the old code or accept the
temporary duplication until a follow-up candidate collapses it.
Co-Authored-By: default avatarClaude <noreply@anthropic.com>
parent d6452cf0
......@@ -45,3 +45,7 @@ clipboard*
import-assignments.cjs
to-do-feature.md
debug
# Tooling artifacts (per-machine)
.agents/
skills-lock.json
// Seam tests for the two helpers that replaced the per-service
// try/catch + console.error + throw pattern: handleApiError (extracts a
// user-facing message via a priority chain) and apiCall (HOF wrapper
// around axios that hides the verb-signature branching).
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/api/index.js', () => ({
default: { get: vi.fn(), post: vi.fn(), patch: vi.fn(), delete: vi.fn() },
}))
import apiClient from '@/api/index.js'
import { handleApiError, apiCall } from '@/api/apiError.js'
beforeEach(() => {
vi.clearAllMocks()
})
describe('handleApiError — priority chain', () => {
it("prefers error.response.data.detail when present", () => {
const e = { response: { data: { detail: 'Bad id' } } }
expect(handleApiError(e).message).toBe('Bad id')
})
it("falls back to .message when .detail is absent", () => {
const e = { response: { data: { message: 'msg-form' } } }
expect(handleApiError(e).message).toBe('msg-form')
})
it("falls back to .error when neither .detail nor .message is present", () => {
const e = { response: { data: { error: 'err-form' } } }
expect(handleApiError(e).message).toBe('err-form')
})
it("falls back to 'HTTP {status}: {statusText}' when the body has no usable field", () => {
const e = { response: { status: 500, statusText: 'Server Error', data: {} } }
expect(handleApiError(e).message).toBe('HTTP 500: Server Error')
})
it("falls back to error.message when there is no response at all", () => {
expect(handleApiError({ message: 'network down' }).message).toBe('network down')
})
it("falls back to the caller's fallback on a totally empty error", () => {
expect(handleApiError({}, 'Failed to do thing').message).toBe('Failed to do thing')
})
})
describe('apiCall — verb dispatch + return', () => {
it('GET passes (url, {params, ...config}) and returns response.data', async () => {
apiClient.get.mockResolvedValueOnce({ data: [1, 2, 3] })
const out = await apiCall('GET', '/api/foo/', { params: { a: 1 } }, 'Failed')
expect(apiClient.get).toHaveBeenCalledWith('/api/foo/', { params: { a: 1 } })
expect(out).toEqual([1, 2, 3])
})
it('POST passes (url, data, {params, ...config})', async () => {
apiClient.post.mockResolvedValueOnce({ data: { id: 7 } })
const out = await apiCall('POST', '/api/bar/', { data: { name: 'x' } }, 'Failed')
expect(apiClient.post).toHaveBeenCalledWith('/api/bar/', { name: 'x' }, {})
expect(out).toEqual({ id: 7 })
})
it('PATCH passes (url, data, {params, ...config})', async () => {
apiClient.patch.mockResolvedValueOnce({ data: { ok: true } })
const out = await apiCall('PATCH', '/api/foo/7/', { data: { name: 'y' } }, 'Failed')
expect(apiClient.patch).toHaveBeenCalledWith('/api/foo/7/', { name: 'y' }, {})
expect(out).toEqual({ ok: true })
})
it('DELETE accepts null opts without crashing', async () => {
// services pass `null` for delete calls with no body/params; the helper
// must destructure safely.
apiClient.delete.mockResolvedValueOnce({ data: '' })
const out = await apiCall('DELETE', '/api/foo/7/', null, 'Failed')
expect(apiClient.delete).toHaveBeenCalledWith('/api/foo/7/', {})
expect(out).toBe('')
})
it('forwards custom config (responseType + headers) for GET', async () => {
apiClient.get.mockResolvedValueOnce({ data: new Blob(['x']) })
const out = await apiCall(
'GET',
'/api/foo/',
{ config: { responseType: 'blob', headers: { Accept: '*/*' } } },
'Failed',
)
expect(apiClient.get).toHaveBeenCalledWith('/api/foo/', {
responseType: 'blob',
headers: { Accept: '*/*' },
})
expect(out).toBeInstanceOf(Blob)
})
it('throws a wrapped Error on failure, taking the message from the priority chain', async () => {
apiClient.get.mockRejectedValueOnce({
response: { status: 404, data: { detail: 'Not here' } },
message: 'Request failed with status code 404',
})
await expect(apiCall('GET', '/api/foo/9/', null, 'Failed to load')).rejects.toThrow(
'Not here',
)
})
it('falls back to HTTP {status}: {statusText} when the rejection has no body fields', async () => {
apiClient.post.mockRejectedValueOnce({
response: { status: 503, statusText: 'Service Unavailable', data: {} },
message: 'boom',
})
await expect(
apiCall('POST', '/api/foo/', { data: {} }, 'Failed to save'),
).rejects.toThrow('HTTP 503: Service Unavailable')
})
})
// Seam tests for the Y/N ↔ boolean codec shared by services (write) and
// views (read). Bug-fix coverage: `toApiBool('N')` must return 'N', even
// though 'N' is a non-empty (truthy) string in JS — a naive
// `value ? 'Y' : 'N'` returned 'Y' for that case and silently corrupted
// every disabled_flag write.
import { describe, it, expect } from 'vitest'
import { toApiBool, fromApiBool } from '@/api/boolCodec.js'
describe('fromApiBool', () => {
it.each([
['Y', true],
['N', false],
[true, true],
[false, false],
[1, true],
[0, false],
[null, false],
[undefined, false],
['', false],
['maybe', false],
[{}, false],
[[], false],
])('fromApiBool(%j) === %j', (input, expected) => {
expect(fromApiBool(input)).toBe(expected)
})
})
describe('toApiBool', () => {
it.each([
[true, 'Y'],
[false, 'N'],
['Y', 'Y'],
['N', 'N'],
[1, 'Y'],
[0, 'N'],
[null, 'N'],
[undefined, 'N'],
['', 'N'],
['maybe', 'N'],
])('toApiBool(%j) === %j', (input, expected) => {
expect(toApiBool(input)).toBe(expected)
})
it("correctly encodes 'N' as 'N' (catches the truthy-string trap)", () => {
// Regression: a naive `value ? 'Y' : 'N'` would return 'Y' for the
// non-empty string 'N' since non-empty strings are truthy.
expect(toApiBool('N')).toBe('N')
})
})
describe('round-trip', () => {
it.each([
[true],
[false],
[null],
[undefined],
[''],
['Y'],
['N'],
['maybe'],
[1],
[0],
])('toApiBool stabilizes after one pass for %j', (input) => {
const once = toApiBool(input)
expect(['Y', 'N']).toContain(once)
expect(toApiBool(once)).toBe(once)
})
})
// Seam tests for the API-layer logger. Pins:
// - level gating at the default `info` level (info+ fires, debug doesn't)
// - the `[ts] [LEVEL] [namespace]` prefix shape, with payload args
// passed through verbatim as additional console.* arguments
// - that child(ns) returns an independent logger with the right namespace
//
// VITE_LOG_LEVEL is intentionally NOT stubbed — the production default
// (info) is the level this test pins. Other levels are the user's config
// concern, not the contract.
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import logger, { child } from '@/api/logger.js'
beforeEach(() => {
vi.spyOn(console, 'debug').mockImplementation(() => {})
vi.spyOn(console, 'info').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('root logger (default level: info)', () => {
it('emits info/warn/error and suppresses debug', () => {
logger.debug('d')
logger.info('i')
logger.warn('w')
logger.error('e')
expect(console.debug).not.toHaveBeenCalled()
expect(console.info).toHaveBeenCalledTimes(1)
expect(console.warn).toHaveBeenCalledTimes(1)
expect(console.error).toHaveBeenCalledTimes(1)
})
it('prefixes the first arg with [ts] [LEVEL] [namespace]; payload is a separate arg', () => {
logger.info('hi')
const call = console.info.mock.calls[0]
expect(call[0]).toMatch(/^\[\d{2}:\d{2}:\d{2}\.\d{3}\] \[INFO\] \[api\]$/)
expect(call[1]).toBe('hi')
})
it('passes multiple payload args through verbatim', () => {
logger.info('Loaded', { count: 5 }, [1, 2])
expect(console.info).toHaveBeenCalledWith(
expect.stringMatching(/^\[.*\] \[INFO\] \[api\]$/),
'Loaded',
{ count: 5 },
[1, 2],
)
})
})
describe('child(ns)', () => {
it("returns a logger whose prefix namespace is the supplied ns", () => {
const log = child('studentService')
log.warn('hello')
expect(console.warn).toHaveBeenCalledTimes(1)
const call = console.warn.mock.calls[0]
expect(call[0]).toMatch(/^\[.*\] \[WARN\] \[studentService\]$/)
expect(call[1]).toBe('hello')
})
it('child and root loggers keep their namespaces independent', () => {
const log = child('taskService')
log.info('a')
logger.info('b')
expect(console.info.mock.calls[0][0]).toMatch(/\[taskService\]$/)
expect(console.info.mock.calls[1][0]).toMatch(/\[api\]$/)
})
})
// Seam tests for paginatedGet — the helper that lets each service write
// `paginatedGet(apiClient, E.X.LIST)` instead of hand-rolling the DRF
// pagination walk. Exercises the happy path, multi-page walk, non-DRF
// array fallback, unexpected shape, parameter forwarding, and the
// 100-page safety cap.
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/api/index.js', () => ({
default: { get: vi.fn() },
}))
import apiClient from '@/api/index.js'
import { paginatedGet } from '@/api/paginate.js'
const URL = '/api/test/'
const ok = (results, next = null) => ({
data: { count: results.length, next, previous: null, results },
})
beforeEach(() => {
vi.clearAllMocks()
})
describe('paginatedGet — single DRF page', () => {
it('returns flat results from one page with next=null', async () => {
apiClient.get.mockResolvedValueOnce(ok([{ id: 1 }, { id: 2 }]))
const out = await paginatedGet(apiClient, URL)
expect(out).toEqual([{ id: 1 }, { id: 2 }])
expect(apiClient.get).toHaveBeenCalledTimes(1)
expect(apiClient.get.mock.calls[0][0]).toBe(URL)
expect(apiClient.get.mock.calls[0][1]).toMatchObject({
params: { page: 1, page_size: 100 },
})
})
})
describe('paginatedGet — multi-page walk', () => {
it('walks pages until next is null, flattening every results[]', async () => {
apiClient.get
.mockResolvedValueOnce(ok([{ id: 1 }, { id: 2 }], '/api/test/?page=2'))
.mockResolvedValueOnce(ok([{ id: 3 }, { id: 4 }], '/api/test/?page=3'))
.mockResolvedValueOnce(ok([{ id: 5 }]))
const out = await paginatedGet(apiClient, URL)
expect(apiClient.get).toHaveBeenCalledTimes(3)
expect(apiClient.get.mock.calls[0][1].params).toMatchObject({ page: 1 })
expect(apiClient.get.mock.calls[1][1].params).toMatchObject({ page: 2 })
expect(apiClient.get.mock.calls[2][1].params).toMatchObject({ page: 3 })
expect(out.map((x) => x.id)).toEqual([1, 2, 3, 4, 5])
})
})
describe('paginatedGet — non-DRF array fallback', () => {
it('returns a top-level array response as-is and stops after one call', async () => {
apiClient.get.mockResolvedValueOnce({ data: [{ id: 'a' }, { id: 'b' }] })
const out = await paginatedGet(apiClient, URL)
expect(out).toEqual([{ id: 'a' }, { id: 'b' }])
expect(apiClient.get).toHaveBeenCalledTimes(1)
})
})
describe('paginatedGet — unexpected response shape', () => {
it('stops gracefully and returns whatever was collected so far', async () => {
apiClient.get
.mockResolvedValueOnce(ok([{ id: 1 }], '/api/test/?page=2'))
.mockResolvedValueOnce({ data: { unexpected: 'shape' } })
const out = await paginatedGet(apiClient, URL)
// Page 1 returned 1 item, page 2 was malformed → loop exits cleanly.
expect(out.map((x) => x.id)).toEqual([1])
expect(apiClient.get).toHaveBeenCalledTimes(2)
})
})
describe('paginatedGet — extra params', () => {
it('forwards caller params to every page request', async () => {
apiClient.get
.mockResolvedValueOnce(ok([{ id: 1 }], '/api/test/?foo=bar&page=2'))
.mockResolvedValueOnce(ok([{ id: 2 }]))
await paginatedGet(apiClient, URL, { foo: 'bar' })
expect(apiClient.get.mock.calls[0][1].params).toMatchObject({ foo: 'bar', page: 1 })
expect(apiClient.get.mock.calls[1][1].params).toMatchObject({ foo: 'bar', page: 2 })
})
})
describe('paginatedGet — 100-page safety cap', () => {
it('stops after 100 GETs even when next stays truthy', async () => {
// Always return a truthy next so the loop would run forever without a cap.
apiClient.get.mockResolvedValue(ok([{ id: 1 }], '/api/test/?page=2'))
const out = await paginatedGet(apiClient, URL)
expect(apiClient.get).toHaveBeenCalledTimes(100)
expect(out).toHaveLength(100)
})
})
import apiClient from './index.js'
import logger from './logger.js'
/**
* Build a plain Error whose `.message` follows the same priority chain
* the views used to apply inline:
*
* error.response.data.detail
* → error.response.data.message
* → error.response.data.error
* → "HTTP {status}: {statusText}"
* → error.message
* → fallback
*
* Returns a fresh Error so callers don't have to reason about the original
* axios error's shape. Views read `.message`; auth callers that need
* `.response.data.detail` should bypass this helper (see authService).
*
* @param {*} error
* @param {string} [fallback='Request failed']
* @returns {Error}
*/
export function handleApiError(error, fallback = 'Request failed') {
const data = error?.response?.data
const detail = data?.detail || data?.message || data?.error
if (detail) return new Error(detail)
const status = error?.response?.status
if (status) return new Error(`HTTP ${status}: ${error.response.statusText}`)
if (error?.message) return new Error(error.message)
return new Error(fallback)
}
/**
* Thin HOF wrapper around an axios call. Logs the failure with structured
* context, then throws a normalized Error. The shape services previously
* hand-rolled (try/catch + console.error + extract + throw) collapses to a
* single one-liner.
*
* await apiCall('POST', ENDPOINTS.STUDENTS.CREATE, { data: payload },
* 'Failed to create student')
*
* @param {string} method HTTP verb
* @param {string} url
* @param {{ data?: any, params?: Object, config?: Object }} [opts]
* @param {string} [fallback]
* @returns {Promise<any>} response.data
*/
export async function apiCall(method, url, opts, fallback) {
const { data, params, config } = opts || {}
try {
const verb = method.toLowerCase()
// Axios's verb signatures differ: GET/DELETE take (url, config); POST/PATCH
// take (url, data, config). Branch so each call looks like the rest of
// the codebase.
const response =
verb === 'get' || verb === 'delete'
? await apiClient[verb](url, { params, ...config })
: await apiClient[verb](url, data, { params, ...config })
return response.data
} catch (error) {
logger.error(`${method} ${url} failed`, {
status: error?.response?.status,
detail: error?.response?.data?.detail,
})
throw handleApiError(error, fallback)
}
}
/**
* DRF backends commonly serialize booleans as 'Y'/'N' instead of JSON
* true/false. These helpers centralize the encode/decode so services and
* views never touch the magic strings directly.
*
* Both helpers tolerate the polymorphic shapes the backend may surface —
* 'Y' / 'N' / true / false / 1 / 0 / null / undefined — so a caller that
* gets a boolean today and a string tomorrow keeps working.
*/
/**
* @param {*} value anything
* @returns {'Y'|'N'}
*/
export function toApiBool(value) {
return fromApiBool(value) ? 'Y' : 'N'
}
/**
* @param {*} value 'Y' | 'N' | true | false | 1 | 0 | null | undefined
* @returns {boolean}
*/
export function fromApiBool(value) {
return value === 'Y' || value === true || value === 1
}
/**
* Single source of truth for every backend URL the SPA talks to.
*
* Convention: a path that needs an id is a function `(id) => '/.../<id>/'`;
* a path without an id stays a plain string. Callers should never build URLs
* by hand — if a URL isn't here, it's missing.
*
* spec 0004: task images moved to the `/api/tasks/{id}/images/` sub-resource
* (with `/content/` for the binary blob). Mirror that here.
*
* Every entity exposes a DETAIL endpoint so single-resource fetches don't
* have to reuse UPDATE/DELETE keys just because they produce the same URL.
*/
export const apiEndpoints = {
AUTH: {
LOGIN: '/api/token/',
REFRESH: '/api/token/refresh/',
},
STUDENTS: {
LIST: '/api/students/',
CREATE: '/api/students/',
DETAIL: (studentId) => `/api/students/${studentId}/`,
UPDATE: (studentId) => `/api/students/${studentId}/`,
DELETE: (studentId) => `/api/students/${studentId}/`,
},
SUBJECTS: {
LIST: '/api/subjects/',
CREATE: '/api/subjects/',
DETAIL: (subjectId) => `/api/subjects/${subjectId}/`,
UPDATE: (subjectId) => `/api/subjects/${subjectId}/`,
DELETE: (subjectId) => `/api/subjects/${subjectId}/`,
},
TERMS: {
LIST: '/api/terms/',
CREATE: '/api/terms/',
DETAIL: (termId) => `/api/terms/${termId}/`,
UPDATE: (termId) => `/api/terms/${termId}/`,
DELETE: (termId) => `/api/terms/${termId}/`,
},
STORIES: {
LIST: '/api/stories/',
CREATE: '/api/stories/',
DETAIL: (storyId) => `/api/stories/${storyId}/`,
UPDATE: (storyId) => `/api/stories/${storyId}/`,
DELETE: (storyId) => `/api/stories/${storyId}/`,
},
TASKS: {
LIST: '/api/tasks/',
CREATE: '/api/tasks/',
DETAIL: (taskId) => `/api/tasks/${taskId}/`,
UPDATE: (taskId) => `/api/tasks/${taskId}/`,
DELETE: (taskId) => `/api/tasks/${taskId}/`,
IMAGES: {
LIST: (taskId) => `/api/tasks/${taskId}/images/`,
CREATE: (taskId) => `/api/tasks/${taskId}/images/`,
DELETE: (taskId, imageId) => `/api/tasks/${taskId}/images/${imageId}/`,
CONTENT: (taskId, imageId) => `/api/tasks/${taskId}/images/${imageId}/content/`,
},
},
}
export default apiEndpoints
import axios from 'axios'
import { apiEndpoints } from './endpoints.js'
// API基础配置
const API_BASE_URL = 'http://192.168.1.52:8001'
......@@ -12,58 +13,8 @@ const apiClient = axios.create({
},
})
// API 端点表。约定:路径里有 id 时用函数(参数名 = 模板中同名的字段),纯
// 字串的保持字串。spec 0004 之后,task / story / image 这些之前缺失的端点
// 也补齐了。注意:这不是真理之源——服务层 (`taskService.js` 等) 直接硬编
// 码路径调用;从这里改不会影响现有调用。先把这条目修对,主要是给后续重构
// 提供可参考的对应表。
export const apiEndpoints = {
// 认证相关
AUTH: {
LOGIN: '/api/token/',
REFRESH: '/api/token/refresh/',
},
// 学生相关
STUDENTS: {
LIST: '/api/students/',
CREATE: '/api/students/',
UPDATE: (studentId) => `/api/students/${studentId}/`,
DELETE: (studentId) => `/api/students/${studentId}/`,
},
// 学科相关
SUBJECTS: {
LIST: '/api/subjects/',
CREATE: '/api/subjects/',
UPDATE: (subjectId) => `/api/subjects/${subjectId}/`,
DELETE: (subjectId) => `/api/subjects/${subjectId}/`,
},
// 学期相关
TERMS: {
LIST: '/api/terms/',
CREATE: '/api/terms/',
UPDATE: (termId) => `/api/terms/${termId}/`,
DELETE: (termId) => `/api/terms/${termId}/`,
},
// 系列任务(Story)
STORIES: {
LIST: '/api/stories/',
CREATE: '/api/stories/',
UPDATE: (storyId) => `/api/stories/${storyId}/`,
DELETE: (storyId) => `/api/stories/${storyId}/`,
},
// 作业任务(spec 0004:图片迁出到子资源)
TASKS: {
LIST: '/api/tasks/',
CREATE: '/api/tasks/',
UPDATE: (taskId) => `/api/tasks/${taskId}/`,
DELETE: (taskId) => `/api/tasks/${taskId}/`,
IMAGES: {
LIST: (taskId) => `/api/tasks/${taskId}/images/`,
CREATE: (taskId) => `/api/tasks/${taskId}/images/`,
DELETE: (taskId, imageId) => `/api/tasks/${taskId}/images/${imageId}/`,
CONTENT: (taskId, imageId) => `/api/tasks/${taskId}/images/${imageId}/content/`,
},
},
}
// Re-export for callers that still import `apiEndpoints` from here.
// Source of truth is `./endpoints.js`; this is just a convenience alias.
export { apiEndpoints }
export default apiClient
/**
* Tiny namespace + level logger for the API layer.
*
* Default level is `info` — matches the historical "log heavily" behavior of the
* fat services. Set `VITE_LOG_LEVEL=warn` (or `error` / `debug` / `silent`) in
* `.env` / `.env.local` to tune without rebuilding.
*
* Usage:
* import logger from '@/api/logger'
* logger.info('students loaded', count)
* logger.error('request failed', error)
*
* import { child } from '@/api/logger'
* const log = child('taskService')
* log.debug('fetching page', page)
*/
const LEVELS = { debug: 10, info: 20, warn: 30, error: 40, silent: 99 }
const envLevel =
(typeof import.meta !== 'undefined' && import.meta.env && import.meta.env.VITE_LOG_LEVEL) ||
'info'
const threshold = LEVELS[envLevel] ?? LEVELS.info
function fmt(ns, level, args) {
const ts = new Date().toISOString().slice(11, 23)
return [`[${ts}] [${level.toUpperCase()}] [${ns}]`, ...args]
}
function makeLogger(ns) {
return {
debug: (...args) => {
if (LEVELS.debug >= threshold) console.debug(...fmt(ns, 'debug', args))
},
info: (...args) => {
if (LEVELS.info >= threshold) console.info(...fmt(ns, 'info', args))
},
warn: (...args) => {
if (LEVELS.warn >= threshold) console.warn(...fmt(ns, 'warn', args))
},
error: (...args) => {
if (LEVELS.error >= threshold) console.error(...fmt(ns, 'error', args))
},
}
}
export function child(ns) {
return makeLogger(ns)
}
export default makeLogger('api')
import logger from './logger.js'
const MAX_PAGES = 100
const PAGE_SIZE = 100
/**
* Walk every page of a DRF paginated list endpoint and return a single flat
* array. Falls back to a raw array response for backward compat (some
* endpoints don't paginate), and stops on any other unexpected shape.
*
* @param {import('axios').AxiosInstance} client shared apiClient
* @param {string} url fully-qualified endpoint URL
* @param {Object} [params] extra query params (forwarded to every page)
* @returns {Promise<Array>}
*/
export async function paginatedGet(client, url, params = {}) {
const all = []
let page = 1
let hasNext = true
logger.debug(`paginatedGet start`, { url })
while (hasNext) {
if (page > MAX_PAGES) {
logger.warn(`paginatedGet hit ${MAX_PAGES}-page cap, stopping`, { url })
break
}
const pageParams = { ...params, page, page_size: PAGE_SIZE }
const response = await client.get(url, { params: pageParams })
const data = response.data
if (data && Array.isArray(data.results)) {
all.push(...data.results)
hasNext = !!data.next
logger.debug(
`paginatedGet page ${page}: +${data.results.length} (total ${all.length}, hasNext=${hasNext})`,
{ url },
)
} else if (Array.isArray(data)) {
all.push(...data)
hasNext = false
logger.debug(`paginatedGet got non-paginated array of ${data.length}`, { url })
} else {
logger.warn(`paginatedGet unexpected response shape, stopping`, { url, page })
hasNext = false
}
page++
}
logger.debug(`paginatedGet done`, { url, total: all.length })
return all
}
import apiClient from './index.js'
import { apiEndpoints as E } from './endpoints.js'
import { paginatedGet } from './paginate.js'
import { apiCall } from './apiError.js'
// storyService passes story data through unchanged; tracked_flag is
// authored by the view as 'Y'/'N' string and the backend accepts either.
/**
* 通用的获取所有分页数据的函数
* @param {Function} apiCall - API调用函数
* @param {Object} params - 查询参数
* @returns {Promise<Array>} 所有页面的数据数组
* Drop null/undefined keys so an empty UI filter doesn't turn into
* `?student_id=undefined` over the wire.
*/
const fetchAllPages = async (apiCall, params = {}) => {
let allData = []
let page = 1
let hasNext = true
console.log('Starting to fetch all pages for API call...')
while (hasNext) {
try {
const currentParams = { ...params, page, page_size: 100 } // 每页100条,减少请求次数
console.log(`Fetching page ${page} with params:`, currentParams)
const response = await apiCall(currentParams)
if (response && response.results) {
// 分页格式响应
allData = [...allData, ...response.results]
hasNext = !!response.next
console.log(`Page ${page}: Got ${response.results.length} items, total so far: ${allData.length}, hasNext: ${hasNext}`)
} else if (Array.isArray(response)) {
// 非分页格式响应(向后兼容)
allData = response
hasNext = false
console.log('Non-paginated response detected, got all data at once:', allData.length, 'items')
} else {
console.log('Unexpected response format, stopping pagination')
hasNext = false
}
page++
// 安全检查:防止无限循环
if (page > 100) {
console.warn('Reached maximum page limit (100), stopping pagination')
break
}
} catch (error) {
console.error(`Error fetching page ${page}:`, error)
throw error
}
const cleanParams = (params) => {
const out = {}
for (const [k, v] of Object.entries(params || {})) {
if (v !== null && v !== undefined) out[k] = v
}
console.log(`Finished fetching all pages. Total items: ${allData.length}`)
return allData
return out
}
// API端点定义
const apiEndpoints = {
STORIES: {
LIST: '/api/stories/',
DETAIL: (storyId) => `/api/stories/${storyId}/`,
UPDATE: (storyId) => `/api/stories/${storyId}/`,
DELETE: (storyId) => `/api/stories/${storyId}/`
}
}
/**
* 单页API调用函数(内部使用)
* @param {Object} params - 查询参数(包含分页参数)
* @returns {Promise} API响应
*/
const getStoriesPage = async (params = {}) => {
const response = await apiClient.get(apiEndpoints.STORIES.LIST, { params })
return response.data
}
/**
* 获取所有系列任务
* @param {Object} params - 查询参数
* @returns {Promise} 系列任务列表(完整数据,已处理分页)
*/
export const getStories = async (params = {}) => {
try {
// 构建查询参数,过滤掉null和undefined值
const queryParams = {}
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
queryParams[key] = value
}
})
console.log('Stories API request params:', queryParams)
// 使用通用分页函数获取所有数据
const allStories = await fetchAllPages(getStoriesPage, queryParams)
console.log('Stories API final result:', {
totalCount: allStories.length,
sampleData: allStories.slice(0, 3) // 显示前3条数据作为样例
})
// Get all stories with optional filters
export const getStories = (params = {}) =>
paginatedGet(apiClient, E.STORIES.LIST, cleanParams(params))
return allStories
} catch (error) {
console.error('Failed to fetch stories:', error)
throw error
}
}
// Get a single story by id
export const getStoryById = (storyId) =>
apiCall('GET', E.STORIES.DETAIL(storyId), null, 'Failed to fetch story detail')
/**
* 根据ID获取系列任务
* @param {number} storyId - 系列任务ID
* @returns {Promise} 系列任务详情
*/
export const getStoryById = async (storyId) => {
try {
const response = await apiClient.get(apiEndpoints.STORIES.DETAIL(storyId))
console.log('Story detail API response:', response.data)
return response.data
} catch (error) {
console.error('Failed to fetch story detail:', error)
// 提供更详细的错误信息
if (error.response) {
// 服务器响应了错误状态码
throw new Error(`获取系列任务详情失败: ${error.response.status} - ${error.response.statusText}`)
} else if (error.request) {
// 请求已发出但没有收到响应
throw new Error('网络错误: 无法连接到服务器')
} else {
// 其他错误
throw new Error(`请求配置错误: ${error.message}`)
}
}
}
// Create story
export const createStory = (data) =>
apiCall('POST', E.STORIES.LIST, { data }, 'Failed to create story')
/**
* 创建新的系列任务
* @param {Object} storyData - 系列任务数据
* @returns {Promise} 创建的系列任务
*/
export const createStory = async (storyData) => {
try {
const response = await apiClient.post(apiEndpoints.STORIES.LIST, storyData)
console.log('Create story API response:', response.data)
return response.data
} catch (error) {
console.error('Failed to create story:', error)
// 提供更详细的错误信息
if (error.response) {
// 服务器响应了错误状态码
throw new Error(`创建系列任务失败: ${error.response.status} - ${error.response.statusText}`)
} else if (error.request) {
// 请求已发出但没有收到响应
throw new Error('网络错误: 无法连接到服务器')
} else {
// 其他错误
throw new Error(`请求配置错误: ${error.message}`)
}
}
}
// Update story
export const updateStory = (storyId, data) =>
apiCall('PATCH', E.STORIES.UPDATE(storyId), { data }, 'Failed to update story')
/**
* 更新系列任务
* @param {number} storyId - 系列任务ID
* @param {Object} storyData - 更新的系列任务数据
* @returns {Promise} 更新后的系列任务
*/
export const updateStory = async (storyId, storyData) => {
try {
const response = await apiClient.patch(apiEndpoints.STORIES.UPDATE(storyId), storyData)
console.log('Update story API response:', response.data)
return response.data
} catch (error) {
console.error('Failed to update story:', error)
// 提供更详细的错误信息
if (error.response) {
// 服务器响应了错误状态码
throw new Error(`更新系列任务失败: ${error.response.status} - ${error.response.statusText}`)
} else if (error.request) {
// 请求已发出但没有收到响应
throw new Error('网络错误: 无法连接到服务器')
} else {
// 其他错误
throw new Error(`请求配置错误: ${error.message}`)
}
}
}
/**
* 删除系列任务
* @param {number} storyId - 系列任务ID
* @returns {Promise} 删除结果
*/
export const deleteStory = async (storyId) => {
try {
const response = await apiClient.delete(apiEndpoints.STORIES.DELETE(storyId))
console.log('Delete story API response:', response.data)
return response.data
} catch (error) {
console.error('Failed to delete story:', error)
// 提供更详细的错误信息
if (error.response) {
// 服务器响应了错误状态码
throw new Error(`删除系列任务失败: ${error.response.status} - ${error.response.statusText}`)
} else if (error.request) {
// 请求已发出但没有收到响应
throw new Error('网络错误: 无法连接到服务器')
} else {
// 其他错误
throw new Error(`请求配置错误: ${error.message}`)
}
}
}
// Delete story
export const deleteStory = (storyId) =>
apiCall('DELETE', E.STORIES.DELETE(storyId), null, 'Failed to delete story')
import apiClient, { apiEndpoints } from './index.js'
import apiClient from './index.js'
import { apiEndpoints as E } from './endpoints.js'
import { paginatedGet } from './paginate.js'
import { apiCall } from './apiError.js'
import { toApiBool } from './boolCodec.js'
/**
* 通用的获取所有分页数据的函数
* @param {Function} apiCall - API调用函数
* @param {Object} params - 查询参数
* @returns {Promise<Array>} 所有页面的数据数组
*/
const fetchAllPages = async (apiCall, params = {}) => {
let allData = []
let page = 1
let hasNext = true
console.log('Starting to fetch all pages for API call...')
while (hasNext) {
try {
const currentParams = { ...params, page, page_size: 100 } // 每页100条,减少请求次数
console.log(`Fetching page ${page} with params:`, currentParams)
const response = await apiCall(currentParams)
if (response && response.results) {
// 分页格式响应
allData = [...allData, ...response.results]
hasNext = !!response.next
console.log(`Page ${page}: Got ${response.results.length} items, total so far: ${allData.length}, hasNext: ${hasNext}`)
} else if (Array.isArray(response)) {
// 非分页格式响应(向后兼容)
allData = response
hasNext = false
console.log('Non-paginated response detected, got all data at once:', allData.length, 'items')
} else {
console.log('Unexpected response format, stopping pagination')
hasNext = false
}
page++
// 安全检查:防止无限循环
if (page > 100) {
console.warn('Reached maximum page limit (100), stopping pagination')
break
}
} catch (error) {
console.error(`Error fetching page ${page}:`, error)
throw error
}
}
console.log(`Finished fetching all pages. Total items: ${allData.length}`)
return allData
}
// Get student list
export const getStudents = () => paginatedGet(apiClient, E.STUDENTS.LIST)
/**
* 单页API调用函数(内部使用)
* @param {Object} params - 查询参数(包含分页参数)
* @returns {Promise} API响应
* Map a frontend student record to the backend's StdtStudentWrite shape.
* Spec: avatar / slider_icon are uploaded together with their mime_type
* and file_name; if any of (file, mime_type) is missing, the icon is dropped.
*/
const getStudentsPage = async (params = {}) => {
const response = await apiClient.get(apiEndpoints.STUDENTS.LIST, { params })
return response.data
}
// Get student list
export const getStudents = async () => {
try {
console.log('Fetching all students with pagination support...')
// 使用通用分页函数获取所有数据
const allStudents = await fetchAllPages(getStudentsPage)
console.log('Students API final result:', {
totalCount: allStudents.length,
sampleData: allStudents.slice(0, 3) // 显示前3条数据作为样例
})
return allStudents
} catch (error) {
console.error('Failed to fetch students:', error)
throw new Error(error?.response?.data?.detail || error?.message || 'Failed to fetch students')
}
}
// Update student information
export const updateStudent = async (studentId, studentData) => {
try {
// Convert data format to match API requirements
const apiData = {
student_name: studentData.student_name || '',
enabled: studentData.enabled ? 'Y' : 'N',
grade: studentData.grade || '',
age: parseInt(studentData.age) || 0,
}
// If avatar data is included, add avatar-related fields
if (studentData.avatar && studentData.avatar_mime_type) {
apiData.avatar_base64 = studentData.avatar
apiData.avatar_mime_type = studentData.avatar_mime_type
apiData.avatar_file_name = studentData.avatar_file_name || ''
}
// If slider_icon data is included, add slider_icon-related fields
if (studentData.slider_icon && studentData.slider_icon_mime_type) {
apiData.slider_icon_base64 = studentData.slider_icon
apiData.slider_icon_mime_type = studentData.slider_icon_mime_type
apiData.slider_icon_file_name = studentData.slider_icon_file_name || ''
}
console.log('Sending update request:', {
studentId,
url: apiEndpoints.STUDENTS.UPDATE(studentId),
data: {
...apiData,
avatar_base64: apiData.avatar_base64 ? '[base64 data]' : undefined,
slider_icon_base64: apiData.slider_icon_base64 ? '[base64 data]' : undefined
} // Don't print full base64
})
const response = await apiClient.patch(apiEndpoints.STUDENTS.UPDATE(studentId), apiData)
return response.data
} catch (error) {
console.error('API error details:', {
status: error?.response?.status,
statusText: error?.response?.statusText,
data: error?.response?.data,
headers: error?.response?.headers
})
const errorMessage = error?.response?.data?.detail ||
error?.response?.data?.message ||
error?.response?.data?.error ||
`HTTP ${error?.response?.status}: ${error?.response?.statusText}` ||
error.message ||
'Failed to update student'
throw new Error(errorMessage)
}
const buildPayload = (s) => {
const payload = {
student_name: s.student_name || '',
enabled: toApiBool(s.enabled ?? true),
grade: s.grade || '',
age: parseInt(s.age) || 0,
}
if (s.avatar && s.avatar_mime_type) {
payload.avatar_base64 = s.avatar
payload.avatar_mime_type = s.avatar_mime_type
payload.avatar_file_name = s.avatar_file_name || ''
}
if (s.slider_icon && s.slider_icon_mime_type) {
payload.slider_icon_base64 = s.slider_icon
payload.slider_icon_mime_type = s.slider_icon_mime_type
payload.slider_icon_file_name = s.slider_icon_file_name || ''
}
return payload
}
// Create student
export const createStudent = async (studentData) => {
try {
// Convert data format to match API requirements
const apiData = {
student_name: studentData.student_name || '',
enabled: studentData.enabled ? 'Y' : 'N',
grade: studentData.grade || '',
age: parseInt(studentData.age) || 0,
}
// If avatar data is included, add avatar-related fields
if (studentData.avatar && studentData.avatar_mime_type) {
apiData.avatar_base64 = studentData.avatar
apiData.avatar_mime_type = studentData.avatar_mime_type
apiData.avatar_file_name = studentData.avatar_file_name || ''
}
export const createStudent = (data) =>
apiCall('POST', E.STUDENTS.CREATE, { data: buildPayload(data) }, 'Failed to create student')
// If slider_icon data is included, add slider_icon-related fields
if (studentData.slider_icon && studentData.slider_icon_mime_type) {
apiData.slider_icon_base64 = studentData.slider_icon
apiData.slider_icon_mime_type = studentData.slider_icon_mime_type
apiData.slider_icon_file_name = studentData.slider_icon_file_name || ''
}
console.log('Sending create request:', {
url: apiEndpoints.STUDENTS.CREATE,
data: {
...apiData,
avatar_base64: apiData.avatar_base64 ? '[base64 data]' : undefined,
slider_icon_base64: apiData.slider_icon_base64 ? '[base64 data]' : undefined
}
})
const response = await apiClient.post(apiEndpoints.STUDENTS.CREATE, apiData)
return response.data
} catch (error) {
console.error('API error details:', {
status: error?.response?.status,
statusText: error?.response?.statusText,
data: error?.response?.data,
headers: error?.response?.headers
})
const errorMessage = error?.response?.data?.detail ||
error?.response?.data?.message ||
error?.response?.data?.error ||
`HTTP ${error?.response?.status}: ${error?.response?.statusText}` ||
error.message ||
'Failed to create student'
throw new Error(errorMessage)
}
}
// Update student information
export const updateStudent = (studentId, data) =>
apiCall(
'PATCH',
E.STUDENTS.UPDATE(studentId),
{ data: buildPayload(data) },
'Failed to update student',
)
// Delete student
export const deleteStudent = async (studentId) => {
try {
console.log('Sending delete request:', {
studentId,
url: apiEndpoints.STUDENTS.DELETE(studentId)
})
const response = await apiClient.delete(apiEndpoints.STUDENTS.DELETE(studentId))
return response.data
} catch (error) {
console.error('API error details:', {
status: error?.response?.status,
statusText: error?.response?.statusText,
data: error?.response?.data,
headers: error?.response?.headers
})
const errorMessage = error?.response?.data?.detail ||
error?.response?.data?.message ||
error?.response?.data?.error ||
`HTTP ${error?.response?.status}: ${error?.response?.statusText}` ||
error.message ||
'Failed to delete student'
throw new Error(errorMessage)
}
}
export const deleteStudent = (studentId) =>
apiCall('DELETE', E.STUDENTS.DELETE(studentId), null, 'Failed to delete student')
import apiClient, { apiEndpoints } from './index.js'
/**
* 通用的获取所有分页数据的函数
* @param {Function} apiCall - API调用函数
* @param {Object} params - 查询参数
* @returns {Promise<Array>} 所有页面的数据数组
*/
const fetchAllPages = async (apiCall, params = {}) => {
let allData = []
let page = 1
let hasNext = true
console.log('Starting to fetch all pages for API call...')
while (hasNext) {
try {
const currentParams = { ...params, page, page_size: 100 } // 每页100条,减少请求次数
console.log(`Fetching page ${page} with params:`, currentParams)
const response = await apiCall(currentParams)
if (response && response.results) {
// 分页格式响应
allData = [...allData, ...response.results]
hasNext = !!response.next
console.log(`Page ${page}: Got ${response.results.length} items, total so far: ${allData.length}, hasNext: ${hasNext}`)
} else if (Array.isArray(response)) {
// 非分页格式响应(向后兼容)
allData = response
hasNext = false
console.log('Non-paginated response detected, got all data at once:', allData.length, 'items')
} else {
console.log('Unexpected response format, stopping pagination')
hasNext = false
}
page++
// 安全检查:防止无限循环
if (page > 100) {
console.warn('Reached maximum page limit (100), stopping pagination')
break
}
} catch (error) {
console.error(`Error fetching page ${page}:`, error)
throw error
}
}
console.log(`Finished fetching all pages. Total items: ${allData.length}`)
return allData
}
/**
* 单页API调用函数(内部使用)
* @param {Object} params - 查询参数(包含分页参数)
* @returns {Promise} API响应
*/
const getSubjectsPage = async (params = {}) => {
const response = await apiClient.get(apiEndpoints.SUBJECTS.LIST, { params })
return response.data
}
import apiClient from './index.js'
import { apiEndpoints as E } from './endpoints.js'
import { paginatedGet } from './paginate.js'
import { apiCall } from './apiError.js'
import { toApiBool } from './boolCodec.js'
// Get subject list
export const getSubjects = async () => {
try {
console.log('Fetching all subjects with pagination support...')
// 使用通用分页函数获取所有数据
const allSubjects = await fetchAllPages(getSubjectsPage)
console.log('Subjects API final result:', {
totalCount: allSubjects.length,
sampleData: allSubjects.slice(0, 3) // 显示前3条数据作为样例
})
export const getSubjects = () => paginatedGet(apiClient, E.SUBJECTS.LIST)
return allSubjects
} catch (error) {
console.error('Failed to fetch subjects:', error)
throw new Error(error?.response?.data?.detail || error?.message || 'Failed to fetch subjects')
}
}
// Update subject information
export const updateSubject = async (subjectId, subjectData) => {
try {
// Convert data format to match API requirements
const apiData = {
subject_name: subjectData.subject_name || '',
enabled_flag: subjectData.enabled_flag || 'Y',
primary_flag: subjectData.primary_flag || 'N',
calendar_color: subjectData.calendar_color || '',
sort_sequence: parseInt(subjectData.sort_sequence) || 0,
tenant_id: subjectData.tenant_id || 1,
}
console.log('Sending update request:', {
subjectId,
url: apiEndpoints.SUBJECTS.UPDATE(subjectId),
data: apiData
})
const response = await apiClient.patch(apiEndpoints.SUBJECTS.UPDATE(subjectId), apiData)
return response.data
} catch (error) {
console.error('API error details:', {
status: error?.response?.status,
statusText: error?.response?.statusText,
data: error?.response?.data,
headers: error?.response?.headers
})
const errorMessage = error?.response?.data?.detail ||
error?.response?.data?.message ||
error?.response?.data?.error ||
`HTTP ${error?.response?.status}: ${error?.response?.statusText}` ||
error.message ||
'Failed to update subject'
throw new Error(errorMessage)
}
}
const buildPayload = (s) => ({
subject_name: s.subject_name || '',
enabled_flag: toApiBool(s.enabled_flag ?? 'Y'),
primary_flag: toApiBool(s.primary_flag ?? 'N'),
calendar_color: s.calendar_color || '',
sort_sequence: parseInt(s.sort_sequence) || 0,
tenant_id: s.tenant_id || 1,
})
// Create subject
export const createSubject = async (subjectData) => {
try {
// Convert data format to match API requirements
const apiData = {
subject_name: subjectData.subject_name || '',
enabled_flag: subjectData.enabled_flag || 'Y',
primary_flag: subjectData.primary_flag || 'N',
calendar_color: subjectData.calendar_color || '',
sort_sequence: parseInt(subjectData.sort_sequence) || 0,
tenant_id: subjectData.tenant_id || 1,
}
export const createSubject = (data) =>
apiCall('POST', E.SUBJECTS.CREATE, { data: buildPayload(data) }, 'Failed to create subject')
console.log('Sending create request:', {
url: apiEndpoints.SUBJECTS.CREATE,
data: apiData
})
const response = await apiClient.post(apiEndpoints.SUBJECTS.CREATE, apiData)
return response.data
} catch (error) {
console.error('API error details:', {
status: error?.response?.status,
statusText: error?.response?.statusText,
data: error?.response?.data,
headers: error?.response?.headers
})
const errorMessage = error?.response?.data?.detail ||
error?.response?.data?.message ||
error?.response?.data?.error ||
`HTTP ${error?.response?.status}: ${error?.response?.statusText}` ||
error.message ||
'Failed to create subject'
throw new Error(errorMessage)
}
}
// Update subject information
export const updateSubject = (subjectId, data) =>
apiCall(
'PATCH',
E.SUBJECTS.UPDATE(subjectId),
{ data: buildPayload(data) },
'Failed to update subject',
)
// Delete subject
export const deleteSubject = async (subjectId) => {
try {
console.log('Sending delete request:', {
subjectId,
url: apiEndpoints.SUBJECTS.DELETE(subjectId)
})
const response = await apiClient.delete(apiEndpoints.SUBJECTS.DELETE(subjectId))
return response.data
} catch (error) {
console.error('API error details:', {
status: error?.response?.status,
statusText: error?.response?.statusText,
data: error?.response?.data,
headers: error?.response?.headers
})
const errorMessage = error?.response?.data?.detail ||
error?.response?.data?.message ||
error?.response?.data?.error ||
`HTTP ${error?.response?.status}: ${error?.response?.statusText}` ||
error.message ||
'Failed to delete subject'
throw new Error(errorMessage)
}
}
export const deleteSubject = (subjectId) =>
apiCall('DELETE', E.SUBJECTS.DELETE(subjectId), null, 'Failed to delete subject')
import apiClient from './index.js'
import { apiEndpoints as E } from './endpoints.js'
import { paginatedGet } from './paginate.js'
import { apiCall } from './apiError.js'
/**
* 通用的获取所有分页数据的函数
* @param {Function} apiCall - API调用函数
* @param {Object} params - 查询参数
* @returns {Promise<Array>} 所有页面的数据数组
* Drop null/undefined keys so an empty UI filter doesn't turn into
* `?student_id=undefined` over the wire.
*/
const fetchAllPages = async (apiCall, params = {}) => {
let allData = []
let page = 1
let hasNext = true
console.log('Starting to fetch all pages for API call...')
while (hasNext) {
try {
const currentParams = { ...params, page, page_size: 100 }
console.log(`Fetching page ${page} with params:`, currentParams)
const response = await apiCall(currentParams)
if (response && response.results) {
allData = [...allData, ...response.results]
hasNext = !!response.next
console.log(`Page ${page}: Got ${response.results.length} items, total so far: ${allData.length}, hasNext: ${hasNext}`)
} else if (Array.isArray(response)) {
allData = response
hasNext = false
console.log('Non-paginated response detected, got all data at once:', allData.length, 'items')
} else {
console.log('Unexpected response format, stopping pagination')
hasNext = false
}
page++
if (page > 100) {
console.warn('Reached maximum page limit (100), stopping pagination')
break
}
} catch (error) {
console.error(`Error fetching page ${page}:`, error)
throw error
}
}
console.log(`Finished fetching all pages. Total items: ${allData.length}`)
return allData
}
/**
* 列出某 task 的所有图片元数据(spec 0004 子资源)。响应按 DRF 标准分页
* { count, next, previous, results },这里把结果数组拉平返回。
* @param {number} taskId
* @param {Object} [params]
* @returns {Promise<Array>} 图片元数据数组(每项含 id / mime_type / file_name / key / etag / size_bytes / 审计字段)
*/
export const listTaskImages = async (taskId, params = {}) => {
const listPage = async (p) => {
const response = await apiClient.get(`/api/tasks/${taskId}/images/`, { params: p })
return response.data
const cleanParams = (params) => {
const out = {}
for (const [k, v] of Object.entries(params || {})) {
if (v !== null && v !== undefined) out[k] = v
}
return fetchAllPages(listPage, params)
}
/**
* 创建 task 图片(spec 0004 子资源)。body 走 StdtTaskImagesWrite schema:
* 必填 image_base64,可选 mime_type/file_name。后端在 POST 时校验 ≤ 20 张软上限。
* @param {number} taskId
* @param {{mime_type?: string, file_name?: string, image_base64: string}} payload
* @returns {Promise<Object>} StdtTaskImagesWrite 响应(含新分配的 id)
*/
export const createTaskImage = async (taskId, payload) => {
const response = await apiClient.post(`/api/tasks/${taskId}/images/`, payload)
console.log(`Task image created for task ${taskId}:`, response.data)
return response.data
}
/**
* 删除 task 图片(spec 0004 子资源)。DELETE 是软删——后端只翻 deleted_flag,
* 不会真删 row;行还在表里、COS 上对象也不动,前端通过 refetch 自然看不到。
* @param {number} taskId
* @param {number} imageId
*/
export const deleteTaskImage = async (taskId, imageId) => {
const response = await apiClient.delete(`/api/tasks/${taskId}/images/${imageId}/`)
console.log(`Task image ${imageId} for task ${taskId} deleted`)
return response.data
return out
}
/**
* 获取 task 图片的二进制内容(spec 0004 子资源 /content/)。
* 必须 responseType: 'blob' + Accept 通配,否则 DRF 默认 JSON renderer 返 406。
* @param {number} taskId
* @param {number} imageId
* @returns {Promise} 解析为 Blob
*/
export const getTaskImageContent = async (taskId, imageId) => {
const response = await apiClient.get(
`/api/tasks/${taskId}/images/${imageId}/content/`,
{
responseType: 'blob',
headers: { Accept: '*/*' },
}
)
console.log(`Task image ${imageId} content for task ${taskId} fetched`)
return response.data
}
/* ---------- task CRUD ---------- */
/**
* 单页API调用函数(内部使用)
*/
const getTasksPage = async (params = {}) => {
const response = await apiClient.get('/api/tasks/', { params })
return response.data
}
// Get all tasks with optional filters
export const getTasks = (params = {}) => paginatedGet(apiClient, E.TASKS.LIST, cleanParams(params))
/**
* 获取所有作业任务
* @param {Object} params - 查询参数(student_id / subject_id / term_id / story_id)
* @returns {Promise<Array>} 全部任务的扁平数组(已处理分页)
*/
export const getTasks = async (params = {}) => {
try {
const queryParams = {}
if (params.student_id) queryParams.student_id = params.student_id
if (params.subject_id) queryParams.subject_id = params.subject_id
if (params.term_id) queryParams.term_id = params.term_id
if (params.story_id) queryParams.story_id = params.story_id
// Get task by id
export const getTaskById = (taskId) =>
apiCall('GET', E.TASKS.DETAIL(taskId), null, 'Failed to fetch task detail')
console.log('Tasks API request params:', queryParams)
// Create task (spec 0004: image_* fields removed; new images go through the sub-resource)
export const createTask = (data) => apiCall('POST', E.TASKS.LIST, { data }, 'Failed to create task')
const allTasks = await fetchAllPages(getTasksPage, queryParams)
// Update task
export const updateTask = (taskId, data) =>
apiCall('PATCH', E.TASKS.UPDATE(taskId), { data }, 'Failed to update task')
console.log('Tasks API final result:', {
totalCount: allTasks.length,
sampleData: allTasks.slice(0, 3)
})
// Delete task (spec 0004: soft-delete only; image rows are not cascade-soft-deleted)
export const deleteTask = (taskId) =>
apiCall('DELETE', E.TASKS.DELETE(taskId), null, 'Failed to delete task')
return allTasks
} catch (error) {
console.error('Failed to fetch tasks:', error)
throw error
}
}
/* ---------- task image sub-resource (spec 0004) ---------- */
/**
* 根据ID获取作业任务
* List every image for a task (paginated; results array is flattened).
* Each entry: id / mime_type / file_name / key / etag / size_bytes / audit fields.
*/
export const getTaskById = async (taskId) => {
try {
const response = await apiClient.get(`/api/tasks/${taskId}/`)
console.log('Task detail API response:', response.data)
return response.data
} catch (error) {
console.error('Failed to fetch task detail:', error)
throw error
}
}
export const listTaskImages = (taskId, params = {}) =>
paginatedGet(apiClient, E.TASKS.IMAGES.LIST(taskId), cleanParams(params))
/**
* 创建新的作业任务
* @param {Object} taskData 任务字段。spec 0004 后不再含 image_XX,新图片走
* /api/tasks/{id}/images/ 子资源。
* Create a task image. Body shape: StdtTaskImagesWrite —
* required `image_base64`, optional `mime_type` / `file_name`. The backend
* enforces a soft cap of 20 images per task at POST time.
*/
export const createTask = async (taskData) => {
try {
const response = await apiClient.post('/api/tasks/', taskData)
console.log('Create task API response:', response.data)
return response.data
} catch (error) {
console.error('Failed to create task:', error)
throw error
}
}
export const createTaskImage = (taskId, payload) =>
apiCall('POST', E.TASKS.IMAGES.CREATE(taskId), { data: payload }, 'Failed to create task image')
/**
* 更新作业任务
* @param {number} taskId
* @param {Object} taskData 任务字段。spec 0004 后不再含 image_XX / delete_image_XX;
* 改图、删图走 image 子资源。
* Delete a task image. Soft-delete only — the row stays in the table and the
* COS object is not removed; a refetch just hides it.
*/
export const updateTask = async (taskId, taskData) => {
try {
const response = await apiClient.patch(`/api/tasks/${taskId}/`, taskData)
console.log('Update task API response:', response.data)
return response.data
} catch (error) {
console.error('Failed to update task:', error)
throw error
}
}
export const deleteTaskImage = (taskId, imageId) =>
apiCall('DELETE', E.TASKS.IMAGES.DELETE(taskId, imageId), null, 'Failed to delete task image')
/**
* 删除作业任务。spec 0004 / ADR-0004:软删,图片 row 不会被级联软删。
* Fetch a task image's binary content (spec 0004 /content/ endpoint).
* Must override `responseType: 'blob'` and force a wildcard Accept header;
* without them DRF's JSON renderer rejects with 406.
*
* Returns a Blob.
*/
export const deleteTask = async (taskId) => {
try {
const response = await apiClient.delete(`/api/tasks/${taskId}/`)
console.log('Delete task API response:', response.data)
return response.data
} catch (error) {
console.error('Failed to delete task:', error)
throw error
}
}
export const getTaskImageContent = (taskId, imageId) =>
apiCall(
'GET',
E.TASKS.IMAGES.CONTENT(taskId, imageId),
{ config: { responseType: 'blob', headers: { Accept: '*/*' } } },
'Failed to fetch task image content',
)
import apiClient, { apiEndpoints } from './index.js'
import apiClient from './index.js'
import { apiEndpoints as E } from './endpoints.js'
import { paginatedGet } from './paginate.js'
import { apiCall } from './apiError.js'
/**
* 通用的获取所有分页数据的函数
* @param {Function} apiCall - API调用函数
* @param {Object} params - 查询参数
* @returns {Promise<Array>} 所有页面的数据数组
*/
const fetchAllPages = async (apiCall, params = {}) => {
let allData = []
let page = 1
let hasNext = true
// Get term list
export const getTerms = () => paginatedGet(apiClient, E.TERMS.LIST)
console.log('Starting to fetch all pages for API call...')
// Create term
export const createTerm = (data) =>
apiCall('POST', E.TERMS.CREATE, { data }, 'Failed to create term')
while (hasNext) {
try {
const currentParams = { ...params, page, page_size: 100 } // 每页100条,减少请求次数
console.log(`Fetching page ${page} with params:`, currentParams)
// Update term
export const updateTerm = (termId, data) =>
apiCall('PATCH', E.TERMS.UPDATE(termId), { data }, 'Failed to update term')
const response = await apiCall(currentParams)
if (response && response.results) {
// 分页格式响应
allData = [...allData, ...response.results]
hasNext = !!response.next
console.log(`Page ${page}: Got ${response.results.length} items, total so far: ${allData.length}, hasNext: ${hasNext}`)
} else if (Array.isArray(response)) {
// 非分页格式响应(向后兼容)
allData = response
hasNext = false
console.log('Non-paginated response detected, got all data at once:', allData.length, 'items')
} else {
console.log('Unexpected response format, stopping pagination')
hasNext = false
}
page++
// 安全检查:防止无限循环
if (page > 100) {
console.warn('Reached maximum page limit (100), stopping pagination')
break
}
} catch (error) {
console.error(`Error fetching page ${page}:`, error)
throw error
}
}
console.log(`Finished fetching all pages. Total items: ${allData.length}`)
return allData
}
/**
* 单页API调用函数(内部使用)
* @param {Object} params - 查询参数(包含分页参数)
* @returns {Promise} API响应
*/
const getTermsPage = async (params = {}) => {
const response = await apiClient.get(apiEndpoints.TERMS.LIST, { params })
return response.data
}
export const getTerms = async () => {
try {
console.log('Fetching all terms with pagination support...')
// 使用通用分页函数获取所有数据
const allTerms = await fetchAllPages(getTermsPage)
console.log('Terms API final result:', {
totalCount: allTerms.length,
sampleData: allTerms.slice(0, 3) // 显示前3条数据作为样例
})
return allTerms
} catch (error) {
console.error('Failed to fetch terms:', error)
throw new Error(error?.response?.data?.detail || error?.message || 'Failed to fetch terms')
}
}
export const createTerm = async (termData) => {
try {
const response = await apiClient.post(apiEndpoints.TERMS.CREATE, termData)
return response.data
} catch (error) {
throw new Error(error?.response?.data?.message || 'Failed to create term')
}
}
export const updateTerm = async (termId, termData) => {
try {
const response = await apiClient.patch(apiEndpoints.TERMS.UPDATE.replace('{termId}', termId), termData)
return response.data
} catch (error) {
throw new Error(error?.response?.data?.message || 'Failed to update term')
}
}
export const deleteTerm = async (termId) => {
try {
const response = await apiClient.delete(apiEndpoints.TERMS.DELETE.replace('{termId}', termId))
return response.data
} catch (error) {
throw new Error(error?.response?.data?.message || 'Failed to delete term')
}
}
// Delete term
export const deleteTerm = (termId) =>
apiCall('DELETE', E.TERMS.DELETE(termId), null, 'Failed to delete term')
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment