提交 3dc107c7 authored 作者: 王鹏飞's avatar 王鹏飞

feat: 新增案例资源

上级 14fc05ef
-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAwZGHX8Zg+EP5uwnEBWkQazq8DMnKz3QfcNRUjFsxsZjM/98r
oYYp+K6/i/b8JXAVMqjC9+X/ZajgbDPLTrgDJ4TQ70ajdusqG3swNqG0qLUogNgU
PcgVRKr2Pk+m79GD/nnxFgeaG6eBfLsb73G2cQUwsgi3bjf6mX6b9bjxjqzwX0PW
es27yyqdS9uQFzV5KLTA2FJJjGGbg54ZLxVc01aVXx2gG0eLnOFcDSmgXUN4hBmN
gYF4FYHhOVXdMl2+yGGp6IvtIIrt+cp2/TQePnxB2QYwrlDMajHrKp0x7LsqWwZT
jepPgGWWDssiSgTCYVaPx28ESbotpzR7D+LlHQIDAQABAoIBAQCCvXGrHcmXRgph
62mrodgGJQioto82aZ9mzPNCTcIVyzgDRtAoa/7jSdx0g+CwxLB8pAH0ADPtjQ/v
5VfXlnAC/DFGu/Zittmc8CqTa8si/CEcTVX2mLLMy5BAa/o8Fs5JTkGo+H99WDkc
byCiLLLmSXQne72xQzwO+rYKVN1K2JUfzch5wpv86IgfJm0gNEOOvtToeNeMbOWj
7AFfpqq7bRMIdZZZhm2n33duSCSnAO7/OuKHY2/RUkc6FvamAth/ME5S2eHrB7yb
KOMZTJH73xnZc3gmsBiIfC1BsqzV/37Ptd7ncTxuK5YZPXIyJ/BjnDTe712LAz+r
f3FEmtJdAoGBAOdPpbT1ylZKinxEIP4sUPUUrrlXqQxYn2xTOCSBz1dB7OpeqiD+
OBa6ur5Sh0+Ot/VRqV2MbXaGee/qqBiziFH23wKoFc6kxeH8RCv247y89yOHqpCP
SM4I7d10RpPh/POU0apGNOP4HOLOMY4V11u68em9hQDeN6oUBWKvDWTPAoGBANY6
mZ/EWKOFJSPfJNNtfXDO27P12ENsa9UvDv7f+FrcPqGkJq1qFBr3ySBjUE7hTv1e
jedRHtBg5Y038vj/MTofEPSaa5POFgzwfG5TPYCcrZkDoLosnuQ92yafMxZPAf16
Nt3vimpxV2Hwf1Nl3IAU/BavngdohWJkO+yOuupTAoGBAMxkTrJmdhJl75T6xoSS
OD83tWTFFvV34fdgWb24jrQ15tj6KS7aNEmxWo4Ocs5T+C1nqVamXZtcHKdpHHwn
OA/lKw435H0ikFCuDrcig/4ko+TbXlY6pkSZE0aaFjtad5/tIo9pVhcTCMNJG/TH
S/Mcb+LwunQX5+v7oP6gXB91AoGAFDbjWBgG5ScjIgdkSi8CrypVwN1p0rLgWfVe
zN4FaU1u+4GpxKNF0mWfLNcyNL893NqXOUSGS9NY1cJ5wtURqBJWXz5S+Fdx7Tak
ByUDoKcoGlAlUmvzckYD79kF0Q0Tev+VPcvkm7amMC+pVA1AiTVxtBJOLxTFwUsD
/IZbTNECgYEA5uwkBWpb/2yYk86cfuUWJjZ0k8jZPrdG4d9gTdD1Fj4abS3FniKM
x9eN8BcnH0j4raRo+MESzYf5hLbfHRG01oRtBQFZXEM0k/zkkRYdKWG1Ulg1+iZR
ZG/lblczYEUW/IOPYZKMX8c5nq7F6huLsioIvsYOdCGjLZbyRHjEPv8=
-----END RSA PRIVATE KEY-----
-----BEGIN CERTIFICATE-----
MIIGtDCCBZygAwIBAgIQJtN62MbxMBaLhLadTXD/LzANBgkqhkiG9w0BAQsFADBj
MQswCQYDVQQGEwJDTjE2MDQGA1UECgwtQmVpamluZyBYaW5jaGFjaGEgQ3JlZGl0
IE1hbmFnZW1lbnQgQ28uLCBMdGQuMRwwGgYDVQQDDBNYY2MgVHJ1c3QgT1YgU1NM
IENBMB4XDTIzMDkwNzA1NTM0MVoXDTI0MDkwNjA1NTM0MFowgY4xCzAJBgNVBAYT
AkNOMRIwEAYDVQQIDAnljJfkuqzluIIxEjAQBgNVBAcMCeWMl+S6rOW4gjE/MD0G
A1UECgw25riF5o6n57Sr6I2G77yI5YyX5Lqs77yJ5pWZ6IKy56eR5oqA6IKh5Lu9
5pyJ6ZmQ5YWs5Y+4MRYwFAYDVQQDDA0qLmV6aWppbmcuY29tMIIBIjANBgkqhkiG
9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwZGHX8Zg+EP5uwnEBWkQazq8DMnKz3QfcNRU
jFsxsZjM/98roYYp+K6/i/b8JXAVMqjC9+X/ZajgbDPLTrgDJ4TQ70ajdusqG3sw
NqG0qLUogNgUPcgVRKr2Pk+m79GD/nnxFgeaG6eBfLsb73G2cQUwsgi3bjf6mX6b
9bjxjqzwX0PWes27yyqdS9uQFzV5KLTA2FJJjGGbg54ZLxVc01aVXx2gG0eLnOFc
DSmgXUN4hBmNgYF4FYHhOVXdMl2+yGGp6IvtIIrt+cp2/TQePnxB2QYwrlDMajHr
Kp0x7LsqWwZTjepPgGWWDssiSgTCYVaPx28ESbotpzR7D+LlHQIDAQABo4IDNjCC
AzIwDAYDVR0TAQH/BAIwADBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8veGluY2hh
Y2hhMm92LmNybC5jZXJ0dW0ucGwveGluY2hhY2hhMm92LmNybDB5BggrBgEFBQcB
AQRtMGswLwYIKwYBBQUHMAGGI2h0dHA6Ly94aW5jaGFjaGEyb3Yub2NzcC1jZXJ0
dW0uY29tMDgGCCsGAQUFBzAChixodHRwOi8vcmVwb3NpdG9yeS5jZXJ0dW0ucGwv
eGluY2hhY2hhMm92LmNlcjAfBgNVHSMEGDAWgBT6oMvCx12BtSCSByALtjtwOwkO
VTAdBgNVHQ4EFgQUXm2TAFqEePvMkmcMN+bVXSvLTH8wTAYDVR0gBEUwQzAIBgZn
gQwBAgIwNwYMKoRoAYb2dwIFARYCMCcwJQYIKwYBBQUHAgEWGWh0dHBzOi8vd3d3
LmNlcnR1bS5wbC9DUFMwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMA4G
A1UdDwEB/wQEAwIFoDAlBgNVHREEHjAcgg0qLmV6aWppbmcuY29tggtlemlqaW5n
LmNvbTCCAXwGCisGAQQB1nkCBAIEggFsBIIBaAFmAHUA7s3QZNXbGs7FXLedtM0T
ojKHRny87N7DUUhZRnEftZsAAAGKbjQU6wAABAMARjBEAiAMbEi/VFFT/mgWiXPw
WgVT5THVJCfr9j1oIcfiB5MFHgIgJ2nAv7xTmfoZfcO8sWoGigLdN8QtGksVUSI2
86Wg3O4AdgDatr9rP7W2Ip+bwrtca+hwkXFsu1GEhTS9pD0wSNf7qwAAAYpuNBUS
AAAEAwBHMEUCIDmQ1/B1s3jRr+0SvDSWEFN9/b4/W8QV5yULASXqQkYqAiEA0wKZ
lklUASXMM8mluAimD5e4qSXu76+148bfZA2d404AdQBIsONr2qZHNA/lagL6nTDr
HFIBy1bdLIHZu7+rOdiEcwAAAYpuNBefAAAEAwBGMEQCICyokQlEyxPTPYNBZzwv
W8AxbKUJl+I5TbJEJsiFECRUAiA8fA86Hof/yLHhMl4amx5AttDNhpDrCHDK1N7m
NYx9XjANBgkqhkiG9w0BAQsFAAOCAQEAq4Q2vW3Xh5TOYk/DofnV2uRiK08P5a5I
NRE5Tet3J5rp2WnNgXoSAOabqWGI6VrTyenTZxrmS/2Aq2A008WOj4Z7T3P9iDS/
MgJkPmc/REqHr15LIDnyRRfmn9+1ShVMwukYHSmZgPewAzSkKdGFN85t3uCEUnXz
kna3ZmSqgJrSWSO9n/3t/kICsVivjqSUuHtnCSbBa+DTSNxIkbkr1t1RJkF4KvSk
AKkvyojoKoeCd+WWEl2MBHY6TK4CzfyLN5qhQub0XqTOh6cG9TwALcLZ4MQeaeOO
ZkGv9DcyeOz7FwwzFa+vAyzhq2XlDPLlpZ4ELALbaqnq3x7HHpzlpw==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEzzCCA7egAwIBAgIRAPJECC2rqQ2ljHLp8pqTQK4wDQYJKoZIhvcNAQELBQAw
fjELMAkGA1UEBhMCUEwxIjAgBgNVBAoTGVVuaXpldG8gVGVjaG5vbG9naWVzIFMu
QS4xJzAlBgNVBAsTHkNlcnR1bSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEiMCAG
A1UEAxMZQ2VydHVtIFRydXN0ZWQgTmV0d29yayBDQTAeFw0yMjA3MDEwNzQ4NDda
Fw0yNzA2MzAwNzQ4NDdaMGMxCzAJBgNVBAYTAkNOMTYwNAYDVQQKDC1CZWlqaW5n
IFhpbmNoYWNoYSBDcmVkaXQgTWFuYWdlbWVudCBDby4sIEx0ZC4xHDAaBgNVBAMM
E1hjYyBUcnVzdCBPViBTU0wgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEK
AoIBAQCz2nxSPEsMeRBdunf+KZ8CrJIj9DUWGVu05WWyES+QkMHzVHCZt8cB2Blp
qzHHqFsXSDHVzcMxDeXBbudmQwYbg5NpSwY9vJHPTl6vlAlK4UWN4+oPTgP9//RN
N6KLmK3kjZ2Zc9F3kFKVaziSwVNjVvneacoUnz6blwLLBc5QelQ8oDT0eEv0QBAY
I0T8pHDgcvWQqTBaRrOb80plDhYju464XbVhtOHvFCiBIkKnfUBnBfptWjGM8Kis
sq4r8YX35B/pioj9g1YX34MLt+5L3vvWXEb2aMiwwB1Z3bSeeiU3N8aMuSb9E9jp
zd0uhzRameU7jAIAr8uuunb7GUJ7AgMBAAGjggFhMIIBXTASBgNVHRMBAf8ECDAG
AQH/AgEAMB0GA1UdDgQWBBT6oMvCx12BtSCSByALtjtwOwkOVTAfBgNVHSMEGDAW
gBQIds3LB/8k9sXN7buQvOKEN0Z19zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0lBBYw
FAYIKwYBBQUHAwEGCCsGAQUFBwMCMC8GA1UdHwQoMCYwJKAioCCGHmh0dHA6Ly9j
cmwuY2VydHVtLnBsL2N0bmNhLmNybDBrBggrBgEFBQcBAQRfMF0wKAYIKwYBBQUH
MAGGHGh0dHA6Ly9zdWJjYS5vY3NwLWNlcnR1bS5jb20wMQYIKwYBBQUHMAKGJWh0
dHA6Ly9yZXBvc2l0b3J5LmNlcnR1bS5wbC9jdG5jYS5jZXIwOgYDVR0gBDMwMTAv
BgRVHSAAMCcwJQYIKwYBBQUHAgEWGWh0dHBzOi8vd3d3LmNlcnR1bS5wbC9DUFMw
DQYJKoZIhvcNAQELBQADggEBACWzdb59JyYc/WivKUcTEZ+H8WOTuv3AiR6rPljB
QUEB/1kxIeBK+276WLJn0nFNFuT9QMUBo/iUvNvuRQqVjouuYB0Oea6zKQpeMWry
oYtbheW+5NYAbdl9tYE4MRRK9zBLMRrQz35q+XfgMInozidhNHCvuulWk38OMM51
O73z53+R1879nnoZZznoYnrnkISVDjlhe6OSMifvIJAwSYwH2z79Y6CTVgtqTDR9
793Gr7UUmk5ydoxClxnku7voTX8iJUPcuAjElxFrhSQoxm6uXXOfItEqGvLkWAcq
IC5mcyU9VpUg1YP+KkDMh+KQM7/k+2ka1em9hO+QHvJ08UQ=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIEtDCCA5ygAwIBAgIRAJOShUABZXFflH8oj+/JmygwDQYJKoZIhvcNAQELBQAw
PjELMAkGA1UEBhMCUEwxGzAZBgNVBAoTElVuaXpldG8gU3AuIHogby5vLjESMBAG
A1UEAxMJQ2VydHVtIENBMB4XDTA4MTAyMjEyMDczN1oXDTI3MDYxMDEwNDYzOVow
fjELMAkGA1UEBhMCUEwxIjAgBgNVBAoTGVVuaXpldG8gVGVjaG5vbG9naWVzIFMu
QS4xJzAlBgNVBAsTHkNlcnR1bSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEiMCAG
A1UEAxMZQ2VydHVtIFRydXN0ZWQgTmV0d29yayBDQTCCASIwDQYJKoZIhvcNAQEB
BQADggEPADCCAQoCggEBAOP7faNyusLwyRSH9WsBTuFuQAe6bSddf/dbLbNax1Ff
q6QypmGHtm4PhtIwApf412lXoRg5XWpkecYBWaw8MUo4fNIE0kso6CBfOweizE1z
2/OuT8dW1Vqnlon686to1COGWSfPCSe8rG5ygxwwct/gounS4XR1Gb0qnnsVVAQb
10M5rVUoxeIau/TA5K44STPMdoWfOUXSpJ7yEoxR+HzkLX/1rF/rFp+xLdG6zJFC
d0wlyZA4b9vwzPuOHpdZPtVgTuYFKO1JeRNLukjbL/ly0znK/h/YNHL1tEDPMQHD
7N4RLRddH7hQ0V4Zp2neBzMoylCV+adUy1SGUEWp+UkCAwEAAaOCAWswggFnMA8G
A1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFAh2zcsH/yT2xc3tu5C84oQ3RnX3MFIG
A1UdIwRLMEmhQqRAMD4xCzAJBgNVBAYTAlBMMRswGQYDVQQKExJVbml6ZXRvIFNw
LiB6IG8uby4xEjAQBgNVBAMTCUNlcnR1bSBDQYIDAQAgMA4GA1UdDwEB/wQEAwIB
BjAsBgNVHR8EJTAjMCGgH6AdhhtodHRwOi8vY3JsLmNlcnR1bS5wbC9jYS5jcmww
aAYIKwYBBQUHAQEEXDBaMCgGCCsGAQUFBzABhhxodHRwOi8vc3ViY2Eub2NzcC1j
ZXJ0dW0uY29tMC4GCCsGAQUFBzAChiJodHRwOi8vcmVwb3NpdG9yeS5jZXJ0dW0u
cGwvY2EuY2VyMDkGA1UdIAQyMDAwLgYEVR0gADAmMCQGCCsGAQUFBwIBFhhodHRw
Oi8vd3d3LmNlcnR1bS5wbC9DUFMwDQYJKoZIhvcNAQELBQADggEBAI3m/UBmo0yc
p6uh2oTdHDAH5tvHLeyDoVbkHTwmoaUJK+h9Yr6ydZTdCPJ/KEHkgGcCToqPwzXQ
1aknKOrS9KsGhkOujOP5iH3g271CgYACEnWy6BdxqyGVMUZCDYgQOdNv7C9C6kBT
Yr/rynieq6LVLgXqM6vp1peUQl4E7Sztapx6lX0FKgV/CF1mrWHUdqx1lpdzY70a
QVkppV4ig8OLWfqaova9ML9yHRyZhpzyhTwd9yaWLy75ArG1qVDoOPqbCl60BMDO
TjksygtbYvBNWFA0meaaLNKQ1wmB1sCqXs7+0vehukvZ1oaOGR+mBkdCcuBWCgAc
eLmNzJkEN0k=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDDDCCAfSgAwIBAgIDAQAgMA0GCSqGSIb3DQEBBQUAMD4xCzAJBgNVBAYTAlBM
MRswGQYDVQQKExJVbml6ZXRvIFNwLiB6IG8uby4xEjAQBgNVBAMTCUNlcnR1bSBD
QTAeFw0wMjA2MTExMDQ2MzlaFw0yNzA2MTExMDQ2MzlaMD4xCzAJBgNVBAYTAlBM
MRswGQYDVQQKExJVbml6ZXRvIFNwLiB6IG8uby4xEjAQBgNVBAMTCUNlcnR1bSBD
QTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM6xwS7TT3zNJc4YPk/E
jG+AanPIW1H4m9LcuwBcsaD8dQPugfCI7iNS6eYVM42sLQnFdvkrOYCJ5JdLkKWo
ePhzQ3ukYbDYWMzhbGZ+nPMJXlVjhNWo7/OxLjBos8Q82KxujZlakE403Daaj4GI
ULdtlkIJ89eVgw1BS7Bqa/j8D35in2fE7SZfECYPCE/wpFcozo+47UX2bu4lXapu
Ob7kky/ZR6By6/qmW6/KUz/iDsaWVhFu9+lmqSbYf5VT7QqFiLpPKaVCjF62/IUg
AKpoC6EahQGcxEZjgoi2IrHu/qpGWX7PNSzVttpd90gzFFS269lvzs2I1qsb2pY7
HVkCAwEAAaMTMBEwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEA
uI3O7+cUus/usESSbLQ5PqKEbq24IXfS1HeCh+YgQYHu4vgRt2PRFze+GXYkHAQa
TOs9qmdvLdTN/mUxcMUbpgIKumB7bVjCmkn+YzILa+M6wKyrO7Do0wlRjBCDxjTg
xSvgGrZgFCdsMneMvLJymM/NzD+5yCRCFNZX/OYmQ6kd5YCQzgNUKD73P9P4Te1q
CjqTE5s7FCMTY5w/0YcneeVMUeMBrYVdGjux1XMQpNPyvG5k9VpWkKjHDkx0Dy5x
O/fIR/RpbxXyEV6DHpx8Uq79AtoSqFlnGNu8cN2bsWntgM6JQEhqDjXKKWYVIZQs
6GAqm4VKQPNriiTsBhYscw==
-----END CERTIFICATE-----
......@@ -7,12 +7,21 @@ interface Props {
const props = defineProps<Props>()
const isMobile = useMediaQuery('(max-width: 767px)')
// 文件扩展名
const cleanUrl = $computed(() => {
return props.url.split('?')[0].split('#')[0]
})
const fileExtensionName = $computed(() => {
return props.url.split('.').pop() || ''
return cleanUrl.split('.').pop()?.toLowerCase() || ''
})
const isOfficeFile = $computed(() => {
return ['pptx', 'doc', 'docx', 'xls', 'xlsx'].includes(fileExtensionName)
})
const isVideoFile = $computed(() => {
return ['mp4', 'mov', 'm4v', 'webm'].includes(fileExtensionName)
})
// office文件
const officeUrl = $computed(() => {
return isOfficeFile
......@@ -32,6 +41,9 @@ const fileName = $computed(() => {
<a :href="officeUrl" target="_blank" rel="noopener noreferrer" class="preview-mobile-office__link">打开文档</a>
</div>
<iframe allowfullscreen :src="officeUrl" :key="officeUrl" frameborder="0" style="width: 100%; height: 100%" v-else-if="officeUrl"></iframe>
<video v-else-if="isVideoFile" controls playsinline style="width: 100%; height: 100%; background: #000">
<source :src="url" />
</video>
<object :data="url" style="width: 100%; height: 100%; object-fit: none" v-else></object>
</template>
......
......@@ -34,7 +34,7 @@ const listOptions = $computed(() => {
return {
remote: {
httpRequest: getBookList,
params: { name: '', experiment_id: route.query.experiment_id || '', status: '' },
params: { name: '', experiment_id: route.query.experiment_id || '', status: '1' },
},
filters: [
{ type: 'input', prop: 'name', label: '实验指导书名称', placeholder: '请输入实验指导书名称' },
......
......@@ -52,7 +52,7 @@ const listOptions = $computed(() => {
return {
remote: {
httpRequest: getCaseList,
params: { name: '', experiment_id: route.query.experiment_id || '', status: '' },
params: { name: '', experiment_id: route.query.experiment_id || '', status: '1' },
callback(data: { total: number; list: CaseItem[] }) {
const { list = [], total = 0 } = data || {}
const dataList = list.map((item) => {
......
......@@ -209,6 +209,40 @@ export function getExperimentCopyResources(params: { experiment_id: string }) {
return httpRequest.get<ExperimentCopyResources>('/api/resource/v1/backend/experiment/copy-resources', { params })
}
// 获取实验关联案例资源列表
export function getExperimentCaseResourceList(params: { experiment_id: string }) {
return httpRequest.get('/api/lab/v1/teacher/experiment-case/list', { params })
}
// 搜索可绑定案例资源
export function searchExperimentCaseResource(params: {
experiment_id?: string
name?: string
authorized?: string
}) {
return httpRequest.get('/api/lab/v1/teacher/experiment-case/search-case', { params })
}
// 关联案例资源
export function bindExperimentCaseResource(data: { experiment_id: string; case_ids: string }) {
return httpRequest.post('/api/lab/v1/teacher/experiment-case/bind', data)
}
// 解绑案例资源
export function unbindExperimentCaseResource(data: { experiment_case_id: string }) {
return httpRequest.post('/api/lab/v1/teacher/experiment-case/unbind', data)
}
// 保存案例资源文件可见性
export function saveExperimentCaseResourceVisibility(data: {
experiment_case_id: string
files: Array<{ file_id: string; is_visible: string; sort: number }>
}) {
return httpRequest.post('/api/lab/v1/teacher/experiment-case/file-visibility', data, {
headers: { 'Content-Type': 'application/json' },
})
}
// 复制实验
export function copyExperiment(data: {
experiment_id: string
......
<script setup lang="ts">
import type { ExperimentCaseResourceFileItem, ExperimentCaseResourceItem } from '../types'
import { CirclePlus, Document } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
getExperimentCaseResourceList,
saveExperimentCaseResourceVisibility,
unbindExperimentCaseResource,
} from '../api'
import Preview from '@/components/Preview.vue'
const BindDialog = defineAsyncComponent(() => import('./ExperimentCaseResourceDialog.vue'))
interface Props {
experimentId: string
}
const props = defineProps<Props>()
const loading = ref(false)
const dialogVisible = ref(false)
const list = ref<ExperimentCaseResourceItem[]>([])
const previewVisible = ref(false)
const currentFile = ref<ExperimentCaseResourceFileItem | null>(null)
function getFileGroups(files: ExperimentCaseResourceFileItem[]) {
const groups: Record<string, ExperimentCaseResourceFileItem[]> = {}
files.forEach((file) => {
const key = file.type_name || '其他'
if (!groups[key]) {
groups[key] = []
}
groups[key].push(file)
})
return Object.entries(groups).map(([title, items]) => ({ title, items }))
}
function handleView(file: ExperimentCaseResourceFileItem) {
currentFile.value = file
previewVisible.value = true
}
async function fetchList() {
if (!props.experimentId) return
loading.value = true
try {
const { data } = await getExperimentCaseResourceList({ experiment_id: props.experimentId })
list.value = data?.list || []
} finally {
loading.value = false
}
}
async function handleSave(item: ExperimentCaseResourceItem) {
await saveExperimentCaseResourceVisibility({
experiment_case_id: item.experiment_case_id,
files: item.files.map((file, index) => ({
file_id: file.file_id,
is_visible: file.is_visible,
sort: index + 1,
})),
})
ElMessage.success('保存成功')
fetchList()
}
function handleVisibilityChange(item: ExperimentCaseResourceItem) {
handleSave(item)
}
async function handleUnbind(item: ExperimentCaseResourceItem) {
await ElMessageBox.confirm(`确定移除案例“${item.name}”吗?`, '提示', { type: 'warning' })
await unbindExperimentCaseResource({ experiment_case_id: item.experiment_case_id })
ElMessage.success('移除成功')
fetchList()
}
watch(
() => props.experimentId,
() => {
fetchList()
},
{ immediate: true },
)
</script>
<template>
<div class="case-resource-panel" v-loading="loading">
<div class="case-resource-panel__toolbar">
<el-button type="primary" :icon="CirclePlus" @click="dialogVisible = true">关联案例</el-button>
</div>
<el-empty v-if="!loading && !list.length" description="暂未绑定案例资源" :image-size="80" />
<div v-else class="case-resource-list">
<div class="case-resource-card" v-for="item in list" :key="item.experiment_case_id">
<div class="case-resource-card__header">
<div class="case-resource-card__summary">
<div class="case-resource-card__title-row">
<div class="case-resource-card__title">{{ item.name }}</div>
<el-tag size="small" :type="item.is_available === '1' ? 'success' : 'danger'">
{{ item.is_available === '1' ? '有效' : '已失效' }}
</el-tag>
</div>
<div class="case-resource-card__description" v-if="item.description">{{ item.description }}</div>
<div class="case-resource-card__meta">
<span>分类:{{ item.classification_name || '暂无' }}</span>
<span>部门:{{ item.organ_id_name || '暂无' }}</span>
<span>创建人:{{ item.created_operator_name || '暂无' }}</span>
</div>
<div class="case-resource-card__invalid" v-if="item.invalid_reason">{{ item.invalid_reason }}</div>
</div>
<div class="case-resource-card__actions">
<el-button type="danger" plain @click="handleUnbind(item)">移除</el-button>
</div>
</div>
<div class="case-resource-groups">
<div class="case-resource-group" v-for="group in getFileGroups(item.files)" :key="group.title">
<div class="case-resource-group__title">{{ group.title }}</div>
<div class="case-resource-files">
<div class="case-resource-file" v-for="file in group.items" :key="file.file_id">
<div class="case-resource-file__main">
<div class="case-resource-file__icon">
<el-icon><Document /></el-icon>
</div>
<div class="case-resource-file__content">
<div class="case-resource-file__name">{{ file.name }}</div>
</div>
</div>
<div class="case-resource-file__tools">
<el-switch
v-model="file.is_visible"
active-value="1"
inactive-value="0"
@change="handleVisibilityChange(item)" />
<el-button link type="primary" @click="handleView(file)">查看</el-button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<el-dialog
v-model="previewVisible"
:title="currentFile?.name || '文件预览'"
width="80%"
destroy-on-close
append-to-body>
<div class="case-resource-preview" v-if="currentFile">
<Preview :url="currentFile.url"></Preview>
</div>
</el-dialog>
<BindDialog
v-model="dialogVisible"
:experiment-id="experimentId"
@update="fetchList"
v-if="dialogVisible"></BindDialog>
</template>
<style scoped lang="scss">
.case-resource-panel__toolbar {
display: flex;
justify-content: flex-start;
margin-bottom: 16px;
}
.case-resource-list {
display: grid;
gap: 12px;
}
.case-resource-card {
border: 1px solid var(--el-border-color);
border-radius: 12px;
background: #fff;
padding: 14px;
}
.case-resource-card__header {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.case-resource-card__summary {
flex: 1;
min-width: 0;
}
.case-resource-card__title-row {
display: flex;
align-items: center;
gap: 10px;
}
.case-resource-card__title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.case-resource-card__description {
margin-top: 6px;
color: #606266;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.case-resource-card__meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
font-size: 12px;
color: #909399;
}
.case-resource-card__invalid {
margin-top: 6px;
font-size: 12px;
color: #f56c6c;
}
.case-resource-card__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
flex-shrink: 0;
}
.case-resource-groups {
display: grid;
gap: 10px;
}
.case-resource-group__title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: #303133;
}
.case-resource-files {
display: grid;
gap: 8px;
}
.case-resource-file {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
}
.case-resource-file__main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
flex: 1;
}
.case-resource-file__icon {
width: 30px;
height: 30px;
border-radius: 8px;
background: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
color: #606266;
flex-shrink: 0;
}
.case-resource-file__content {
min-width: 0;
}
.case-resource-file__name {
font-size: 13px;
color: #303133;
word-break: break-all;
}
.case-resource-file__tools {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.case-resource-preview {
height: 70vh;
}
@media (max-width: 767px) {
.case-resource-card__header {
flex-direction: column;
}
.case-resource-card__actions {
justify-content: flex-start;
}
.case-resource-file {
flex-direction: column;
align-items: stretch;
}
.case-resource-file__tools {
justify-content: space-between;
}
}
</style>
<script setup lang="ts">
import type { ExperimentCaseResourceSearchItem } from '../types'
import { ElMessage } from 'element-plus'
import { bindExperimentCaseResource, searchExperimentCaseResource } from '../api'
const props = defineProps<{
experimentId: string
}>()
const emit = defineEmits<{
(e: 'update'): void
(e: 'update:modelValue', visible: boolean): void
}>()
const loading = ref(false)
const list = ref<ExperimentCaseResourceSearchItem[]>([])
const selectedIds = ref<string[]>([])
const form = reactive({
name: '',
})
async function fetchList() {
loading.value = true
try {
const { data } = await searchExperimentCaseResource({
experiment_id: props.experimentId,
name: form.name || undefined,
})
list.value = data?.list || []
selectedIds.value = selectedIds.value.filter((id) => list.value.some((item) => item.id === id))
} finally {
loading.value = false
}
}
function handleSelect(row: ExperimentCaseResourceSearchItem) {
if (selectedIds.value.includes(row.id)) {
selectedIds.value = selectedIds.value.filter((id) => id !== row.id)
return
}
selectedIds.value = [...selectedIds.value, row.id]
}
async function handleSubmit() {
if (!selectedIds.value.length) {
ElMessage.warning('请选择要关联的案例')
return
}
await bindExperimentCaseResource({
experiment_id: props.experimentId,
case_ids: selectedIds.value.join(','),
})
ElMessage.success(`成功关联${selectedIds.value.length}个案例`)
emit('update')
emit('update:modelValue', false)
}
onMounted(() => {
fetchList()
})
</script>
<template>
<el-dialog
title="关联案例"
width="900px"
:close-on-click-modal="false"
@update:modelValue="(value) => $emit('update:modelValue', value)">
<div class="dialog-filter">
<el-input v-model="form.name" clearable placeholder="请输入案例名称" @keyup.enter="fetchList" />
<el-button class="dialog-filter__search-btn" type="primary" @click="fetchList">搜索</el-button>
</div>
<el-table
v-if="loading || list.length"
:data="list"
v-loading="loading"
row-key="id"
@row-click="handleSelect"
height="460px">
<el-table-column label="" width="54" align="center">
<template #default="{ row }">
<span class="row-selector" :class="{ 'is-active': selectedIds.includes(row.id) }"></span>
</template>
</el-table-column>
<el-table-column label="案例名称" prop="name" min-width="260" />
<el-table-column label="文件数" prop="file_count" width="90" align="center" />
<el-table-column label="状态" prop="status_name" width="100" align="center" />
<el-table-column label="创建人" prop="created_operator_name" width="120" align="center" />
<el-table-column label="所属部门" prop="organ_id_name" min-width="150" />
</el-table>
<el-empty v-if="!loading && !list.length" description="暂无可关联案例" :image-size="72" />
<template #footer>
<el-row justify="center">
<el-button round auto-insert-space @click="$emit('update:modelValue', false)">取消</el-button>
<el-button type="primary" round auto-insert-space @click="handleSubmit">确认关联</el-button>
</el-row>
</template>
</el-dialog>
</template>
<style scoped lang="scss">
.dialog-filter {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 16px;
> * {
flex: 1 1 220px;
}
}
.dialog-filter__search-btn {
flex: 0 0 88px;
}
.row-selector {
display: inline-flex;
width: 16px;
height: 16px;
border: 2px solid #c0c4cc;
border-radius: 4px;
position: relative;
}
.row-selector.is-active {
border-color: var(--el-color-primary);
}
.row-selector.is-active::after {
content: '';
position: absolute;
inset: 2px;
border-radius: 2px;
background: var(--el-color-primary);
}
@media (max-width: 767px) {
.dialog-filter {
> * {
flex-basis: 100%;
}
}
}
</style>
......@@ -147,3 +147,47 @@ export interface ExperimentCopyResources {
books: ExperimentCopyResourceItem[]
videos: ExperimentCopyResourceItem[]
}
export interface ExperimentCaseResourceFileItem {
id: string
file_id: string
name: string
url: string
size: string
size_name: string
mime_type: string
type: string
type_name: string
is_visible: string
sort: string
}
export interface ExperimentCaseResourceItem {
experiment_case_id: string
case_id: string
name: string
description: string
classification_name: string
organ_id_name: string
created_operator_name: string
belong_operator_name: string
status: string
status_name: string
is_available: string
invalid_reason: string
department_public: string
platform_public: string
files: ExperimentCaseResourceFileItem[]
}
export interface ExperimentCaseResourceSearchItem {
id: string
name: string
classification_name: string
organ_id_name: string
created_operator_name: string
status: string
status_name: string
file_count: string
bind_count: string
}
......@@ -27,7 +27,7 @@ const params = reactive({
id: '',
course_id: '',
name: '',
status: '',
status: '1',
})
// watch(
// () => params.course_id,
......
......@@ -14,6 +14,7 @@ const StudentListDialog = defineAsyncComponent(() => import('../components/Stude
const ViewGradeRules = defineAsyncComponent(() => import('../components/ViewGradeRules.vue'))
const ViewReportRules = defineAsyncComponent(() => import('../components/ViewReportRules.vue'))
const ViewExam = defineAsyncComponent(() => import('../components/ViewExam.vue'))
const ExperimentCaseResource = defineAsyncComponent(() => import('../components/ExperimentCaseResource.vue'))
const CopyDialog = defineAsyncComponent(() => import('../components/CopyDialog.vue'))
const DMLFormDialog = defineAsyncComponent(() => import('../components/DMLFormDialog.vue'))
const GradeRulesDialog = defineAsyncComponent(() => import('../components/GradeRulesDialog.vue'))
......@@ -207,6 +208,9 @@ function handleUpdateGradeRules() {
<AppCard :title="detail?.exam_status === '0' ? '训练考试' : '理论考试'">
<ViewExam v-if="detail" :id="id" :type="detail?.exam_status === '0' ? '2' : '1'"></ViewExam>
</AppCard>
<AppCard title="案例资源" v-permission="'experiment-case'">
<ExperimentCaseResource :experiment-id="id"></ExperimentCaseResource>
</AppCard>
<!-- 关联班级 -->
<SelectClassDialog v-model="selectClassVisible" @update="handleRefetch" v-if="selectClassVisible"></SelectClassDialog>
<!-- 学生分组 -->
......
......@@ -34,7 +34,7 @@ const listOptions = $computed(() => {
return {
remote: {
httpRequest: getVideoList,
params: { name: '', experiment_id: route.query.experiment_id || '', status: '' },
params: { name: '', experiment_id: route.query.experiment_id || '', status: '1' },
},
filters: [
{ type: 'input', prop: 'name', label: '实验操作视频名称', placeholder: '请输入实验操作视频名称' },
......
......@@ -15,6 +15,11 @@ export function getExperimentCase(params: { experiment_id: string }) {
return httpRequest.get('/api/lab/v1/student/experiment-cases/detail', { params })
}
// 获取实验案例资源
export function getExperimentCaseResourceList(params: { experiment_id: string }) {
return httpRequest.get('/api/lab/v1/student/experiment-case/detail', { params })
}
// 获取实验指导书
export function getExperimentBookList(params: { experiment_id: string }) {
return httpRequest.get('/api/lab/v1/student/experiment-book/list', { params })
......
<script setup lang="ts">
import type { ExperimentCaseResourceFileType, ExperimentCaseResourceType } from '../types'
import { Document } from '@element-plus/icons-vue'
import { getExperimentCaseResourceList } from '../api'
import Preview from '@/components/Preview.vue'
interface Props {
experiment_id?: string
}
const props = defineProps<Props>()
const emits = defineEmits(['empty'])
const loading = ref(false)
const list = ref<ExperimentCaseResourceType[]>([])
const previewVisible = ref(false)
const currentFile = ref<ExperimentCaseResourceFileType | null>(null)
function getFileGroups(files: ExperimentCaseResourceFileType[]) {
const groups: Record<string, ExperimentCaseResourceFileType[]> = {}
files.forEach((file) => {
const key = file.type_name || '其他资料'
if (!groups[key]) {
groups[key] = []
}
groups[key].push(file)
})
return Object.entries(groups).map(([title, items]) => ({ title, items }))
}
function handleView(file: ExperimentCaseResourceFileType) {
currentFile.value = file
previewVisible.value = true
}
async function fetchInfo() {
if (!props.experiment_id) return
loading.value = true
try {
const { data } = await getExperimentCaseResourceList({ experiment_id: props.experiment_id })
list.value = data?.list || []
if (!list.value.length) {
emits('empty')
}
} finally {
loading.value = false
}
}
watch(
() => props.experiment_id,
() => {
fetchInfo()
},
{ immediate: true },
)
</script>
<template>
<div class="case-resource-wrap" v-loading="loading">
<div class="case-resource-list" v-if="list.length">
<div class="case-resource-card" v-for="item in list" :key="item.experiment_case_id">
<div class="case-resource-card__header">
<div class="case-resource-card__title">{{ item.name }}</div>
<div class="case-resource-card__description" v-if="item.description">{{ item.description }}</div>
</div>
<div class="case-resource-group" v-for="group in getFileGroups(item.files)" :key="group.title">
<div class="case-resource-group__title">{{ group.title }}</div>
<div class="case-resource-files">
<div class="case-resource-file" v-for="file in group.items" :key="file.file_id">
<div class="case-resource-file__main">
<div class="case-resource-file__icon">
<el-icon><Document /></el-icon>
</div>
<div class="case-resource-file__content">
<div class="case-resource-file__name">{{ file.name }}</div>
</div>
</div>
<el-button link type="primary" @click="handleView(file)">查看</el-button>
</div>
</div>
</div>
</div>
</div>
<el-empty description="暂无数据" v-else-if="!loading" />
<el-dialog v-model="previewVisible" :title="currentFile?.name || '文件预览'" width="80%" destroy-on-close append-to-body>
<div class="case-resource-preview" v-if="currentFile">
<Preview :url="currentFile.url"></Preview>
</div>
</el-dialog>
</div>
</template>
<style lang="scss" scoped>
.case-resource-list {
display: grid;
gap: 12px;
}
.case-resource-card {
border: 1px solid #ebeef5;
border-radius: 12px;
background: #fff;
padding: 14px;
}
.case-resource-card__header {
margin-bottom: 12px;
}
.case-resource-card__title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.case-resource-card__description {
margin-top: 6px;
color: #606266;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.case-resource-group + .case-resource-group {
margin-top: 10px;
}
.case-resource-group__title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
color: #303133;
}
.case-resource-files {
display: grid;
gap: 8px;
}
.case-resource-file {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid #ebeef5;
border-radius: 8px;
}
.case-resource-file__main {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
min-width: 0;
}
.case-resource-file__icon {
width: 30px;
height: 30px;
border-radius: 8px;
background: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #606266;
}
.case-resource-file__content {
min-width: 0;
}
.case-resource-file__name {
font-size: 13px;
color: #303133;
word-break: break-all;
}
.case-resource-preview {
height: 70vh;
}
@media (max-width: 767px) {
.case-resource-file {
flex-direction: column;
align-items: stretch;
}
}
</style>
......@@ -30,6 +30,26 @@ export interface ExperimentVideoType {
cover: string
}
export interface ExperimentCaseResourceFileType {
id: string
file_id: string
name: string
url: string
size: string
size_name: string
mime_type: string
type: string
type_name: string
}
export interface ExperimentCaseResourceType {
id: string
experiment_case_id: string
name: string
description: string
files: ExperimentCaseResourceFileType[]
}
export interface PlayInfo {
BitDepth: number
Bitrate: string
......
......@@ -20,6 +20,7 @@ const appConfig = useAppConfig()
const Question = defineAsyncComponent(() => import('../components/Question.vue'))
const Info = defineAsyncComponent(() => import('../components/Info.vue'))
const Case = defineAsyncComponent(() => import('../components/Case.vue'))
const CaseResource = defineAsyncComponent(() => import('../components/CaseResource.vue'))
const Book = defineAsyncComponent(() => import('../components/Book.vue'))
const Video = defineAsyncComponent(() => import('../components/Video.vue'))
const Discuss = defineAsyncComponent(() => import('../components/Discuss.vue'))
......@@ -109,6 +110,7 @@ const mobilePanelExpanded = ref(false)
const tabLabelMap: Record<string, string> = {
info: '实验信息',
example: '实验案例',
caseResource: '案例资源',
case: '案例原文',
book: '实训指导',
video: '操作视频',
......@@ -302,6 +304,9 @@ function handleGoToFunction(path: string) {
@empty="handleEmpty('example')"
@goToFunction="handleGoToFunction"></Example>
</el-tab-pane>
<el-tab-pane label="案例资源" name="caseResource" v-if="!empty.includes('caseResource')">
<CaseResource :experiment_id="form.experiment_id" @empty="handleEmpty('caseResource')"></CaseResource>
</el-tab-pane>
<el-tab-pane label="案例原文" name="case" v-if="!empty.includes('case')">
<Case
:course_id="form.course_id"
......
// import fs from 'node:fs'
// import path from 'node:path'
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
......@@ -25,25 +23,16 @@ export default defineConfig(({ mode }) => ({
server: {
open: true,
host: 'dev.ezijing.com',
// https: {
// key: fs.readFileSync(path.join(__dirname, './https/ezijing.com.key')),
// cert: fs.readFileSync(path.join(__dirname, './https/ezijing.com.pem'))
// },
proxy: {
// '/api/lab': {
// target: 'http://local-com-resource-api.frontend.ezijing.com',
// changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api\/lab/, ''),
// },
// '/api/resource': {
// target: 'http://com-resource-admin-test.ezijing.com',
// changeOrigin: true,
// rewrite: path => path.replace(/^\/api\/resource/, '')
// },
'/api/dev': {
'/api/resource': {
target: 'http://localhost:20082',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/resource/, ''),
},
'/api/lab': {
target: 'http://localhost:20081',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/dev/, ''),
rewrite: (path) => path.replace(/^\/api\/lab/, ''),
},
'/api': 'https://saas-lab.ezijing.com',
},
......
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论