提交 48826ec2 authored 作者: 王鹏飞's avatar 王鹏飞

feat: 新增案例授权

上级 151ff894
......@@ -43,7 +43,7 @@ function handleClick(path: string) {
</script>
<template>
<aside class="app-aside">
<aside class="app-aside" v-if="menuList.length > 0">
<nav class="nav">
<el-menu :default-active="defaultActive" class="app-menu">
<template v-for="item in menuList" :key="item.path">
......
import { getCreateAuth, updateAuth } from '@/api/base'
import httpRequest from '@/utils/axios'
import type {
BookAuthorizeCreateItem,
CaseAuthorizeCreateItem,
ResourceDocumentCreateItem,
ResourceVideoCreateItem,
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)
}
// 统一资源平台视频上传凭证
export function getResourceUploadVideoAuth(data: VideoUploadAuthParams) {
return getCreateAuth(data)
}
// 统一资源平台刷新上传视频地址凭证
export function updateResourceUploadVideoAuth(data: VideoUploadRefreshParams) {
return updateAuth(data)
}
// 创建统一资源平台视频
export function createResourceVideo(data: ResourceVideoCreateItem) {
return httpRequest.post('/api/resource/v1/resource/video/create', data)
}
// 创建统一资源平台课件
export function createResourceCourseware(data: ResourceDocumentCreateItem) {
return httpRequest.post('/api/resource/v1/resource/courseware/create', data)
}
// 创建统一资源平台教案
export function createResourceLessonPlan(data: ResourceDocumentCreateItem) {
return httpRequest.post('/api/resource/v1/resource/lesson-plan/create', data)
}
// 创建统一资源平台其他资料
export function createResourceOther(data: ResourceDocumentCreateItem) {
return httpRequest.post('/api/resource/v1/resource/other-information/create', data)
}
import axios from 'axios'
import { ElMessage } from 'element-plus'
import { useGetCategoryList } from '@/composables/useGetCategoryList'
import { upload } from '@/utils/upload'
import {
createBook,
createCase,
createResourceCourseware,
createResourceLessonPlan,
createResourceOther,
createResourceVideo,
createVideo,
getResourceUploadVideoAuth,
updateResourceUploadVideoAuth,
} from '../api'
import type { AuthorizePlatform, CaseFileSelectionType, CaseLibraryFile, CaseLibraryItem } from '../types'
import { useCaseLibrary } from './useCaseLibrary'
import { useGetCourseList } from './useGetCourseList'
import { useGetExperimentList } from './useGetExperimentList'
const caseFileTypeMap: Record<CaseFileSelectionType, string> = {
case: '1',
ppt: '2',
book: '3',
video: '4',
dataset: '5',
}
const caseFileSectionConfig: Array<{
key: CaseFileSelectionType
title: string
emptyText: string
}> = [
{ key: 'case', title: '案例原文', emptyText: '暂无案例原文' },
{ key: 'ppt', title: '案例PPT', emptyText: '暂无案例PPT' },
{ key: 'book', title: '案例指导书', emptyText: '暂无案例指导书' },
{ key: 'video', title: '案例操作视频', emptyText: '暂无案例操作视频' },
{ key: 'dataset', title: '案例数据集', emptyText: '暂无案例数据集' },
]
const experimentSupportedTypes = new Set<CaseFileSelectionType>(['case', 'book', 'video'])
const resourceSupportedTypes = new Set<CaseFileSelectionType>(['ppt', 'book', 'video', 'dataset'])
function createEmptySelections(): Record<CaseFileSelectionType, string[]> {
return {
case: [],
ppt: [],
book: [],
video: [],
dataset: [],
}
}
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'
}
function getFileExtension(fileName: string) {
return fileName.split('.').pop()?.toLowerCase() || ''
}
function getFileNameWithoutExt(fileName: string) {
return fileName.replace(/\.[^.]+$/, '')
}
function getFilesByType(files: CaseLibraryItem['files'], type: string) {
return files.filter((file) => file.type === type)
}
function buildSummaryText(stats: {
experiment: { case: number; book: number; video: number }
resource: { courseware: number; lessonPlan: number; video: number; other: number }
}) {
const parts: string[] = []
if (stats.experiment.case || stats.experiment.book || stats.experiment.video) {
parts.push(
`实操平台:案例原文 ${stats.experiment.case} 个,指导书 ${stats.experiment.book} 个,视频 ${stats.experiment.video} 个`,
)
}
if (stats.resource.courseware || stats.resource.lessonPlan || stats.resource.video || stats.resource.other) {
parts.push(
`统一资源平台:课件 ${stats.resource.courseware} 个,教案 ${stats.resource.lessonPlan} 个,视频 ${stats.resource.video} 个,其他资料 ${stats.resource.other} 个`,
)
}
return parts.join(';')
}
export function useCaseAuthorization() {
const { list, loading, fetchList } = useCaseLibrary()
const { courses, updateCourses } = useGetCourseList()
const { experiments, updateExperiments } = useGetExperimentList()
const { list: categoryList } = useGetCategoryList()
const platformSelections = ref<AuthorizePlatform[]>(['experiment'])
const selectedCourse = ref('')
const selectedExperiment = ref('')
const resourceClassification = ref('')
const selectedCaseId = ref('')
const fileSelections = ref<Record<CaseFileSelectionType, string[]>>(createEmptySelections())
const submitting = ref(false)
const progress = ref(0)
const progressText = ref('')
updateCourses()
fetchList()
watch(selectedCourse, (val) => {
selectedExperiment.value = ''
updateExperiments(val)
})
watch(selectedCaseId, () => {
fileSelections.value = createEmptySelections()
})
function isPlatformSelected(platform: AuthorizePlatform) {
return platformSelections.value.includes(platform)
}
function isTypeSelectable(type: CaseFileSelectionType) {
return (
(isPlatformSelected('experiment') && experimentSupportedTypes.has(type)) ||
(isPlatformSelected('resource') && resourceSupportedTypes.has(type))
)
}
watch(
platformSelections,
() => {
const nextSelections = createEmptySelections()
for (const section of caseFileSectionConfig) {
nextSelections[section.key] = isTypeSelectable(section.key) ? [...fileSelections.value[section.key]] : []
}
fileSelections.value = nextSelections
},
{ deep: true },
)
const selectedCase = computed(() => list.value.find((item) => item.id === selectedCaseId.value) || null)
const groupedFiles = computed(() => {
const files = selectedCase.value?.files || []
return {
case: getFilesByType(files, caseFileTypeMap.case),
ppt: getFilesByType(files, caseFileTypeMap.ppt),
book: getFilesByType(files, caseFileTypeMap.book),
video: getFilesByType(files, caseFileTypeMap.video),
dataset: getFilesByType(files, caseFileTypeMap.dataset),
}
})
const selectedCount = computed(() =>
caseFileSectionConfig.reduce((total, section) => total + fileSelections.value[section.key].length, 0),
)
const selectedItems = computed(() => {
if (!selectedCase.value) return []
const result: Array<{
row: CaseLibraryItem
type: CaseFileSelectionType
file: CaseLibraryFile
authorizeToExperiment: boolean
authorizeToResource: boolean
}> = []
for (const section of caseFileSectionConfig) {
for (const key of fileSelections.value[section.key]) {
const file = selectedCase.value.files.find(
(item) => `${item.type}-${item.url}` === key && item.type === caseFileTypeMap[section.key],
)
if (!file) continue
result.push({
row: selectedCase.value,
type: section.key,
file,
authorizeToExperiment: isPlatformSelected('experiment') && experimentSupportedTypes.has(section.key),
authorizeToResource: isPlatformSelected('resource') && resourceSupportedTypes.has(section.key),
})
}
}
return result
})
function handleCaseChange(caseId: string) {
selectedCaseId.value = caseId
}
function getFileKey(file: CaseLibraryFile) {
return `${file.type}-${file.url}`
}
function getAllChecked(type: CaseFileSelectionType) {
const files = groupedFiles.value[type]
return !!files.length && fileSelections.value[type].length === files.length
}
function handleToggleAll(type: CaseFileSelectionType, checked: boolean) {
if (!isTypeSelectable(type)) {
fileSelections.value[type] = []
return
}
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 {
file,
url: uploadedUrl,
}
}
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}`)
getResourceUploadVideoAuth({ title: uploadInfo.file.name, file_name: uploadInfo.file.name })
.then((res: any) => {
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) {
updateResourceUploadVideoAuth({ source_id: uploadInfo.videoId })
.then((res: any) => {
uploader.resumeUploadWithAuth(res.data.UploadAuth || res.data.upload_auth || res.UploadAuth)
})
.catch(reject)
},
})
uploader.addFile(file, null, null, null, '{"Vod":{}}')
uploader.startUpload()
})
}
async function submitAuthorize() {
if (!platformSelections.value.length) {
ElMessage.warning('请选择授权平台')
return
}
if (isPlatformSelected('experiment') && !selectedExperiment.value) {
ElMessage.warning('授权到实操平台时请选择课程和实验')
return
}
if (isPlatformSelected('resource') && !resourceClassification.value) {
ElMessage.warning('授权到统一资源平台时请选择分类')
return
}
if (!selectedItems.value.length) {
ElMessage.warning('请勾选需要授权的案例文件')
return
}
const actionableItems = selectedItems.value.filter((item) => item.authorizeToExperiment || item.authorizeToResource)
if (!actionableItems.length) {
ElMessage.warning('当前授权平台下没有可授权的案例文件')
return
}
const successStats = {
experiment: { case: 0, book: 0, video: 0 },
resource: { courseware: 0, lessonPlan: 0, video: 0, other: 0 },
}
submitting.value = true
progress.value = 0
progressText.value = `准备处理 0/${actionableItems.length}`
try {
for (let index = 0; index < actionableItems.length; index += 1) {
const item = actionableItems[index]
const { type, file, authorizeToExperiment, authorizeToResource } = item
setStepProgress(index, actionableItems.length, 0.05, `准备处理 ${index + 1}/${actionableItems.length}${file.name}`)
if (type === 'video') {
const sourceId = await uploadVideoFromCase(file.url, file.name, index, actionableItems.length)
if (authorizeToExperiment) {
setStepProgress(index, actionableItems.length, 0.98, `正在创建实验操作视频:${file.name}`)
await createVideo({
experiment_id: selectedExperiment.value,
name: getFileNameWithoutExt(file.name),
source_id: sourceId,
status: '1',
})
successStats.experiment.video += 1
}
if (authorizeToResource) {
setStepProgress(index, actionableItems.length, 0.98, `正在创建统一资源视频:${file.name}`)
await createResourceVideo({
name: getFileNameWithoutExt(file.name),
source: '2',
classification: resourceClassification.value,
knowledge_points: '',
cover: '',
source_id: sourceId,
})
successStats.resource.video += 1
}
progress.value = Math.round(((index + 1) / actionableItems.length) * 100)
continue
}
const uploadedFile = await uploadDocumentFromCase(file.url, file.name, index, actionableItems.length)
if (type === 'case' && authorizeToExperiment) {
setStepProgress(index, actionableItems.length, 0.95, `正在创建实验案例原文:${file.name}`)
await createCase({
experiment_id: selectedExperiment.value,
name: getFileNameWithoutExt(file.name),
type: getMimeType(file.name),
url: uploadedFile.url,
size: (uploadedFile.file.size / 1024 / 1024).toString(),
status: '1',
})
successStats.experiment.case += 1
}
if (type === 'ppt' && authorizeToResource) {
setStepProgress(index, actionableItems.length, 0.95, `正在创建统一资源课件:${file.name}`)
await createResourceCourseware({
name: getFileNameWithoutExt(file.name),
source: '2',
classification: resourceClassification.value,
knowledge_points: '',
url: uploadedFile.url,
type: getFileExtension(file.name),
size: `${uploadedFile.file.size}`,
})
successStats.resource.courseware += 1
}
if (type === 'book') {
if (authorizeToExperiment) {
setStepProgress(index, actionableItems.length, 0.95, `正在创建实验案例指导书:${file.name}`)
await createBook({
experiment_id: selectedExperiment.value,
name: getFileNameWithoutExt(file.name),
type: getMimeType(file.name),
url: uploadedFile.url,
size: (uploadedFile.file.size / 1024 / 1024).toString(),
status: '1',
})
successStats.experiment.book += 1
}
if (authorizeToResource) {
setStepProgress(index, actionableItems.length, 0.97, `正在创建统一资源教案:${file.name}`)
await createResourceLessonPlan({
name: getFileNameWithoutExt(file.name),
source: '2',
classification: resourceClassification.value,
knowledge_points: '',
url: uploadedFile.url,
type: getFileExtension(file.name),
size: `${uploadedFile.file.size}`,
})
successStats.resource.lessonPlan += 1
}
}
if (type === 'dataset' && authorizeToResource) {
setStepProgress(index, actionableItems.length, 0.95, `正在创建统一资源其他资料:${file.name}`)
await createResourceOther({
name: getFileNameWithoutExt(file.name),
source: '2',
classification: resourceClassification.value,
knowledge_points: '',
url: uploadedFile.url,
type: getFileExtension(file.name),
size: `${uploadedFile.file.size}`,
})
successStats.resource.other += 1
}
progress.value = Math.round(((index + 1) / actionableItems.length) * 100)
}
const summaryText = buildSummaryText(successStats)
if (!summaryText) {
ElMessage.warning('当前授权平台下没有可授权的案例文件')
return
}
ElMessage.success(`授权完成:${summaryText}`)
progressText.value = `授权完成 ${actionableItems.length}/${actionableItems.length}`
} catch (error) {
console.error(error)
ElMessage.error('批量授权失败')
progressText.value = '授权失败'
} finally {
submitting.value = false
}
}
return {
caseFileSectionConfig,
caseFileTypeMap,
categoryList,
courses,
experiments,
fileSelections,
groupedFiles,
list,
loading,
platformSelections,
progress,
progressText,
resourceClassification,
selectedCase,
selectedCaseId,
selectedCount,
selectedCourse,
selectedExperiment,
submitting,
getAllChecked,
getFileKey,
getFilesByType,
handleCaseChange,
handleToggleAll,
isTypeSelectable,
submitAuthorize,
}
}
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: '/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 type CaseFileSelectionType = 'case' | 'ppt' | 'book' | 'video' | 'dataset'
export type AuthorizePlatform = 'resource' | 'experiment'
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
}
export interface ResourceDocumentCreateItem {
name: string
source: string
classification: string
knowledge_points?: string
url: string
type: string
size: string
}
export interface ResourceVideoCreateItem {
name: string
source: string
classification: string
knowledge_points: string
cover: string
source_id: string
}
<script setup lang="ts">
import { useCaseAuthorization } from '../composables/useCaseAuthorization'
const defaultProps = {
children: 'children',
label: 'category_name',
value: 'id',
}
const {
caseFileSectionConfig,
caseFileTypeMap,
categoryList,
courses,
experiments,
fileSelections,
groupedFiles,
list,
loading,
platformSelections,
progress,
progressText,
resourceClassification,
selectedCase,
selectedCaseId,
selectedCount,
selectedCourse,
selectedExperiment,
submitting,
getAllChecked,
getFileKey,
getFilesByType,
handleCaseChange,
handleToggleAll,
isTypeSelectable,
submitAuthorize,
} = useCaseAuthorization()
</script>
<template>
<AppCard title="案例授权">
<div class="case-auth-toolbar">
<el-checkbox-group v-model="platformSelections">
<el-checkbox label="resource">统一资源平台</el-checkbox>
<el-checkbox label="experiment">实操平台</el-checkbox>
</el-checkbox-group>
<el-tree-select
v-if="platformSelections.includes('resource')"
v-model="resourceClassification"
:data="categoryList"
:props="defaultProps"
node-key="id"
clearable
check-strictly
filterable
style="width: 240px"
placeholder="请选择统一资源分类" />
<el-select
v-if="platformSelections.includes('experiment')"
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-if="platformSelections.includes('experiment')"
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="submitAuthorize" :disabled="!selectedCount">
批量授权
</el-button>
</div>
<el-alert type="warning" :closable="false" class="case-auth-alert">
<div class="case-auth-alert__content">
<div class="case-auth-alert__title">重要提示:</div>
<div>1、授权到实操平台前请先创建课程和实验</div>
<div>2、授权到统一资源平台时必须选择分类</div>
<div class="case-auth-alert__title">授权资源映射说明:</div>
<div>1、案例原文 -> 实操平台:实验案例原文</div>
<div>2、案例指导书 -> 实操平台:实验案例指导书;统一资源平台:教案</div>
<div>3、案例PPT -> 统一资源平台:课件</div>
<div>4、案例操作视频 -> 统一资源平台:视频;实操平台:实验操作视频</div>
<div>5、案例数据集 -> 统一资源平台:其他资料</div>
</div>
</el-alert>
<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
v-for="section in caseFileSectionConfig"
:key="section.key"
size="small"
:type="getFilesByType(item.files, caseFileTypeMap[section.key]).length ? 'success' : 'info'">
{{ section.title }} {{ getFilesByType(item.files, caseFileTypeMap[section.key]).length }}
</el-tag>
</div>
</div>
</div>
<div v-if="selectedCase" class="case-auth-sections">
<div
v-for="section in caseFileSectionConfig"
:key="section.key"
class="case-auth-section"
:class="{ 'case-auth-section--disabled': !isTypeSelectable(section.key) }">
<div class="case-auth-section__header">
<div class="case-auth-section__title">{{ section.title }}</div>
<el-checkbox
:model-value="getAllChecked(section.key)"
:disabled="!groupedFiles[section.key].length || !isTypeSelectable(section.key)"
@update:model-value="(value) => handleToggleAll(section.key, !!value)">
全选
</el-checkbox>
</div>
<el-checkbox-group v-model="fileSelections[section.key]" class="case-auth-options">
<el-checkbox
v-for="file in groupedFiles[section.key]"
:key="getFileKey(file)"
:label="getFileKey(file)"
:disabled="!isTypeSelectable(section.key)">
{{ file.name }}
</el-checkbox>
</el-checkbox-group>
<el-empty v-if="!groupedFiles[section.key].length" :description="section.emptyText" :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-alert {
margin-bottom: 16px;
}
.case-auth-alert__title {
font-weight: 600;
}
.case-auth-alert__content {
display: flex;
flex-direction: column;
gap: 6px;
line-height: 1.6;
}
.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--disabled {
opacity: 0.65;
}
.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,
.case-auth-sections {
grid-template-columns: 1fr;
}
}
</style>
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论