学生实践

This commit is contained in:
hebo 2026-02-04 09:20:42 +08:00
parent ae1ced2a0c
commit 99e7e43e9d
41 changed files with 3974 additions and 37 deletions

View File

@ -118,3 +118,37 @@ export const jzXsQjActivitiHistoryApi = async (params: any) => {
export const getUserLatestInfoApi = async () => {
return await get("/open/login/getLatestInfo");
};
/**
* ID查询作品执行数据
*/
export const zpzxFindByKcParamsApi = async (params: {
kcId?: string;
njId?: string;
njmcId?: string;
bjId?: string;
xsId?: string;
}) => {
return await get("/api/zpzx/findByKcParams", params);
};
/**
* ID获取作品任务完整详情
*/
export const zpFindDetailByIdApi = async (params: { id: string }) => {
return await get("/api/zp/findDetailById", params);
};
/**
* /
*/
export const zpzxSaveApi = async (params: any) => {
return await post("/api/zpzx/save", params);
};
/**
* ID查询作品清单
*/
export const zpqdFindByZpzxIdApi = async (params: { zpzxId: string }) => {
return await get("/api/zpqd/findPage", { zpzxId: params.zpzxId, page: 1, rows: 100 });
};

View File

@ -0,0 +1,232 @@
<template>
<view class="basic-file-preview">
<view class="file-list" v-if="hasAttachments">
<!-- 显示所有解析后的附件 -->
<view
v-for="(file, index) in parsedAttachments"
:key="(file as any).id || index"
class="file-item"
>
<text class="file-label">附件</text>
<text class="file-name" @click="previewFile(file)">{{ getFileName(file) }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { previewFile as previewFileUtil } from "@/utils/filePreview";
import { imagUrl } from "@/utils";
//
interface Props {
fileUrl?: string;
fileName?: string;
fileFormat?: string;
files?: FileInfo[];
}
interface FileInfo {
id?: string;
name: string;
size?: number;
url: string;
resourName?: string;
resourUrl?: string;
resSuf?: string;
}
//
const props = withDefaults(defineProps<Props>(), {
fileUrl: '',
fileName: '',
fileFormat: '',
files: () => []
});
//
const emit = defineEmits<{
(e: 'preview', file: FileInfo): void;
(e: 'download', file: FileInfo): void;
}>();
//
const hasAttachments = computed(() => {
const attachments = [];
// files
if (props.files && props.files.length > 0) {
attachments.push(...props.files);
}
// fileUrl
else if (props.fileUrl) {
const fileUrls = props.fileUrl.split(',').map(url => url.trim()).filter(url => url);
// fileName
let fileNames: string[] = [];
if (props.fileName) {
fileNames = props.fileName.split(',').map(name => name.trim()).filter(name => name);
}
// URL
fileUrls.forEach((url, index) => {
// 使URL
let displayName = fileNames[index] || url.split('/').pop() || `文件${index + 1}`;
const fileFormat = url.split('.').pop() || props.fileFormat || 'unknown';
// resourName
let resourName = displayName;
if (displayName.includes('.' + fileFormat)) {
resourName = displayName.replace('.' + fileFormat, '');
}
attachments.push({
id: `file_${index}`,
name: displayName,
url: url,
resourUrl: url,
resourName: resourName,
resSuf: fileFormat,
size: 0
});
});
}
return attachments.length > 0;
});
//
const parsedAttachments = computed(() => {
const attachments = [];
// files
if (props.files && props.files.length > 0) {
attachments.push(...props.files);
}
// fileUrl
else if (props.fileUrl) {
const fileUrls = props.fileUrl.split(',').map(url => url.trim()).filter(url => url);
// fileName
let fileNames: string[] = [];
if (props.fileName) {
fileNames = props.fileName.split(',').map(name => name.trim()).filter(name => name);
}
// URL
fileUrls.forEach((url, index) => {
// 使URL
let displayName = fileNames[index] || url.split('/').pop() || `文件${index + 1}`;
const fileFormat = url.split('.').pop() || props.fileFormat || 'unknown';
// resourName
let resourName = displayName;
if (displayName.includes('.' + fileFormat)) {
resourName = displayName.replace('.' + fileFormat, '');
}
attachments.push({
id: `file_${index}`,
name: displayName,
url: url,
resourUrl: url,
resourName: resourName,
resSuf: fileFormat,
size: 0
});
});
}
return attachments;
});
//
const getFileName = (file: FileInfo) => {
if (file.resourName) {
return file.resourName;
}
if (file.name) {
// name
const lastDotIndex = file.name.lastIndexOf('.');
if (lastDotIndex > 0) {
return file.name.substring(0, lastDotIndex);
}
return file.name;
}
return '未知文件';
};
//
const getFileSuffix = (file: FileInfo) => {
if (file.resSuf) {
return file.resSuf;
}
if (file.url) {
const lastDotIndex = file.url.lastIndexOf('.');
if (lastDotIndex > 0) {
return file.url.substring(lastDotIndex + 1);
}
}
if (file.name) {
const lastDotIndex = file.name.lastIndexOf('.');
if (lastDotIndex > 0) {
return file.name.substring(lastDotIndex + 1);
}
}
return 'unknown';
};
//
const previewFile = (file: FileInfo) => {
emit('preview', file);
// URL
const fileUrl = file.resourUrl ? imagUrl(file.resourUrl) : imagUrl(file.url);
const fileName = file.resourName ? `${file.resourName}.${file.resSuf}` : file.name;
const fileSuf = file.resSuf || file.name.split('.').pop() || '';
// 使
previewFileUtil(fileUrl, fileName, fileSuf)
.catch((error: any) => {
console.error('预览失败:', error);
uni.showToast({
title: '预览失败',
icon: 'error'
});
});
};
</script>
<style lang="scss" scoped>
.basic-file-preview {
.file-list {
.file-item {
display: flex;
align-items: center;
margin-bottom: 8px;
.file-label {
color: #666;
font-size: 14px;
margin-right: 8px;
flex-shrink: 0;
}
.file-name {
color: #4e73df;
font-size: 14px;
text-decoration: underline;
cursor: pointer;
word-break: break-all;
flex: 1;
&:active {
color: #2e59d9;
}
}
}
}
}
</style>

View File

@ -0,0 +1,517 @@
<template>
<view class="basic-editor-wrapper">
<!-- #ifdef MP-WEIXIN || H5 || APP-PLUS -->
<!-- 图片插入按钮工具栏 -->
<view v-if="!readOnly" class="editor-toolbar">
<view class="toolbar-btn" @click="handleInsertImage">
<text class="iconfont icon-image" style="font-size: 18px; margin-right: 4px;">📷</text>
<text>插入图片</text>
</view>
</view>
<editor
:id="editorId"
class="editor"
:placeholder="placeholder"
:show-img-size="true"
:show-img-toolbar="true"
:show-img-resize="true"
@ready="onEditorReady"
@input="onEditorInput"
@insertimage="onInsertImage"
:read-only="readOnly"
/>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN || H5 || APP-PLUS -->
<!-- 不支持的平台使用 textarea 作为降级方案 -->
<textarea
class="editor-fallback"
:placeholder="placeholder"
:value="newValue"
@input="onTextareaInput"
:disabled="readOnly"
/>
<view class="fallback-tip">当前平台不支持富文本编辑器请使用文本输入</view>
<!-- #endif -->
</view>
</template>
<script lang="ts">
// #ifdef MP-WEIXIN
export default {
options: {
styleIsolation: 'shared'
}
}
// #endif
</script>
<script setup lang="ts">
import { ref, computed, watch, onMounted, nextTick } from 'vue';
import { attachmentUpload } from "@/api/system/upload";
const attrs: any = useAttrs();
const props = defineProps(['modelValue']);
const emit = defineEmits(['update:modelValue']);
// ID
const editorId = ref(`editor_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`);
const editorContext = ref<any>(null);
const isReady = ref(false);
const isInternalUpdate = ref(false); //
const lastContent = ref(''); //
const placeholder = computed(() => {
return attrs.componentProps?.placeholder || '请输入富文本内容';
});
const readOnly = computed(() => {
return attrs.componentProps?.disabled || false;
});
const newValue = computed({
get() {
return props.modelValue || '';
},
set(value) {
emit('update:modelValue', value);
}
});
//
const onEditorReady = () => {
// #ifdef MP-WEIXIN
//
nextTick(() => {
const query = uni.createSelectorQuery();
query.select(`#${editorId.value}`).context((res: any) => {
if (res && res.context) {
editorContext.value = res.context;
isReady.value = true;
//
if (newValue.value) {
isInternalUpdate.value = true;
editorContext.value.setContents({
html: newValue.value
});
lastContent.value = newValue.value;
nextTick(() => {
isInternalUpdate.value = false;
});
}
}
}).exec();
});
// #endif
// #ifdef H5
// H5
nextTick(() => {
const query = uni.createSelectorQuery();
query.select(`#${editorId.value}`).context((res: any) => {
if (res && res.context) {
editorContext.value = res.context;
isReady.value = true;
//
if (newValue.value) {
try {
isInternalUpdate.value = true;
editorContext.value.setContents({
html: newValue.value
});
lastContent.value = newValue.value;
nextTick(() => {
isInternalUpdate.value = false;
});
} catch (error) {
//
}
}
} else {
isReady.value = true;
}
}).exec();
});
// #endif
// #ifdef APP-PLUS
// APP editor
isReady.value = true;
// #endif
};
//
const onEditorInput = (e: any) => {
const html = e.detail.html || '';
// watch setContents
isInternalUpdate.value = true;
newValue.value = html;
lastContent.value = html;
// watch
nextTick(() => {
isInternalUpdate.value = false;
});
};
// textarea
const onTextareaInput = (e: any) => {
newValue.value = e.detail.value;
};
//
const handleInsertImage = () => {
onInsertImage(null);
};
//
const onInsertImage = async (e: any) => {
if (readOnly.value) {
uni.showToast({ title: '当前为只读模式', icon: 'none' });
return;
}
//
uni.chooseImage({
count: 1,
sizeType: ['compressed', 'original'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempFilePath = res.tempFilePaths[0];
try {
//
uni.showLoading({ title: '上传中...' });
//
// attachmentUpload filePath
const uploadResult = await attachmentUpload(tempFilePath as any);
uni.hideLoading();
// zhxy-jzd
// { result: [{ filePath: '...' }] } { resultCode: 1, result: [{ filePath: '...' }] } { url: '...' }
let imageUrl = '';
const result: any = uploadResult;
// result zhxy-jzd
if (result && result.result && Array.isArray(result.result) && result.result.length > 0) {
imageUrl = result.result[0].filePath || result.result[0].url;
}
// resultCode
else if (result && result.resultCode === 1 && result.result && result.result.length > 0) {
imageUrl = result.result[0].filePath || result.result[0].url;
}
// url
else if (result && result.url) {
imageUrl = result.url;
}
// URL
else if (typeof result === 'string') {
imageUrl = result;
}
if (imageUrl) {
// #ifdef MP-WEIXIN
//
if (editorContext.value && isReady.value) {
editorContext.value.insertImage({
src: imageUrl,
width: '100%',
height: 'auto',
alt: '图片'
});
uni.showToast({ title: '图片插入成功', icon: 'success' });
} else {
uni.showToast({ title: '编辑器未就绪,请稍后重试', icon: 'error' });
}
// #endif
// #ifdef H5
// H5
nextTick(() => {
const query = uni.createSelectorQuery();
query.select(`#${editorId.value}`).context((res: any) => {
if (res && res.context) {
const ctx = res.context;
// 使 insertImage
ctx.insertImage({
src: imageUrl,
width: '100%',
height: 'auto',
alt: '图片',
success: () => {
// watch
isInternalUpdate.value = true;
//
ctx.getContents({
success: (res: any) => {
if (res && res.html) {
lastContent.value = res.html;
newValue.value = res.html;
}
nextTick(() => {
isInternalUpdate.value = false;
});
}
});
uni.showToast({ title: '图片插入成功', icon: 'success' });
},
fail: (err: any) => {
// insertImage
isInternalUpdate.value = true;
const currentHtml = newValue.value || '';
const imgTag = `<img src="${imageUrl}" style="max-width: 100%; height: auto;" alt="图片" />`;
const newHtml = currentHtml + imgTag;
lastContent.value = newHtml;
newValue.value = newHtml;
nextTick(() => {
isInternalUpdate.value = false;
});
uni.showToast({ title: '图片已添加', icon: 'success' });
}
});
} else {
//
isInternalUpdate.value = true;
const currentHtml = newValue.value || '';
const imgTag = `<img src="${imageUrl}" style="max-width: 100%; height: auto;" alt="图片" />`;
const newHtml = currentHtml + imgTag;
lastContent.value = newHtml;
newValue.value = newHtml;
nextTick(() => {
isInternalUpdate.value = false;
});
uni.showToast({ title: '图片已添加', icon: 'success' });
}
}).exec();
});
// #endif
// #ifdef APP-PLUS
// APP
if (editorContext.value && isReady.value) {
editorContext.value.insertImage({
src: imageUrl,
width: '100%',
height: 'auto',
alt: '图片'
});
uni.showToast({ title: '图片插入成功', icon: 'success' });
} else {
uni.showToast({ title: '编辑器未就绪,请稍后重试', icon: 'error' });
}
// #endif
} else {
uni.showToast({ title: '图片上传失败', icon: 'error' });
}
} catch (error) {
uni.hideLoading();
uni.showToast({ title: '图片上传失败,请重试', icon: 'error' });
}
},
fail: (err) => {
uni.showToast({ title: '选择图片失败', icon: 'error' });
}
});
};
//
watch(() => props.modelValue, (newVal) => {
//
if (isInternalUpdate.value) {
return;
}
//
const newValStr = newVal || '';
if (newValStr === lastContent.value) {
return;
}
if (isReady.value && newVal !== undefined) {
// #ifdef MP-WEIXIN
//
if (editorContext.value) {
try {
editorContext.value.setContents({
html: newValStr
});
lastContent.value = newValStr;
} catch (error) {
//
}
}
// #endif
// #ifdef H5
// H5
//
if (editorContext.value) {
try {
//
// H5
editorContext.value.setContents({
html: newValStr
});
lastContent.value = newValStr;
// H5
nextTick(() => {
try {
// 使 DOM API H5
const editorElement = document.querySelector(`#${editorId.value}`);
if (editorElement) {
const range = document.createRange();
const selection = window.getSelection();
if (selection && editorElement) {
range.selectNodeContents(editorElement);
range.collapse(false); // false
selection.removeAllRanges();
selection.addRange(range);
}
}
} catch (err) {
//
}
});
} catch (error) {
//
}
}
// #endif
// #ifdef APP-PLUS
// APP
if (editorContext.value) {
try {
editorContext.value.setContents({
html: newValStr
});
lastContent.value = newValStr;
} catch (error) {
//
}
}
// #endif
}
}, { immediate: false });
onMounted(() => {
//
setTimeout(() => {
if (!isReady.value) {
onEditorReady();
}
}, 300);
});
</script>
<style scoped lang="scss">
.basic-editor-wrapper {
width: 100%;
min-height: 200px;
max-height: none; //
background-color: #fff;
border: 1px solid #e5e7eb !important; //
border-top: 1px solid #e5e7eb !important;
border-right: 1px solid #e5e7eb !important; //
border-bottom: 1px solid #e5e7eb !important;
border-left: 1px solid #e5e7eb !important;
border-radius: 8px;
overflow: visible; //
box-sizing: border-box; //
position: relative; //
.editor-toolbar {
display: flex;
align-items: center;
justify-content: flex-end; //
padding: 8px 12px;
background-color: #f8f9fa;
border-bottom: 1px solid #e5e7eb;
.toolbar-btn {
display: flex;
align-items: center;
padding: 6px 12px;
background-color: #fff;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 14px;
color: #374151;
cursor: pointer;
transition: all 0.2s;
&:active {
background-color: #f3f4f6;
transform: scale(0.98);
}
text {
font-size: 14px;
color: #374151;
}
}
}
.editor {
width: 100%;
min-height: 200px;
max-height: none; //
height: auto; //
padding: 12px;
box-sizing: border-box;
font-size: 14px;
line-height: 1.6;
display: block;
border: none !important; // 使
outline: none; //
overflow-y: auto; //
}
.editor-fallback {
width: 100%;
min-height: 200px;
height: auto; //
padding: 12px;
box-sizing: border-box;
font-size: 14px;
line-height: 1.6;
border: none;
background-color: #fff;
resize: vertical; //
}
.fallback-tip {
padding: 8px 12px;
font-size: 12px;
color: #999;
background-color: #f5f5f5;
border-top: 1px solid #e5e7eb;
}
}
//
:deep(.editor[read-only]) {
background-color: #f5f5f5;
color: #666;
}
//
:deep(editor) {
border: none !important;
outline: none !important;
}
//
:deep(.editor img) {
max-width: 100%;
height: auto;
display: block;
margin: 8px 0;
}
</style>

View File

@ -14,7 +14,7 @@
<uni-icons type="right" size="18" color="#999999" v-if="!attrs.componentProps.disabled"/>
</view>
<Picker ref="popup" :title="attrs.label" :range="range" :rangeKey="rangeKey" v-model="pickerKey" @ok="ok"
@change="change"/>
@change="change" @popupChange="handlePopupChange"/>
</view>
</template>
@ -115,6 +115,13 @@ function change(e: any) {
}
}
//
function handlePopupChange(e: any) {
if (attrs.componentProps.onPopupChange && typeof attrs.componentProps.onPopupChange === 'function') {
attrs.componentProps.onPopupChange(e)
}
}
const pickerValue = ref<any>('')
watchEffect(() => {

View File

@ -15,11 +15,29 @@
<FormBasicSearchList v-bind="attrs" v-if="isShow('BasicSearchList')" v-model="newValue"/>
<FormBasicDateTimes v-bind="attrs" v-if="isShow('BasicDateTimes')" v-model="newValue"/>
<FormBasicTree v-bind="attrs" v-if="isShow('BasicTree')" v-model="newValue"/>
<!-- ImageVideoUpload 组件 -->
<ImageVideoUpload
v-if="isShow('ImageVideoUpload')"
v-bind="attrs.componentProps || {}"
:image-list="getImageList()"
:video-list="getVideoList()"
:file-list="getFileList()"
@update:imageList="handleImageListUpdate"
@update:videoList="handleVideoListUpdate"
@update:fileList="handleFileListUpdate"
/>
<!-- BasicEditor 组件 - 富文本编辑器 -->
<FormBasicEditor
v-if="isShow('BasicEditor')"
v-bind="attrs"
v-model="newValue"
/>
</view>
</template>
<script setup lang="ts">
import {useAttrs} from "vue";
import {useAttrs, computed} from "vue";
import { ImageVideoUpload } from "@/components/ImageVideoUpload";
const attrs = useAttrs()
@ -38,5 +56,50 @@ function isShow(key: string) {
return attrs.component === key;
}
// ImageVideoUpload
const getImageList = () => {
const fieldName = attrs.field as string;
if (fieldName && fieldName.includes('_imageList')) {
return props.modelValue || [];
}
return attrs.componentProps?.imageList || [];
}
const getVideoList = () => {
const fieldName = attrs.field as string;
if (fieldName && fieldName.includes('_videoList')) {
return props.modelValue || [];
}
return attrs.componentProps?.videoList || [];
}
const getFileList = () => {
const fieldName = attrs.field as string;
if (fieldName && fieldName.includes('_fileList')) {
return props.modelValue || [];
}
return attrs.componentProps?.fileList || [];
}
const handleImageListUpdate = (images: any[]) => {
const fieldName = attrs.field as string;
if (fieldName && fieldName.includes('_imageList')) {
emit('update:modelValue', images);
}
}
const handleVideoListUpdate = (videos: any[]) => {
const fieldName = attrs.field as string;
if (fieldName && fieldName.includes('_videoList')) {
emit('update:modelValue', videos);
}
}
const handleFileListUpdate = (files: any[]) => {
const fieldName = attrs.field as string;
if (fieldName && fieldName.includes('_fileList')) {
emit('update:modelValue', files);
}
}
</script>

View File

@ -2,7 +2,7 @@
<view class="image-video-upload">
<!-- 图片上传区域 -->
<view class="upload-section" v-if="enableImage">
<view class="section-title">
<view class="section-title" v-if="showSectionTitle">
<text class="title-text">图片</text>
<text class="count-text">({{ imageList.length }}/{{ maxImageCount }})</text>
</view>
@ -14,14 +14,14 @@
:key="index"
>
<image
:src="image.url ? imagUrl(image.url) : image.tempPath"
:src="getImageSrc(image)"
class="image-preview"
@click="previewImage(index)"
mode="aspectFill"
/>
<view class="image-actions">
<view class="delete-btn" @click="removeImage(index)">
<u-icon name="close" size="16" color="#fff"></u-icon>
<uni-icons type="close" size="16" color="#fff"></uni-icons>
</view>
</view>
<view class="image-status" v-if="image.isCompressed">
@ -34,7 +34,7 @@
v-if="imageList.length < maxImageCount"
@click="chooseImage"
>
<u-icon name="camera" size="24" color="#999"></u-icon>
<uni-icons type="camera" size="24" color="#999"></uni-icons>
<text class="add-text">添加图片</text>
</view>
</view>
@ -42,7 +42,7 @@
<!-- 视频上传区域 -->
<view class="upload-section" v-if="enableVideo">
<view class="section-title">
<view class="section-title" v-if="showSectionTitle">
<text class="title-text">视频</text>
<text class="count-text">({{ videoList.length }}/{{ maxVideoCount }})</text>
</view>
@ -60,7 +60,7 @@
mode="aspectFill"
/>
<view class="play-icon">
<u-icon name="play-circle" size="24" color="#fff"></u-icon>
<uni-icons type="play-filled" size="24" color="#fff"></uni-icons>
</view>
<view class="video-duration" v-if="video.duration">
{{ formatDuration(video.duration) }}
@ -68,7 +68,7 @@
</view>
<view class="video-actions">
<view class="delete-btn" @click="removeVideo(index)">
<u-icon name="close" size="16" color="#fff"></u-icon>
<uni-icons type="close" size="16" color="#fff"></uni-icons>
</view>
</view>
<view class="video-info">
@ -82,7 +82,7 @@
v-if="videoList.length < maxVideoCount"
@click="chooseVideo"
>
<u-icon name="videocam" size="24" color="#999"></u-icon>
<uni-icons type="videocam" size="24" color="#999"></uni-icons>
<text class="add-text">添加视频</text>
</view>
</view>
@ -90,7 +90,7 @@
<!-- 文件上传区域 -->
<view class="upload-section" v-if="enableFile">
<view class="section-title">
<view class="section-title" v-if="showSectionTitle">
<text class="title-text">文件</text>
<text class="count-text">({{ fileList.length }}/{{ maxFileCount }})</text>
</view>
@ -117,7 +117,7 @@
</view>
<view class="file-actions">
<view class="delete-btn" @click="removeFile(index)">
<u-icon name="close" size="16" color="#fff"></u-icon>
<uni-icons type="close" size="16" color="#fff"></uni-icons>
</view>
</view>
</view>
@ -127,7 +127,7 @@
v-if="fileList.length < maxFileCount"
@click="chooseFile"
>
<u-icon name="attach" size="24" color="#999"></u-icon>
<uni-icons type="paperclip" size="24" color="#999"></uni-icons>
<text class="add-text">添加文件</text>
</view>
</view>
@ -139,7 +139,8 @@
import { ref, computed, watch } from 'vue'
import { imagUrl } from '@/utils'
import {
previewFile as previewFileUtil
previewFile as previewFileUtil,
previewVideo as previewVideoUtil
} from '@/utils/filePreview'
//
@ -208,6 +209,9 @@ interface Props {
//
autoUpload?: boolean
uploadApi?: (file: any) => Promise<any>
//
showSectionTitle?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@ -240,7 +244,9 @@ const props = withDefaults(defineProps<Props>(), {
}),
autoUpload: true,
uploadApi: undefined
uploadApi: undefined,
showSectionTitle: true
})
// Emits
@ -969,25 +975,61 @@ const uploadVideos = async (videos: VideoItem[]) => {
}
}
//
const getImageSrc = (image: ImageItem): string => {
if (!image) return '';
// url URL
if (image.url) {
// URLhttp/https/blob
if (/^(https?|blob):/i.test(image.url)) {
return image.url;
}
// 使 imagUrl
return imagUrl(image.url);
}
// tempPathtempPath URL
return image.tempPath || '';
}
//
const previewImage = (index: number) => {
const urls = imageList.value.map(img => img.url || img.tempPath).filter(Boolean)
const urls = imageList.value.map(img => getImageSrc(img)).filter(Boolean)
uni.previewImage({
urls: urls,
current: index
})
}
//
const getVideoSrc = (video: VideoItem): string => {
if (!video) return '';
// url URL
if (video.url) {
// URLhttp/https/blob
if (/^(https?|blob):/i.test(video.url)) {
return video.url;
}
// 使 imagUrl
return imagUrl(video.url);
}
// tempPathtempPath URL
return video.tempPath || '';
}
//
const previewVideo = (index: number) => {
const video = videoList.value[index]
if (video.url || video.tempPath) {
uni.previewVideo({
sources: [{
src: video.url || video.tempPath,
type: 'mp4'
}]
})
const videoSrc = getVideoSrc(video);
if (videoSrc) {
const videoName = video.originalName || video.name || '视频';
previewVideoUtil(videoSrc, videoName)
.catch((error: any) => {
console.error('视频预览失败:', error);
uni.showToast({
title: '视频预览失败',
icon: 'error'
});
});
}
}
@ -1282,4 +1324,3 @@ defineExpose({
justify-content: center;
}
</style>

View File

@ -0,0 +1,223 @@
# ImageVideoUpload 图片视频上传组件
一个功能完整的图片和视频上传组件,支持压缩、预览、删除等功能。
## 功能特性
- ✅ 图片和视频上传
- ✅ 智能图片压缩支持H5和APP环境
- ✅ 文件大小和时长限制
- ✅ 预览功能
- ✅ 删除功能
- ✅ 上传进度显示
- ✅ 自定义压缩配置
- ✅ 自动上传或手动上传
- ✅ 完整的事件回调
## 基本使用
```vue
<template>
<view class="container">
<ImageVideoUpload
v-model:image-list="imageList"
v-model:video-list="videoList"
:upload-api="uploadApi"
:compress-config="compressConfig"
@image-upload-success="onImageUploadSuccess"
@video-upload-success="onVideoUploadSuccess"
/>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ImageVideoUpload, type ImageItem, type VideoItem, COMPRESS_PRESETS } from '@/components/ImageVideoUpload'
import { attachmentUpload } from '@/api/system/upload'
// 数据
const imageList = ref<ImageItem[]>([])
const videoList = ref<VideoItem[]>([])
// 压缩配置
const compressConfig = ref(COMPRESS_PRESETS.medium)
// 上传API
const uploadApi = attachmentUpload
// 事件处理
const onImageUploadSuccess = (image: ImageItem, index: number) => {
console.log('图片上传成功:', image, index)
}
const onVideoUploadSuccess = (video: VideoItem, index: number) => {
console.log('视频上传成功:', video, index)
}
</script>
```
## Props 属性
| 属性名 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| enableImage | boolean | true | 是否启用图片上传 |
| enableVideo | boolean | true | 是否启用视频上传 |
| maxImageCount | number | 5 | 最大图片数量 |
| maxVideoCount | number | 3 | 最大视频数量 |
| imageList | ImageItem[] | [] | 图片列表 |
| videoList | VideoItem[] | [] | 视频列表 |
| compressConfig | CompressConfig | 默认配置 | 压缩配置 |
| autoUpload | boolean | true | 是否自动上传 |
| uploadApi | Function | - | 上传API函数 |
## Events 事件
| 事件名 | 参数 | 说明 |
|--------|------|------|
| update:imageList | images: ImageItem[] | 图片列表更新 |
| update:videoList | videos: VideoItem[] | 视频列表更新 |
| image-upload-success | image: ImageItem, index: number | 图片上传成功 |
| image-upload-error | error: any, index: number | 图片上传失败 |
| video-upload-success | video: VideoItem, index: number | 视频上传成功 |
| video-upload-error | error: any, index: number | 视频上传失败 |
| upload-progress | type: 'image' \| 'video', current: number, total: number | 上传进度 |
## 压缩配置
### 使用预设配置
```javascript
import { COMPRESS_PRESETS } from '@/components/ImageVideoUpload'
// 高质量配置
const highQualityConfig = COMPRESS_PRESETS.high
// 平衡配置(默认)
const mediumConfig = COMPRESS_PRESETS.medium
// 高压缩配置
const lowQualityConfig = COMPRESS_PRESETS.low
```
### 自定义配置
```javascript
const customConfig = {
image: {
quality: 70, // 压缩质量 (1-100)
maxWidth: 1600, // 最大宽度
maxHeight: 900, // 最大高度
maxSize: 300 * 1024, // 最大文件大小 300KB
minQuality: 25 // 最低压缩质量
},
video: {
maxDuration: 45, // 最大时长45秒
maxSize: 8 * 1024 * 1024, // 最大文件大小 8MB
quality: 'medium' // 视频质量
}
}
```
## 高级用法
### 手动上传
```vue
<template>
<ImageVideoUpload
ref="uploadRef"
v-model:image-list="imageList"
v-model:video-list="videoList"
:auto-upload="false"
:upload-api="uploadApi"
/>
<button @click="handleUpload">手动上传</button>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const uploadRef = ref()
const handleUpload = async () => {
// 上传所有图片
await uploadRef.value.uploadImages(imageList.value)
// 上传所有视频
await uploadRef.value.uploadVideos(videoList.value)
}
</script>
```
### 监听上传进度
```vue
<template>
<ImageVideoUpload
v-model:image-list="imageList"
v-model:video-list="videoList"
:upload-api="uploadApi"
@upload-progress="onUploadProgress"
/>
<view v-if="uploading" class="progress">
上传进度: {{ currentProgress }}/{{ totalProgress }}
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const uploading = ref(false)
const currentProgress = ref(0)
const totalProgress = ref(0)
const onUploadProgress = (type: 'image' | 'video', current: number, total: number) => {
uploading.value = true
currentProgress.value = current
totalProgress.value = total
if (current === total) {
uploading.value = false
}
}
</script>
```
## 样式自定义
组件使用 scoped 样式,如需自定义样式,可以通过以下方式:
```vue
<style>
/* 全局样式覆盖 */
.image-video-upload .add-btn {
border-color: #007aff;
}
.image-video-upload .add-btn .add-text {
color: #007aff;
}
</style>
```
## 注意事项
1. **H5环境**使用Canvas进行图片压缩
2. **APP环境**使用uni.compressImage进行压缩
3. **文件大小**:建议根据实际需求调整压缩配置
4. **上传API**:需要返回标准的响应格式
5. **内存管理**:上传成功后会自动清理临时文件
## 响应格式要求
上传API需要返回以下格式的响应
```javascript
{
resultCode: 1, // 1表示成功
result: [
{
filePath: "服务器文件路径"
}
]
}
```

View File

@ -0,0 +1,65 @@
<template>
<view class="example-page">
<text class="title">图片视频上传组件示例</text>
<ImageVideoUpload
v-model:image-list="imageList"
v-model:video-list="videoList"
:max-image-count="5"
:max-video-count="3"
:compress-config="compressConfig"
:upload-api="uploadApi"
@image-upload-success="onImageUploadSuccess"
@video-upload-success="onVideoUploadSuccess"
/>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ImageVideoUpload, type ImageItem, type VideoItem, COMPRESS_PRESETS } from './index'
//
const imageList = ref<ImageItem[]>([])
const videoList = ref<VideoItem[]>([])
//
const compressConfig = ref(COMPRESS_PRESETS.medium)
// API
const uploadApi = async (file: any) => {
console.log('上传文件:', file)
//
await new Promise(resolve => setTimeout(resolve, 1000))
//
return {
resultCode: 1,
result: [{
filePath: `https://example.com/uploads/${Date.now()}.jpg`
}]
}
}
//
const onImageUploadSuccess = (image: ImageItem, index: number) => {
console.log('图片上传成功:', image, index)
}
const onVideoUploadSuccess = (video: VideoItem, index: number) => {
console.log('视频上传成功:', video, index)
}
</script>
<style scoped>
.example-page {
padding: 20rpx;
}
.title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 20rpx;
display: block;
}
</style>

View File

@ -4,4 +4,3 @@ export * from './types'
// 默认导出
export { default } from './ImageVideoUpload.vue'

View File

@ -153,4 +153,3 @@ export const COMPRESS_PRESETS = {
}
}
}

View File

@ -0,0 +1,204 @@
# ImageVideoUpload 图片视频上传组件
## 组件位置
`D:\code\zhxy-jsd\src\components\ImageVideoUpload\`
## 文件结构
```
ImageVideoUpload/
├── ImageVideoUpload.vue # 主组件文件
├── types.ts # 类型定义
├── index.ts # 导出文件
├── example.vue # 使用示例
├── README.md # 详细文档
└── 使用说明.md # 中文说明
```
## 快速使用
### 1. 基本用法
```vue
<template>
<ImageVideoUpload
v-model:image-list="imageList"
v-model:video-list="videoList"
:upload-api="uploadApi"
/>
</template>
<script setup>
import { ref } from 'vue'
import { ImageVideoUpload } from '@/components/ImageVideoUpload'
const imageList = ref([])
const videoList = ref([])
const uploadApi = async (file) => {
// 你的上传逻辑
return { resultCode: 1, result: [{ filePath: '服务器路径' }] }
}
</script>
```
### 2. 完整配置
```vue
<template>
<ImageVideoUpload
v-model:image-list="imageList"
v-model:video-list="videoList"
:max-image-count="5"
:max-video-count="3"
:compress-config="compressConfig"
:upload-api="uploadApi"
:auto-upload="true"
@image-upload-success="onImageUploadSuccess"
@video-upload-success="onVideoUploadSuccess"
/>
</template>
<script setup>
import { ref } from 'vue'
import { ImageVideoUpload, COMPRESS_PRESETS } from '@/components/ImageVideoUpload'
const imageList = ref([])
const videoList = ref([])
const compressConfig = ref(COMPRESS_PRESETS.medium)
const uploadApi = async (file) => {
// 上传逻辑
}
const onImageUploadSuccess = (image, index) => {
console.log('图片上传成功', image)
}
</script>
```
## 主要功能
### ✅ 图片功能
- 拍照/相册选择
- 智能压缩H5/APP自适应
- 预览功能
- 删除功能
- 压缩状态显示
### ✅ 视频功能
- 拍摄/相册选择
- 文件大小检查
- 时长限制
- 预览播放
- 删除功能
### ✅ 压缩功能
- 尺寸压缩(降低分辨率)
- 质量压缩调整JPEG质量
- 智能压缩(根据文件大小自动调整)
- 多级压缩(确保达到目标大小)
## 压缩配置
### 预设配置
```javascript
import { COMPRESS_PRESETS } from '@/components/ImageVideoUpload'
// 高质量(文件较大)
const highConfig = COMPRESS_PRESETS.high
// 平衡(推荐)
const mediumConfig = COMPRESS_PRESETS.medium
// 高压缩(文件很小)
const lowConfig = COMPRESS_PRESETS.low
```
### 自定义配置
```javascript
const customConfig = {
image: {
quality: 70, // 压缩质量 1-100
maxWidth: 1600, // 最大宽度
maxHeight: 900, // 最大高度
maxSize: 300 * 1024, // 最大文件大小(字节)
minQuality: 25 // 最低压缩质量
},
video: {
maxDuration: 45, // 最大时长(秒)
maxSize: 8 * 1024 * 1024, // 最大文件大小(字节)
quality: 'medium' // 视频质量
}
}
```
## 事件回调
```javascript
// 图片上传成功
@image-upload-success="(image, index) => {}"
// 图片上传失败
@image-upload-error="(error, index) => {}"
// 视频上传成功
@video-upload-success="(video, index) => {}"
// 视频上传失败
@video-upload-error="(error, index) => {}"
// 上传进度
@upload-progress="(type, current, total) => {}"
```
## 在 xcXkkcDetail.vue 中的使用
原来的代码已经替换为:
```vue
<ImageVideoUpload
v-model:image-list="imageList"
v-model:video-list="videoList"
:max-image-count="5"
:max-video-count="3"
:compress-config="compressConfig"
:upload-api="attachmentUpload"
@image-upload-success="onImageUploadSuccess"
@video-upload-success="onVideoUploadSuccess"
/>
```
## 注意事项
1. **环境兼容**自动检测H5/APP环境使用不同的压缩方案
2. **文件大小**:建议根据实际需求调整压缩配置
3. **上传API**:需要返回标准格式的响应
4. **内存管理**:上传成功后自动清理临时文件
5. **错误处理**:完善的错误处理和用户提示
## 响应格式要求
上传API需要返回
```javascript
{
resultCode: 1, // 1表示成功
result: [
{
filePath: "服务器文件路径"
}
]
}
```
## 样式自定义
组件使用scoped样式如需自定义
```css
/* 全局样式覆盖 */
.image-video-upload .add-btn {
border-color: #007aff;
}
```
## 测试
可以运行 `example.vue` 来测试组件功能。

View File

@ -17,6 +17,8 @@ export const BASE_WS_URL: string =
//图片地址
// export const BASE_IMAGE_URL: string = process.env.NODE_ENV == "development" ? `https://${ip}` : `http://${fwqip}`;
export const BASE_IMAGE_URL: string = `http://${fwqip}`;
// kkFileView预览服务地址
export const KK_FILE_VIEW_URL: string = `https://${fwqip}/kkpro`;
//存token的key
export const AUTH_KEY: string = "satoken";
//token过期返回状态码

View File

@ -76,6 +76,17 @@
"backgroundColor": "#ffffff"
}
},
{
"path": "pages/system/video-player/index",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "视频播放",
"enablePullDownRefresh": false,
"navigationBarBackgroundColor": "#000000",
"navigationBarTextStyle": "white",
"backgroundColor": "#000000"
}
},
{
"path": "pages/system/updatePopup/updatePopup",
"style": {
@ -143,6 +154,27 @@
"enablePullDownRefresh": false
}
},
{
"path": "pages/base/xszp/index",
"style": {
"navigationBarTitleText": "作品任务",
"enablePullDownRefresh": false
}
},
{
"path": "pages/base/xszp/submit",
"style": {
"navigationBarTitleText": "作品提交",
"enablePullDownRefresh": false
}
},
{
"path": "pages/base/xszp/detail",
"style": {
"navigationBarTitleText": "任务详情",
"enablePullDownRefresh": false
}
},
{
"path": "pages/base/jl/detailwb",
"style": {

View File

@ -28,7 +28,7 @@
class="grid-item" @click="handleMenuClick(item)">
<view class="grid-icon-container">
<view class="icon-background"></view>
<image :src="item.icon" class="grid-icon"></image>
<image :src="item.icon" class="grid-icon" @error="handleImageError" mode="aspectFit"></image>
</view>
<text class="grid-text">{{ item.title }}</text>
</view>
@ -204,7 +204,7 @@ const menuItems = ref([
},
{
title: "就餐详情",
icon: "/static/base/home/contacts-book-3-line.png",
icon: "/static/base/home/jcxq.png",
path: "/pages/base/jc/index",
permissionKey: "school-jcxq",
},
@ -226,7 +226,7 @@ const menuItems = ref([
},
{
title: "就餐报名",
icon: "/static/base/home/contacts-book-3-line.png",
icon: "/static/base/home/jcxq.png",
path: "/pages/base/gzs/index",
permissionKey: "school-jcjf",
action: 'jf',
@ -248,6 +248,12 @@ const menuItems = ref([
action: "tf",
lxId: "816059832",
},
{
title: "新苗成长",
icon: "/static/base/home/xszp.png",
path: "/pages/base/xszp/index",
permissionKey: "school-xszp",
},
]);
//
@ -287,6 +293,15 @@ function switchXs(xs: any) {
getArticleList();
}
//
function handleImageError(e: any) {
// 使
const target = e.target || e.currentTarget;
if (target) {
target.src = '/static/base/home/file-text-line.png'; //
}
}
//
function goToDetail(notice: any) {
setData(notice)

View File

@ -0,0 +1,426 @@
<!-- 作品任务详情页面 -->
<template>
<BasicLayout>
<view class="task-detail-page">
<view v-if="isLoading" class="loading-indicator">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<view v-else class="content-wrapper">
<view class="p-15">
<!-- 任务基本信息 -->
<view class="task-info-section">
<view class="section-title">任务要求</view>
<!-- 任务名称 -->
<view class="info-item">
<text class="label">任务名称</text>
<text class="value title-bold">{{ taskInfo.zpmc || '作品任务' }}</text>
</view>
<!-- 任务描述 -->
<view v-if="taskInfo.zpms" class="info-item">
<text class="label">任务描述</text>
<text class="value">{{ taskInfo.zpms }}</text>
</view>
<!-- 任务时间 -->
<view v-if="taskInfo.zpkstime || taskInfo.zpjstime" class="info-item">
<text class="label">任务时间</text>
<text class="value">{{ formatTimeRange(taskInfo.zpkstime, taskInfo.zpjstime) }}</text>
</view>
<!-- 附件预览 -->
<view v-if="taskInfo.fileUrl && taskInfo.fileName" class="file-preview mt-15">
<view class="file-preview-item">
<text class="file-label">附件</text>
<text class="file-name" @click="previewFile(taskInfo.fileUrl)">{{ taskInfo.fileName }}</text>
</view>
</view>
</view>
<!-- 任务项列表 -->
<view v-if="taskItemList.length > 0" class="task-items-section">
<view class="section-title">任务项</view>
<view
v-for="(item, index) in taskItemList"
:key="item.id || index"
class="task-item-card"
>
<view class="item-header">
<text class="item-number">{{ index + 1 }}</text>
<text class="item-title">{{ item.zpbt || '任务项' }}</text>
<view v-if="item.isbt === 1 || item.isbt === true" class="required-badge">
<text class="required-text">必填</text>
</view>
</view>
<view v-if="item.remark" class="item-remark">
<text class="remark-text">{{ item.remark }}</text>
</view>
<view class="item-type">
<text class="type-label">类型</text>
<text class="type-value">{{ getTaskTypeText(item.zpfl) }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</BasicLayout>
</template>
<script lang="ts" setup>
import { ref, onMounted } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { zpFindDetailByIdApi } from "@/api/base/server";
import { imagUrl } from "@/utils";
interface TaskInfo {
id?: string;
zpmc?: string;
zpms?: string;
zpkstime?: string;
zpjstime?: string;
fileUrl?: string;
fileName?: string;
[key: string]: any;
}
interface TaskItem {
id: string;
zpbt?: string;
zpfl?: string;
isbt?: number | boolean;
remark?: string;
zpfldm?: string;
[key: string]: any;
}
const isLoading = ref(false);
const taskInfo = ref<TaskInfo>({});
const taskItemList = ref<TaskItem[]>([]);
onLoad(async (options) => {
console.log('作品任务详情页面加载参数:', options);
const zpId = options?.zpId || '';
if (!zpId) {
uni.showToast({ title: '缺少任务ID', icon: 'error' });
setTimeout(() => {
uni.navigateBack();
}, 1500);
return;
}
await loadTaskDetail(zpId);
});
//
const loadTaskDetail = async (zpId: string) => {
try {
isLoading.value = true;
console.log('加载作品任务详情任务ID:', zpId);
const response: any = await zpFindDetailByIdApi({ id: zpId });
const detailData = response?.result || response?.data || response;
if (!detailData) {
throw new Error('未找到任务数据');
}
//
const zpData = detailData.zp || {};
taskInfo.value = zpData;
//
taskItemList.value = detailData.zplxList || [];
console.log('任务项列表:', taskItemList.value);
} catch (error) {
console.error('加载任务详情失败:', error);
uni.showToast({
title: error instanceof Error ? error.message : '加载失败,请重试',
icon: 'error'
});
} finally {
isLoading.value = false;
}
};
//
const formatTimeRange = (startTime?: string, endTime?: string) => {
if (!startTime && !endTime) return '未设置';
const formatTime = (time: string) => {
if (!time) return '';
const date = new Date(time);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
const start = formatTime(startTime || '');
const end = formatTime(endTime || '');
if (start && end) {
return `${start}${end}`;
} else if (start) {
return `${start} 开始`;
} else if (end) {
return `${end} 结束`;
}
return '未设置';
};
//
const getTaskTypeText = (type?: string) => {
const typeMap: Record<string, string> = {
'sctp': '上传图片',
'scsp': '上传视频',
'scwd': '上传文档',
'fwb': '富文本',
'text': '文本',
'dxsx': '多项选择',
'dxxz': '单项选择'
};
return typeMap[type || ''] || '未知类型';
};
//
const previewFile = (fileUrl: string) => {
if (!fileUrl) return;
const fullUrl = imagUrl(fileUrl);
const fileExt = fileUrl.split('.').pop()?.toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(fileExt || '')) {
uni.previewImage({
urls: [fullUrl],
current: fullUrl
});
} else {
uni.showToast({ title: '请下载查看', icon: 'none' });
}
};
</script>
<style scoped lang="scss">
.task-detail-page {
background-color: #f4f5f7;
min-height: 100vh;
}
.loading-indicator {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #f3f3f3;
border-top: 3px solid #4e73df;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 10px;
}
.loading-text {
color: #999;
font-size: 14px;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.content-wrapper {
.p-15 {
padding: 15px;
}
.mt-15 {
margin-top: 15px;
}
}
//
.task-info-section {
margin-bottom: 20px;
padding: 15px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.section-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
color: #333;
border-bottom: 2px solid #4e73df;
padding-bottom: 5px;
}
.info-item {
display: flex;
margin-bottom: 15px;
&:last-child {
margin-bottom: 0;
}
.label {
width: 80px;
color: #666;
font-size: 14px;
flex-shrink: 0;
}
.value {
flex: 1;
color: #333;
font-size: 14px;
word-break: break-word;
&.title-bold {
font-weight: bold;
font-size: 16px;
}
}
}
}
//
.task-items-section {
margin-bottom: 20px;
padding: 15px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.section-title {
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
color: #333;
border-bottom: 2px solid #4e73df;
padding-bottom: 5px;
}
.task-item-card {
padding: 12px;
margin-bottom: 12px;
background: #f8f9fa;
border-radius: 8px;
border-left: 3px solid #4e73df;
&:last-child {
margin-bottom: 0;
}
.item-header {
display: flex;
align-items: center;
margin-bottom: 8px;
.item-number {
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
background: #4e73df;
color: #fff;
border-radius: 50%;
font-size: 12px;
font-weight: bold;
margin-right: 8px;
flex-shrink: 0;
}
.item-title {
flex: 1;
font-size: 15px;
font-weight: 600;
color: #333;
}
.required-badge {
padding: 2px 8px;
background: #fff3cd;
color: #856404;
border-radius: 4px;
font-size: 11px;
margin-left: 8px;
.required-text {
font-weight: 500;
}
}
}
.item-remark {
margin-bottom: 8px;
padding-left: 32px;
.remark-text {
font-size: 13px;
color: #666;
line-height: 1.5;
}
}
.item-type {
padding-left: 32px;
font-size: 13px;
.type-label {
color: #999;
}
.type-value {
color: #4e73df;
font-weight: 500;
}
}
}
}
//
.file-preview {
padding: 12px;
background: #f8f9fa;
border-radius: 6px;
border: 1px solid #e9ecef;
.file-preview-item {
display: flex;
align-items: center;
.file-label {
color: #666;
font-size: 14px;
margin-right: 8px;
flex-shrink: 0;
}
.file-name {
color: #4e73df;
font-size: 14px;
text-decoration: underline;
cursor: pointer;
word-break: break-all;
flex: 1;
&:active {
color: #2e59d9;
}
}
}
}
</style>

View File

@ -0,0 +1,662 @@
<!-- 作品任务列表页面 -->
<template>
<BasicLayout>
<!-- 认证遮罩层 -->
<view v-if="isAuthenticating" class="auth-mask">
<view class="auth-mask-content">
<view class="auth-spinner"></view>
<text class="auth-mask-text">正在认证中...</text>
</view>
</view>
<view class="zp-list-page">
<!-- 页面标题横幅 -->
<view class="page-banner">
<view class="banner-content">
<view class="banner-title-wrapper">
<text class="banner-icon">📝</text>
<view class="banner-text">
<text class="banner-title">作品任务</text>
<text class="banner-subtitle">独立项目选择 · 学习成长</text>
</view>
</view>
<view class="task-count">
<text class="count-number">{{ taskList.length }}</text>
<text class="count-label">个任务</text>
</view>
</view>
</view>
<!-- 独立项目选择提示 -->
<view class="selection-tip" v-if="taskList.length > 0">
<view class="tip-content">
<text class="tip-title">独立项目选择</text>
<text class="tip-desc">请从下方{{ taskList.length }}个任务中选择1个完成可自主挑战更高层级</text>
</view>
</view>
<!-- 任务列表 -->
<scroll-view scroll-y class="list-scroll-view">
<view v-if="isLoading && taskList.length === 0" class="loading-indicator">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<template v-else-if="taskList.length > 0">
<view
v-for="task in taskList"
:key="task.id"
class="task-card"
>
<view class="card-header">
<view class="header-left">
<view class="task-icon">📝</view>
<text class="task-title">{{ task.zpmc || '作品任务' }}</text>
</view>
<view class="status-badge" :class="getStatusClass(task.zpzt)">
<text class="status-text">{{ getStatusText(task.zpzt) }}</text>
</view>
</view>
<view class="card-body">
<view v-if="task.zpms" class="task-description">
<text class="description-text">{{ task.zpms }}</text>
</view>
<view class="task-meta">
<view class="meta-item" v-if="task.kcmc">
<text class="meta-label">课程</text>
<text class="meta-value">{{ task.kcmc }}</text>
</view>
<view class="meta-item" v-if="task.zptjsj">
<text class="meta-label">提交时间</text>
<text class="meta-value">{{ formatTime(task.zptjsj) }}</text>
</view>
</view>
</view>
<view class="card-footer">
<button class="action-btn challenge-btn" @click.stop="startChallenge(task)">
开始挑战
</button>
<button class="action-btn detail-btn" @click.stop="viewTaskDetail(task)">
详情
</button>
</view>
</view>
</template>
<view v-else class="empty-state">
<view class="empty-icon">📭</view>
<text class="empty-text">暂无作品任务</text>
</view>
</scroll-view>
</view>
</BasicLayout>
</template>
<script lang="ts" setup>
import { ref, onMounted, onUnmounted } from "vue";
import { onShow, onLoad } from "@dcloudio/uni-app";
import { useUserStore } from "@/store/modules/user";
import { zpzxFindByKcParamsApi } from "@/api/base/server";
const userStore = useUserStore();
interface TaskItem {
id: string;
zpId: string;
zpmc: string;
zpms: string;
zpzt: string; // A:, B:, C:
zptjsj?: string;
kcmc?: string;
kcId?: string;
xsId?: string;
ispj?: string; // A:
}
const taskList = ref<TaskItem[]>([]);
const isLoading = ref(false);
const kcId = ref<string>('');
const xsId = ref<string>('');
const openId = ref<string>(''); // openId
const isLoginReady = ref<boolean>(false); //
const isAuthenticating = ref<boolean>(false); //
onLoad(async (options) => {
// openId
if (options && options.openId) {
openId.value = options.openId;
isAuthenticating.value = true; //
try {
const loginSuccess = await userStore.loginByOpenId(openId.value);
if (!loginSuccess) {
isAuthenticating.value = false; //
uni.showToast({ title: "认证失败,请重新登录", icon: "none" });
setTimeout(() => {
uni.reLaunch({ url: "/pages/system/login/login" });
}, 1500);
return;
}
// token store
await new Promise(resolve => setTimeout(resolve, 100));
isAuthenticating.value = false; //
} catch (e) {
console.error("openId认证失败:", e);
isAuthenticating.value = false; //
uni.showToast({ title: "认证失败", icon: "none" });
setTimeout(() => {
uni.reLaunch({ url: "/pages/system/login/login" });
}, 1500);
return;
}
}
// openId
isLoginReady.value = true;
// from=db xxts
// kcId xsId
//
if (options && options.kcId) {
kcId.value = options.kcId;
}
if (options && options.xsId) {
xsId.value = options.xsId;
}
loadTaskList();
});
onShow(() => {
//
if (isLoginReady.value) {
loadTaskList();
}
});
//
uni.$on('refreshTaskList', () => {
console.log('收到刷新任务列表事件');
loadTaskList();
});
//
onUnmounted(() => {
uni.$off('refreshTaskList');
});
const loadTaskList = async () => {
//
if (!isLoginReady.value) {
return;
}
try {
isLoading.value = true;
//
const curXs = userStore.curXs;
const currentXsId = xsId.value || curXs?.id || '';
if (!currentXsId) {
uni.showToast({ title: "请先选择学生", icon: "none" });
return;
}
//
const params: any = {
xsId: currentXsId
};
if (kcId.value) {
params.kcId = kcId.value;
}
const response = await zpzxFindByKcParamsApi(params);
if (response && response.rows) {
taskList.value = response.rows || [];
} else {
taskList.value = [];
}
} catch (error) {
console.error("加载任务列表失败:", error);
uni.showToast({ title: "加载失败,请重试", icon: "error" });
taskList.value = [];
} finally {
isLoading.value = false;
}
};
// -
const startChallenge = (task: TaskItem) => {
//
const submittedTask = taskList.value.find(t => t.zpzt === 'A' && t.id !== task.id);
if (submittedTask) {
uni.showToast({
title: '已完成挑战',
icon: 'none',
duration: 2000
});
return;
}
const params = new URLSearchParams();
params.append('zpzxId', task.id);
params.append('zpId', task.zpId);
if (task.kcId || kcId.value) {
params.append('kcId', task.kcId || kcId.value);
}
if (task.xsId || xsId.value) {
params.append('xsId', task.xsId || xsId.value);
}
if (task.zpzt) {
params.append('zpzt', task.zpzt);
}
if (task.ispj) {
params.append('ispj', task.ispj);
}
uni.navigateTo({
url: `/pages/base/xszp/submit?${params.toString()}`
});
};
// -
const viewTaskDetail = (task: TaskItem) => {
const params = new URLSearchParams();
params.append('zpId', task.zpId);
if (task.kcId || kcId.value) {
params.append('kcId', task.kcId || kcId.value);
}
if (task.xsId || xsId.value) {
params.append('xsId', task.xsId || xsId.value);
}
uni.navigateTo({
url: `/pages/base/xszp/detail?${params.toString()}`
});
};
const getStatusClass = (status: string) => {
switch (status) {
case 'A':
return 'status-submitted';
case 'B':
return 'status-pending';
case 'C':
return 'status-evaluated';
default:
return '';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'A':
return '已提交';
case 'B':
return '待提交';
case 'C':
return '已评价';
default:
return '未知';
}
};
const formatTime = (timeStr?: string) => {
if (!timeStr) return '';
const date = new Date(timeStr);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
};
</script>
<style scoped lang="scss">
.zp-list-page {
display: flex;
flex-direction: column;
height: 100%;
background: linear-gradient(180deg, #f0f5ff 0%, #f5f7fa 100%);
}
//
.page-banner {
background: linear-gradient(135deg, #4e73df 0%, #2e59d9 100%);
padding: 20px 16px;
box-shadow: 0 4px 12px rgba(78, 115, 223, 0.3);
.banner-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.banner-title-wrapper {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.banner-icon {
font-size: 32px;
line-height: 1;
}
.banner-text {
display: flex;
flex-direction: column;
gap: 4px;
}
.banner-title {
font-size: 24px;
font-weight: bold;
color: #ffffff;
line-height: 1.2;
}
.banner-subtitle {
font-size: 13px;
color: rgba(255, 255, 255, 0.9);
letter-spacing: 1px;
}
.task-count {
display: flex;
flex-direction: column;
align-items: center;
background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(10px);
padding: 10px 16px;
border-radius: 12px;
min-width: 60px;
}
.count-number {
font-size: 24px;
font-weight: bold;
color: #ffffff;
line-height: 1;
}
.count-label {
font-size: 11px;
color: rgba(255, 255, 255, 0.9);
margin-top: 4px;
}
}
//
.selection-tip {
background: linear-gradient(135deg, #e6f4ff 0%, #f0f8ff 100%);
margin: 0;
padding: 16px;
box-shadow: 0 2px 8px rgba(78, 115, 223, 0.1);
.tip-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
text-align: center;
.tip-title {
font-size: 17px;
font-weight: 700;
color: #2c3e50;
line-height: 1.4;
}
.tip-desc {
font-size: 14px;
color: #4e73df;
line-height: 1.5;
font-weight: 500;
}
}
}
.list-scroll-view {
flex: 1;
padding: 0;
}
.loading-indicator {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 0;
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #f3f3f3;
border-top: 3px solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 10px;
}
.loading-text {
color: #999;
font-size: 14px;
}
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.task-card {
background: linear-gradient(135deg, #ffffff 0%, #fafbfc 100%);
border-radius: 16px;
margin: 0 0 16px 0;
padding: 16px;
box-shadow:
0 2px 16px rgba(0, 0, 0, 0.08),
0 0 0 1px rgba(78, 115, 223, 0.1);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: visible;
&:active {
transform: translateY(2px) scale(0.98);
box-shadow:
0 1px 8px rgba(0, 0, 0, 0.12),
0 0 0 1px rgba(78, 115, 223, 0.15);
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.header-left {
display: flex;
align-items: center;
flex: 1;
}
.task-icon {
font-size: 20px;
margin-right: 8px;
}
.task-title {
font-size: 16px;
font-weight: 600;
color: #333;
flex: 1;
}
.status-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
&.status-submitted {
background-color: #e6f7ff;
color: #1890ff;
}
&.status-pending {
background-color: #fff7e6;
color: #fa8c16;
}
&.status-evaluated {
background-color: #f6ffed;
color: #52c41a;
}
}
}
.card-body {
margin-bottom: 12px;
.task-description {
margin-bottom: 10px;
.description-text {
font-size: 14px;
color: #666;
line-height: 1.5;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
}
}
.task-meta {
display: flex;
flex-direction: column;
gap: 6px;
.meta-item {
display: flex;
align-items: center;
font-size: 13px;
.meta-label {
color: #999;
margin-right: 4px;
}
.meta-value {
color: #666;
}
}
}
}
.card-footer {
padding-top: 12px;
border-top: 1px solid #f0f0f0;
display: flex;
gap: 12px;
justify-content: space-between;
.action-btn {
flex: 1;
height: 36px;
line-height: 36px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
border: none;
transition: all 0.3s;
&.challenge-btn {
background: linear-gradient(135deg, #4e73df 0%, #2e59d9 100%);
color: #ffffff;
&:active {
background: linear-gradient(135deg, #2e59d9 0%, #1e3fa8 100%);
transform: scale(0.98);
}
}
&.detail-btn {
background: #f5f7fa;
color: #4e73df;
border: 1px solid #e0e6ed;
&:active {
background: #e8ecf1;
transform: scale(0.98);
}
}
}
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px 20px;
.empty-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.6;
}
.empty-text {
font-size: 16px;
color: #4a5568;
font-weight: 600;
}
}
.auth-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
.auth-mask-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: white;
padding: 40px 60px;
border-radius: 16px;
.auth-spinner {
width: 40px;
height: 40px;
border: 3px solid #f3f3f3;
border-top: 3px solid #409eff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
.auth-mask-text {
font-size: 14px;
color: #333;
}
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,159 @@
<template>
<view class="video-player-page">
<view class="player-header">
<view class="header-left">
<u-icon name="arrow-left" size="20" @click="goBack"></u-icon>
<text class="header-title">{{ videoTitle }}</text>
</view>
</view>
<view class="video-container">
<video
:src="videoUrl"
:poster="posterUrl"
:title="videoTitle"
controls
show-center-play-btn
show-play-btn
show-fullscreen-btn
show-progress
enable-progress-gesture
@error="handleVideoError"
@fullscreenchange="handleFullscreenChange"
@play="handleVideoPlay"
@pause="handleVideoPause"
@ended="handleVideoEnded"
class="video-player"
></video>
</view>
<view class="video-info">
<view class="info-title">{{ videoTitle }}</view>
<view class="info-desc" v-if="videoDesc">{{ videoDesc }}</view>
</view>
</view>
</template>
<script setup>
import { ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
const videoUrl = ref('');
const videoTitle = ref('视频播放');
const videoDesc = ref('');
const posterUrl = ref('');
onLoad((options) => {
if (options.url) {
videoUrl.value = decodeURIComponent(options.url);
}
if (options.title) {
videoTitle.value = decodeURIComponent(options.title);
}
if (options.desc) {
videoDesc.value = decodeURIComponent(options.desc);
}
if (options.poster) {
posterUrl.value = decodeURIComponent(options.poster);
}
console.log('视频播放URL:', videoUrl.value);
});
const goBack = () => {
uni.navigateBack();
};
const handleVideoError = (e) => {
console.error('视频播放错误:', e);
uni.showToast({
title: '视频播放失败,请检查网络连接',
icon: 'none',
duration: 3000
});
};
const handleFullscreenChange = (e) => {
console.log('全屏状态变化:', e.detail.fullScreen);
};
const handleVideoPlay = () => {
console.log('视频开始播放');
};
const handleVideoPause = () => {
console.log('视频暂停');
};
const handleVideoEnded = () => {
console.log('视频播放结束');
};
</script>
<style scoped>
.video-player-page {
width: 100%;
height: 100vh;
display: flex;
flex-direction: column;
background-color: #000;
}
.player-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 30rpx;
background-color: rgba(0, 0, 0, 0.8);
flex-shrink: 0;
z-index: 10;
}
.header-left {
display: flex;
align-items: center;
gap: 20rpx;
}
.header-title {
font-size: 32rpx;
font-weight: bold;
color: #ffffff;
max-width: 400rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.video-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: #000;
}
.video-player {
width: 100%;
height: 100%;
}
.video-info {
padding: 30rpx;
background-color: #ffffff;
flex-shrink: 0;
}
.info-title {
font-size: 36rpx;
font-weight: bold;
color: #333;
margin-bottom: 20rpx;
}
.info-desc {
font-size: 28rpx;
color: #666;
line-height: 1.5;
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 B

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 899 B

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 706 B

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 639 B

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 645 B

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -1,24 +1,132 @@
/**
*
*
*/
import { KK_FILE_VIEW_URL } from '@/config';
// 检查是否是压缩包文件
const isCompressedFile = (fileName: string): boolean => {
const compressedExtensions = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2'];
const extension = fileName.split('.').pop()?.toLowerCase();
return compressedExtensions.includes(extension || '');
};
// 检查文件是否可以预览
const canPreview = (fileName: string): boolean => {
const previewableExtensions = [
'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
'txt', 'rtf', 'odt', 'ods', 'odp',
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp',
'mp4', 'avi', 'mov', 'wmv', 'flv', 'webm',
'mp3', 'wav', 'ogg', 'aac', 'm4a'
];
const extension = fileName.split('.').pop()?.toLowerCase();
return previewableExtensions.includes(extension || '');
};
// 文件预览工具函数
export const previewFile = (fileUrl: string, fileName: string, fileType: string) => {
return new Promise((resolve, reject) => {
const type = fileType.toLowerCase();
console.log('=== 文件预览调试信息 ===');
console.log('原始文件URL:', fileUrl);
console.log('文件名:', fileName);
console.log('文件类型:', fileType);
// 检查是否是压缩包文件,如果是则直接下载
if (['zip', 'rar', '7z', 'tar', 'gz'].includes(type)) {
if (isCompressedFile(fileName)) {
console.log('=== 检测到压缩包文件,直接下载 ===');
uni.showToast({ title: '压缩包文件不支持预览,将为您下载', icon: 'none' });
downloadFile(fileUrl, fileName).then(resolve).catch(resolve);
return;
}
// 使用 kkview 预览
const previewUrl = fileUrl;
const needLandscape = ['ppt', 'pptx'].includes(type);
// 检查是否支持预览的文件格式
const canPreviewType = canPreview(fileName);
if (!canPreviewType) {
// 不支持预览的文件格式,直接下载
console.log('不支持预览的文件格式,执行下载');
uni.showToast({ title: '该文件格式暂不支持预览,将为您下载', icon: 'none' });
downloadFile(fileUrl, fileName).then(resolve).catch(resolve);
return;
}
// 检查URL是否已经是kkFileView生成的压缩包内文件URL
const isCompressedFileUrl = fileUrl.includes('kkCompressfileKey') && fileUrl.includes('kkCompressfilepath');
console.log('=== 压缩包内文件检测 ===');
console.log('URL包含kkCompressfileKey:', fileUrl.includes('kkCompressfileKey'));
console.log('URL包含kkCompressfilepath:', fileUrl.includes('kkCompressfilepath'));
console.log('是否为压缩包内文件URL:', isCompressedFileUrl);
if (isCompressedFileUrl) {
console.log('=== 压缩包内文件处理 ===');
console.log('检测到压缩包内文件URL直接使用');
console.log('原始压缩包内文件URL:', fileUrl);
const previewUrl = fileUrl; // 直接使用这个URL不重新编码
console.log('最终预览URL (压缩包内文件):', previewUrl);
const needLandscape = ['ppt', 'pptx'].includes(type);
console.log('是否需要横屏显示:', needLandscape);
const targetPageUrl = `/pages/system/webView/webView?url=${encodeURIComponent(previewUrl)}`;
console.log('跳转目标页面URL:', targetPageUrl);
uni.navigateTo({
url: targetPageUrl,
success: () => {
console.log('跳转到压缩包内文件预览页面成功');
resolve(true);
},
fail: (err) => {
console.error('跳转到压缩包内文件预览页面失败:', err);
console.log('错误详情:', JSON.stringify(err));
uni.showToast({ title: '预览加载失败,尝试下载', icon: 'none' });
setTimeout(() => {
downloadFile(fileUrl, fileName).then(resolve).catch(resolve);
}, 1000);
}
});
return; // 退出函数
}
console.log('=== 普通文件处理 ===');
// 处理普通文件预览
const finalFileUrl = fileUrl;
console.log('DEBUG: 最终用于编码的URL (finalFileUrl):', finalFileUrl);
// 使用kkFileView进行预览
const encodedUrl = btoa(finalFileUrl);
console.log('使用kkFileView预览Base64编码后的URL:', encodedUrl);
const previewUrl = `${KK_FILE_VIEW_URL}/onlinePreview?url=${encodeURIComponent(encodedUrl)}`;
console.log('最终预览URL (普通文件):', previewUrl);
const needLandscape = ['ppt', 'pptx'].includes(type);
console.log('是否需要横屏显示:', needLandscape);
const targetPageUrl = `/pages/system/webView/webView?url=${encodeURIComponent(previewUrl)}`;
console.log('跳转目标页面URL:', targetPageUrl);
// 打开预览页面
uni.navigateTo({
url: `/pages/base/view/index?url=${encodeURIComponent(previewUrl)}&name=${encodeURIComponent(fileName)}&landscape=${needLandscape}`,
success: resolve,
fail: reject
url: targetPageUrl,
success: () => {
console.log('跳转到预览页面成功');
resolve(true);
},
fail: (err) => {
console.error('跳转到预览页面失败:', err);
console.log('错误详情:', JSON.stringify(err));
uni.showToast({ title: '预览加载失败,尝试下载', icon: 'none' });
setTimeout(() => {
downloadFile(fileUrl, fileName).then(resolve).catch(resolve);
}, 1000);
}
});
});
};
@ -47,3 +155,38 @@ const downloadFile = (fileUrl: string, fileName: string) => {
});
};
// 视频预览
export const previewVideo = (videoUrl: string, videoTitle: string) => {
console.log('=== 视频预览调试信息 ===');
console.log('视频URL:', videoUrl);
console.log('视频标题:', videoTitle);
return new Promise((resolve) => {
const targetUrl = `/pages/system/video-player/index?url=${encodeURIComponent(videoUrl)}&title=${encodeURIComponent(videoTitle)}`;
console.log('跳转目标URL:', targetUrl);
uni.navigateTo({
url: targetUrl,
success: () => {
console.log('跳转到视频播放页面成功');
resolve(true);
},
fail: (err) => {
console.error('跳转到视频播放页面失败:', err);
console.log('错误详情:', JSON.stringify(err));
// 跳转失败时尝试使用系统播放器
uni.showToast({
title: '跳转失败,尝试使用系统播放器',
icon: 'none',
duration: 2000
});
// 延迟后尝试下载视频
setTimeout(() => {
downloadFile(videoUrl, videoTitle + '.mp4').then(resolve).catch(resolve);
}, 2000);
}
});
});
};