提交 608dd550 authored 作者: 王鹏飞's avatar 王鹏飞

feat: 新增案例授权

上级 4fa89d9d
# Repository Guidelines
## Project Structure & Module Organization
This repository is a Vue 3 + Vite frontend. Main application code lives in `src/`. Feature areas are organized under `src/modules/`, typically with `views/`, `components/`, `api.ts`, `types.ts`, and `composables/`. Shared UI lives in `src/components/` and `src/components/base/`. Cross-cutting logic is in `src/api/`, `src/utils/`, and `src/composables/`. Pinia stores are in `src/stores/`. Static assets live in `public/` and `src/assets/`.
## Build, Test, and Development Commands
- `npm install`: install dependencies.
- `npm run dev`: start the local Vite dev server.
- `npm run typecheck`: run `vue-tsc --noEmit` for Vue/TypeScript validation.
- `npm run lint`: run ESLint with auto-fix.
- `npm run build:test`: create a test build without deploy.
- `npm run build`: production build plus deploy. Use with care because it runs `npm run deploy`.
## Coding Style & Naming Conventions
Use TypeScript and Vue SFCs with `script setup` where the module already follows that pattern. Use 2-space indentation. Name components in PascalCase, for example `AppCard.vue` or `FormDialog.vue`. Name composables `useXxx.ts`, such as `useGetCourseList.ts`. Keep feature-specific code inside its module folder under `src/modules/...` instead of adding new global utilities unless the code is truly shared.
## Testing Guidelines
There is no dedicated unit test framework configured in this repository today. Minimum validation before opening a PR is:
- `npm run typecheck`
- `npm run lint`
- `npm run build:test` for changes that could affect packaging, routing, or environment-specific behavior
## Commit & Pull Request Guidelines
Recent history uses lightweight messages such as `feat: ...`, `chore: ...`, and `bug fixes`. Prefer consistent prefixes going forward: `feat:`, `fix:`, `chore:`. Keep commit scope focused. PRs should include a short summary, affected modules, linked issues if available, and screenshots or GIFs for UI changes. Also list the validation commands you ran.
## Configuration & Safety Notes
Avoid running `npm run build` unless deployment is intended. Do not commit generated files or macOS artifacts such as `.DS_Store`. When working with upload or deploy flows, verify environment targets before testing against shared infrastructure.
import httpRequest from '@/utils/axios'
import type {
BookAuthorizeCreateItem,
CaseAuthorizeCreateItem,
VideoAuthorizeCreateItem,
VideoUploadAuthParams,
VideoUploadRefreshParams,
} from './types'
// 获取课程列表
export function getCourseList() {
return httpRequest.get('/api/lab/v1/teacher/course/list')
}
// 获取实验列表
export function getExperimentList(params: { course_id: string }) {
return httpRequest.get('/api/resource/v1/backend/experiment/experiments', { params })
}
// 创建案例原文
export function createCase(data: CaseAuthorizeCreateItem) {
return httpRequest.post('/api/lab/v1/teacher/experiment-cases/create', data)
}
// 创建指导书
export function createBook(data: BookAuthorizeCreateItem) {
return httpRequest.post('/api/lab/v1/teacher/book/create', data)
}
// 创建操作视频
export function createVideo(data: VideoAuthorizeCreateItem) {
return httpRequest.post('/api/lab/v1/teacher/video/create', data)
}
// 获取上传视频凭证
export function getUploadVideoAuth(data: VideoUploadAuthParams) {
return httpRequest.post('/api/lab/v1/teacher/video/auth-create', data)
}
// 刷新上传视频地址凭证
export function updateUploadVideoAuth(data: VideoUploadRefreshParams) {
return httpRequest.post('/api/lab/v1/teacher/video/create-auth', data)
}
import axios from 'axios'
import type { CaseLibraryItem } from '../types'
export function useCaseLibrary() {
const list = ref<CaseLibraryItem[]>([])
const loading = ref(false)
function fetchList() {
loading.value = true
return axios.get(`https://webapp-pub.ezijing.com/case_library/case.json?_t=${Date.now()}`)
.then((res) => {
list.value = res.data
})
.finally(() => {
loading.value = false
})
}
return { list, loading, fetchList }
}
import { getCourseList } from '../api'
export interface CourseType {
id: string
name: string
}
const courses = ref<CourseType[]>([])
export function useGetCourseList() {
function updateCourses() {
getCourseList().then((res: any) => {
courses.value = res.data
})
}
return { courses, updateCourses }
}
import { getExperimentList } from '../api'
export interface ExperimentType {
id: string
name: string
}
export function useGetExperimentList() {
const experiments = ref<ExperimentType[]>([])
function updateExperiments(courseId?: string) {
if (!courseId) {
experiments.value = []
return
}
getExperimentList({ course_id: courseId }).then((res: any) => {
experiments.value = res.data
}).catch((err) => {
console.error('获取实验列表失败', err)
experiments.value = []
})
}
return { experiments, updateExperiments }
}
import type { RouteRecordRaw } from 'vue-router'
import AppLayout from '@/components/layout/Index.vue'
export const routes: Array<RouteRecordRaw> = [
{
path: '/admin/lab/case-auth',
component: AppLayout,
children: [{ path: '', component: () => import('./views/Index.vue') }],
},
{
path: '/case-auth',
component: AppLayout,
children: [{ path: '', component: () => import('./views/Index.vue') }],
},
]
export interface CaseLibraryFile {
type: string
type_name: string
name: string
url: string
size?: string
source_id?: string
created_at: string
updated_at: string
}
export interface CaseLibraryItem {
id: string
name: string
description: string
files: CaseLibraryFile[]
operator: { id: string; name: string }
created_at: string
updated_at: string
}
export interface CaseAuthorizeCreateItem {
experiment_id: string
status: string
name: string
type: string
url: string
size: string
}
export interface BookAuthorizeCreateItem {
experiment_id: string
status: string
name: string
type: string
url: string
size: string
}
export interface VideoAuthorizeCreateItem {
experiment_id: string
status: string
name: string
source_id: string
}
export interface VideoUploadAuthParams {
title: string
file_name: string
}
export interface VideoUploadRefreshParams {
source_id: string
}
<script setup lang="ts">
import axios from 'axios'
import OSS from 'ali-oss'
import '@/lib/aliyun-upload-sdk/aliyun-upload-sdk-1.5.3.min.js'
import { ElMessage } from 'element-plus'
import type { CaseLibraryItem } from '../types'
import { createBook, createCase, createVideo, getUploadVideoAuth, updateUploadVideoAuth } from '../api'
import { upload } from '@/utils/upload'
import { useCaseLibrary } from '../composables/useCaseLibrary'
import { useGetCourseList } from '../composables/useGetCourseList'
import { useGetExperimentList } from '../composables/useGetExperimentList'
;(window as any).OSS = OSS
const { list, loading, fetchList } = useCaseLibrary()
const { courses, updateCourses } = useGetCourseList()
const { experiments, updateExperiments } = useGetExperimentList()
updateCourses()
fetchList()
const selectedCourse = ref('')
const selectedExperiment = ref('')
const selectedCaseId = ref('')
const fileSelections = ref<Record<'case' | 'book' | 'video', string[]>>({
case: [],
book: [],
video: [],
})
const submitting = ref(false)
const progress = ref(0)
const progressText = ref('')
const caseFileTypeMap = {
case: '1',
book: '3',
video: '4',
} as const
watch(selectedCourse, (val) => {
selectedExperiment.value = ''
updateExperiments(val)
})
function getFilesByType(files: CaseLibraryItem['files'], type: string) {
return files.filter((f) => f.type === type)
}
function getMimeType(fileName: string) {
const ext = fileName.split('.').pop()?.toLowerCase() || ''
const mimeMap: Record<string, string> = {
doc: 'application/msword',
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
pdf: 'application/pdf',
ppt: 'application/vnd.ms-powerpoint',
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
xls: 'application/vnd.ms-excel',
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
csv: 'text/csv',
mp4: 'video/mp4',
}
return mimeMap[ext] || 'application/octet-stream'
}
const selectedCase = computed(() => {
return list.value.find((item) => item.id === selectedCaseId.value) || null
})
const groupedFiles = computed(() => {
const files = selectedCase.value?.files || []
return {
case: getFilesByType(files, caseFileTypeMap.case),
book: getFilesByType(files, caseFileTypeMap.book),
video: getFilesByType(files, caseFileTypeMap.video),
}
})
const selectedCount = computed(() => {
return fileSelections.value.case.length + fileSelections.value.book.length + fileSelections.value.video.length
})
const selectedItems = computed(() => {
if (!selectedCase.value) return []
return (Object.keys(caseFileTypeMap) as Array<'case' | 'book' | 'video'>).flatMap((type) => {
return fileSelections.value[type]
.map((key) => {
const file = selectedCase.value?.files.find(
(item) => `${item.type}-${item.url}` === key && item.type === caseFileTypeMap[type],
)
return file ? { row: selectedCase.value, type, file } : null
})
.filter(Boolean)
})
})
watch(selectedCaseId, () => {
fileSelections.value = { case: [], book: [], video: [] }
})
function handleCaseChange(caseId: string) {
selectedCaseId.value = caseId
}
function getFileKey(file: CaseLibraryItem['files'][number]) {
return `${file.type}-${file.url}`
}
function getAllChecked(type: 'case' | 'book' | 'video') {
const files = groupedFiles.value[type]
return !!files.length && fileSelections.value[type].length === files.length
}
function handleToggleAll(type: 'case' | 'book' | 'video', checked: boolean) {
fileSelections.value[type] = checked ? groupedFiles.value[type].map((file) => getFileKey(file)) : []
}
function setStepProgress(index: number, total: number, ratio: number, label: string) {
const safeRatio = Math.max(0, Math.min(1, ratio))
progress.value = Math.round(((index + safeRatio) / total) * 100)
progressText.value = label
}
async function downloadRemoteFile(url: string, fileName: string, index: number, total: number) {
const response = await axios.get(url, {
responseType: 'blob',
onDownloadProgress: (event) => {
if (!event.total) {
setStepProgress(index, total, 0.15, `正在下载:${fileName}`)
return
}
const ratio = event.loaded / event.total
setStepProgress(index, total, ratio * 0.45, `正在下载 ${Math.round(ratio * 100)}%:${fileName}`)
},
})
return new File([response.data], fileName, {
type: response.data.type || getMimeType(fileName),
})
}
async function uploadDocumentFromCase(url: string, fileName: string, index: number, total: number) {
const file = await downloadRemoteFile(url, fileName, index, total)
setStepProgress(index, total, 0.55, `正在上传文件:${fileName}`)
const uploadedUrl = await upload(file)
return {
url: uploadedUrl,
size: (file.size / 1024 / 1024).toString(),
}
}
async function uploadVideoFromCase(url: string, fileName: string, index: number, total: number) {
const file = await downloadRemoteFile(url, fileName, index, total)
return new Promise<string>((resolve, reject) => {
const uploader = new (window as any).AliyunUpload.Vod({
userId: '1303984639806000',
region: 'cn-shanghai',
partSize: 1048576,
parallel: 5,
retryCount: 3,
retryDuration: 2,
onUploadstarted(uploadInfo: any) {
setStepProgress(index, total, 0.55, `正在上传视频:${uploadInfo.file.name}`)
getUploadVideoAuth({ title: uploadInfo.file.name, file_name: uploadInfo.file.name })
.then((res) => {
uploader.setUploadAuthAndAddress(
uploadInfo,
res.data.upload_auth,
res.data.upload_address,
res.data.source_id,
)
})
.catch(reject)
},
onUploadSucceed(uploadInfo: any) {
setStepProgress(index, total, 0.95, `视频上传完成:${uploadInfo.file.name}`)
resolve(uploadInfo.videoId)
},
onUploadFailed(_uploadInfo: any, code: number, message: string) {
reject(new Error(message || `视频上传失败: ${code}`))
},
onUploadProgress(_uploadInfo: any, _totalSize: number, loadedPercent: number) {
setStepProgress(
index,
total,
0.55 + loadedPercent * 0.4,
`正在上传视频 ${Math.round(loadedPercent * 100)}%:${fileName}`,
)
},
onUploadTokenExpired(uploadInfo: any) {
updateUploadVideoAuth({ source_id: uploadInfo.videoId })
.then((res) => {
uploader.resumeUploadWithAuth(res.data.UploadAuth)
})
.catch(reject)
},
onUploadEnd() {},
})
uploader.addFile(file, null, null, null, '{"Vod":{}}')
uploader.startUpload()
})
}
async function handleBatchAuthorize() {
if (!selectedExperiment.value) {
ElMessage.warning('请选择实验')
return
}
if (!selectedItems.value.length) {
ElMessage.warning('请勾选需要授权的案例文件')
return
}
const successStats = { case: 0, book: 0, video: 0 }
const total = selectedItems.value.length
submitting.value = true
progress.value = 0
progressText.value = `准备处理 0/${total}`
try {
for (let index = 0; index < selectedItems.value.length; index += 1) {
const item = selectedItems.value[index]
if (!item) continue
const { type, file } = item
setStepProgress(index, total, 0.05, `准备处理 ${index + 1}/${total}${file.name}`)
if (type === 'case') {
const uploadedFile = await uploadDocumentFromCase(file.url, file.name, index, total)
setStepProgress(index, total, 0.95, `正在创建案例原文:${file.name}`)
await createCase({
experiment_id: selectedExperiment.value,
name: file.name.replace(/\.[^.]+$/, ''),
type: getMimeType(file.name),
url: uploadedFile.url,
size: uploadedFile.size,
status: '1',
})
successStats.case += 1
progress.value = Math.round(((index + 1) / total) * 100)
continue
}
if (type === 'book') {
const uploadedFile = await uploadDocumentFromCase(file.url, file.name, index, total)
setStepProgress(index, total, 0.95, `正在创建案例指导书:${file.name}`)
await createBook({
experiment_id: selectedExperiment.value,
name: file.name.replace(/\.[^.]+$/, ''),
type: getMimeType(file.name),
url: uploadedFile.url,
size: uploadedFile.size,
status: '1',
})
successStats.book += 1
progress.value = Math.round(((index + 1) / total) * 100)
continue
}
const sourceId = await uploadVideoFromCase(file.url, file.name, index, total)
setStepProgress(index, total, 0.98, `正在创建案例操作视频:${file.name}`)
await createVideo({
experiment_id: selectedExperiment.value,
name: file.name.replace(/\.[^.]+$/, ''),
source_id: sourceId,
status: '1',
})
successStats.video += 1
progress.value = Math.round(((index + 1) / total) * 100)
}
if (!successStats.case && !successStats.book && !successStats.video) {
ElMessage.warning('没有可授权的文件,案例视频缺少 source_id 时无法直接授权')
return
}
ElMessage.success(
`授权完成:案例原文 ${successStats.case} 个,指导书 ${successStats.book} 个,视频 ${successStats.video} 个`,
)
progressText.value = `授权完成 ${total}/${total}`
} catch {
ElMessage.error('批量授权失败')
progressText.value = '授权失败'
} finally {
submitting.value = false
}
}
</script>
<template>
<AppCard title="案例授权">
<div class="case-auth-toolbar">
<el-select v-model="selectedCourse" placeholder="请选择课程" filterable clearable style="width: 200px">
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-select v-model="selectedExperiment" placeholder="请选择实验" filterable clearable style="width: 200px">
<el-option v-for="item in experiments" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-button type="primary" :loading="submitting" @click="handleBatchAuthorize" :disabled="!selectedCount"
>批量授权到实验</el-button
>
</div>
<div v-if="selectedCount || submitting || progress" class="case-auth-progress">
<div class="case-auth-progress__header">
<span>{{ progressText || `准备处理 0/${selectedCount}` }}</span>
<span>{{ progress }}%</span>
</div>
<el-progress :percentage="progress" :show-text="false" />
</div>
<div v-loading="loading" class="case-list">
<div
v-for="item in list"
:key="item.id"
class="case-card"
:class="{ 'case-card--active': selectedCaseId === item.id }"
@click="handleCaseChange(item.id)">
<div class="case-card__top">
<div>
<div class="case-card__name">{{ item.name }}</div>
<div class="case-card__id">{{ item.id }}</div>
</div>
<el-radio :model-value="selectedCaseId === item.id" :label="true">选择</el-radio>
</div>
<div class="case-card__desc">{{ item.description || '暂无案例简介' }}</div>
<div class="case-card__meta">
<el-tag size="small" :type="getFilesByType(item.files, caseFileTypeMap.case).length ? 'success' : 'info'"
>原文 {{ getFilesByType(item.files, caseFileTypeMap.case).length }}</el-tag
>
<el-tag size="small" :type="getFilesByType(item.files, caseFileTypeMap.book).length ? 'success' : 'info'"
>指导书 {{ getFilesByType(item.files, caseFileTypeMap.book).length }}</el-tag
>
<el-tag size="small" :type="getFilesByType(item.files, caseFileTypeMap.video).length ? 'success' : 'info'"
>视频 {{ getFilesByType(item.files, caseFileTypeMap.video).length }}</el-tag
>
</div>
</div>
</div>
<div v-if="selectedCase" class="case-auth-sections">
<div class="case-auth-section">
<div class="case-auth-section__header">
<div class="case-auth-section__title">案例原文</div>
<el-checkbox
:model-value="getAllChecked('case')"
:disabled="!groupedFiles.case.length"
@update:model-value="(value) => handleToggleAll('case', !!value)">
全选
</el-checkbox>
</div>
<el-checkbox-group v-model="fileSelections.case" class="case-auth-options">
<el-checkbox v-for="file in groupedFiles.case" :key="getFileKey(file)" :label="getFileKey(file)">
{{ file.name }}
</el-checkbox>
</el-checkbox-group>
<el-empty v-if="!groupedFiles.case.length" description="暂无案例原文" :image-size="72" />
</div>
<div class="case-auth-section">
<div class="case-auth-section__header">
<div class="case-auth-section__title">案例指导书</div>
<el-checkbox
:model-value="getAllChecked('book')"
:disabled="!groupedFiles.book.length"
@update:model-value="(value) => handleToggleAll('book', !!value)">
全选
</el-checkbox>
</div>
<el-checkbox-group v-model="fileSelections.book" class="case-auth-options">
<el-checkbox v-for="file in groupedFiles.book" :key="getFileKey(file)" :label="getFileKey(file)">
{{ file.name }}
</el-checkbox>
</el-checkbox-group>
<el-empty v-if="!groupedFiles.book.length" description="暂无案例指导书" :image-size="72" />
</div>
<div class="case-auth-section">
<div class="case-auth-section__header">
<div class="case-auth-section__title">案例操作视频</div>
<el-checkbox
:model-value="getAllChecked('video')"
:disabled="!groupedFiles.video.length"
@update:model-value="(value) => handleToggleAll('video', !!value)">
全选
</el-checkbox>
</div>
<el-checkbox-group v-model="fileSelections.video" class="case-auth-options">
<el-checkbox v-for="file in groupedFiles.video" :key="getFileKey(file)" :label="getFileKey(file)">
{{ file.name }}
</el-checkbox>
</el-checkbox-group>
<el-empty v-if="!groupedFiles.video.length" description="暂无案例操作视频" :image-size="72" />
</div>
</div>
</AppCard>
</template>
<style lang="scss" scoped>
.case-auth-toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
.case-auth-progress {
margin-bottom: 16px;
padding: 12px 16px;
background: var(--el-fill-color-light);
border-radius: 8px;
}
.case-auth-progress__header {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
font-size: 14px;
color: var(--el-text-color-regular);
}
.case-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.case-card {
padding: 16px;
border: 1px solid var(--el-border-color);
border-radius: 10px;
background: #fff;
cursor: pointer;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.case-card:hover,
.case-card--active {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 2px rgb(64 158 255 / 12%);
}
.case-card__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.case-card__name {
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.case-card__id {
margin-top: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.case-card__desc {
min-height: 44px;
font-size: 14px;
line-height: 1.6;
color: var(--el-text-color-regular);
}
.case-card__meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.case-auth-sections {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 16px;
}
.case-auth-section {
padding: 16px;
background: var(--el-fill-color-light);
border-radius: 8px;
min-height: 180px;
}
.case-auth-section__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.case-auth-section__title {
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.case-auth-options {
display: flex;
flex-direction: column;
gap: 10px;
}
@media (max-width: 1200px) {
.case-list {
grid-template-columns: 1fr;
}
.case-auth-sections {
grid-template-columns: 1fr;
}
}
</style>
......@@ -8,6 +8,8 @@ interface State {
adminMenus: IMenuItem[]
}
const CASE_AUTH_ALLOWED_MOBILES = ['13811534871', '13785189195']
// 学生菜单
const studentMenus: IMenuItem[] = [
{ name: '首页', path: '/' },
......@@ -32,6 +34,7 @@ const adminMenus: IMenuItem[] = [
{ name: '实验成绩管理', path: '/admin/lab/score', tag: 'v1-teacher-record' },
{ name: '实验监控', path: '/admin/lab/dashboard' },
{ name: '案例管理', path: '/admin/lab/example' },
{ name: '案例授权', path: '/admin/lab/case-auth' },
],
},
{
......@@ -67,7 +70,21 @@ export const useMenuStore = defineStore('menu', {
if (userStore.role?.id === 1) {
return appConfig.studentMenus || state.studentMenus
} else {
return appConfig.adminMenus || state.adminMenus
const sourceMenus = appConfig.adminMenus || state.adminMenus
const currentMobile = userStore.user?.mobile || ''
return sourceMenus.map((item) => {
if (!item.children?.length) return item
if (item.path !== '/admin/lab') return item
return {
...item,
children: item.children.filter((child) => {
if (child.path !== '/admin/lab/case-auth') return true
return CASE_AUTH_ALLOWED_MOBILES.includes(currentMobile)
}),
}
})
}
},
},
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论