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
*.tsbuildinfo
# Claude Code local config (per-developer permissions/cache)
.claude/
.qoder*
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({
},
})
// API端点定义
// API 端点表。约定:路径里有 id 时用函数(参数名 = 模板中同名的字段),纯
// 字串的保持字串。spec 0004 之后,task / story / image 这些之前缺失的端点
// 也补齐了。注意:这不是真理之源——服务层 (`taskService.js` 等) 直接硬编
// 码路径调用;从这里改不会影响现有调用。先把这条目修对,主要是给后续重构
// 提供可参考的对应表。
export const apiEndpoints = {
// 认证相关
AUTH: {
......@@ -37,8 +41,28 @@ export const apiEndpoints = {
TERMS: {
LIST: '/api/terms/',
CREATE: '/api/terms/',
UPDATE: '/api/terms/{termId}/',
DELETE: '/api/terms/{termId}/',
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/`,
},
},
}
......
......@@ -15,18 +15,16 @@ const fetchAllPages = async (apiCall, params = {}) => {
while (hasNext) {
try {
const currentParams = { ...params, page, page_size: 100 } // 每页100条,减少请求次数
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')
......@@ -37,7 +35,6 @@ const fetchAllPages = async (apiCall, params = {}) => {
page++
// 安全检查:防止无限循环
if (page > 100) {
console.warn('Reached maximum page limit (100), stopping pagination')
break
......@@ -52,10 +49,67 @@ const fetchAllPages = async (apiCall, params = {}) => {
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调用函数(内部使用)
* @param {Object} params - 查询参数(包含分页参数)
* @returns {Promise} API响应
*/
const getTasksPage = async (params = {}) => {
const response = await apiClient.get('/api/tasks/', { params })
......@@ -64,15 +118,11 @@ const getTasksPage = async (params = {}) => {
/**
* 获取所有作业任务
* @param {Object} params - 查询参数
* @param {number} params.student_id - 学生ID
* @param {number} params.subject_id - 学科ID
* @param {number} params.term_id - 学期ID
* @returns {Promise} 作业任务列表(完整数据,已处理分页)
* @param {Object} params - 查询参数(student_id / subject_id / term_id / story_id)
* @returns {Promise<Array>} 全部任务的扁平数组(已处理分页)
*/
export const getTasks = async (params = {}) => {
try {
// 构建查询参数,过滤掉null和undefined值
const queryParams = {}
if (params.student_id) queryParams.student_id = params.student_id
if (params.subject_id) queryParams.subject_id = params.subject_id
......@@ -81,12 +131,11 @@ export const getTasks = async (params = {}) => {
console.log('Tasks API request params:', queryParams)
// 使用通用分页函数获取所有数据
const allTasks = await fetchAllPages(getTasksPage, queryParams)
console.log('Tasks API final result:', {
totalCount: allTasks.length,
sampleData: allTasks.slice(0, 3) // 显示前3条数据作为样例
sampleData: allTasks.slice(0, 3)
})
return allTasks
......@@ -98,8 +147,6 @@ export const getTasks = async (params = {}) => {
/**
* 根据ID获取作业任务
* @param {number} taskId - 作业任务ID
* @returns {Promise} 作业任务详情
*/
export const getTaskById = async (taskId) => {
try {
......@@ -114,8 +161,8 @@ export const getTaskById = async (taskId) => {
/**
* 创建新的作业任务
* @param {Object} taskData - 作业任务数据
* @returns {Promise} 创建的作业任务
* @param {Object} taskData 任务字段。spec 0004 后不再含 image_XX,新图片走
* /api/tasks/{id}/images/ 子资源。
*/
export const createTask = async (taskData) => {
try {
......@@ -130,9 +177,9 @@ export const createTask = async (taskData) => {
/**
* 更新作业任务
* @param {number} taskId - 作业任务ID
* @param {Object} taskData - 更新的作业任务数据
* @returns {Promise} 更新后的作业任务
* @param {number} taskId
* @param {Object} taskData 任务字段。spec 0004 后不再含 image_XX / delete_image_XX;
* 改图、删图走 image 子资源。
*/
export const updateTask = async (taskId, taskData) => {
try {
......@@ -146,9 +193,7 @@ export const updateTask = async (taskId, taskData) => {
}
/**
* 删除作业任务
* @param {number} taskId - 作业任务ID
* @returns {Promise} 删除结果
* 删除作业任务。spec 0004 / ADR-0004:软删,图片 row 不会被级联软删。
*/
export const deleteTask = async (taskId) => {
try {
......@@ -160,25 +205,3 @@ export const deleteTask = async (taskId) => {
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 {
clickToLoad: 'Click to load image',
loadingImage: 'Loading 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: {
title: 'Filters',
......@@ -82,7 +87,10 @@ export default {
uploadImageError: 'Failed to upload image',
deleteSuccess: 'Task {name} has been successfully deleted',
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: {
nameRequired: 'Task name is required',
......@@ -92,6 +100,8 @@ export default {
termRequired: 'Please select a term',
completionRequired: 'Please enter completion percentage',
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 {
clickToLoad: '点击加载图像',
loadingImage: '正在加载图像...',
updateImage: '更新图像',
deleteImage: '删除图像'
deleteImage: '删除图像',
// spec 0004:动态网格 + 轮播对话框
addImage: '添加图像',
pendingUpload: '待上传',
cancelPendingImage: '取消该图上传',
undoDeleteImage: '撤销删除'
},
filters: {
title: '筛选条件',
......@@ -82,7 +87,10 @@ export default {
uploadImageError: '上传图像失败',
deleteSuccess: '作业 {name} 已成功删除',
deleteError: '删除作业失败',
deleteConfirmation: '您确定要删除此作业吗?此操作无法撤销。'
deleteConfirmation: '您确定要删除此作业吗?此操作无法撤销。',
// spec 0004:图片子资源 commit 部分失败/全部失败
imagePartialFailure: '图片部分成功:已上传 {uploaded} 张,{failed} 张失败,请重试。',
imageCommitError: '图片保存失败,请重试。'
},
validation: {
nameRequired: '作业名称不能为空',
......@@ -92,6 +100,8 @@ export default {
termRequired: '请选择学期',
completionRequired: '请输入完成度',
startDateRequired: '请选择开始日期',
endDateRequired: '请选择截止日期'
endDateRequired: '请选择截止日期',
// spec 0004:软上限 20
imageLimitReached: '每个作业最多上传 20 张图片,请先删除部分图片。'
}
}
......@@ -330,71 +330,127 @@
</v-slider>
</v-col>
<!-- 图像显示区域 -->
<!-- 图像显示区域(spec 0004:动态网格,含 add 按钮和删除标记) -->
<v-col cols="12">
<v-divider class="my-4" />
<div class="text-h6 mb-4">{{ $t('task.task.images') }}</div>
<div class="d-flex align-center justify-space-between mb-4">
<div class="text-h6">{{ $t('task.task.images') }}</div>
<div class="text-caption text-medium-emphasis">
{{ img.totalCount.value }} / {{ img.MAX_IMAGES }}
</div>
</div>
</v-col>
<v-col cols="12" sm="6" md="4" v-for="index in 5" :key="index">
<!-- 已存在的图片:thumbnail + 删除切换 + 缩略图点击放大 -->
<v-col
v-for="image in img.images.value"
:key="`existing-${image.id}`"
cols="12"
sm="6"
md="4"
>
<div class="image-container">
<div class="image-label">{{ $t('task.task.image') }} {{ index }}</div>
<div class="image-label">{{ image.file_name || $t('task.task.image') }}</div>
<div
v-if="imageCache[index]"
v-if="!img.pendingDeletes.value.includes(image.id) && img.imageCache[image.id]"
class="image-preview clickable"
@click="openImageDialog(index)"
@click="openImageInCarousel(image.id)"
>
<img
:src="imageCache[index]"
:alt="`${$t('task.task.image')} ${index}`"
class="preview-image"
/>
<img :src="img.imageCache[image.id]" :alt="image.file_name" class="preview-image" />
</div>
<div v-if="imageCache[index]" class="image-actions-bottom">
<v-btn
icon="mdi-upload"
size="small"
color="primary"
variant="tonal"
@click.stop="uploadImage(index)"
:title="$t('task.task.updateImage')"
/>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="tonal"
@click.stop="deleteImage(index)"
:title="$t('task.task.deleteImage')"
/>
<div
v-else-if="img.pendingDeletes.value.includes(image.id)"
class="image-preview deleted"
>
<v-icon icon="mdi-eye-off" size="32" color="error" />
</div>
<div
v-else-if="imageLoading[index]"
v-else-if="img.loadingMap[image.id]"
class="image-preview loading"
>
<v-progress-circular
indeterminate
color="primary"
size="32"
width="3"
/>
<v-progress-circular indeterminate color="primary" size="32" width="3" />
</div>
<!-- 兜底:缩略图 fetch 失败时(imageCache 为空、loadingMap 也为空)
让用户点击重试 —— openImageInCarousel 会再调一次 getContentUrl。 -->
<div
v-else
class="image-preview empty"
class="image-preview empty clickable"
@click="openImageInCarousel(image.id)"
:title="$t('task.task.clickToLoad')"
>
<v-icon icon="mdi-image-off-outline" size="32" color="grey" />
</div>
<div v-if="!imageCache[index] && !imageLoading[index]" class="image-actions-bottom">
<div class="image-actions-bottom">
<v-btn
icon="mdi-upload"
:icon="img.pendingDeletes.value.includes(image.id) ? 'mdi-undo' : 'mdi-delete'"
size="small"
color="primary"
:color="img.pendingDeletes.value.includes(image.id) ? 'warning' : 'error'"
variant="tonal"
@click.stop="
img.pendingDeletes.value.includes(image.id)
? img.unmarkDelete(image.id)
: img.markDelete(image.id)
"
:title="
img.pendingDeletes.value.includes(image.id)
? $t('task.task.undoDeleteImage')
: $t('task.task.deleteImage')
"
/>
</div>
</div>
</v-col>
<!-- 待上传的预览(base64 显示) -->
<v-col
v-for="p in img.pendingImages.value"
:key="`pending-${p.localId}`"
cols="12"
sm="6"
md="4"
>
<div class="image-container">
<div class="image-label">{{ p.file_name }} <span class="text-caption text-warning">({{ $t('task.task.pendingUpload') }})</span></div>
<div class="image-preview clickable">
<img :src="p.base64" :alt="p.file_name" class="preview-image" />
</div>
<div class="image-actions-bottom">
<v-btn
icon="mdi-close"
size="small"
color="warning"
variant="tonal"
@click.stop="uploadImage(index)"
:title="$t('task.task.updateImage')"
@click.stop="img.cancelPending(p.localId)"
:title="$t('task.task.cancelPendingImage')"
/>
</div>
</div>
</v-col>
<!-- Add 按钮:上限以下可点,到顶禁用 -->
<v-col cols="12" sm="6" md="4">
<div class="image-container">
<div class="image-label">{{ $t('task.task.addImage') }}</div>
<div
class="image-preview empty clickable add-tile"
:class="{ disabled: !img.canAddMore.value }"
@click="img.canAddMore.value && triggerFilePicker()"
>
<v-icon
:icon="img.canAddMore.value ? 'mdi-plus' : 'mdi-lock'"
size="40"
:color="img.canAddMore.value ? 'primary' : 'grey'"
/>
</div>
</div>
<input
ref="filePickerRef"
type="file"
accept="image/*"
multiple
hidden
@change="onFileChange"
/>
</v-col>
</v-row>
</v-form>
......@@ -479,51 +535,48 @@
</v-card>
</v-dialog>
<!-- 图像放大查看对话框 -->
<v-dialog v-model="imageDialog" :max-width="$vuetify.display.smAndDown ? '100%' : 800">
<!-- 图像轮播对话框(spec 0004 —— 可看所有现存图片,prev/next 切图) -->
<v-dialog v-model="imageDialog" :max-width="$vuetify.display.smAndDown ? '100%' : 900">
<v-card>
<v-card-title>
<span>{{ $t('task.task.image') }} {{ currentImageIndex }}</span>
<v-card-title class="d-flex align-center">
<span>{{ currentImageName }}</span>
<v-spacer />
<v-btn
icon="mdi-close"
variant="text"
@click="closeImageDialog"
/>
<span class="text-caption text-medium-emphasis mr-2">
{{ carouselIndex + 1 }} / {{ carouselImages.length }}
</span>
<v-btn icon="mdi-close" variant="text" @click="closeImageDialog" />
</v-card-title>
<v-card-text class="d-flex justify-center align-center image-viewer-container" style="min-height: 500px;">
<div
v-if="currentImageSrc"
class="image-zoom-container"
@wheel.prevent="handleWheel"
@mousedown="startDrag"
@mousemove="doDrag"
@mouseup="stopDrag"
@mouseleave="stopDrag"
<v-card-text
class="d-flex justify-center align-center image-carousel-container"
@keydown.left.prevent="carouselPrev"
@keydown.right.prevent="carouselNext"
tabindex="0"
>
<img
:src="currentImageSrc"
:alt="`${$t('task.task.image')} ${currentImageIndex}`"
class="enlarged-image"
:style="{
transform: `translate(${imagePosition.x}px, ${imagePosition.y}px) scale(${zoomLevel})`,
cursor: isDragging ? 'grabbing' : 'grab'
}"
@dragstart.prevent
<v-btn
v-if="carouselImages.length > 1"
icon="mdi-chevron-left"
size="large"
variant="text"
class="carousel-nav-left"
@click="carouselPrev"
/>
<div class="image-carousel-stage">
<div v-if="carouselCurrentUrl" class="d-flex justify-center align-center h-100">
<img :src="carouselCurrentUrl" :alt="currentImageName" class="enlarged-image" />
</div>
<div v-else-if="currentImageIndex && !currentImageSrc" class="d-flex flex-column align-center">
<v-progress-circular
indeterminate
color="primary"
size="64"
width="6"
/>
<div v-else class="d-flex flex-column align-center">
<v-progress-circular indeterminate color="primary" size="64" width="6" />
<div class="mt-4">{{ $t('task.task.loadingImage') }}</div>
</div>
<div v-else class="text-center">
{{ $t('task.task.noImage') }}
</div>
<v-btn
v-if="carouselImages.length > 1"
icon="mdi-chevron-right"
size="large"
variant="text"
class="carousel-nav-right"
@click="carouselNext"
/>
</v-card-text>
</v-card>
</v-dialog>
......@@ -535,12 +588,13 @@ import { onMounted, ref, computed, watch, onUnmounted, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { useDisplay } from 'vuetify'
import { useAuthStore } from '@/stores/auth'
import { getTasks, getTaskImage, updateTask as updateTaskAPI, deleteTask } from '@/api/taskService.js'
import { getTasks, updateTask as updateTaskAPI, deleteTask } from '@/api/taskService.js'
import { getStudents } from '@/api/studentService.js'
import { getSubjects } from '@/api/subjectService.js'
import { getTerms } from '@/api/termService.js'
import { getStories } from '@/api/storyService.js'
import { COLOR_MAPPING } from '@/components/constants/colors'
import { useTaskImages } from '@/composables/useTaskImages.js'
import FullCalendar from '@fullcalendar/vue3'
import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
......@@ -555,6 +609,9 @@ defineOptions({
const { t, locale: i18nLocale } = useI18n()
const authStore = useAuthStore()
const { xs } = useDisplay()
// 图片子资源(spec 0004)—— 由 useTaskImages 统一管理:list/缓存/commit。
// 同一个 dialog 周期内复用,关闭时 reset。
const img = useTaskImages()
// FullCalendar 使用 'en' / 'zh-cn'(小写),与项目内部 'zh-CN' 略有差异
const fcLocale = computed(() => (i18nLocale.value === 'zh-CN' ? 'zh-cn' : 'en'))
......@@ -1111,23 +1168,9 @@ const editForm = ref({
term_id: null,
start_date: '',
end_date: '',
completion_percent: 0,
// 图像数据
image_01: null,
image_01_mime_type: null,
image_02: null,
image_02_mime_type: null,
image_03: null,
image_03_mime_type: null,
image_04: null,
image_04_mime_type: null,
image_05: null,
image_05_mime_type: null,
delete_image_01: false,
delete_image_02: false,
delete_image_03: false,
delete_image_04: false,
delete_image_05: false
completion_percent: 0
// 图片相关字段已全部迁出到 useTaskImages(spec 0004)。
// 新增 / 删除走 /api/tasks/{id}/images/ 子资源,不再随 PATCH 主任务带。
})
const isSaving = ref(false)
const editFormRef = ref(null)
......@@ -1184,56 +1227,35 @@ const completionSliderStyle = computed(() => {
const isCompletionSliderCustom = computed(() => Boolean(completionSliderStyle.value['--v-slider-thumb-image']))
// 图像放大查看相关状态
// 图像查看对话框(spec 0004 —— 轮播:prev/next 切图,箭头键也可)
const imageDialog = ref(false)
const currentImageIndex = ref(null)
const currentImageSrc = ref(null)
const zoomLevel = ref(1) // 缩放级别,1表示原始大小
const imagePosition = ref({ x: 0, y: 0 }) // 图像位置,用于拖拽平移
const isDragging = ref(false) // 是否正在拖拽
const dragStart = ref({ x: 0, y: 0 }) // 拖拽起始位置
// 图像加载状态管理
const imageLoading = ref({
1: false,
2: false,
3: false,
4: false,
5: false
const filePickerRef = ref(null)
// 轮播态:当前显示的索引 + 当前图片的缓存 URL
const carouselImages = computed(() => img.images.value)
const carouselIndex = ref(0)
const carouselCurrentUrl = ref(null)
// `editForm.value.image_id_of_index` 这类映射不需要了——
// `img.images.value` 已经按后端真实顺序展开。
const carouselCurrentId = computed(() => {
const list = carouselImages.value
if (!list.length) return null
const clamped = Math.max(0, Math.min(carouselIndex.value, list.length - 1))
return list[clamped]?.id ?? null
})
const imageCache = ref({
1: null,
2: null,
3: null,
4: null,
5: null
const currentImageName = computed(() => {
const list = carouselImages.value
if (!list.length) return ''
const item = list[carouselIndex.value]
return item?.file_name || t('task.task.image')
})
// 编辑功能方法
const openEditDialog = async (task) => {
// 设置为编辑模式
isCreateMode.value = false;
console.log('Setting isCreateMode to false for editing existing task');
// 清理图像缓存
Object.keys(imageCache.value).forEach(key => {
if (imageCache.value[key]) {
URL.revokeObjectURL(imageCache.value[key]);
imageCache.value[key] = null;
}
});
// 清理图片删除标记
for (let i = 1; i <= 5; i++) {
const imageKey = `image_0${i}`;
editForm.value[`delete_${imageKey}`] = false;
}
// 重置加载状态
Object.keys(imageLoading.value).forEach(key => {
imageLoading.value[key] = false;
});
isCreateMode.value = false
console.log('Setting isCreateMode to false for editing existing task')
editingTask.value = task
// 抑制 cascade watcher,避免加载时把 term/story 重置为 null
......@@ -1247,18 +1269,7 @@ const openEditDialog = async (task) => {
story_id: task.story_id || null,
start_date: task.start_date ? task.start_date.split('T')[0] : '',
end_date: task.end_date ? task.end_date.split('T')[0] : '',
completion_percent: task.completion_percent || 0,
// 图像数据
image_01: task.image_01 || null,
image_01_mime_type: task.image_01_mime_type || null,
image_02: task.image_02 || null,
image_02_mime_type: task.image_02_mime_type || null,
image_03: task.image_03 || null,
image_03_mime_type: task.image_03_mime_type || null,
image_04: task.image_04 || null,
image_04_mime_type: task.image_04_mime_type || null,
image_05: task.image_05 || null,
image_05_mime_type: task.image_05_mime_type || null
completion_percent: task.completion_percent || 0
}
console.log('Editing task data:', task)
console.log('Converted form data:', editForm.value)
......@@ -1266,99 +1277,74 @@ const openEditDialog = async (task) => {
suppressCascade.value = false
editDialog.value = true
// 对话框打开后,预加载所有图片
// spec 0004:图片走子资源。打开对话框时一次性 list 现有图片
if (task && task.task_id) {
// 使用setTimeout让对话框先显示出来,然后再加载图片
// 用 setTimeout 让对话框先显示出来,再 list 图片,避免首屏被 spinner 占满。
setTimeout(() => {
// 仅当该槽位确实有图时才去拉,否则会触发一堆 404 + 5 个 spinner 一起转的视觉噪音。
for (let i = 1; i <= 5; i++) {
const slotKey = `image_0${i}`
if (!editForm.value[slotKey]) continue
loadTaskImage(task.task_id, i).catch((error) => {
// 404 表示该槽位没有图,正常情况
if (error?.response?.status === 404) return
console.error(`Failed to preload image ${i}:`, error)
img.load(task.task_id).catch((err) => {
console.error('Failed to load task images:', err)
})
}
}, 100);
}, 100)
}
}
// 打开创建对话框
const openCreateDialog = async (date) => {
// 添加调试信息,记录传入的date参数
console.log('openCreateDialog called with date:', date);
console.log('date type:', typeof date);
console.log('openCreateDialog called with date:', date)
console.log('date type:', typeof date)
if (date) {
console.log('date properties:', Object.keys(date));
}
// 清理图像缓存
Object.keys(imageCache.value).forEach(key => {
if (imageCache.value[key]) {
URL.revokeObjectURL(imageCache.value[key]);
imageCache.value[key] = null;
}
});
// 清理图片删除标记
for (let i = 1; i <= 5; i++) {
const imageKey = `image_0${i}`;
editForm.value[`delete_${imageKey}`] = false;
console.log('date properties:', Object.keys(date))
}
// 重置加载状态
Object.keys(imageLoading.value).forEach(key => {
imageLoading.value[key] = false;
});
// 设置为创建模式
isCreateMode.value = true;
editingTask.value = null;
isCreateMode.value = true
editingTask.value = null
// 图片初始为空——新建任务时数据库里还没有 row,列表本来就该是 []。
img.reset()
// 初始化表单数据 - 增强对各种date参数格式的处理
let formattedDate;
let formattedDate
try {
// 处理不同格式的日期参数
if (date) {
if (date.date) {
// v-calendar可能传递{date: '2023-01-01'}格式
console.log('Using date.date property:', date.date);
formattedDate = new Date(date.date).toISOString().split('T')[0];
console.log('Using date.date property:', date.date)
formattedDate = new Date(date.date).toISOString().split('T')[0]
} else if (date instanceof Date) {
// 直接传递Date对象
console.log('Using Date object');
formattedDate = date.toISOString().split('T')[0];
console.log('Using Date object')
formattedDate = date.toISOString().split('T')[0]
} else if (typeof date === 'string') {
// 直接传递日期字符串
console.log('Using date string');
formattedDate = new Date(date).toISOString().split('T')[0];
console.log('Using date string')
formattedDate = new Date(date).toISOString().split('T')[0]
} else if (date.year && date.month && date.day) {
// v-calendar可能传递{year: 2023, month: 1, day: 1}格式
console.log('Using year/month/day properties');
formattedDate = new Date(date.year, date.month - 1, date.day).toISOString().split('T')[0];
console.log('Using year/month/day properties')
formattedDate = new Date(date.year, date.month - 1, date.day).toISOString().split('T')[0]
} else {
// 其他情况,尝试转换为日期
console.log('Attempting to convert unknown date format');
const dateObj = new Date(date);
console.log('Attempting to convert unknown date format')
const dateObj = new Date(date)
if (!isNaN(dateObj.getTime())) {
formattedDate = dateObj.toISOString().split('T')[0];
formattedDate = dateObj.toISOString().split('T')[0]
} else {
throw new Error('Invalid date format');
throw new Error('Invalid date format')
}
}
} else {
throw new Error('No date provided');
throw new Error('No date provided')
}
} catch (error) {
// 如果日期处理出错,使用当前日期
console.warn('Error processing date, using current date:', error);
formattedDate = new Date().toISOString().split('T')[0];
console.warn('Error processing date, using current date:', error)
formattedDate = new Date().toISOString().split('T')[0]
}
// 抑制 cascade watcher,避免加载时把预填的 term 重置为 null
suppressCascade.value = true;
suppressCascade.value = true
editForm.value = {
task_name: '',
task_description: '',
......@@ -1368,39 +1354,23 @@ const openCreateDialog = async (date) => {
story_id: null,
start_date: formattedDate,
end_date: formattedDate,
completion_percent: 0,
// 图像数据初始化为空
image_01: null,
image_01_mime_type: null,
image_02: null,
image_02_mime_type: null,
image_03: null,
image_03_mime_type: null,
image_04: null,
image_04_mime_type: null,
image_05: null,
image_05_mime_type: null,
delete_image_01: false,
delete_image_02: false,
delete_image_03: false,
delete_image_04: false,
delete_image_05: false
};
console.log('Creating new task with date:', formattedDate);
await nextTick();
suppressCascade.value = false;
editDialog.value = true;
completion_percent: 0
}
console.log('Creating new task with date:', formattedDate)
await nextTick()
suppressCascade.value = false
editDialog.value = true
}
const closeEditDialog = () => {
editDialog.value = false;
editingTask.value = null;
isCreateMode.value = false;
editDialog.value = false
editingTask.value = null
isCreateMode.value = false
// 清除错误消息状态
isError.value = false;
errorMessage.value = '';
isError.value = false
errorMessage.value = ''
editForm.value = {
task_name: '',
......@@ -1411,45 +1381,73 @@ const closeEditDialog = () => {
story_id: null,
start_date: '',
end_date: '',
completion_percent: 0,
// 图像数据
image_01: null,
image_01_mime_type: null,
image_02: null,
image_02_mime_type: null,
image_03: null,
image_03_mime_type: null,
image_04: null,
image_04_mime_type: null,
image_05: null,
image_05_mime_type: null,
delete_image_01: false,
delete_image_02: false,
delete_image_03: false,
delete_image_04: false,
delete_image_05: false
}
// 清理图像缓存
Object.keys(imageCache.value).forEach(key => {
if (imageCache.value[key]) {
URL.revokeObjectURL(imageCache.value[key]);
imageCache.value[key] = null;
}
});
// 重置加载状态
Object.keys(imageLoading.value).forEach(key => {
imageLoading.value[key] = false;
});
completion_percent: 0
}
// 图片子资源状态随之清空——把 blob: URL 也释放掉
img.reset()
}
// ───────── 轮播对话框 ─────────
const openImageInCarousel = async (imageId) => {
const list = carouselImages.value
const idx = list.findIndex((i) => i.id === imageId)
carouselIndex.value = idx >= 0 ? idx : 0
carouselCurrentUrl.value = null
imageDialog.value = true
// 触发懒加载:carouselCurrentId 改变后 v-card-text 里调用 getContentUrl
await nextTick()
const url = await img.getContentUrl(carouselCurrentId.value)
carouselCurrentUrl.value = url
}
const carouselNext = async () => {
const list = carouselImages.value
if (list.length < 2) return
carouselIndex.value = (carouselIndex.value + 1) % list.length
carouselCurrentUrl.value = null
const url = await img.getContentUrl(carouselCurrentId.value)
carouselCurrentUrl.value = url
}
const carouselPrev = async () => {
const list = carouselImages.value
if (list.length < 2) return
carouselIndex.value = (carouselIndex.value - 1 + list.length) % list.length
carouselCurrentUrl.value = null
const url = await img.getContentUrl(carouselCurrentId.value)
carouselCurrentUrl.value = url
}
const closeImageDialog = () => {
imageDialog.value = false
currentImageIndex.value = null
currentImageSrc.value = null
resetZoom() // 关闭对话框时重置缩放
resetPosition() // 关闭对话框时重置位置
carouselIndex.value = 0
carouselCurrentUrl.value = null
}
// ───────── 添加图片(+ 按钮 → hidden file input → composable) ─────────
const triggerFilePicker = () => {
filePickerRef.value?.click()
}
const onFileChange = async (event) => {
const files = Array.from(event.target.files || [])
// 清空 input.value 才能连选两张图(change 事件对同一文件不重 fire)
event.target.value = ''
for (const file of files) {
try {
await img.addPending(file)
} catch (err) {
if (err?.message === 'image_limit_reached') {
errorMessage.value = t('task.validation.imageLimitReached')
isError.value = true
break
}
console.error('Failed to add pending image:', err)
}
}
// 重新聚焦 picker:UX 上让用户能继续选
filePickerRef.value?.focus?.()
}
// 删除功能方法
......@@ -1501,247 +1499,9 @@ const confirmDeleteTask = async () => {
}
}
// 图像缩放功能 - 这些方法已被鼠标滚轮缩放功能替代,可以移除
// const zoomIn = () => {
// if (zoomLevel.value < 3) { // 最大放大3倍
// zoomLevel.value += 0.25
// }
// }
// const zoomOut = () => {
// if (zoomLevel.value > 0.5) { // 最小缩小到0.5倍
// zoomLevel.value -= 0.25
// }
// }
const resetZoom = () => {
zoomLevel.value = 1 // 重置为原始大小
}
// 图像拖拽平移功能
const startDrag = (event) => {
if (event.button === 0) { // 只响应鼠标左键
isDragging.value = true
dragStart.value = {
x: event.clientX - imagePosition.value.x,
y: event.clientY - imagePosition.value.y
}
}
}
const doDrag = (event) => {
if (isDragging.value) {
imagePosition.value = {
x: event.clientX - dragStart.value.x,
y: event.clientY - dragStart.value.y
}
}
}
const stopDrag = () => {
isDragging.value = false
}
const resetPosition = () => {
imagePosition.value = { x: 0, y: 0 }
}
// 鼠标滚轮缩放功能
const handleWheel = (event) => {
event.preventDefault()
// 获取鼠标在图像上的相对位置
//const container = event.currentTarget
// const rect = container.getBoundingClientRect()
// 计算缩放前的缩放级别
// const oldZoom = zoomLevel.value
// 根据滚轮方向调整缩放级别
if (event.deltaY < 0) { // 向上滚动,放大
if (zoomLevel.value < 3) {
zoomLevel.value += 0.1
}
} else { // 向下滚动,缩小
if (zoomLevel.value > 0.5) {
zoomLevel.value -= 0.1
}
}
// 限制缩放范围
zoomLevel.value = Math.max(0.5, Math.min(3, zoomLevel.value))
}
// 上传图片方法
const uploadImage = async (index) => {
// 创建文件选择器
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*'; // 只接受图片文件
// 监听文件选择事件
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
// 设置加载状态
imageLoading.value[index] = true;
// 读取文件为base64
const reader = new FileReader();
reader.onload = (event) => {
const base64String = event.target.result;
// 更新表单数据
const imageKey = `image_0${index}`;
// 重要:如果之前标记了删除,现在上传新图片,需要取消删除标记
editForm.value[`delete_${imageKey}`] = false;
// 清除旧的图片数据,确保不会有冲突
editForm.value[imageKey] = null;
// 设置新的图片数据
editForm.value[`${imageKey}_mime_type`] = file.type;
editForm.value[`${imageKey}_file_name`] = file.name;
editForm.value[`${imageKey}_base64`] = base64String;
// 更新图片缓存,立即显示上传的图片
if (imageCache.value[index]) {
URL.revokeObjectURL(imageCache.value[index]);
}
imageCache.value[index] = URL.createObjectURL(file);
console.log(`Image ${index} uploaded successfully:`, {
fileName: file.name,
mimeType: file.type,
hasBase64: !!base64String,
deleteFlag: editForm.value[`delete_${imageKey}`]
});
// 重置加载状态
imageLoading.value[index] = false;
};
reader.readAsDataURL(file);
} catch (error) {
console.error(`Failed to upload image ${index}:`, error);
errorMessage.value = t('task.messages.uploadImageError');
isError.value = true;
setTimeout(() => {
isError.value = false;
errorMessage.value = '';
}, 3000);
// 重置加载状态
imageLoading.value[index] = false;
}
};
// 触发文件选择器
input.click();
};
// 删除图片方法
const deleteImage = (index) => {
// 确认删除
if (confirm(t('task.messages.confirmDeleteImage'))) {
// 更新表单数据,标记为删除
const imageKey = `image_0${index}`;
editForm.value[`delete_${imageKey}`] = true;
// 清除图片缓存
if (imageCache.value[index]) {
URL.revokeObjectURL(imageCache.value[index]);
imageCache.value[index] = null;
}
// 清除其他相关字段
editForm.value[`${imageKey}_mime_type`] = null;
editForm.value[`${imageKey}_file_name`] = null;
editForm.value[`${imageKey}_base64`] = null;
}
};
// 图像加载方法
const loadTaskImage = async (taskId, imageIndex) => {
// 检查缓存
if (imageCache.value[imageIndex]) {
return imageCache.value[imageIndex];
}
// 设置加载状态
imageLoading.value[imageIndex] = true;
try {
const imageBlob = await getTaskImage(taskId, imageIndex);
const imageUrl = URL.createObjectURL(imageBlob);
// 缓存图像URL
imageCache.value[imageIndex] = imageUrl;
return imageUrl;
} catch (error) {
// 如果是404错误,说明该索引位置没有图片,这是正常的
if (error.response && error.response.status === 404) {
console.log(`No image found at index ${imageIndex} - this is normal`);
return null;
}
console.error(`Failed to load task image ${imageIndex}:`, error);
throw error;
} finally {
imageLoading.value[imageIndex] = false;
}
}
// 图像放大查看方法(更新版)
const openImageDialog = async (index) => {
// 首先检查是否有缓存的图像
if (imageCache.value[index]) {
// 使用已缓存的图像
currentImageIndex.value = index;
currentImageSrc.value = imageCache.value[index];
imageDialog.value = true;
} else if (editingTask.value && editingTask.value.task_id) {
// 从API加载图像
try {
// 显示加载对话框
currentImageIndex.value = index;
currentImageSrc.value = null; // 初始化为null,显示加载状态
imageDialog.value = true;
// 加载图像
const imageUrl = await loadTaskImage(editingTask.value.task_id, index);
// 更新图像源
if (imageDialog.value && currentImageIndex.value === index) {
currentImageSrc.value = imageUrl;
}
} catch (error) {
// 如果是404错误,说明该索引位置没有图片,这是正常的
if (error.response && error.response.status === 404) {
console.log(`No image found at index ${index} - this is normal`);
// 关闭对话框,但不显示错误消息
imageDialog.value = false;
} else {
console.error(`Failed to open image dialog for image ${index}:`, error);
// 显示错误消息
errorMessage.value = t('task.messages.loadImageError');
isError.value = true;
setTimeout(() => {
isError.value = false;
errorMessage.value = '';
}, 3000);
// 关闭对话框
imageDialog.value = false;
}
}
} else {
// 没有图像可显示
console.log(`No image data available for index ${index}`);
}
}
// 旧的 zoom / drag / per-slot upload / loadTaskImage / openImageDialog 已
// 全部迁出。spec 0004:图片走 /api/tasks/{id}/images/ 子资源,UI 用动态
// 网格 + 轮播对话框,状态全在 useTaskImages 实例里。
// 导入创建任务API
import { createTask as createTaskAPI } from '@/api/taskService'
......@@ -1775,7 +1535,8 @@ const updateTask = async () => {
throw new Error(t('task.validation.endDateRequired') || '请选择截止日期')
}
// 构建要发送的数据
// 构建要发送的数据(spec 0004:图片不再随 PATCH 主任务携带,
// 删 / 增走 /images/ 子资源)
const taskData = {
task_name: editForm.value.task_name,
task_description: editForm.value.task_description,
......@@ -1788,29 +1549,6 @@ const updateTask = async () => {
completion_percent: parseInt(editForm.value.completion_percent)
}
// 添加图片上传和删除相关数据
for (let i = 1; i <= 5; i++) {
const imageKey = `image_0${i}`;
// 处理图片上传 - 优先处理上传,因为上传会覆盖删除标记
if (editForm.value[`${imageKey}_base64`]) {
// 如果有新上传的图片,确保删除标记为false
taskData[`delete_${imageKey}`] = false;
// 添加图片数据
taskData[`${imageKey}_mime_type`] = editForm.value[`${imageKey}_mime_type`];
taskData[`${imageKey}_file_name`] = editForm.value[`${imageKey}_file_name`];
taskData[`${imageKey}_base64`] = editForm.value[`${imageKey}_base64`];
console.log(`Sending new image data for ${imageKey}`);
}
// 处理图片删除 - 只有在没有新上传图片的情况下才处理删除
else if (editForm.value[`delete_${imageKey}`]) {
taskData[`delete_${imageKey}`] = true;
console.log(`Marking ${imageKey} for deletion`);
}
}
console.log('Sending data:', taskData)
let result;
......@@ -1818,27 +1556,60 @@ const updateTask = async () => {
// 根据模式决定是创建还是更新任务
if (isCreateMode.value) {
// 创建新任务
console.log('Creating new task...');
result = await createTaskAPI(taskData);
console.log('Creating new task...')
result = await createTaskAPI(taskData)
// 将新任务添加到任务列表
tasks.value.push(result);
console.log('Task created successfully:', result);
tasks.value.push(result)
console.log('Task created successfully:', result)
// spec 0004:新建任务时如果本地有待上传图片,commit 时 useTaskImages
// 还没拿到 task_id(任务不存在就没法 POST /images/)。这里把新建出来的
// id 喂给它;如果失败,pendingImages 不会被清空,用户重试即可。
if (img.pendingImages.value.length > 0 || img.pendingDeletes.value.length > 0) {
try {
await img.commit(result.task_id)
} catch (imgErr) {
console.error('Image commit after task create failed:', imgErr)
}
}
} else {
// 更新现有任务
console.log('Updating existing task...');
result = await updateTaskAPI(editingTask.value.task_id, taskData);
console.log('Updating existing task...')
result = await updateTaskAPI(editingTask.value.task_id, taskData)
// 更新本地列表中的任务
const index = tasks.value.findIndex(t => t.task_id === editingTask.value.task_id);
const index = tasks.value.findIndex(t => t.task_id === editingTask.value.task_id)
if (index !== -1) {
tasks.value[index] = { ...tasks.value[index], ...result };
tasks.value[index] = { ...tasks.value[index], ...result }
}
console.log('Task updated successfully:', result)
// spec 0004:图片子资源的删/增走 commit(),跟主任务 PATCH 分两步。
// 失败保留在 pendingImages 里等下次重试;成功项会进 refetch 后的列表。
// 必须显式传 editingTask.value.task_id:useTaskImages() 实例化时没
// 收 taskIdRef,不传会让 resolveTaskId(undefined) 解析成 undefined,
// POST URL 变成 /api/tasks/undefined/images/ → 404。
if (img.pendingImages.value.length > 0 || img.pendingDeletes.value.length > 0) {
try {
const imgResult = await img.commit(editingTask.value.task_id)
if (imgResult.failed.length > 0) {
errorMessage.value = t('task.messages.imagePartialFailure', {
uploaded: imgResult.uploaded.length,
failed: imgResult.failed.length,
})
isError.value = true
}
} catch (imgErr) {
console.error('Image commit failed:', imgErr)
errorMessage.value = t('task.messages.imageCommitError')
isError.value = true
}
}
console.log('Task updated successfully:', result);
}
// 关闭对话框
closeEditDialog();
// 关闭对话框(在 commit() 之后再关:失败时给用户看到重试线索)
closeEditDialog()
} catch (error) {
console.error('Operation failed:', error);
......@@ -2055,6 +1826,53 @@ watch(() => editForm.value.subject_id, (newSubjectId, oldSubjectId) => {
user-select: none;
}
/* spec 0004:删除标记 / 软删除预览 */
.deleted {
background-color: #ffebee;
color: #c62828;
}
/* spec 0004:「+」按钮格 */
.add-tile {
border: 2px dashed #bdbdbd;
background-color: #fafafa;
display: flex;
justify-content: center;
align-items: center;
}
.add-tile:hover {
border-color: var(--v-theme-primary);
background-color: #f5f5f5;
}
.add-tile.disabled {
cursor: not-allowed;
opacity: 0.5;
border-style: solid;
}
.add-tile.disabled:hover {
transform: none;
border-color: #bdbdbd;
background-color: #fafafa;
}
/* spec 0004:轮播对话框 */
.image-carousel-container {
position: relative;
min-height: 500px;
outline: none;
}
.image-carousel-stage {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
min-width: 0;
}
.carousel-nav-left,
.carousel-nav-right {
z-index: 1;
}
/* 悬浮提示 */
.event-hover-tip {
display: none;
......
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