后端新增 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>
496 lines
13 KiB
Vue
496 lines
13 KiB
Vue
<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>
|