学生实践
@ -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 });
|
||||
};
|
||||
232
src/components/BasicFile/preview.vue
Normal 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>
|
||||
517
src/components/BasicForm/components/BasicEditor.vue
Normal 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>
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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>
|
||||
@ -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) {
|
||||
// 如果已经是完整 URL(http/https/blob),直接返回
|
||||
if (/^(https?|blob):/i.test(image.url)) {
|
||||
return image.url;
|
||||
}
|
||||
// 否则使用 imagUrl 拼接
|
||||
return imagUrl(image.url);
|
||||
}
|
||||
// 如果只有 tempPath,直接返回(tempPath 通常是本地路径或完整 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) {
|
||||
// 如果已经是完整 URL(http/https/blob),直接返回
|
||||
if (/^(https?|blob):/i.test(video.url)) {
|
||||
return video.url;
|
||||
}
|
||||
// 否则使用 imagUrl 拼接
|
||||
return imagUrl(video.url);
|
||||
}
|
||||
// 如果只有 tempPath,直接返回(tempPath 通常是本地路径或完整 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>
|
||||
|
||||
|
||||
223
src/components/ImageVideoUpload/README.md
Normal 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: "服务器文件路径"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
65
src/components/ImageVideoUpload/example.vue
Normal 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>
|
||||
@ -4,4 +4,3 @@ export * from './types'
|
||||
|
||||
// 默认导出
|
||||
export { default } from './ImageVideoUpload.vue'
|
||||
|
||||
|
||||
@ -153,4 +153,3 @@ export const COMPRESS_PRESETS = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
204
src/components/ImageVideoUpload/使用说明.md
Normal 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` 来测试组件功能。
|
||||
@ -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过期返回状态码
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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)
|
||||
|
||||
426
src/pages/base/xszp/detail.vue
Normal 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>
|
||||
662
src/pages/base/xszp/index.vue
Normal 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>
|
||||
1114
src/pages/base/xszp/submit.vue
Normal file
159
src/pages/system/video-player/index.vue
Normal 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>
|
||||
|
Before Width: | Height: | Size: 479 B After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 899 B After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 706 B After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 639 B After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 645 B After Width: | Height: | Size: 5.3 KiB |
BIN
src/static/base/home/jcxq.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
src/static/base/home/xszp.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src/static/base/view/excel.png
Normal file
|
After Width: | Height: | Size: 6.6 KiB |
BIN
src/static/base/view/fire-black.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src/static/base/view/fire-white.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src/static/base/view/fire.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
src/static/base/view/image.png
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
src/static/base/view/more.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
src/static/base/view/pdf.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
src/static/base/view/ppt.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
src/static/base/view/qdself.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src/static/base/view/rgzn.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src/static/base/view/sptg.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src/static/base/view/word.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
src/static/base/view/zip.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
src/static/base/view/zyyl.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||