library-picturebook-activity/frontend/src/views/homework/StudentList.vue
En b9ed5e17c6 feat: OSS 客户端直传改造(STS Token 签发 + 前端直传 + CORS 自动配置)
后端新增 OssUtils/OssTokenVo/OssCorsInitRunner,通过 STS 临时凭证实现客户端直传 OSS;
前端 upload API 适配直传流程,赛事创建/作品提交/作业/富文本编辑器均已切换;
多环境(dev/test/prod) OSS 配置补全;新增 oss-direct-upload-demo 示例项目及 E2E 测试。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 15:19:43 +08:00

496 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="student-homework-page">
<a-card class="mb-4">
<template #title>我的作业</template>
</a-card>
<!-- 搜索表单 -->
<a-form
:model="searchParams"
layout="inline"
class="search-form"
@finish="handleSearch"
>
<a-form-item label="作业名称">
<a-input
v-model:value="searchParams.name"
placeholder="请输入作业名称"
allow-clear
style="width: 200px"
/>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
</a-form-item>
</a-form>
<!-- 数据表格 -->
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
row-key="id"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
{{ record.name }}
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.submission ? 'success' : 'warning'">
{{ record.submission ? "已提交" : "待提交" }}
</a-tag>
</template>
<template v-else-if="column.key === 'submitTime'">
<div>
{{ formatDateTime(record.submitStartTime) }} ~
{{ formatDateTime(record.submitEndTime) }}
</div>
</template>
<template v-else-if="column.key === 'action'">
<a-button type="link" size="small" @click="handleViewDetail(record)">
详情
</a-button>
</template>
</template>
</a-table>
<!-- 作业详情侧边弹框 -->
<a-drawer
v-model:open="detailModalVisible"
title="作业详情"
placement="right"
width="500px"
destroy-on-close
:footer-style="{ textAlign: 'right' }"
>
<a-spin :spinning="detailLoading">
<!-- 作业信息 -->
<div class="section-title">作业信息</div>
<a-descriptions :column="1" size="small" bordered class="mb-4">
<a-descriptions-item label="作业名称">
{{ currentHomework?.name }}
</a-descriptions-item>
<a-descriptions-item label="提交时间">
{{ formatDateTime(currentHomework?.submitStartTime) }} ~
{{ formatDateTime(currentHomework?.submitEndTime) }}
</a-descriptions-item>
<a-descriptions-item label="作业描述">
<div class="homework-content" v-html="formatContent(currentHomework?.content)"></div>
</a-descriptions-item>
<a-descriptions-item label="附件下载" v-if="homeworkAttachments.length > 0">
<div class="attachment-list">
<a
v-for="(att, index) in homeworkAttachments"
:key="index"
:href="att.fileUrl"
target="_blank"
class="attachment-item"
>
<DownloadOutlined />
{{ att.fileName }}
</a>
</div>
</a-descriptions-item>
</a-descriptions>
<!-- 作业提交 -->
<div class="section-title">作业提交</div>
<a-result
v-if="isExpired && !currentSubmission"
status="warning"
title="提交已截止"
sub-title="很抱歉,作业提交时间已过"
/>
<a-form
v-else
ref="formRef"
:model="submitForm"
:rules="currentSubmission ? {} : formRules"
layout="vertical"
>
<a-form-item label="作品名称" name="workName">
<a-input
v-model:value="submitForm.workName"
placeholder="请输入作品名称"
:disabled="!!currentSubmission"
/>
</a-form-item>
<a-form-item label="作品介绍" name="workDescription">
<a-textarea
v-model:value="submitForm.workDescription"
placeholder="请输入作品介绍"
:rows="4"
:disabled="!!currentSubmission"
/>
</a-form-item>
<a-form-item label="上传作品" name="files">
<template v-if="currentSubmission">
<!-- 已提交:显示已上传的文件 -->
<div class="file-list" v-if="submissionFiles.length > 0">
<a
v-for="(file, index) in submissionFiles"
:key="index"
:href="file.fileUrl"
target="_blank"
class="file-item"
>
<FileOutlined />
{{ file.fileName }}
</a>
</div>
<span v-else class="text-gray">暂无附件</span>
</template>
<template v-else>
<!-- 未提交:显示上传组件 -->
<a-upload
v-model:file-list="submitForm.fileList"
:before-upload="beforeUpload"
:custom-request="customUpload"
>
<a-button>
<template #icon><UploadOutlined /></template>
上传附件
</a-button>
</a-upload>
</template>
</a-form-item>
<!-- 已提交显示得分 -->
<a-form-item v-if="currentSubmission && currentSubmission.totalScore !== null && currentSubmission.totalScore !== undefined" label="得分">
<a-tag color="blue" style="font-size: 16px;">
{{ currentSubmission.totalScore }}分
</a-tag>
</a-form-item>
</a-form>
</a-spin>
<!-- 底部按钮 -->
<template #footer>
<a-space>
<a-button @click="detailModalVisible = false">取消</a-button>
<a-button
v-if="!currentSubmission && !isExpired"
type="primary"
:loading="submitting"
@click="handleSubmit"
>
确定提交
</a-button>
</a-space>
</template>
</a-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed, onMounted } from "vue"
import { useRoute } from "vue-router"
import { message } from "ant-design-vue"
import type { FormInstance, UploadProps } from "ant-design-vue"
import {
SearchOutlined,
ReloadOutlined,
DownloadOutlined,
FileOutlined,
UploadOutlined,
} from "@ant-design/icons-vue"
import { useListRequest } from "@/composables/useListRequest"
import {
homeworksApi,
submissionsApi,
type Homework,
type QueryHomeworkParams,
type HomeworkSubmission,
type HomeworkAttachment,
} from "@/api/homework"
import { uploadApi } from "@/api/upload"
import dayjs from "dayjs"
const route = useRoute()
// 使用列表请求组合函数 - 只获取已发布的作业
const {
loading,
dataSource,
pagination,
searchParams,
fetchList,
resetSearch,
search,
handleTableChange,
} = useListRequest<Homework, QueryHomeworkParams>({
requestFn: (params) => homeworksApi.getMyList({ ...params, status: 'published' }),
defaultSearchParams: {} as QueryHomeworkParams,
defaultPageSize: 10,
errorMessage: "获取作业列表失败",
})
// 表格列定义
const columns = [
{
title: "序号",
key: "index",
width: 60,
customRender: ({ index }: { index: number }) => index + 1,
},
{
title: "作业名称",
key: "name",
dataIndex: "name",
width: 250,
},
{
title: "状态",
key: "status",
width: 100,
},
{
title: "提交时间",
key: "submitTime",
width: 350,
},
{
title: "操作",
key: "action",
width: 100,
fixed: "right" as const,
},
]
// 格式化日期时间
const formatDateTime = (dateStr?: string) => {
if (!dateStr) return "-"
return dayjs(dateStr).format("YYYY-MM-DD HH:mm:ss")
}
// 格式化内容(保留换行)
const formatContent = (content?: string) => {
if (!content) return ""
return content.replace(/\n/g, "<br/>")
}
// 搜索
const handleSearch = () => {
search()
}
// 重置搜索
const handleReset = () => {
resetSearch()
}
// =============== 详情弹框相关 ===============
const detailModalVisible = ref(false)
const detailLoading = ref(false)
const currentHomework = ref<Homework | null>(null)
const currentSubmission = ref<HomeworkSubmission | null>(null)
const submitting = ref(false)
const formRef = ref<FormInstance>()
// 解析作业附件
const homeworkAttachments = computed<HomeworkAttachment[]>(() => {
if (!currentHomework.value?.attachments) return []
if (typeof currentHomework.value.attachments === "string") {
try {
return JSON.parse(currentHomework.value.attachments)
} catch {
return []
}
}
return currentHomework.value.attachments as HomeworkAttachment[]
})
// 解析提交的文件
const submissionFiles = computed<HomeworkAttachment[]>(() => {
if (!currentSubmission.value?.files) return []
if (typeof currentSubmission.value.files === "string") {
try {
return JSON.parse(currentSubmission.value.files)
} catch {
return []
}
}
return currentSubmission.value.files as HomeworkAttachment[]
})
// 是否过期
const isExpired = computed(() => {
if (!currentHomework.value) return false
return dayjs().isAfter(dayjs(currentHomework.value.submitEndTime))
})
// 提交表单
const submitForm = reactive<{
workName: string
workDescription: string
fileList: any[]
}>({
workName: "",
workDescription: "",
fileList: [],
})
const formRules = {
workName: [{ required: true, message: "请输入作品名称" }],
}
// 查看详情
const handleViewDetail = async (record: Homework) => {
// 重置状态
currentHomework.value = null
currentSubmission.value = null
detailLoading.value = true
detailModalVisible.value = true
// 重置表单
submitForm.workName = ""
submitForm.workDescription = ""
submitForm.fileList = []
// 加载作业详情
try {
const detail = await homeworksApi.getDetail(record.id)
currentHomework.value = detail
} catch (error: any) {
message.error(error?.response?.data?.message || "获取作业详情失败")
detailLoading.value = false
return
}
// 获取我的提交记录404 是正常情况,不提示错误)
try {
const mySubmission = await submissionsApi.getMySubmission(record.id)
currentSubmission.value = mySubmission
} catch {
// 未找到提交记录是正常情况,不提示错误
currentSubmission.value = null
}
detailLoading.value = false
}
// 文件上传
const beforeUpload: UploadProps["beforeUpload"] = () => {
return false
}
const customUpload = async ({ file, onSuccess, onError }: any) => {
try {
const formData = new FormData()
formData.append("file", file)
const result = await uploadApi.upload(formData, "homework/attachment")
file.url = result.url
onSuccess(result)
} catch (error) {
onError(error)
}
}
// 提交作业
const handleSubmit = async () => {
if (!currentHomework.value) return
try {
await formRef.value?.validate()
submitting.value = true
const files = submitForm.fileList.map((file) => ({
fileName: file.name,
fileUrl: file.url || file.response?.url,
size: file.size?.toString(),
}))
await submissionsApi.submit({
homeworkId: currentHomework.value.id,
workName: submitForm.workName,
workDescription: submitForm.workDescription,
files,
})
message.success("提交成功")
detailModalVisible.value = false
// 刷新列表
fetchList()
} catch (error: any) {
if (error?.errorFields) return
message.error(error?.response?.data?.message || "提交失败")
} finally {
submitting.value = false
}
}
onMounted(() => {
fetchList()
})
</script>
<style scoped>
.search-form {
margin-bottom: 16px;
}
.mb-4 {
margin-bottom: 16px;
}
.section-title {
font-size: 15px;
font-weight: 600;
color: #333;
margin-bottom: 12px;
padding-left: 8px;
border-left: 3px solid #1890ff;
}
.homework-content {
white-space: pre-wrap;
line-height: 1.6;
color: #666;
}
.attachment-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.attachment-item {
display: flex;
align-items: center;
gap: 6px;
color: #1890ff;
cursor: pointer;
}
.attachment-item:hover {
text-decoration: underline;
}
.file-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.file-item {
display: flex;
align-items: center;
gap: 6px;
color: #1890ff;
cursor: pointer;
}
.file-item:hover {
text-decoration: underline;
}
</style>