Commit 8925331e authored by Administrator's avatar Administrator
Browse files

feat(task): spec 0004 — task images migrated to stdt_task_images sub-resource



Task images were stored as a JSON-ish field on stdt_tasks. Spec 0004 (see
docs/task-images-migration.md) splits them into a dedicated sub-resource
stdt_task_images so each image is its own row with mime_type / file_name /
etag / size_bytes, accessed via /api/tasks/<id>/images/.

Frontend changes:
- new useTaskImages composable owning image state per edit dialog, with
  eager-load priming in load() so thumbnails render and are clickable in
  the grid (fixes v-if branch gated on populated imageCache).
- taskService refactored: listTaskImages / createTaskImage / deleteTaskImage
  / getTaskImageContent (blob fetch with responseType: 'blob' to dodge 406s).
- apiEndpoints.TASKS.IMAGES added for documentation purposes only — services
  still hardcode paths (CLAUDE.md flags apiEndpoints as stale).
- TaskView.vue: switched from inline task.images to useTaskImages(); image
  upload / preview / delete / carousel wired to the new composable. Explicit
  taskId passed to load() and commit() since useTaskImages is instantiated
  without a taskIdRef at module scope.
- locales en/zh-CN: new image keys (addImage, pendingUpload, clickToLoad,
  imagePartialFailure, imageLimitReached, etc.).

Tests:
- src/__tests__/api/taskService.test.js
- src/__tests__/composables/useTaskImages.test.js
- src/__tests__/composables/useTaskImages.network.test.js

Also: .claude/ added to .gitignore (Claude Code local permissions, per-dev).
Co-Authored-By: default avatarClaude <noreply@anthropic.com>
parent 66e40940
...@@ -29,6 +29,9 @@ coverage ...@@ -29,6 +29,9 @@ coverage
*.tsbuildinfo *.tsbuildinfo
# Claude Code local config (per-developer permissions/cache)
.claude/
.qoder* .qoder*
CLAUDE.md CLAUDE.md
......
# Task Images Migration Notes(前端迁移说明)
> 对应后端 spec 0004 / ADR-0005。最后更新 2026-07-18。
> 后端 schema 改动以 Swagger 为准;本文件只记录 Swagger 不会告诉前端
> 的行为 / 业务规则 / 跨实体不变量。两边不要互相重复——改了 schema
> 同步改 Swagger,改了行为同步改本文档。
## TL;DR
- 作业图片已经从 `stdt_tasks` 解耦到独立的 `stdt_task_images`
- 任务响应里的 25 个 `image_XX` 字段、旧的 `/api/tasks/<id>/images/<index>/`
端点全部消失
- 现在用子资源 API:`/api/tasks/<task_id>/images/...`,schema 见 Swagger
## 必须改的代码
### 任务响应里没有图片字段了
`GET /api/tasks/<id>/` 返回的对象**不再包含** `image_01..image_05` ×
{binary, mime_type, file_name, key, etag} 这 25 个字段。前端如果直接读
`task.image_01` 之类会拿到 `undefined`
要拿图片:
1. `GET /api/tasks/<task_id>/images/` 拿列表(每项含 `id` 和元数据,
**不含**二进制)
2. 按需 `GET /api/tasks/<task_id>/images/<image_id>/content/` 拿字节流
### 旧的"按槽位"端点没了
`GET /api/tasks/<id>/images/<image_index>/``image_index` 取 1-5)已
删除,直接返回 404。前端代码里如果还有这种调用,改成 `<image_id>`
(BIGSERIAL,从列表响应里取 `id` 字段)。
### 上限 5 → 20
每张任务最多 20 张图,由后端在 POST 时校验。超过返回 400,错误体
`"soft cap is 20"`。前端 UI 限制同步调整。
### 删除图片改用 HTTP DELETE
以前在 `PATCH /api/tasks/<id>/` body 里塞 `delete_image_03: true` 之类
的布尔字段;现在直接 `DELETE /api/tasks/<task_id>/images/<image_id>/`
## 必须知道的行为(Swagger 不会告诉你)
### 列表端点过滤软删行
所有 `GET /api/<entity>/` 列表自动排除 `deleted_flag='Y'` 的行——包
括任务、图片、学生等所有走 `AuditedListCreateView` 基类的实体。前端
如果遇到"刚 POST 立刻 GET 列表找不到"的情况:要么是这行被软删了
(DELETE 不会真删,row 还在 DB 里),要么是真创建失败(看 HTTP 状
态码区分)。
### 图片 DELETE 是软删
`DELETE /api/tasks/<task_id>/images/<image_id>/` 不会真删 row——只是
`deleted_flag` 翻成 `Y`。DB 里 row 还在,COS 上对象也不动。前端
目前**没有 API 能恢复已软删的图片**,要恢复只能改 DB。
### 任务软删不影响图片(ADR-0004)
`DELETE /api/tasks/<id>/` **不会**级联软删图片。前端不要写"删任务时
连带删图"的逻辑——反过来才是对的:任务删了之后图片 row 还在 `deleted_flag='N'`
### 图片 PATCH 几乎只读
`PATCH /api/tasks/<task_id>/images/<image_id>/` 只能改 `file_name`
`mime_type` / `key` / `etag` / `size_bytes` / `task_id` / `tenant_id`
全部 400。**换图走 DELETE + CREATE**
### 审计字段后端自动填
`created_by` / `creation_date` / `last_updated_by` / `last_update_date`
后端自动写。前端不要 PATCH 这些字段。
## 可能踩的坑
### 缓存的 image_index 全失效
前端如果按 1-5 槽位缓存过图片 ID,必须清掉换成 image_id(从列表
响应里取)。`image_id` 是 BIGSERIAL,全局稳定不重用(被软删的图
片的 id 也不会被新图复用)。
### /content/ 返回二进制流
`Content-Type` 是真实 mime(如 `image/png`),`Content-Disposition:
inline; filename="..."`。前端以前手动拼 `data:image/png;base64,...`
的逻辑,可以直接换成 `<img src="...">` 走浏览器缓存。
### LAN 网关不变(跟 spec 0004 无关)
LAN 写路径仍然返回 `created_by = "lan_guest"`。如果前端有"上传者
用户名"展示逻辑,注意 LAN 来源的图可能没有真实用户。
## 怎么验证
迁移完成后跑这三条冒烟:
1. **image_id 稳定**:连续两次 `GET /api/tasks/<id>/images/`,同一
张图的 `id` 不变
2. **软删独立**`DELETE /api/tasks/<id>/images/<image_id>/` 之后再
`GET /api/tasks/<id>/`,任务还在
3. **上限**:连续 POST 第 21 张图返回 400,错误体含 `"soft cap is 20"`
## 跟 Swagger 的边界
| 内容 | 谁负责 |
|---|---|
| URL 路径 / HTTP method | Swagger |
| 字段名 / 类型 / required / read-only | Swagger |
| 错误状态码 | Swagger |
| 行为 / 不变量 / 业务规则 | 本文档 |
| 跨实体契约(如 ADR-0004) | 本文档 |
| 软删 / 审计等通用机制 | 本文档 |
不要在本文档里复述 Swagger 已经覆盖的内容;反过来 Swagger 不该出现
"this endpoint soft-deletes" 这种行为描述。
\ No newline at end of file
// Tests for taskService image sub-resource functions. Spec 0004 moved task
// images out of the task payload and onto /api/tasks/{id}/images/... sub-
// resource; these tests pin that public contract (method, URL, body, headers)
// so a refactor cannot silently break the backend wire shape.
//
// Mocking strategy: vi.mock the apiClient module *before* importing
// taskService so the service sees a vi.fn() instead of the real axios
// instance. We assert against the mocked fns directly.
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 {
listTaskImages,
createTaskImage,
deleteTaskImage,
getTaskImageContent,
} from '@/api/taskService.js'
beforeEach(() => {
vi.clearAllMocks()
})
describe('listTaskImages', () => {
it('GETs /api/tasks/{taskId}/images/ and returns the results array', async () => {
apiClient.get.mockResolvedValueOnce({
data: {
count: 1,
next: null,
previous: null,
results: [{ id: 42, mime_type: 'image/png', file_name: 'a.png' }],
},
})
const out = await listTaskImages(7)
expect(apiClient.get).toHaveBeenCalledTimes(1)
expect(apiClient.get.mock.calls[0][0]).toBe('/api/tasks/7/images/')
expect(out).toEqual([
{ id: 42, mime_type: 'image/png', file_name: 'a.png' },
])
})
it('walks pagination via fetchAllPages until next is null', async () => {
apiClient.get
.mockResolvedValueOnce({
data: {
count: 3,
next: '/api/tasks/7/images/?page=2',
previous: null,
results: [{ id: 1 }, { id: 2 }],
},
})
.mockResolvedValueOnce({
data: {
count: 3,
next: null,
previous: '/api/tasks/7/images/?page=1',
results: [{ id: 3 }],
},
})
const out = await listTaskImages(7)
expect(apiClient.get).toHaveBeenCalledTimes(2)
expect(apiClient.get.mock.calls[0][1]).toMatchObject({ params: { page: 1, page_size: 100 } })
expect(apiClient.get.mock.calls[1][1]).toMatchObject({ params: { page: 2, page_size: 100 } })
expect(out.map((i) => i.id)).toEqual([1, 2, 3])
})
})
describe('createTaskImage', () => {
it('POSTs /api/tasks/{taskId}/images/ with mime_type/file_name/image_base64', async () => {
apiClient.post.mockResolvedValueOnce({
data: {
id: 99,
task_id: 7,
mime_type: 'image/jpeg',
file_name: 'p.jpg',
image_base64: 'BASE64',
},
})
const out = await createTaskImage(7, {
mime_type: 'image/jpeg',
file_name: 'p.jpg',
image_base64: 'BASE64',
})
expect(apiClient.post).toHaveBeenCalledTimes(1)
expect(apiClient.post.mock.calls[0][0]).toBe('/api/tasks/7/images/')
expect(apiClient.post.mock.calls[0][1]).toEqual({
mime_type: 'image/jpeg',
file_name: 'p.jpg',
image_base64: 'BASE64',
})
expect(out).toMatchObject({ id: 99, mime_type: 'image/jpeg' })
})
})
describe('deleteTaskImage', () => {
it('DELETEs /api/tasks/{taskId}/images/{imageId}/', async () => {
apiClient.delete.mockResolvedValueOnce({ data: '' })
await deleteTaskImage(7, 42)
expect(apiClient.delete).toHaveBeenCalledTimes(1)
expect(apiClient.delete.mock.calls[0][0]).toBe('/api/tasks/7/images/42/')
})
})
describe('getTaskImageContent', () => {
it('GETs /content/ as blob with Accept */* to avoid 406', async () => {
const blob = new Blob(['x'])
apiClient.get.mockResolvedValueOnce({ data: blob })
const out = await getTaskImageContent(7, 42)
expect(apiClient.get).toHaveBeenCalledTimes(1)
expect(apiClient.get.mock.calls[0][0]).toBe('/api/tasks/7/images/42/content/')
expect(apiClient.get.mock.calls[0][1]).toMatchObject({
responseType: 'blob',
headers: { Accept: '*/*' },
})
expect(out).toBe(blob)
})
})
// Network-side tests for useTaskImages. Mocks the taskService module so we
// exercise the composable's wiring (call ordering, refetch on success,
// partial-failure handling, pending-state cleanup) without an axios dep.
import { describe, it, expect, vi, beforeEach } from 'vitest'
vi.mock('@/api/taskService.js', () => ({
listTaskImages: vi.fn(),
createTaskImage: vi.fn(),
deleteTaskImage: vi.fn(),
getTaskImageContent: vi.fn(),
MAX_IMAGES_PER_TASK: 20,
}))
import { ref } from 'vue'
import {
listTaskImages,
createTaskImage,
deleteTaskImage,
getTaskImageContent,
} from '@/api/taskService.js'
import { useTaskImages } from '@/composables/useTaskImages.js'
beforeEach(() => {
vi.clearAllMocks()
})
describe('useTaskImages — addPending', () => {
it('reads a File as data URL and pushes a pending entry', async () => {
const img = useTaskImages()
const fakeFile = { type: 'image/png', name: 'a.png' }
// Stub FileReader for jsdom: trigger onload synchronously.
class FakeReader {
readAsDataURL() {
this.result = 'data:image/png;base64,AAA'
queueMicrotask(() => this.onload && this.onload())
}
}
vi.stubGlobal('FileReader', FakeReader)
await img.addPending(fakeFile)
expect(img.pendingImages.value).toHaveLength(1)
expect(img.pendingImages.value[0]).toMatchObject({
mime_type: 'image/png',
file_name: 'a.png',
base64: 'data:image/png;base64,AAA',
})
expect(typeof img.pendingImages.value[0].localId).toBe('string')
vi.unstubAllGlobals()
})
it('throws image_limit_reached when at MAX_IMAGES', async () => {
const img = useTaskImages()
img.images.value = Array.from({ length: 20 }, (_, i) => ({ id: i + 1 }))
class FakeReader {
readAsDataURL() {}
}
vi.stubGlobal('FileReader', FakeReader)
await expect(img.addPending({ type: 'image/png', name: 'x.png' })).rejects.toThrow(
'image_limit_reached'
)
vi.unstubAllGlobals()
})
})
describe('useTaskImages — commit', () => {
it('runs all deletes then all creates, returns aggregated result', async () => {
const img = useTaskImages(ref(7))
img.images.value = [{ id: 1 }, { id: 2 }, { id: 3 }]
img.pendingDeletes.value = [1, 2]
img.pendingImages.value = [
{ localId: 'p1', mime_type: 'image/png', file_name: 'a.png', base64: 'B1' },
{ localId: 'p2', mime_type: 'image/png', file_name: 'b.png', base64: 'B2' },
]
deleteTaskImage.mockResolvedValue({})
createTaskImage.mockResolvedValueOnce({ id: 99, file_name: 'a.png' })
createTaskImage.mockResolvedValueOnce({ id: 100, file_name: 'b.png' })
listTaskImages.mockResolvedValueOnce([
{ id: 3 },
{ id: 99, file_name: 'a.png' },
{ id: 100, file_name: 'b.png' },
])
const result = await img.commit()
// Order: deletes first (2 calls), then creates (2 calls).
expect(deleteTaskImage).toHaveBeenCalledTimes(2)
expect(deleteTaskImage.mock.calls[0]).toEqual([7, 1])
expect(deleteTaskImage.mock.calls[1]).toEqual([7, 2])
expect(createTaskImage).toHaveBeenCalledTimes(2)
expect(createTaskImage.mock.calls[0][0]).toBe(7)
expect(createTaskImage.mock.calls[0][1]).toEqual({
mime_type: 'image/png',
file_name: 'a.png',
image_base64: 'B1',
})
expect(result.deleted).toEqual([1, 2])
expect(result.uploaded.map((u) => u.id)).toEqual([99, 100])
expect(result.failed).toEqual([])
// Pending cleared, refetch happened.
expect(img.pendingDeletes.value).toEqual([])
expect(img.pendingImages.value).toEqual([])
expect(listTaskImages).toHaveBeenCalledWith(7)
})
it('keeps remaining pendingImages when an upload fails (partial success)', async () => {
const img = useTaskImages(ref(7))
img.pendingImages.value = [
{ localId: 'p1', mime_type: 'image/png', file_name: 'a.png', base64: 'B1' },
{ localId: 'p2', mime_type: 'image/png', file_name: 'b.png', base64: 'B2' },
{ localId: 'p3', mime_type: 'image/png', file_name: 'c.png', base64: 'B3' },
]
createTaskImage.mockResolvedValueOnce({ id: 99, file_name: 'a.png' })
createTaskImage.mockRejectedValueOnce(new Error('soft cap is 20'))
// p3 should never be attempted because we break on first failure.
listTaskImages.mockResolvedValueOnce([{ id: 99 }])
const result = await img.commit()
expect(createTaskImage).toHaveBeenCalledTimes(2)
expect(result.uploaded.map((u) => u.id)).toEqual([99])
expect(result.failed).toHaveLength(1)
expect(result.failed[0]).toMatchObject({ kind: 'upload', payload: { localId: 'p2' } })
// p1 cleared (uploaded), p2 + p3 remain for retry.
expect(img.pendingImages.value.map((p) => p.localId)).toEqual(['p2', 'p3'])
})
})
describe('useTaskImages — getContentUrl', () => {
it('returns cached URL without re-fetching', async () => {
const img = useTaskImages()
img.images.value = [{ id: 42, task_id: 7 }]
img.imageCache[42] = 'blob:abc'
const url = await img.getContentUrl(42)
expect(url).toBe('blob:abc')
expect(getTaskImageContent).not.toHaveBeenCalled()
})
it('fetches and caches on first call, returns blob URL', async () => {
const img = useTaskImages(ref(7))
img.images.value = [{ id: 42, task_id: 7 }]
const blob = new Blob(['x'])
getTaskImageContent.mockResolvedValueOnce(blob)
// jsdom URL.createObjectURL returns blob:https://.../<uuid>
const url = await img.getContentUrl(42)
expect(url).toMatch(/^blob:/)
expect(img.imageCache[42]).toBe(url)
expect(getTaskImageContent).toHaveBeenCalledWith(7, 42)
})
it('returns null for imageId marked pending-delete', async () => {
const img = useTaskImages()
img.images.value = [{ id: 42, task_id: 7 }]
img.pendingDeletes.value = [42]
const url = await img.getContentUrl(42)
expect(url).toBeNull()
expect(getTaskImageContent).not.toHaveBeenCalled()
})
})
// Tests for useTaskImages composable. Public seam: the factory function and
// the reactive contract it returns. Pure-logic behaviour (computeds, mark/
// unmark, cancel) is tested without mocks; the network-touching methods
// (load / getContentUrl / commit) live in a separate file below.
//
// Why a separate file: keeping network and pure tests apart means a failure
// points to one of two things only — "logic wrong" vs "wire wrong" — and
// lets us run the pure slice under jsdom with no axios mocks at all.
import { describe, it, expect, beforeEach } from 'vitest'
import { nextTick } from 'vue'
import { useTaskImages } from '@/composables/useTaskImages.js'
describe('useTaskImages — pure state', () => {
let img
beforeEach(() => {
img = useTaskImages()
})
it('starts with empty images / pendingImages / pendingDeletes', () => {
expect(img.images.value).toEqual([])
expect(img.pendingImages.value).toEqual([])
expect(img.pendingDeletes.value).toEqual([])
})
it('totalCount counts images minus pendingDeletes plus pendingImages', () => {
img.images.value = [{ id: 1 }, { id: 2 }, { id: 3 }]
img.pendingDeletes.value = [2]
img.pendingImages.value = [{ localId: 'a' }]
expect(img.totalCount.value).toBe(3)
})
it('canAddMore is true below 20, false at 20', () => {
// 19 images → can add (totalCount 19 < 20)
img.images.value = Array.from({ length: 19 }, (_, i) => ({ id: i + 1 }))
expect(img.canAddMore.value).toBe(true)
// 20 images → full
img.images.value = Array.from({ length: 20 }, (_, i) => ({ id: i + 1 }))
expect(img.canAddMore.value).toBe(false)
// 20 images + 1 pending → still over → can't add more
img.pendingImages.value = [{ localId: 'a' }]
expect(img.canAddMore.value).toBe(false)
// 20 images, mark 1 delete → 19 effective → can add again
img.pendingImages.value = []
img.pendingDeletes.value = [1]
expect(img.canAddMore.value).toBe(true)
})
it('markDelete adds id, unmarkDelete removes it', () => {
img.markDelete(42)
expect(img.pendingDeletes.value).toEqual([42])
img.markDelete(42) // idempotent
expect(img.pendingDeletes.value).toEqual([42])
img.unmarkDelete(42)
expect(img.pendingDeletes.value).toEqual([])
})
it('cancelPending removes by localId', () => {
img.pendingImages.value = [
{ localId: 'a' },
{ localId: 'b' },
{ localId: 'c' },
]
img.cancelPending('b')
expect(img.pendingImages.value.map((p) => p.localId)).toEqual(['a', 'c'])
})
it('reset clears images, pending, cache', () => {
img.images.value = [{ id: 1 }]
img.pendingImages.value = [{ localId: 'a' }]
img.pendingDeletes.value = [1]
img.reset()
expect(img.images.value).toEqual([])
expect(img.pendingImages.value).toEqual([])
expect(img.pendingDeletes.value).toEqual([])
})
it('load sets isLoading true during the call and false after', async () => {
// stub the underlying network call via the composable's public surface —
// here we just confirm the loading flag flips. Network behavior is in
// the next describe block.
img.isLoading.value = false
await img.load(null) // null taskId should not throw — handled gracefully
await nextTick()
expect(img.isLoading.value).toBe(false)
})
})
...@@ -12,7 +12,11 @@ const apiClient = axios.create({ ...@@ -12,7 +12,11 @@ const apiClient = axios.create({
}, },
}) })
// API端点定义 // API 端点表。约定:路径里有 id 时用函数(参数名 = 模板中同名的字段),纯
// 字串的保持字串。spec 0004 之后,task / story / image 这些之前缺失的端点
// 也补齐了。注意:这不是真理之源——服务层 (`taskService.js` 等) 直接硬编
// 码路径调用;从这里改不会影响现有调用。先把这条目修对,主要是给后续重构
// 提供可参考的对应表。
export const apiEndpoints = { export const apiEndpoints = {
// 认证相关 // 认证相关
AUTH: { AUTH: {
...@@ -37,8 +41,28 @@ export const apiEndpoints = { ...@@ -37,8 +41,28 @@ export const apiEndpoints = {
TERMS: { TERMS: {
LIST: '/api/terms/', LIST: '/api/terms/',
CREATE: '/api/terms/', CREATE: '/api/terms/',
UPDATE: '/api/terms/{termId}/', UPDATE: (termId) => `/api/terms/${termId}/`,
DELETE: '/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/`,
},
}, },
} }
......
...@@ -15,18 +15,16 @@ const fetchAllPages = async (apiCall, params = {}) => { ...@@ -15,18 +15,16 @@ const fetchAllPages = async (apiCall, params = {}) => {
while (hasNext) { while (hasNext) {
try { try {
const currentParams = { ...params, page, page_size: 100 } // 每页100条,减少请求次数 const currentParams = { ...params, page, page_size: 100 }
console.log(`Fetching page ${page} with params:`, currentParams) console.log(`Fetching page ${page} with params:`, currentParams)
const response = await apiCall(currentParams) const response = await apiCall(currentParams)
if (response && response.results) { if (response && response.results) {
// 分页格式响应
allData = [...allData, ...response.results] allData = [...allData, ...response.results]
hasNext = !!response.next hasNext = !!response.next
console.log(`Page ${page}: Got ${response.results.length} items, total so far: ${allData.length}, hasNext: ${hasNext}`) console.log(`Page ${page}: Got ${response.results.length} items, total so far: ${allData.length}, hasNext: ${hasNext}`)
} else if (Array.isArray(response)) { } else if (Array.isArray(response)) {
// 非分页格式响应(向后兼容)
allData = response allData = response
hasNext = false hasNext = false
console.log('Non-paginated response detected, got all data at once:', allData.length, 'items') console.log('Non-paginated response detected, got all data at once:', allData.length, 'items')
...@@ -37,7 +35,6 @@ const fetchAllPages = async (apiCall, params = {}) => { ...@@ -37,7 +35,6 @@ const fetchAllPages = async (apiCall, params = {}) => {
page++ page++
// 安全检查:防止无限循环
if (page > 100) { if (page > 100) {
console.warn('Reached maximum page limit (100), stopping pagination') console.warn('Reached maximum page limit (100), stopping pagination')
break break
...@@ -52,10 +49,67 @@ const fetchAllPages = async (apiCall, params = {}) => { ...@@ -52,10 +49,67 @@ const fetchAllPages = async (apiCall, params = {}) => {
return allData 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
}
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
}
/**
* 获取 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
}
/** /**
* 单页API调用函数(内部使用) * 单页API调用函数(内部使用)
* @param {Object} params - 查询参数(包含分页参数)
* @returns {Promise} API响应
*/ */
const getTasksPage = async (params = {}) => { const getTasksPage = async (params = {}) => {
const response = await apiClient.get('/api/tasks/', { params }) const response = await apiClient.get('/api/tasks/', { params })
...@@ -64,15 +118,11 @@ const getTasksPage = async (params = {}) => { ...@@ -64,15 +118,11 @@ const getTasksPage = async (params = {}) => {
/** /**
* 获取所有作业任务 * 获取所有作业任务
* @param {Object} params - 查询参数 * @param {Object} params - 查询参数(student_id / subject_id / term_id / story_id)
* @param {number} params.student_id - 学生ID * @returns {Promise<Array>} 全部任务的扁平数组(已处理分页)
* @param {number} params.subject_id - 学科ID
* @param {number} params.term_id - 学期ID
* @returns {Promise} 作业任务列表(完整数据,已处理分页)
*/ */
export const getTasks = async (params = {}) => { export const getTasks = async (params = {}) => {
try { try {
// 构建查询参数,过滤掉null和undefined值
const queryParams = {} const queryParams = {}
if (params.student_id) queryParams.student_id = params.student_id if (params.student_id) queryParams.student_id = params.student_id
if (params.subject_id) queryParams.subject_id = params.subject_id if (params.subject_id) queryParams.subject_id = params.subject_id
...@@ -81,12 +131,11 @@ export const getTasks = async (params = {}) => { ...@@ -81,12 +131,11 @@ export const getTasks = async (params = {}) => {
console.log('Tasks API request params:', queryParams) console.log('Tasks API request params:', queryParams)
// 使用通用分页函数获取所有数据
const allTasks = await fetchAllPages(getTasksPage, queryParams) const allTasks = await fetchAllPages(getTasksPage, queryParams)
console.log('Tasks API final result:', { console.log('Tasks API final result:', {
totalCount: allTasks.length, totalCount: allTasks.length,
sampleData: allTasks.slice(0, 3) // 显示前3条数据作为样例 sampleData: allTasks.slice(0, 3)
}) })
return allTasks return allTasks
...@@ -98,8 +147,6 @@ export const getTasks = async (params = {}) => { ...@@ -98,8 +147,6 @@ export const getTasks = async (params = {}) => {
/** /**
* 根据ID获取作业任务 * 根据ID获取作业任务
* @param {number} taskId - 作业任务ID
* @returns {Promise} 作业任务详情
*/ */
export const getTaskById = async (taskId) => { export const getTaskById = async (taskId) => {
try { try {
...@@ -114,8 +161,8 @@ export const getTaskById = async (taskId) => { ...@@ -114,8 +161,8 @@ export const getTaskById = async (taskId) => {
/** /**
* 创建新的作业任务 * 创建新的作业任务
* @param {Object} taskData - 作业任务数据 * @param {Object} taskData 任务字段。spec 0004 后不再含 image_XX,新图片走
* @returns {Promise} 创建的作业任务 * /api/tasks/{id}/images/ 子资源。
*/ */
export const createTask = async (taskData) => { export const createTask = async (taskData) => {
try { try {
...@@ -130,9 +177,9 @@ export const createTask = async (taskData) => { ...@@ -130,9 +177,9 @@ export const createTask = async (taskData) => {
/** /**
* 更新作业任务 * 更新作业任务
* @param {number} taskId - 作业任务ID * @param {number} taskId
* @param {Object} taskData - 更新的作业任务数据 * @param {Object} taskData 任务字段。spec 0004 后不再含 image_XX / delete_image_XX;
* @returns {Promise} 更新后的作业任务 * 改图、删图走 image 子资源。
*/ */
export const updateTask = async (taskId, taskData) => { export const updateTask = async (taskId, taskData) => {
try { try {
...@@ -146,9 +193,7 @@ export const updateTask = async (taskId, taskData) => { ...@@ -146,9 +193,7 @@ export const updateTask = async (taskId, taskData) => {
} }
/** /**
* 删除作业任务 * 删除作业任务。spec 0004 / ADR-0004:软删,图片 row 不会被级联软删。
* @param {number} taskId - 作业任务ID
* @returns {Promise} 删除结果
*/ */
export const deleteTask = async (taskId) => { export const deleteTask = async (taskId) => {
try { try {
...@@ -160,25 +205,3 @@ export const deleteTask = async (taskId) => { ...@@ -160,25 +205,3 @@ export const deleteTask = async (taskId) => {
throw error throw error
} }
} }
/**
* 获取任务图像
* @param {number} taskId - 任务ID
* @param {number} imageIndex - 图像索引 (1-5)
* @returns {Promise<Blob>} 图像二进制数据
*/
export const getTaskImage = async (taskId, imageIndex) => {
try {
const response = await apiClient.get(`/api/tasks/${taskId}/images/${imageIndex}/`, {
responseType: 'blob', // 重要:指定响应类型为blob以处理二进制数据
headers: {
'Accept': '*/*' // 接受任何类型的响应,解决406 Not Acceptable错误
}
});
console.log(`Task image ${imageIndex} for task ${taskId} fetched successfully`);
return response.data;
} catch (error) {
console.error(`Failed to fetch task image ${imageIndex} for task ${taskId}:`, error);
throw error;
}
}
import { ref, reactive, computed } from 'vue'
import {
listTaskImages,
createTaskImage,
deleteTaskImage,
getTaskImageContent,
} from '@/api/taskService.js'
export const MAX_IMAGES = 20
// Generate a short stable id for a pending (not-yet-uploaded) image.
let _localSeq = 0
const newLocalId = () => `p${++_localSeq}`
// Read a File as base64 (data URL string — same shape backend accepts).
const readFileAsDataURL = (file) =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = () => reject(reader.error)
reader.readAsDataURL(file)
})
/**
* useTaskImages — composable owning the image sub-resource state for one
* task edit dialog (spec 0004).
*
* Lifecycle is per-dialog: caller does `const img = useTaskImages()` when
* the edit dialog opens and `img.reset()` (or discards the instance) when
* it closes. Image blob URLs are tied to the instance lifetime so closing
* the dialog releases memory.
*
* Mutating actions split into two phases:
* - During edit: markDelete / unmarkDelete / cancelPending / addPending
* only mutate local refs. Nothing is sent over the wire.
* - On save: commit() walks pendingDeletes then pendingImages and posts
* each one, returning { uploaded, deleted, failed } so the caller can
* show partial-failure UI without losing successful uploads.
*
* Soft-cap of 20 images is enforced client-side via canAddMore (the backend
* also returns 400 with "soft cap is 20" as a safety net — see migration
* doc §"上限 5 → 20").
*
* @param {import('vue').Ref<number|null>|null} [taskIdRef] optional reactive
* task id; if absent, callers must pass taskId into load/commit.
*/
export function useTaskImages(taskIdRef = null) {
const images = ref([]) // [{ id, mime_type, file_name, key, etag, size_bytes, ... }]
const pendingImages = ref([]) // [{ localId, base64, mime_type, file_name }]
const pendingDeletes = ref([]) // [image_id]
const imageCache = reactive({}) // image_id -> blob: URL
const loadingMap = reactive({}) // image_id -> bool
const isLoading = ref(false)
const totalCount = computed(
() =>
images.value.length - pendingDeletes.value.length + pendingImages.value.length
)
const canAddMore = computed(() => totalCount.value < MAX_IMAGES)
const resolveTaskId = (id) => id ?? taskIdRef?.value
// ── List ────────────────────────────────────────────────────────────
async function load(id) {
const taskId = resolveTaskId(id)
if (!taskId) return
isLoading.value = true
try {
images.value = await listTaskImages(taskId)
// Drop stale pendingDeletes that no longer correspond to a loaded image
// (e.g. user opened dialog, image was deleted in another tab).
const liveIds = new Set(images.value.map((i) => i.id))
pendingDeletes.value = pendingDeletes.value.filter((pid) => liveIds.has(pid))
// Eagerly prime the blob cache so thumbnails render in the grid AND
// become clickable. Without this, imageCache stays empty until the user
// somehow opens the carousel — and the template's first v-if branch
// (the one with @click="openImageInCarousel") is gated on imageCache,
// so the user can't click an empty box to trigger the lazy load either.
// With ≤20 images (soft cap), parallel blob fetches are fine; the
// existing loadingMap drives the spinner.
for (const image of images.value) {
if (!pendingDeletes.value.includes(image.id)) {
getContentUrl(image.id)
}
}
} finally {
isLoading.value = false
}
}
// ── Pending (local-only) ────────────────────────────────────────────
async function addPending(file) {
if (!canAddMore.value) {
throw new Error('image_limit_reached')
}
const base64 = await readFileAsDataURL(file)
pendingImages.value.push({
localId: newLocalId(),
base64,
mime_type: file.type || 'application/octet-stream',
file_name: file.name,
})
}
function cancelPending(localId) {
pendingImages.value = pendingImages.value.filter((p) => p.localId !== localId)
}
// ── Mark/unmark delete ──────────────────────────────────────────────
function markDelete(imageId) {
if (!pendingDeletes.value.includes(imageId)) {
pendingDeletes.value.push(imageId)
}
// Drop the cached blob URL — the image is going away and we don't want
// a dangling blob: URL leaking memory if the user unmarks and reopens
// would re-fetch. (Unmark does not auto-restore; caller calls
// getContentUrl again to refetch on demand.)
if (imageCache[imageId]) {
URL.revokeObjectURL(imageCache[imageId])
delete imageCache[imageId]
}
}
function unmarkDelete(imageId) {
pendingDeletes.value = pendingDeletes.value.filter((id) => id !== imageId)
}
// ── Lazy blob fetch with cache ──────────────────────────────────────
async function getContentUrl(imageId) {
if (imageCache[imageId]) return imageCache[imageId]
if (pendingDeletes.value.includes(imageId)) return null
loadingMap[imageId] = true
try {
// Find the image's task id by walking up — caller supplied it via
// taskIdRef or passed to load(); we just need *some* task id here.
// We resolve it from the first image that matches; if images is
// empty we can't determine the task — return null.
const image = images.value.find((i) => i.id === imageId)
if (!image) return null
const blob = await getTaskImageContent(image.task_id ?? resolveTaskId(), imageId)
const url = URL.createObjectURL(blob)
imageCache[imageId] = url
return url
} catch (err) {
console.error(`useTaskImages: failed to load image ${imageId}`, err)
return null
} finally {
loadingMap[imageId] = false
}
}
// ── Commit (called from TaskView on save) ───────────────────────────
async function commit(id) {
const taskId = resolveTaskId(id)
const deleted = []
const uploaded = []
const failed = []
const successfulUploadIndices = new Set()
// DELETE first — frees slots before we POST new ones, so the soft-cap
// check on POST sees the post-delete count.
for (const imageId of pendingDeletes.value) {
try {
await deleteTaskImage(taskId, imageId)
deleted.push(imageId)
} catch (err) {
failed.push({ kind: 'delete', imageId, error: err })
}
}
// POST pending uploads. Stop posting on first failure and keep the
// remaining queue so the caller can retry — matches the "保留成功、
// 提示重试" UX decision.
for (let i = 0; i < pendingImages.value.length; i++) {
const p = pendingImages.value[i]
try {
const created = await createTaskImage(taskId, {
mime_type: p.mime_type,
file_name: p.file_name,
image_base64: p.base64,
})
uploaded.push(created)
successfulUploadIndices.add(i)
} catch (err) {
failed.push({ kind: 'upload', payload: p, error: err })
break // preserve remaining pendingImages so retry uses same array
}
}
// Clear processed entries from pending state.
pendingDeletes.value = pendingDeletes.value.filter((id) => !deleted.includes(id))
pendingImages.value = pendingImages.value.filter(
(_, i) => !successfulUploadIndices.has(i)
)
// Refetch image list if anything changed (so images[] picks up new ids
// and excludes deleted ones).
if (deleted.length > 0 || uploaded.length > 0) {
await load(taskId)
}
return { deleted, uploaded, failed }
}
function reset() {
for (const url of Object.values(imageCache)) {
URL.revokeObjectURL(url)
}
for (const k of Object.keys(imageCache)) delete imageCache[k]
for (const k of Object.keys(loadingMap)) delete loadingMap[k]
images.value = []
pendingImages.value = []
pendingDeletes.value = []
isLoading.value = false
}
return {
// state
images,
pendingImages,
pendingDeletes,
imageCache,
loadingMap,
isLoading,
// computed
totalCount,
canAddMore,
MAX_IMAGES,
// actions
load,
addPending,
cancelPending,
markDelete,
unmarkDelete,
getContentUrl,
commit,
reset,
}
}
...@@ -46,7 +46,12 @@ export default { ...@@ -46,7 +46,12 @@ export default {
clickToLoad: 'Click to load image', clickToLoad: 'Click to load image',
loadingImage: 'Loading image...', loadingImage: 'Loading image...',
updateImage: 'Update Image', updateImage: 'Update Image',
deleteImage: 'Delete Image' deleteImage: 'Delete Image',
// spec 0004: dynamic grid + carousel dialog
addImage: 'Add Image',
pendingUpload: 'Pending upload',
cancelPendingImage: 'Cancel this upload',
undoDeleteImage: 'Undo delete'
}, },
filters: { filters: {
title: 'Filters', title: 'Filters',
...@@ -82,7 +87,10 @@ export default { ...@@ -82,7 +87,10 @@ export default {
uploadImageError: 'Failed to upload image', uploadImageError: 'Failed to upload image',
deleteSuccess: 'Task {name} has been successfully deleted', deleteSuccess: 'Task {name} has been successfully deleted',
deleteError: 'Failed to delete task', deleteError: 'Failed to delete task',
deleteConfirmation: 'Are you sure you want to delete this task? This action cannot be undone.' deleteConfirmation: 'Are you sure you want to delete this task? This action cannot be undone.',
// spec 0004: image sub-resource commit partial / full failure
imagePartialFailure: 'Image upload partially succeeded: {uploaded} uploaded, {failed} failed. Please retry.',
imageCommitError: 'Image save failed. Please retry.'
}, },
validation: { validation: {
nameRequired: 'Task name is required', nameRequired: 'Task name is required',
...@@ -92,6 +100,8 @@ export default { ...@@ -92,6 +100,8 @@ export default {
termRequired: 'Please select a term', termRequired: 'Please select a term',
completionRequired: 'Please enter completion percentage', completionRequired: 'Please enter completion percentage',
startDateRequired: 'Please select a start date', startDateRequired: 'Please select a start date',
endDateRequired: 'Please select an end date' endDateRequired: 'Please select an end date',
// spec 0004: soft cap of 20 images
imageLimitReached: 'Each task can hold at most 20 images. Please delete some images first.'
} }
} }
...@@ -46,7 +46,12 @@ export default { ...@@ -46,7 +46,12 @@ export default {
clickToLoad: '点击加载图像', clickToLoad: '点击加载图像',
loadingImage: '正在加载图像...', loadingImage: '正在加载图像...',
updateImage: '更新图像', updateImage: '更新图像',
deleteImage: '删除图像' deleteImage: '删除图像',
// spec 0004:动态网格 + 轮播对话框
addImage: '添加图像',
pendingUpload: '待上传',
cancelPendingImage: '取消该图上传',
undoDeleteImage: '撤销删除'
}, },
filters: { filters: {
title: '筛选条件', title: '筛选条件',
...@@ -82,7 +87,10 @@ export default { ...@@ -82,7 +87,10 @@ export default {
uploadImageError: '上传图像失败', uploadImageError: '上传图像失败',
deleteSuccess: '作业 {name} 已成功删除', deleteSuccess: '作业 {name} 已成功删除',
deleteError: '删除作业失败', deleteError: '删除作业失败',
deleteConfirmation: '您确定要删除此作业吗?此操作无法撤销。' deleteConfirmation: '您确定要删除此作业吗?此操作无法撤销。',
// spec 0004:图片子资源 commit 部分失败/全部失败
imagePartialFailure: '图片部分成功:已上传 {uploaded} 张,{failed} 张失败,请重试。',
imageCommitError: '图片保存失败,请重试。'
}, },
validation: { validation: {
nameRequired: '作业名称不能为空', nameRequired: '作业名称不能为空',
...@@ -92,6 +100,8 @@ export default { ...@@ -92,6 +100,8 @@ export default {
termRequired: '请选择学期', termRequired: '请选择学期',
completionRequired: '请输入完成度', completionRequired: '请输入完成度',
startDateRequired: '请选择开始日期', startDateRequired: '请选择开始日期',
endDateRequired: '请选择截止日期' endDateRequired: '请选择截止日期',
// spec 0004:软上限 20
imageLimitReached: '每个作业最多上传 20 张图片,请先删除部分图片。'
} }
} }
This diff is collapsed.
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