VR调整
This commit is contained in:
parent
6161a2fa8d
commit
85f74332c0
@ -31,6 +31,16 @@ export const findAllZw = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
293
src/api/vr/index.ts
Normal file
293
src/api/vr/index.ts
Normal file
@ -0,0 +1,293 @@
|
||||
import { get, post, file } from '@/utils/request';
|
||||
import { config, interceptor } from '@/utils/request';
|
||||
|
||||
/**
|
||||
* 获取楼栋列表(分页)
|
||||
*/
|
||||
export const getBuildingList = async (params?: {
|
||||
buildingName?: string;
|
||||
buildingType?: string;
|
||||
status?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}) => {
|
||||
return await get('/api/building/findPage', params);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有启用的楼栋列表(不分页)
|
||||
*/
|
||||
export const getAllBuildings = async () => {
|
||||
const res = await get('/api/building/findPage', {
|
||||
status: 'A',
|
||||
pageNum: 1,
|
||||
pageSize: 1000,
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取VR位置列表(分页)
|
||||
*/
|
||||
export const getVRLocations = async (params?: {
|
||||
buildingId?: string;
|
||||
locationType?: string;
|
||||
parentId?: string;
|
||||
level?: number;
|
||||
status?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}) => {
|
||||
return await get('/api/vrLocation/findPage', params);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有启用的VR位置列表(不分页,用于展示)
|
||||
*/
|
||||
export const getAllVRLocations = async (params?: {
|
||||
buildingId?: string;
|
||||
locationType?: string;
|
||||
status?: string;
|
||||
}) => {
|
||||
const res = await get('/api/vrLocation/findPage', {
|
||||
...params,
|
||||
status: params?.status || 'A',
|
||||
pageNum: 1,
|
||||
pageSize: 1000,
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取VR位置详情(通过ID查询)
|
||||
*/
|
||||
export const getVRLocationDetail = async (locationId: string) => {
|
||||
const res = await get('/api/vrLocation/findById', {
|
||||
id: locationId,
|
||||
});
|
||||
|
||||
// 后端返回的数据结构:{resultCode, message, data} 或 {success, resultCode, message, data}
|
||||
// 检查多种可能的返回格式
|
||||
const resultCode = res?.resultCode ?? (res?.success ? 1 : 0);
|
||||
const data = res?.data ?? res?.result;
|
||||
|
||||
if (resultCode === 1 && data) {
|
||||
return {
|
||||
resultCode: 1,
|
||||
message: res?.message || '查询成功',
|
||||
data: data,
|
||||
};
|
||||
}
|
||||
return {
|
||||
resultCode: 0,
|
||||
message: res?.message || '未找到数据',
|
||||
data: null,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取位置连接关系(热点)
|
||||
*/
|
||||
export const getVRLocationLinks = async (fromLocationId: string) => {
|
||||
return await get('/api/vrLocationLink/findPage', {
|
||||
fromLocationId: fromLocationId,
|
||||
status: 'A',
|
||||
pageNum: 1,
|
||||
pageSize: 100,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存VR位置信息
|
||||
*/
|
||||
export const saveVRLocation = async (data: {
|
||||
id?: string;
|
||||
locationName: string;
|
||||
locationType: string;
|
||||
parentId?: string;
|
||||
level: number;
|
||||
buildingId?: string;
|
||||
floorNum?: number;
|
||||
roomNum?: string;
|
||||
description?: string;
|
||||
imageUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
longitude?: number;
|
||||
latitude?: number;
|
||||
sortOrder?: number;
|
||||
status?: string;
|
||||
}) => {
|
||||
return await post('/api/vrLocation/save', data);
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存VR位置(包含位置、拍摄记录、位置连接)
|
||||
*/
|
||||
export const saveVRLocationWithDetails = async (data: {
|
||||
// 位置信息
|
||||
id?: string;
|
||||
locationName: string;
|
||||
locationType: string;
|
||||
parentId?: string;
|
||||
level: number;
|
||||
buildingId?: string;
|
||||
floorNum?: number;
|
||||
roomNum?: string;
|
||||
description?: string;
|
||||
imageUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
longitude?: number;
|
||||
latitude?: number;
|
||||
sortOrder?: number;
|
||||
status?: string;
|
||||
// 拍摄记录信息
|
||||
saveShootingRecord?: boolean;
|
||||
shootingMethod?: string;
|
||||
doorDirection?: string;
|
||||
shootTime?: string;
|
||||
shootBy?: string;
|
||||
// 位置连接信息
|
||||
createLocationLink?: boolean;
|
||||
lastLocationId?: string;
|
||||
lastLocationDoorDirection?: string;
|
||||
lastLocationName?: string;
|
||||
}) => {
|
||||
return await post('/api/vrLocation/saveWithDetails', data);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取VR拍摄记录列表(分页)
|
||||
*/
|
||||
export const getVRShootingRecords = async (params?: {
|
||||
locationId?: string;
|
||||
status?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}) => {
|
||||
return await get('/api/vrShootingRecord/findPage', params);
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存VR拍摄记录
|
||||
*/
|
||||
export const saveVRShootingRecord = async (data: {
|
||||
id?: string;
|
||||
locationId?: string;
|
||||
locationName: string;
|
||||
locationType: string;
|
||||
shootingMethod?: string;
|
||||
imageUrl: string;
|
||||
doorDirection?: string;
|
||||
shootTime?: string;
|
||||
shootBy?: string;
|
||||
status?: string;
|
||||
}) => {
|
||||
return await post('/api/vrShootingRecord/save', data);
|
||||
};
|
||||
|
||||
/**
|
||||
* 保存VR位置连接(热点)
|
||||
*/
|
||||
export const saveVRLocationLink = async (data: {
|
||||
id?: string;
|
||||
fromLocationId: string;
|
||||
toLocationId: string;
|
||||
hotspotPositionX?: number;
|
||||
hotspotPositionY?: number;
|
||||
hotspotPositionZ?: number;
|
||||
hotspotLabel?: string;
|
||||
direction?: string;
|
||||
sortOrder?: number;
|
||||
status?: string;
|
||||
}) => {
|
||||
return await post('/api/vrLocationLink/save', data);
|
||||
};
|
||||
|
||||
/**
|
||||
* VR全景图上传(如果需要单独上传接口)
|
||||
* @param filePath 图片文件路径
|
||||
* @param formData 表单数据
|
||||
*/
|
||||
export const uploadVRImage = async (
|
||||
filePath: string,
|
||||
formData?: Record<string, any>
|
||||
) => {
|
||||
// 使用uni.uploadFile上传
|
||||
return new Promise((resolve, reject) => {
|
||||
const uploadOption: UniNamespace.UploadFileOption = {
|
||||
url: `${config.baseUrl}/api/attachment/upload`, // 使用通用的附件上传接口
|
||||
filePath: filePath,
|
||||
name: 'file',
|
||||
formData: formData || {},
|
||||
header: {},
|
||||
};
|
||||
|
||||
// 应用拦截器
|
||||
interceptor.request(uploadOption);
|
||||
|
||||
uni.uploadFile({
|
||||
...uploadOption,
|
||||
success: (res) => {
|
||||
try {
|
||||
const data = JSON.parse(res.data);
|
||||
if (data.resultCode === 1) {
|
||||
resolve(data);
|
||||
} else {
|
||||
reject(new Error(data.message || '上传失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 上传视频并处理为全景图
|
||||
* @param videoPath 视频文件路径
|
||||
* @param params 其他参数(可选)
|
||||
*/
|
||||
export const uploadVideoAndProcess = async (
|
||||
videoPath: string,
|
||||
params?: {
|
||||
locationId?: string;
|
||||
locationName?: string;
|
||||
}
|
||||
) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const uploadOption: UniNamespace.UploadFileOption = {
|
||||
url: `${config.baseUrl}/api/vrLocation/video/processPanorama`,
|
||||
filePath: videoPath,
|
||||
name: 'video',
|
||||
formData: params || {},
|
||||
header: {},
|
||||
};
|
||||
|
||||
// 应用拦截器
|
||||
interceptor.request(uploadOption);
|
||||
|
||||
uni.uploadFile({
|
||||
...uploadOption,
|
||||
success: (res) => {
|
||||
try {
|
||||
const data = JSON.parse(res.data);
|
||||
if (data.resultCode === 1) {
|
||||
resolve(data);
|
||||
} else {
|
||||
reject(new Error(data.message || '视频处理失败'));
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
496
src/components/BasicForm/components/BasicEditor.vue
Normal file
496
src/components/BasicForm/components/BasicEditor.vue
Normal file
@ -0,0 +1,496 @@
|
||||
<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();
|
||||
|
||||
// 处理上传结果,根据 ImageVideoUpload 组件的处理方式
|
||||
// 返回格式可能是:{ resultCode: 1, result: [{ filePath: '...' }] } 或 { url: '...' }
|
||||
let imageUrl = '';
|
||||
const result: any = uploadResult;
|
||||
if (result && result.resultCode === 1 && result.result && result.result.length > 0) {
|
||||
// 标准返回格式:result 数组中的第一个元素包含 filePath
|
||||
imageUrl = result.result[0].filePath || result.result[0].url;
|
||||
} else if (result && result.url) {
|
||||
// 直接返回 url
|
||||
imageUrl = result.url;
|
||||
} else if (typeof result === 'string') {
|
||||
// 直接返回字符串 URL
|
||||
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;
|
||||
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: hidden;
|
||||
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;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
border: none !important; // 确保编辑器内部没有边框,使用外层容器的边框
|
||||
outline: none; // 移除焦点时的轮廓
|
||||
}
|
||||
|
||||
.editor-fallback {
|
||||
width: 100%;
|
||||
min-height: 200px;
|
||||
padding: 12px;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
border: none;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
<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"/>
|
||||
<FormBasicEditor v-bind="attrs" v-if="isShow('BasicEditor')" v-model="newValue"/>
|
||||
<FormBasicNjBjPicker v-bind="attrs" v-if="isShow('BasicNjBjPicker')" v-model="newValue"/>
|
||||
<FormBasicNjBjSelect v-bind="attrs" v-if="isShow('BasicNjBjSelect')" v-model="newValue"/>
|
||||
<FormBasicXsPicker v-bind="attrs" v-if="isShow('BasicXsPicker')" v-model="newValue"/>
|
||||
|
||||
2
src/components/BasicForm/type/useForm.d.ts
vendored
2
src/components/BasicForm/type/useForm.d.ts
vendored
@ -31,6 +31,8 @@ type Component =
|
||||
| 'BasicNjBjPicker'
|
||||
| 'BasicNjBjSelect'
|
||||
| 'BasicXsPicker'
|
||||
| 'BasicEditor'
|
||||
| 'ImageVideoUpload'
|
||||
|
||||
interface FormsSchema {
|
||||
field?: string,
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
:key="index"
|
||||
>
|
||||
<image
|
||||
:src="image.url || image.tempPath"
|
||||
:src="image.url ? imagUrl(image.url) : image.tempPath"
|
||||
class="image-preview"
|
||||
@click="previewImage(index)"
|
||||
mode="aspectFill"
|
||||
|
||||
@ -304,6 +304,27 @@
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/vr/shooting/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "VR位置列表",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/vr/shooting/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "VR全景拍摄",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/vr/view/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "VR展示",
|
||||
"enablePullDownRefresh": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/statistics/xs/gradeDetail",
|
||||
"style": {
|
||||
|
||||
@ -583,6 +583,32 @@ const sections = reactive<Section[]>([
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "gnyy-vr",
|
||||
icon: "vr",
|
||||
text: "VR制作",
|
||||
show: true,
|
||||
permissionKey: "gnyycommon-vr", // VR制作权限编码
|
||||
isFolder: true, // 标记为文件夹类型
|
||||
folderItems: [
|
||||
{
|
||||
id: "vr-shooting",
|
||||
icon: "vrps",
|
||||
text: "VR拍摄",
|
||||
show: true,
|
||||
permissionKey: "vr-shooting",
|
||||
path: "/pages/vr/shooting/index",
|
||||
},
|
||||
{
|
||||
id: "vr-view",
|
||||
icon: "vrzs",
|
||||
text: "VR展示",
|
||||
show: true,
|
||||
permissionKey: "vr-view",
|
||||
path: "/pages/vr/view/index",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@ -686,13 +686,13 @@ const loadTeacherInfo = async () => {
|
||||
// 基础信息
|
||||
get('/api/js/findJsById', { id: teacherId.value }),
|
||||
// 职称信息
|
||||
get('/api/zcxx/findPage', { jsId: teacherId.value, page: 1, limit: 100 }),
|
||||
get('/api/zcxx/findPage', { jsId: teacherId.value, page: 1, rows: 100 }),
|
||||
// 岗位情况
|
||||
get('/api/gwqk/findPage', { jsId: teacherId.value, page: 1, limit: 100 }),
|
||||
get('/api/gwqk/findPage', { jsId: teacherId.value, page: 1, rows: 100 }),
|
||||
// 工作经历
|
||||
get('/api/gzjl/findPage', { jsId: teacherId.value, page: 1, limit: 100 }),
|
||||
get('/api/gzjl/findPage', { jsId: teacherId.value, page: 1, rows: 100 }),
|
||||
// 家庭成员
|
||||
get('/api/jtcy/findPage', { jsId: teacherId.value, page: 1, limit: 100 }),
|
||||
get('/api/jtcy/findPage', { jsId: teacherId.value, page: 1, rows: 100 }),
|
||||
// 党政职务列表
|
||||
getZwListByLx('党政职务'),
|
||||
// 其他职务列表
|
||||
|
||||
@ -123,7 +123,7 @@ import {
|
||||
import {onShow} from "@dcloudio/uni-app";
|
||||
import {getUserViewApi} from "@/api/system/login";
|
||||
import BasicTree from '@/components/BasicTree/Tree.vue';
|
||||
import { findAllNjBjTree } from '@/api/base/server';
|
||||
import { findAllNjBjKzTreeApi } from '@/api/base/server';
|
||||
|
||||
// 树形数据
|
||||
const treeData = ref<any[]>([]);
|
||||
@ -144,7 +144,7 @@ const selectedClassText = computed(() => {
|
||||
// 加载树形数据
|
||||
const loadTreeData = async () => {
|
||||
try {
|
||||
const res = await findAllNjBjTree();
|
||||
const res = await findAllNjBjKzTreeApi();
|
||||
if (res.resultCode === 1 && res.result) {
|
||||
// 递归转换数据格式以适配 BasicTree 组件
|
||||
const convertTreeData = (items: any[]): any[] => {
|
||||
|
||||
@ -93,18 +93,14 @@
|
||||
<view class="section-card">
|
||||
<view class="section-title">
|
||||
<text class="title-text">任务下发设置</text>
|
||||
<view class="add-task-type-btn" @click="addTaskType">
|
||||
<text class="add-icon">+</text>
|
||||
<text class="add-text">新增任务方式</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 任务方式列表 -->
|
||||
<!-- 任务类型列表 -->
|
||||
<view v-for="(taskType, typeIndex) in taskTypes" :key="taskType.id" class="task-type-section">
|
||||
<!-- 任务方式选择 -->
|
||||
<!-- 任务类型选择 -->
|
||||
<view class="task-type-header">
|
||||
<view class="task-type-selector">
|
||||
<text class="selector-label">任务方式:</text>
|
||||
<text class="selector-label">任务类型:</text>
|
||||
<picker
|
||||
:range="taskTypeOptions"
|
||||
range-key="label"
|
||||
@ -118,59 +114,70 @@
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="delete-type-btn" @click="removeTaskType(typeIndex)">
|
||||
<text class="delete-icon">×</text>
|
||||
<view class="task-type-actions">
|
||||
<view class="move-buttons">
|
||||
<view
|
||||
v-if="typeIndex > 0"
|
||||
class="move-btn move-up-btn"
|
||||
@click="moveTaskType(typeIndex, 'up')"
|
||||
title="上移"
|
||||
>
|
||||
<text class="move-icon">↑</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="typeIndex < taskTypes.length - 1"
|
||||
class="move-btn move-down-btn"
|
||||
@click="moveTaskType(typeIndex, 'down')"
|
||||
title="下移"
|
||||
>
|
||||
<text class="move-icon">↓</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="delete-type-btn" @click="removeTaskType(typeIndex)">
|
||||
<text class="delete-icon">×</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 任务项列表 -->
|
||||
<view class="task-items-container">
|
||||
<view
|
||||
v-for="(item, itemIndex) in taskType.items"
|
||||
:key="item.key"
|
||||
class="task-item-card"
|
||||
>
|
||||
<!-- 删除按钮放在右上角 -->
|
||||
<view class="delete-item-btn-top" @click="removeTaskItem(taskType, itemIndex)">
|
||||
<text class="delete-icon">×</text>
|
||||
</view>
|
||||
|
||||
<view class="task-item-header">
|
||||
<text class="item-number">{{ itemIndex + 1 }}</text>
|
||||
<input
|
||||
v-model="item.rwbt"
|
||||
:placeholder="`任务项 ${itemIndex + 1}`"
|
||||
class="item-title-input"
|
||||
maxlength="100"
|
||||
/>
|
||||
<view class="item-actions">
|
||||
<view class="required-checkbox" @click="toggleRequired(taskType, itemIndex)">
|
||||
<view :class="['checkbox-box', { checked: item.rwbs }]">
|
||||
<text v-if="item.rwbs" class="check-icon">✓</text>
|
||||
</view>
|
||||
<view v-if="showTooltip" class="tooltip">默认必填</view>
|
||||
<!-- 任务项(一个任务类型对应一个任务项) -->
|
||||
<view class="task-items-container" v-if="taskType.items && taskType.items.length > 0">
|
||||
<view class="task-item-header">
|
||||
<text class="item-number">{{ typeIndex + 1 }}</text>
|
||||
<input
|
||||
v-model="taskType.items[0].rwbt"
|
||||
:placeholder="`请输入${getTaskTypeLabel(taskType.rwfl)}任务描述`"
|
||||
class="item-title-input"
|
||||
maxlength="100"
|
||||
/>
|
||||
<view class="item-actions">
|
||||
<view class="required-checkbox" @click="toggleRequired(taskType)">
|
||||
<view :class="['checkbox-box', { checked: taskType.items[0].rwbs }]">
|
||||
<text v-if="taskType.items[0].rwbs" class="check-icon">✓</text>
|
||||
</view>
|
||||
<view v-if="showTooltip" class="tooltip">默认必填</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 选择题选项 -->
|
||||
<view v-if="taskType.rwfl === 'dxxz' || taskType.rwfl === 'dxsx'" class="item-options">
|
||||
<textarea
|
||||
v-model="item.remark"
|
||||
:placeholder="taskType.rwfl === 'dxxz' ? '单项选择,请输入如:选项1;选项2;选项3' : '多项选择,请输入如:选项1;选项2;选项3'"
|
||||
class="options-textarea"
|
||||
maxlength="500"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 添加任务项按钮 -->
|
||||
<view class="add-item-btn" @click="addTaskItem(taskType)">
|
||||
<text class="add-icon">+</text>
|
||||
<text class="add-text">添加任务项</text>
|
||||
<!-- 选择题选项 -->
|
||||
<view v-if="taskType.rwfl === 'dxxz' || taskType.rwfl === 'dxsx'" class="item-options">
|
||||
<textarea
|
||||
v-model="taskType.items[0].remark"
|
||||
:placeholder="taskType.rwfl === 'dxxz' ? '单项选择,请输入如:选项1;选项2;选项3(支持中文;或英文;分号)' : '多项选择,请输入如:选项1;选项2;选项3(支持中文;或英文;分号)'"
|
||||
class="options-textarea"
|
||||
maxlength="500"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 新增任务项按钮 -->
|
||||
<view class="add-task-item-wrapper">
|
||||
<view class="add-task-type-btn" @click="addTaskType">
|
||||
<text class="add-icon">+</text>
|
||||
<text class="add-text">新增任务项</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view>
|
||||
@ -275,6 +282,7 @@ const formData = reactive({
|
||||
// 任务类型选项
|
||||
const taskTypeOptions = [
|
||||
{ label: '填写文本', value: 'text' },
|
||||
{ label: '富文本', value: 'fwb' },
|
||||
{ label: '单项选择', value: 'dxxz' },
|
||||
{ label: '多项选择', value: 'dxsx' },
|
||||
{ label: '上传图片', value: 'sctp' },
|
||||
@ -424,14 +432,42 @@ const addTaskType = () => {
|
||||
taskTypes.value.push({
|
||||
id: uniqueId,
|
||||
rwfl: 'text',
|
||||
items: []
|
||||
items: [
|
||||
{
|
||||
key: `item_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
rwbt: '',
|
||||
rwbs: true, // 默认必填项打勾
|
||||
remark: ''
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// 显示冒泡提示
|
||||
showTooltip.value = true;
|
||||
setTimeout(() => {
|
||||
showTooltip.value = false;
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const removeTaskType = (typeIndex: number) => {
|
||||
taskTypes.value.splice(typeIndex, 1);
|
||||
};
|
||||
|
||||
// 移动任务类型
|
||||
const moveTaskType = (typeIndex: number, direction: 'up' | 'down') => {
|
||||
if (direction === 'up' && typeIndex > 0) {
|
||||
// 上移:与上一个交换位置
|
||||
const temp = taskTypes.value[typeIndex];
|
||||
taskTypes.value[typeIndex] = taskTypes.value[typeIndex - 1];
|
||||
taskTypes.value[typeIndex - 1] = temp;
|
||||
} else if (direction === 'down' && typeIndex < taskTypes.value.length - 1) {
|
||||
// 下移:与下一个交换位置
|
||||
const temp = taskTypes.value[typeIndex];
|
||||
taskTypes.value[typeIndex] = taskTypes.value[typeIndex + 1];
|
||||
taskTypes.value[typeIndex + 1] = temp;
|
||||
}
|
||||
};
|
||||
|
||||
const getTaskTypeIndex = (rwfl: string) => {
|
||||
return taskTypeOptions.findIndex(option => option.value === rwfl);
|
||||
};
|
||||
@ -445,31 +481,29 @@ const onTaskTypeChange = (e: any, typeIndex: number) => {
|
||||
const selectedIndex = e.detail.value;
|
||||
const selectedType = taskTypeOptions[selectedIndex];
|
||||
taskTypes.value[typeIndex].rwfl = selectedType.value;
|
||||
// 清空现有的任务项
|
||||
taskTypes.value[typeIndex].items = [];
|
||||
// 保持任务项,但清空选项内容(如果不是选择题类型)
|
||||
if (taskTypes.value[typeIndex].items.length === 0) {
|
||||
// 如果没有任务项,创建一个默认的
|
||||
taskTypes.value[typeIndex].items = [
|
||||
{
|
||||
key: `item_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
rwbt: '',
|
||||
rwbs: true,
|
||||
remark: ''
|
||||
}
|
||||
];
|
||||
} else {
|
||||
// 如果切换为非选择题类型,清空选项
|
||||
if (selectedType.value !== 'dxxz' && selectedType.value !== 'dxsx') {
|
||||
taskTypes.value[typeIndex].items[0].remark = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addTaskItem = (taskType: TaskType) => {
|
||||
taskType.items.push({
|
||||
key: `item_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
rwbt: '',
|
||||
rwbs: true, // 默认必填项打勾
|
||||
remark: ''
|
||||
});
|
||||
|
||||
// 显示冒泡提示 - 使用签到界面风格
|
||||
showTooltip.value = true;
|
||||
setTimeout(() => {
|
||||
showTooltip.value = false;
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const removeTaskItem = (taskType: TaskType, itemIndex: number) => {
|
||||
taskType.items.splice(itemIndex, 1);
|
||||
};
|
||||
|
||||
const toggleRequired = (taskType: TaskType, itemIndex: number) => {
|
||||
taskType.items[itemIndex].rwbs = !taskType.items[itemIndex].rwbs;
|
||||
const toggleRequired = (taskType: TaskType) => {
|
||||
if (taskType.items.length > 0) {
|
||||
taskType.items[0].rwbs = !taskType.items[0].rwbs;
|
||||
}
|
||||
};
|
||||
|
||||
// 表单验证
|
||||
@ -490,47 +524,48 @@ const validateForm = (): boolean => {
|
||||
}
|
||||
|
||||
// 验证是否有任务类型
|
||||
let hasTaskType = false;
|
||||
for (const taskType of taskTypes.value) {
|
||||
if (taskType.items.length > 0) {
|
||||
hasTaskType = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasTaskType) {
|
||||
uni.showToast({ title: '请至少添加一个任务项', icon: 'error' });
|
||||
if (taskTypes.value.length === 0) {
|
||||
uni.showToast({ title: '请至少添加一个任务方式', icon: 'error' });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证任务项
|
||||
for (const taskType of taskTypes.value) {
|
||||
// 验证任务项(一个任务方式对应一个任务项)
|
||||
for (let i = 0; i < taskTypes.value.length; i++) {
|
||||
const taskType = taskTypes.value[i];
|
||||
const taskTypeName = getTaskTypeLabel(taskType.rwfl);
|
||||
|
||||
for (let i = 0; i < taskType.items.length; i++) {
|
||||
const item = taskType.items[i];
|
||||
|
||||
// 任务项的文字描述必填
|
||||
if (!item.rwbt || !item.rwbt.trim()) {
|
||||
// 确保每个任务方式都有一个任务项
|
||||
if (taskType.items.length === 0) {
|
||||
uni.showToast({
|
||||
title: `${taskTypeName}缺少任务项`,
|
||||
icon: 'error',
|
||||
duration: 2000
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const item = taskType.items[0];
|
||||
|
||||
// 任务项的文字描述必填
|
||||
if (!item.rwbt || !item.rwbt.trim()) {
|
||||
uni.showToast({
|
||||
title: `请填写${taskTypeName}的任务描述`,
|
||||
icon: 'error',
|
||||
duration: 2000
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果任务项打勾(必填)且是单项选择或多项选择
|
||||
if (item.rwbs && (taskType.rwfl === 'dxxz' || taskType.rwfl === 'dxsx')) {
|
||||
if (!item.remark || !item.remark.trim()) {
|
||||
uni.showToast({
|
||||
title: `请填写${taskTypeName}的任务项${i + 1}描述`,
|
||||
title: `${taskTypeName}的选项内容不能为空`,
|
||||
icon: 'error',
|
||||
duration: 2000
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果任务项打勾(必填)且是单项选择或多项选择
|
||||
if (item.rwbs && (taskType.rwfl === 'dxxz' || taskType.rwfl === 'dxsx')) {
|
||||
if (!item.remark || !item.remark.trim()) {
|
||||
uni.showToast({
|
||||
title: `${taskTypeName}的任务项${i + 1}选项内容不能为空`,
|
||||
icon: 'error',
|
||||
duration: 2000
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -586,18 +621,20 @@ async function performSubmit() {
|
||||
rwlxes: [] as any[]
|
||||
};
|
||||
|
||||
// 收集所有任务类型
|
||||
taskTypes.value.forEach(taskType => {
|
||||
taskType.items.forEach(item => {
|
||||
// 收集所有任务类型(一个任务方式对应一个任务项)
|
||||
taskTypes.value.forEach((taskType, index) => {
|
||||
if (taskType.items.length > 0) {
|
||||
const item = taskType.items[0];
|
||||
if (item.rwbt.trim()) {
|
||||
submitData.rwlxes.push({
|
||||
rwfl: taskType.rwfl,
|
||||
rwbt: item.rwbt,
|
||||
rwbs: item.rwbs ? 1 : 0,
|
||||
remark: item.remark
|
||||
remark: item.remark,
|
||||
sort: index + 1 // 排序字段,从1开始
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await rwSaveApi(submitData);
|
||||
@ -693,30 +730,47 @@ const goBack = () => {
|
||||
color: #1f2937;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
}
|
||||
|
||||
// 新增任务项按钮容器
|
||||
.add-task-item-wrapper {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.add-task-type-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #409eff 0%, #3a8ee6 100%);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
min-width: 140px;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.add-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -832,6 +886,9 @@ const goBack = () => {
|
||||
background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: visible;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
@ -886,6 +943,51 @@ const goBack = () => {
|
||||
}
|
||||
}
|
||||
|
||||
.task-type-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-right: 32px; // 为删除按钮留出空间
|
||||
|
||||
.move-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.move-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, #67c23a 0%, #5daf34 100%);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 6px rgba(103, 194, 58, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid #ffffff;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 10px rgba(103, 194, 58, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.move-icon {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delete-type-btn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
@ -916,199 +1018,143 @@ const goBack = () => {
|
||||
|
||||
// 任务项列表
|
||||
.task-items-container {
|
||||
.task-item-card {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.task-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
// 右上角删除按钮
|
||||
.delete-item-btn-top {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
.item-number {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, #409eff 0%, #3a8ee6 100%);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 6px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.item-title-input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10;
|
||||
border: 2px solid #ffffff;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||
&:focus {
|
||||
border-color: #409eff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.task-item-header {
|
||||
.item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.item-number {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, #409eff 0%, #3a8ee6 100%);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
.required-checkbox {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 6px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.item-title-input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: #409eff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
|
||||
&:hover {
|
||||
background-color: rgba(64, 158, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.required-checkbox {
|
||||
position: relative;
|
||||
.checkbox-box {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(64, 158, 255, 0.05);
|
||||
}
|
||||
|
||||
.checkbox-box {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
&.checked {
|
||||
background-color: #409eff;
|
||||
border-color: #409eff;
|
||||
|
||||
&.checked {
|
||||
background-color: #409eff;
|
||||
border-color: #409eff;
|
||||
|
||||
.check-icon {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 冒泡提示样式
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
top: -45px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
animation: tooltipFadeIn 0.3s ease-out;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: rgba(0, 0, 0, 0.8);
|
||||
.check-icon {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-options {
|
||||
.options-textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
min-height: 80px;
|
||||
background-color: #ffffff;
|
||||
transition: all 0.3s ease;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
border-color: #409eff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
|
||||
background-color: #ffffff;
|
||||
// 冒泡提示样式
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
top: -45px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
animation: tooltipFadeIn 0.3s ease-out;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-item-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border: 2px dashed #409eff;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, rgba(64, 158, 255, 0.05) 0%, rgba(64, 158, 255, 0.1) 100%);
|
||||
color: #409eff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
.item-options {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, rgba(64, 158, 255, 0.1) 0%, rgba(64, 158, 255, 0.15) 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
.options-textarea {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
min-height: 80px;
|
||||
background-color: #ffffff;
|
||||
transition: all 0.3s ease;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
&:focus {
|
||||
border-color: #409eff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
|
||||
background-color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,23 +69,23 @@
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- 附件上传 -->
|
||||
<view class="form-item attachment-item">
|
||||
<text class="item-label">附件:</text>
|
||||
<view class="attachment-container">
|
||||
<ImageVideoUpload
|
||||
v-model:file-list="fileList"
|
||||
:enable-image="false"
|
||||
:enable-video="false"
|
||||
:enable-file="true"
|
||||
:max-file-count="5"
|
||||
:allowed-file-types="['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'mp3', 'wav', 'zip', 'rar']"
|
||||
:compress-config="compressConfig"
|
||||
:upload-api="attachmentUpload"
|
||||
@file-upload-success="onFileUploadSuccess"
|
||||
/>
|
||||
<!-- 附件上传 -->
|
||||
<view class="form-item attachment-item">
|
||||
<text class="item-label">附件:</text>
|
||||
<view class="attachment-container">
|
||||
<ImageVideoUpload
|
||||
v-model:file-list="fileList"
|
||||
:enable-image="false"
|
||||
:enable-video="false"
|
||||
:enable-file="true"
|
||||
:max-file-count="5"
|
||||
:allowed-file-types="['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'mp3', 'wav', 'zip', 'rar']"
|
||||
:compress-config="compressConfig"
|
||||
:upload-api="attachmentUpload"
|
||||
@file-upload-success="onFileUploadSuccess"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
|
||||
@ -93,18 +93,14 @@
|
||||
<view class="section-card">
|
||||
<view class="section-title">
|
||||
<text class="title-text">任务下发设置</text>
|
||||
<view class="add-task-type-btn" @click="addTaskType">
|
||||
<text class="add-icon">+</text>
|
||||
<text class="add-text">新增任务方式</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 任务方式列表 -->
|
||||
<!-- 任务类型列表 -->
|
||||
<view v-for="(taskType, typeIndex) in taskTypes" :key="taskType.id" class="task-type-section">
|
||||
<!-- 任务方式选择 -->
|
||||
<!-- 任务类型选择 -->
|
||||
<view class="task-type-header">
|
||||
<view class="task-type-selector">
|
||||
<text class="selector-label">任务方式:</text>
|
||||
<text class="selector-label">任务类型:</text>
|
||||
<picker
|
||||
:range="taskTypeOptions"
|
||||
range-key="label"
|
||||
@ -118,59 +114,70 @@
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
<view class="delete-type-btn" @click="removeTaskType(typeIndex)">
|
||||
<text class="delete-icon">×</text>
|
||||
<view class="task-type-actions">
|
||||
<view class="move-buttons">
|
||||
<view
|
||||
v-if="typeIndex > 0"
|
||||
class="move-btn move-up-btn"
|
||||
@click="moveTaskType(typeIndex, 'up')"
|
||||
title="上移"
|
||||
>
|
||||
<text class="move-icon">↑</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="typeIndex < taskTypes.length - 1"
|
||||
class="move-btn move-down-btn"
|
||||
@click="moveTaskType(typeIndex, 'down')"
|
||||
title="下移"
|
||||
>
|
||||
<text class="move-icon">↓</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="delete-type-btn" @click="removeTaskType(typeIndex)">
|
||||
<text class="delete-icon">×</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 任务项列表 -->
|
||||
<view class="task-items-container">
|
||||
<view
|
||||
v-for="(item, itemIndex) in taskType.items"
|
||||
:key="item.key"
|
||||
class="task-item-card"
|
||||
>
|
||||
<!-- 删除按钮放在右上角 -->
|
||||
<view class="delete-item-btn-top" @click="removeTaskItem(taskType, itemIndex)">
|
||||
<text class="delete-icon">×</text>
|
||||
</view>
|
||||
|
||||
<view class="task-item-header">
|
||||
<text class="item-number">{{ itemIndex + 1 }}</text>
|
||||
<input
|
||||
v-model="item.rwbt"
|
||||
:placeholder="`任务项 ${itemIndex + 1}`"
|
||||
class="item-title-input"
|
||||
maxlength="100"
|
||||
/>
|
||||
<view class="item-actions">
|
||||
<view class="required-checkbox" @click="toggleRequired(taskType, itemIndex)">
|
||||
<view :class="['checkbox-box', { checked: item.rwbs }]">
|
||||
<text v-if="item.rwbs" class="check-icon">✓</text>
|
||||
</view>
|
||||
<view v-if="showTooltip" class="tooltip">默认必填</view>
|
||||
<!-- 任务项(一个任务类型对应一个任务项) -->
|
||||
<view class="task-items-container" v-if="taskType.items && taskType.items.length > 0">
|
||||
<view class="task-item-header">
|
||||
<text class="item-number">{{ typeIndex + 1 }}</text>
|
||||
<input
|
||||
v-model="taskType.items[0].rwbt"
|
||||
:placeholder="`请输入${getTaskTypeLabel(taskType.rwfl)}任务描述`"
|
||||
class="item-title-input"
|
||||
maxlength="100"
|
||||
/>
|
||||
<view class="item-actions">
|
||||
<view class="required-checkbox" @click="toggleRequired(taskType)">
|
||||
<view :class="['checkbox-box', { checked: taskType.items[0].rwbs }]">
|
||||
<text v-if="taskType.items[0].rwbs" class="check-icon">✓</text>
|
||||
</view>
|
||||
<view v-if="showTooltip" class="tooltip">默认必填</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 选择题选项 -->
|
||||
<view v-if="taskType.rwfl === 'dxxz' || taskType.rwfl === 'dxsx'" class="item-options">
|
||||
<textarea
|
||||
v-model="item.remark"
|
||||
:placeholder="taskType.rwfl === 'dxxz' ? '单项选择,请输入如:选项1;选项2;选项3' : '多项选择,请输入如:选项1;选项2;选项3'"
|
||||
class="options-textarea"
|
||||
maxlength="500"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 添加任务项按钮 -->
|
||||
<view class="add-item-btn" @click="addTaskItem(taskType)">
|
||||
<text class="add-icon">+</text>
|
||||
<text class="add-text">添加任务项</text>
|
||||
<!-- 选择题选项 -->
|
||||
<view v-if="taskType.rwfl === 'dxxz' || taskType.rwfl === 'dxsx'" class="item-options">
|
||||
<textarea
|
||||
v-model="taskType.items[0].remark"
|
||||
:placeholder="taskType.rwfl === 'dxxz' ? '单项选择,请输入如:选项1;选项2;选项3(支持中文;或英文;分号)' : '多项选择,请输入如:选项1;选项2;选项3(支持中文;或英文;分号)'"
|
||||
class="options-textarea"
|
||||
maxlength="500"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 新增任务项按钮 -->
|
||||
<view class="add-task-item-wrapper">
|
||||
<view class="add-task-type-btn" @click="addTaskType">
|
||||
<text class="add-icon">+</text>
|
||||
<text class="add-text">新增任务项</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view>
|
||||
@ -278,6 +285,7 @@ const formData = reactive({
|
||||
// 任务类型选项
|
||||
const taskTypeOptions = [
|
||||
{ label: '填写文本', value: 'text' },
|
||||
{ label: '富文本', value: 'fwb' },
|
||||
{ label: '单项选择', value: 'dxxz' },
|
||||
{ label: '多项选择', value: 'dxsx' },
|
||||
{ label: '上传图片', value: 'sctp' },
|
||||
@ -290,7 +298,14 @@ const taskTypes = ref<TaskType[]>([
|
||||
{
|
||||
id: `task_type_${Date.now()}`,
|
||||
rwfl: 'text',
|
||||
items: []
|
||||
items: [
|
||||
{
|
||||
key: `item_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
rwbt: '',
|
||||
rwbs: true, // 默认必填项打勾
|
||||
remark: ''
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
|
||||
@ -395,27 +410,43 @@ const loadTaskDataFromStorage = () => {
|
||||
|
||||
// 处理任务类型数据
|
||||
if (taskInfo.rwlxes && taskInfo.rwlxes.length > 0) {
|
||||
// 按任务类型分组
|
||||
const groupedTasks: { [key: string]: any[] } = {};
|
||||
taskInfo.rwlxes.forEach((item: any) => {
|
||||
if (!groupedTasks[item.rwfl]) {
|
||||
groupedTasks[item.rwfl] = [];
|
||||
// 先按 sort 排序,如果 sort 为空则按 id 排序
|
||||
const sortedRwlxes = [...taskInfo.rwlxes].sort((a: any, b: any) => {
|
||||
const sortA = a.sort || 0;
|
||||
const sortB = b.sort || 0;
|
||||
if (sortA !== sortB) {
|
||||
return sortA - sortB;
|
||||
}
|
||||
groupedTasks[item.rwfl].push(item);
|
||||
// sort 相同时按 id 排序
|
||||
return (a.id || '').localeCompare(b.id || '');
|
||||
});
|
||||
|
||||
// 构建任务类型列表
|
||||
taskTypes.value = Object.keys(groupedTasks).map((rwfl, index) => ({
|
||||
id: `task_type_${index + 1}`,
|
||||
rwfl: rwfl,
|
||||
items: groupedTasks[rwfl].map((item, itemIndex) => ({
|
||||
key: `item_${index + 1}_${itemIndex + 1}`,
|
||||
id: item.id, // 保留任务项的ID
|
||||
rwbt: item.rwbt || '',
|
||||
rwbs: item.rwbs === 1 || item.rwbs === true,
|
||||
remark: item.remark || ''
|
||||
}))
|
||||
}));
|
||||
// 构建任务类型列表(每个任务类型只保留第一个任务项,保持排序)
|
||||
// 由于前端显示时一个任务类型对应一个任务项,所以每个 rwfl 只保留第一个出现的
|
||||
const seenRwfl = new Set<string>();
|
||||
taskTypes.value = sortedRwlxes
|
||||
.filter((item: any) => {
|
||||
if (seenRwfl.has(item.rwfl)) {
|
||||
return false; // 如果已经见过这个 rwfl,跳过
|
||||
}
|
||||
seenRwfl.add(item.rwfl);
|
||||
return true;
|
||||
})
|
||||
.map((item: any, index: number) => {
|
||||
return {
|
||||
id: `task_type_${index + 1}`,
|
||||
rwfl: item.rwfl,
|
||||
items: [
|
||||
{
|
||||
key: `item_${index + 1}_1`,
|
||||
id: item.id, // 保留任务项的ID
|
||||
rwbt: item.rwbt || '',
|
||||
rwbs: item.rwbs === 1 || item.rwbs === true,
|
||||
remark: item.remark || ''
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// 处理附件数据
|
||||
@ -638,14 +669,42 @@ const addTaskType = () => {
|
||||
taskTypes.value.push({
|
||||
id: uniqueId,
|
||||
rwfl: 'text',
|
||||
items: []
|
||||
items: [
|
||||
{
|
||||
key: `item_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
rwbt: '',
|
||||
rwbs: true, // 默认必填项打勾
|
||||
remark: ''
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
// 显示冒泡提示
|
||||
showTooltip.value = true;
|
||||
setTimeout(() => {
|
||||
showTooltip.value = false;
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const removeTaskType = (typeIndex: number) => {
|
||||
taskTypes.value.splice(typeIndex, 1);
|
||||
};
|
||||
|
||||
// 移动任务类型
|
||||
const moveTaskType = (typeIndex: number, direction: 'up' | 'down') => {
|
||||
if (direction === 'up' && typeIndex > 0) {
|
||||
// 上移:与上一个交换位置
|
||||
const temp = taskTypes.value[typeIndex];
|
||||
taskTypes.value[typeIndex] = taskTypes.value[typeIndex - 1];
|
||||
taskTypes.value[typeIndex - 1] = temp;
|
||||
} else if (direction === 'down' && typeIndex < taskTypes.value.length - 1) {
|
||||
// 下移:与下一个交换位置
|
||||
const temp = taskTypes.value[typeIndex];
|
||||
taskTypes.value[typeIndex] = taskTypes.value[typeIndex + 1];
|
||||
taskTypes.value[typeIndex + 1] = temp;
|
||||
}
|
||||
};
|
||||
|
||||
const getTaskTypeIndex = (rwfl: string) => {
|
||||
return taskTypeOptions.findIndex(option => option.value === rwfl);
|
||||
};
|
||||
@ -659,32 +718,29 @@ const onTaskTypeChange = (e: any, typeIndex: number) => {
|
||||
const selectedIndex = e.detail.value;
|
||||
const selectedType = taskTypeOptions[selectedIndex];
|
||||
taskTypes.value[typeIndex].rwfl = selectedType.value;
|
||||
// 清空现有的任务项
|
||||
taskTypes.value[typeIndex].items = [];
|
||||
// 保持任务项,但清空选项内容(如果不是选择题类型)
|
||||
if (taskTypes.value[typeIndex].items.length === 0) {
|
||||
// 如果没有任务项,创建一个默认的
|
||||
taskTypes.value[typeIndex].items = [
|
||||
{
|
||||
key: `item_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
rwbt: '',
|
||||
rwbs: true,
|
||||
remark: ''
|
||||
}
|
||||
];
|
||||
} else {
|
||||
// 如果切换为非选择题类型,清空选项
|
||||
if (selectedType.value !== 'dxxz' && selectedType.value !== 'dxsx') {
|
||||
taskTypes.value[typeIndex].items[0].remark = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addTaskItem = (taskType: TaskType) => {
|
||||
taskType.items.push({
|
||||
key: `item_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
// 新增的任务项不设置id,让后端生成新的ID
|
||||
rwbt: '',
|
||||
rwbs: true, // 默认必填项打勾
|
||||
remark: ''
|
||||
});
|
||||
|
||||
// 显示冒泡提示 - 使用签到界面风格
|
||||
showTooltip.value = true;
|
||||
setTimeout(() => {
|
||||
showTooltip.value = false;
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const removeTaskItem = (taskType: TaskType, itemIndex: number) => {
|
||||
taskType.items.splice(itemIndex, 1);
|
||||
};
|
||||
|
||||
const toggleRequired = (taskType: TaskType, itemIndex: number) => {
|
||||
taskType.items[itemIndex].rwbs = !taskType.items[itemIndex].rwbs;
|
||||
const toggleRequired = (taskType: TaskType) => {
|
||||
if (taskType.items.length > 0) {
|
||||
taskType.items[0].rwbs = !taskType.items[0].rwbs;
|
||||
}
|
||||
};
|
||||
|
||||
// 表单验证
|
||||
@ -705,36 +761,47 @@ const validateForm = (): boolean => {
|
||||
}
|
||||
|
||||
// 验证是否有任务类型
|
||||
let hasTaskType = false;
|
||||
for (const taskType of taskTypes.value) {
|
||||
if (taskType.items.length > 0) {
|
||||
hasTaskType = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasTaskType) {
|
||||
uni.showToast({ title: '请至少添加一个任务项', icon: 'error' });
|
||||
if (taskTypes.value.length === 0) {
|
||||
uni.showToast({ title: '请至少添加一个任务方式', icon: 'error' });
|
||||
return false;
|
||||
}
|
||||
|
||||
// 验证任务项:如果打勾(必填)且是单选/多选,则 remark 必填
|
||||
for (const taskType of taskTypes.value) {
|
||||
// 验证任务项(一个任务方式对应一个任务项)
|
||||
for (let i = 0; i < taskTypes.value.length; i++) {
|
||||
const taskType = taskTypes.value[i];
|
||||
const taskTypeName = getTaskTypeLabel(taskType.rwfl);
|
||||
|
||||
for (let i = 0; i < taskType.items.length; i++) {
|
||||
const item = taskType.items[i];
|
||||
|
||||
// 如果任务项打勾(必填)且是单项选择或多项选择
|
||||
if (item.rwbs && (taskType.rwfl === 'dxxz' || taskType.rwfl === 'dxsx')) {
|
||||
if (!item.remark || !item.remark.trim()) {
|
||||
uni.showToast({
|
||||
title: `${taskTypeName}的任务项${i + 1}选项内容不能为空`,
|
||||
icon: 'error',
|
||||
duration: 2000
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// 确保每个任务方式都有一个任务项
|
||||
if (taskType.items.length === 0) {
|
||||
uni.showToast({
|
||||
title: `${taskTypeName}缺少任务项`,
|
||||
icon: 'error',
|
||||
duration: 2000
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const item = taskType.items[0];
|
||||
|
||||
// 任务项的文字描述必填
|
||||
if (!item.rwbt || !item.rwbt.trim()) {
|
||||
uni.showToast({
|
||||
title: `请填写${taskTypeName}的任务描述`,
|
||||
icon: 'error',
|
||||
duration: 2000
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果任务项打勾(必填)且是单项选择或多项选择
|
||||
if (item.rwbs && (taskType.rwfl === 'dxxz' || taskType.rwfl === 'dxsx')) {
|
||||
if (!item.remark || !item.remark.trim()) {
|
||||
uni.showToast({
|
||||
title: `${taskTypeName}的选项内容不能为空`,
|
||||
icon: 'error',
|
||||
duration: 2000
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -792,15 +859,17 @@ async function performSubmit() {
|
||||
rwlxes: [] as any[]
|
||||
};
|
||||
|
||||
// 收集所有任务类型
|
||||
taskTypes.value.forEach(taskType => {
|
||||
taskType.items.forEach(item => {
|
||||
// 收集所有任务类型(一个任务方式对应一个任务项)
|
||||
taskTypes.value.forEach((taskType, index) => {
|
||||
if (taskType.items.length > 0) {
|
||||
const item = taskType.items[0];
|
||||
if (item.rwbt.trim()) {
|
||||
const taskItem: any = {
|
||||
rwfl: taskType.rwfl,
|
||||
rwbt: item.rwbt,
|
||||
rwbs: item.rwbs ? 1 : 0,
|
||||
remark: item.remark
|
||||
remark: item.remark,
|
||||
sort: index + 1 // 排序字段,从1开始
|
||||
};
|
||||
|
||||
// 如果任务项有ID,说明是编辑现有记录,需要传递ID
|
||||
@ -810,7 +879,7 @@ async function performSubmit() {
|
||||
|
||||
submitData.rwlxes.push(taskItem);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await rwSaveApi(submitData);
|
||||
@ -909,30 +978,47 @@ const goBack = () => {
|
||||
color: #1f2937;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
}
|
||||
|
||||
// 新增任务项按钮容器
|
||||
.add-task-item-wrapper {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.add-task-type-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #409eff 0%, #3a8ee6 100%);
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
min-width: 140px;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
font-size: 16px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.add-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1048,6 +1134,9 @@ const goBack = () => {
|
||||
background: linear-gradient(135deg, #f9fafb 0%, #ffffff 100%);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: visible;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
@ -1102,6 +1191,51 @@ const goBack = () => {
|
||||
}
|
||||
}
|
||||
|
||||
.task-type-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-right: 32px; // 为删除按钮留出空间
|
||||
|
||||
.move-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.move-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, #67c23a 0%, #5daf34 100%);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 6px rgba(103, 194, 58, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
border: 2px solid #ffffff;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 10px rgba(103, 194, 58, 0.4);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.move-icon {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.delete-type-btn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
@ -1132,199 +1266,143 @@ const goBack = () => {
|
||||
|
||||
// 任务项列表
|
||||
.task-items-container {
|
||||
.task-item-card {
|
||||
margin-bottom: 16px;
|
||||
padding: 16px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%);
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.task-item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
&:hover {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
// 右上角删除按钮
|
||||
.delete-item-btn-top {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
|
||||
.item-number {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, #409eff 0%, #3a8ee6 100%);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 8px rgba(239, 68, 68, 0.3);
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 6px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.item-title-input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 10;
|
||||
border: 2px solid #ffffff;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.4);
|
||||
&:focus {
|
||||
border-color: #409eff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.task-item-header {
|
||||
.item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
|
||||
.item-number {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: linear-gradient(135deg, #409eff 0%, #3a8ee6 100%);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
.required-checkbox {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 2px 6px rgba(64, 158, 255, 0.3);
|
||||
}
|
||||
|
||||
.item-title-input {
|
||||
flex: 1;
|
||||
padding: 10px 14px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:focus {
|
||||
border-color: #409eff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
|
||||
&:hover {
|
||||
background-color: rgba(64, 158, 255, 0.05);
|
||||
}
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.required-checkbox {
|
||||
position: relative;
|
||||
.checkbox-box {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 6px;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(64, 158, 255, 0.05);
|
||||
}
|
||||
|
||||
.checkbox-box {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.3s ease;
|
||||
&.checked {
|
||||
background-color: #409eff;
|
||||
border-color: #409eff;
|
||||
|
||||
&.checked {
|
||||
background-color: #409eff;
|
||||
border-color: #409eff;
|
||||
|
||||
.check-icon {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 冒泡提示样式
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
top: -45px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
animation: tooltipFadeIn 0.3s ease-out;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: rgba(0, 0, 0, 0.8);
|
||||
.check-icon {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-options {
|
||||
.options-textarea {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
min-height: 80px;
|
||||
background-color: #ffffff;
|
||||
transition: all 0.3s ease;
|
||||
resize: vertical;
|
||||
|
||||
&:focus {
|
||||
border-color: #409eff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
|
||||
background-color: #ffffff;
|
||||
// 冒泡提示样式
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
top: -45px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
animation: tooltipFadeIn 0.3s ease-out;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border: 5px solid transparent;
|
||||
border-top-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-item-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 14px;
|
||||
border: 2px dashed #409eff;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, rgba(64, 158, 255, 0.05) 0%, rgba(64, 158, 255, 0.1) 100%);
|
||||
color: #409eff;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
.item-options {
|
||||
margin-top: 12px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
&:hover {
|
||||
background: linear-gradient(135deg, rgba(64, 158, 255, 0.1) 0%, rgba(64, 158, 255, 0.15) 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.2);
|
||||
}
|
||||
|
||||
.add-icon {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
.options-textarea {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 12px;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
min-height: 80px;
|
||||
background-color: #ffffff;
|
||||
transition: all 0.3s ease;
|
||||
resize: vertical;
|
||||
box-sizing: border-box;
|
||||
overflow-wrap: break-word;
|
||||
word-wrap: break-word;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
|
||||
&:focus {
|
||||
border-color: #409eff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(64, 158, 255, 0.1);
|
||||
background-color: #ffffff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,8 +49,8 @@
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
<template v-else-if="courseList.length > 0">
|
||||
<view v-for="course in courseList" :key="course.id" class="course-card" @click="viewCourseDetail(course.id)">
|
||||
<view class="card-badge">
|
||||
<view v-for="course in courseList" :key="course.id" class="course-card">
|
||||
<view class="card-badge" @click="viewCourseDetail(course.id)">
|
||||
<text class="badge-text">课程规划</text>
|
||||
</view>
|
||||
|
||||
@ -64,10 +64,32 @@
|
||||
</view>
|
||||
|
||||
<view class="card-body">
|
||||
<view class="course-description" v-if="course.kcms">
|
||||
<!-- 如果有附件,显示附件;否则显示课程描述 -->
|
||||
<view class="course-description" v-if="course.kcms && !hasAttachments(course)" @click.stop="showDescriptionModal(course.kcms)">
|
||||
<text class="description-text">{{ course.kcms }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 附件列表 -->
|
||||
<view class="attachments-section" v-if="hasAttachments(course)">
|
||||
<view class="attachments-list">
|
||||
<view
|
||||
v-for="(file, fileIndex) in parseFileList(course)"
|
||||
:key="fileIndex"
|
||||
class="attachment-item"
|
||||
@click.stop="previewAttachmentFile(file)"
|
||||
>
|
||||
<view class="attachment-icon">
|
||||
<image
|
||||
:src="getFileIcon(getFileSuffix(file))"
|
||||
class="icon-image"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<text class="attachment-name">{{ getFileName(file) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="course-info">
|
||||
<view class="info-item" v-if="course.jsxm">
|
||||
<text class="info-icon">👨🏫</text>
|
||||
@ -107,6 +129,21 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 课程描述弹窗 -->
|
||||
<uni-popup ref="descriptionPopup" type="center" :mask-click="true">
|
||||
<view class="description-modal">
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">课程描述</text>
|
||||
<view class="modal-close" @click="closeDescriptionModal">
|
||||
<text class="close-icon">×</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-content">
|
||||
<text class="modal-text">{{ currentDescription }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@ -115,9 +152,24 @@ import { ref, reactive, onMounted } from "vue";
|
||||
import { onShow } from "@dcloudio/uni-app";
|
||||
import { kcjbFindPageApi, kcjbBannerApi, kcjbRegisterApi, getXqList } from "@/api/base/kcjbApi";
|
||||
import { useUserStore } from "@/store/modules/user";
|
||||
import { imagUrl } from "@/utils";
|
||||
import {
|
||||
previewFile as previewFileUtil
|
||||
} from "@/utils/filePreview";
|
||||
|
||||
const { getJs, getUser } = useUserStore();
|
||||
|
||||
// 附件类型定义
|
||||
interface FileInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
url: string;
|
||||
resourName?: string; // 资源名称
|
||||
resourUrl?: string; // 资源URL
|
||||
resSuf?: string; // 文件后缀
|
||||
resourSuf?: string; // 资源文件后缀
|
||||
}
|
||||
|
||||
interface CourseItem {
|
||||
id: string;
|
||||
kcmc: string; // 课程名称
|
||||
@ -129,6 +181,9 @@ interface CourseItem {
|
||||
jsxm?: string; // 教师姓名
|
||||
bzrxm?: string; // 班主任姓名
|
||||
ljlxxm?: string; // 联系人姓名
|
||||
fileUrl?: string; // 文件存储路径
|
||||
fileName?: string; // 原文件名称
|
||||
fileFormat?: string; // 文件格式
|
||||
}
|
||||
|
||||
// 搜索表单
|
||||
@ -146,6 +201,10 @@ const hasMore = ref(true);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(10);
|
||||
|
||||
// 课程描述弹窗
|
||||
const descriptionPopup = ref<any>(null);
|
||||
const currentDescription = ref('');
|
||||
|
||||
// 获取学期列表
|
||||
const loadXqList = async () => {
|
||||
try {
|
||||
@ -309,6 +368,137 @@ onMounted(() => {
|
||||
loadXqList(); // 加载学期列表,会自动选择第一条并查询
|
||||
});
|
||||
|
||||
// 检查是否有附件
|
||||
const hasAttachments = (data: any) => {
|
||||
return data.fileUrl && data.fileName;
|
||||
};
|
||||
|
||||
// 解析逗号分隔的文件信息
|
||||
const parseFileList = (data: any) => {
|
||||
const fileList: FileInfo[] = [];
|
||||
|
||||
// 处理单个文件(从data直接获取)
|
||||
if (data.fileUrl && data.fileName) {
|
||||
const urls = data.fileUrl.split(',').map((url: string) => url.trim());
|
||||
const names = data.fileName.split(',').map((name: string) => name.trim());
|
||||
const formats = data.fileFormat ? data.fileFormat.split(',').map((format: string) => format.trim()) : [];
|
||||
|
||||
urls.forEach((url: string, index: number) => {
|
||||
if (url) {
|
||||
const fileName = names[index] || `文件${index + 1}`;
|
||||
const fileFormat = formats[index] || '';
|
||||
const fileSuffix = fileFormat || url.split('.').pop() || '';
|
||||
|
||||
fileList.push({
|
||||
name: fileName,
|
||||
url: url,
|
||||
resourName: fileName,
|
||||
resourUrl: url,
|
||||
resourSuf: fileSuffix,
|
||||
size: 0 // 无法从URL获取文件大小
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return fileList;
|
||||
};
|
||||
|
||||
// 预览附件文件
|
||||
const previewAttachmentFile = (file: FileInfo) => {
|
||||
const fileUrl = file.resourUrl ? imagUrl(file.resourUrl) : (file.url ? imagUrl(file.url) : '');
|
||||
const fileName = file.resourName || file.name || '未知文件';
|
||||
const fileSuf = getFileSuffix(file);
|
||||
|
||||
if (!fileUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 统一使用 kkview 预览
|
||||
const fullFileName = fileSuf ? `${fileName}.${fileSuf}` : fileName;
|
||||
previewFileUtil(fileUrl, fullFileName, fileSuf)
|
||||
.catch((error: any) => {
|
||||
uni.showToast({
|
||||
title: '预览失败',
|
||||
icon: 'error'
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 获取文件名
|
||||
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.resourSuf) {
|
||||
return file.resourSuf;
|
||||
}
|
||||
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 getFileIcon = (fileType: string) => {
|
||||
const type = fileType.toLowerCase();
|
||||
switch (type) {
|
||||
case 'pdf':
|
||||
return '/static/base/view/pdf.png';
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return '/static/base/view/word.png';
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
return '/static/base/view/excel.png';
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return '/static/base/view/ppt.png';
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case '7z':
|
||||
return '/static/base/view/zip.png';
|
||||
default:
|
||||
return '/static/base/view/more.png';
|
||||
}
|
||||
};
|
||||
|
||||
// 显示课程描述弹窗
|
||||
const showDescriptionModal = (description: string) => {
|
||||
currentDescription.value = description;
|
||||
descriptionPopup.value?.open();
|
||||
};
|
||||
|
||||
// 关闭课程描述弹窗
|
||||
const closeDescriptionModal = () => {
|
||||
descriptionPopup.value?.close();
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@ -597,6 +787,64 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// 附件相关样式
|
||||
.attachments-section {
|
||||
margin-top: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(78, 115, 223, 0.1);
|
||||
}
|
||||
|
||||
.attachments-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: linear-gradient(135deg, #f7f9fc 0%, #ffffff 100%);
|
||||
border: 1px solid rgba(78, 115, 223, 0.15);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
background: rgba(78, 115, 223, 0.1);
|
||||
border-color: #4e73df;
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
margin-right: 6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
.icon-image {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
font-size: 12px;
|
||||
color: #2d3748;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.course-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -734,4 +982,70 @@ onMounted(() => {
|
||||
padding: 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
// 课程描述弹窗样式
|
||||
.description-modal {
|
||||
width: 85vw;
|
||||
max-width: 600px;
|
||||
max-height: 70vh;
|
||||
background-color: #ffffff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #4e73df 0%, #2e59d9 100%);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-close:active {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
font-size: 24px;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.modal-text {
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: #333333;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@ -400,7 +400,23 @@ onLoad(async (options: any) => {
|
||||
disabled: isReadOnly.value // 禁用上传组件
|
||||
}
|
||||
})
|
||||
} else if (rwflx.value[i].rwfl == "fwb") {
|
||||
// 富文本类型 - 使用富文本编辑器
|
||||
schema.value.push({
|
||||
field: `${rwflx.value[i].id}`,
|
||||
label: `${rwflx.value[i].rwbt}`,
|
||||
component: "BasicEditor", // 使用富文本编辑器组件
|
||||
required: rwflx.value[i].rwbs && !isReadOnly.value, // 只读模式下不要求必填
|
||||
itemProps: {
|
||||
labelPosition: "top",
|
||||
},
|
||||
componentProps: {
|
||||
placeholder: isReadOnly.value ? "" : "请输入富文本内容,支持插入图片",
|
||||
disabled: isReadOnly.value // 禁用编辑器
|
||||
},
|
||||
})
|
||||
} else if (rwflx.value[i].rwfl == "text") {
|
||||
// 普通文本类型 - 使用 BasicInput
|
||||
schema.value.push({
|
||||
field: `${rwflx.value[i].id}`,
|
||||
label: `${rwflx.value[i].rwbt}`,
|
||||
@ -416,7 +432,8 @@ onLoad(async (options: any) => {
|
||||
},
|
||||
})
|
||||
} else if (rwflx.value[i].rwfl == "dxsx" || rwflx.value[i].rwfl == "dxxz") {
|
||||
let options = rwflx.value[i].remark.split(";");
|
||||
// 同时支持中文分号(;)和英文分号(;)分割选项
|
||||
let options = rwflx.value[i].remark.split(/[;;]/);
|
||||
let range = [];
|
||||
for (let j = 0; j < options.length; j++) {
|
||||
range.push({
|
||||
|
||||
@ -44,10 +44,32 @@
|
||||
</view>
|
||||
|
||||
<view class="card-body">
|
||||
<view class="course-description" v-if="course.kcms">
|
||||
<!-- 如果有附件,显示附件;否则显示课程描述 -->
|
||||
<view class="course-description" v-if="course.kcms && !hasAttachments(course)" @click.stop="showDescriptionModal(course.kcms)">
|
||||
<text class="description-text">{{ course.kcms }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 附件列表 -->
|
||||
<view class="attachments-section" v-if="hasAttachments(course)">
|
||||
<view class="attachments-list">
|
||||
<view
|
||||
v-for="(file, fileIndex) in parseFileList(course)"
|
||||
:key="fileIndex"
|
||||
class="attachment-item"
|
||||
@click.stop="previewAttachmentFile(file)"
|
||||
>
|
||||
<view class="attachment-icon">
|
||||
<image
|
||||
:src="getFileIcon(getFileSuffix(file))"
|
||||
class="icon-image"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<text class="attachment-name">{{ getFileName(file) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="course-meta">
|
||||
<view class="meta-item" v-if="course.jsxm">
|
||||
<view class="meta-icon">👨🏫</view>
|
||||
@ -93,6 +115,21 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 课程描述弹窗 -->
|
||||
<uni-popup ref="descriptionPopup" type="center" :mask-click="true">
|
||||
<view class="description-modal">
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">课程描述</text>
|
||||
<view class="modal-close" @click="closeDescriptionModal">
|
||||
<text class="close-icon">×</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="modal-content">
|
||||
<text class="modal-text">{{ currentDescription }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@ -102,12 +139,27 @@ import { onShow, onLoad } from "@dcloudio/uni-app";
|
||||
import { kcjbFindPageApi, kcjbBannerApi, kcjbRegisterApi, getXqList } from "@/api/base/kcjbApi";
|
||||
import { jyjbFindPageApi } from "@/api/base/jyjbApi";
|
||||
import { useUserStore } from "@/store/modules/user";
|
||||
import { imagUrl } from "@/utils";
|
||||
import {
|
||||
previewFile as previewFileUtil
|
||||
} from "@/utils/filePreview";
|
||||
|
||||
const { getJs, getUser } = useUserStore();
|
||||
|
||||
// 任务类型:ysyc-一师一策(学习任务)、xkjy-学科教研(教研任务)
|
||||
const taskType = ref<'ysyc' | 'xkjy'>('ysyc');
|
||||
|
||||
// 附件类型定义
|
||||
interface FileInfo {
|
||||
name: string;
|
||||
size: number;
|
||||
url: string;
|
||||
resourName?: string; // 资源名称
|
||||
resourUrl?: string; // 资源URL
|
||||
resSuf?: string; // 文件后缀
|
||||
resourSuf?: string; // 资源文件后缀
|
||||
}
|
||||
|
||||
interface CourseItem {
|
||||
id: string;
|
||||
kcmc: string; // 课程名称
|
||||
@ -119,6 +171,9 @@ interface CourseItem {
|
||||
jsxm?: string; // 教师姓名
|
||||
bzrxm?: string; // 班主任姓名
|
||||
ljlxxm?: string; // 联系人姓名
|
||||
fileUrl?: string; // 文件存储路径
|
||||
fileName?: string; // 原文件名称
|
||||
fileFormat?: string; // 文件格式
|
||||
}
|
||||
|
||||
// 搜索表单
|
||||
@ -136,6 +191,10 @@ const hasMore = ref(true);
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(10);
|
||||
|
||||
// 课程描述弹窗
|
||||
const descriptionPopup = ref<any>(null);
|
||||
const currentDescription = ref('');
|
||||
|
||||
// 获取学期列表
|
||||
const loadXqList = async () => {
|
||||
try {
|
||||
@ -336,6 +395,137 @@ onMounted(() => {
|
||||
loadXqList(); // 加载学期列表,会自动选择第一条并查询
|
||||
});
|
||||
|
||||
// 检查是否有附件
|
||||
const hasAttachments = (data: any) => {
|
||||
return data.fileUrl && data.fileName;
|
||||
};
|
||||
|
||||
// 解析逗号分隔的文件信息
|
||||
const parseFileList = (data: any) => {
|
||||
const fileList: FileInfo[] = [];
|
||||
|
||||
// 处理单个文件(从data直接获取)
|
||||
if (data.fileUrl && data.fileName) {
|
||||
const urls = data.fileUrl.split(',').map((url: string) => url.trim());
|
||||
const names = data.fileName.split(',').map((name: string) => name.trim());
|
||||
const formats = data.fileFormat ? data.fileFormat.split(',').map((format: string) => format.trim()) : [];
|
||||
|
||||
urls.forEach((url: string, index: number) => {
|
||||
if (url) {
|
||||
const fileName = names[index] || `文件${index + 1}`;
|
||||
const fileFormat = formats[index] || '';
|
||||
const fileSuffix = fileFormat || url.split('.').pop() || '';
|
||||
|
||||
fileList.push({
|
||||
name: fileName,
|
||||
url: url,
|
||||
resourName: fileName,
|
||||
resourUrl: url,
|
||||
resourSuf: fileSuffix,
|
||||
size: 0 // 无法从URL获取文件大小
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return fileList;
|
||||
};
|
||||
|
||||
// 预览附件文件
|
||||
const previewAttachmentFile = (file: FileInfo) => {
|
||||
const fileUrl = file.resourUrl ? imagUrl(file.resourUrl) : (file.url ? imagUrl(file.url) : '');
|
||||
const fileName = file.resourName || file.name || '未知文件';
|
||||
const fileSuf = getFileSuffix(file);
|
||||
|
||||
if (!fileUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 统一使用 kkview 预览
|
||||
const fullFileName = fileSuf ? `${fileName}.${fileSuf}` : fileName;
|
||||
previewFileUtil(fileUrl, fullFileName, fileSuf)
|
||||
.catch((error: any) => {
|
||||
uni.showToast({
|
||||
title: '预览失败',
|
||||
icon: 'error'
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// 获取文件名
|
||||
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.resourSuf) {
|
||||
return file.resourSuf;
|
||||
}
|
||||
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 getFileIcon = (fileType: string) => {
|
||||
const type = fileType.toLowerCase();
|
||||
switch (type) {
|
||||
case 'pdf':
|
||||
return '/static/base/view/pdf.png';
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return '/static/base/view/word.png';
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
return '/static/base/view/excel.png';
|
||||
case 'ppt':
|
||||
case 'pptx':
|
||||
return '/static/base/view/ppt.png';
|
||||
case 'zip':
|
||||
case 'rar':
|
||||
case '7z':
|
||||
return '/static/base/view/zip.png';
|
||||
default:
|
||||
return '/static/base/view/more.png';
|
||||
}
|
||||
};
|
||||
|
||||
// 显示课程描述弹窗
|
||||
const showDescriptionModal = (description: string) => {
|
||||
currentDescription.value = description;
|
||||
descriptionPopup.value?.open();
|
||||
};
|
||||
|
||||
// 关闭课程描述弹窗
|
||||
const closeDescriptionModal = () => {
|
||||
descriptionPopup.value?.close();
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@ -506,6 +696,15 @@ onMounted(() => {
|
||||
|
||||
.course-description {
|
||||
margin-bottom: 10px;
|
||||
padding: 10px;
|
||||
background: rgba(102, 126, 234, 0.05);
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #667eea;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&:active {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.description-text {
|
||||
font-size: 14px;
|
||||
@ -519,6 +718,64 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// 附件相关样式
|
||||
.attachments-section {
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.attachments-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 10px;
|
||||
background: linear-gradient(135deg, #f7f9fc 0%, #ffffff 100%);
|
||||
border: 1px solid rgba(102, 126, 234, 0.15);
|
||||
border-radius: 8px;
|
||||
transition: all 0.3s ease;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.attachment-icon {
|
||||
margin-right: 6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
.icon-image {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
font-size: 12px;
|
||||
color: #2d3748;
|
||||
font-weight: 500;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.course-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -661,4 +918,69 @@ onMounted(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// 课程描述弹窗样式
|
||||
.description-modal {
|
||||
width: 85vw;
|
||||
max-width: 600px;
|
||||
max-height: 70vh;
|
||||
background-color: #ffffff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-close:active {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
font-size: 24px;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.modal-text {
|
||||
font-size: 15px;
|
||||
line-height: 1.8;
|
||||
color: #333333;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
@ -103,7 +103,7 @@ onLoad(async (options) => {
|
||||
required: rwflx.value[i].rwbs,
|
||||
componentProps: {}
|
||||
})
|
||||
} else if (rwflx.value[i].rwfl == "text") {
|
||||
} else if (rwflx.value[i].rwfl == "text" || rwflx.value[i].rwfl == "fwb") {
|
||||
schema.push({
|
||||
field: `${rwflx.value[i].id}`,
|
||||
label: `${rwflx.value[i].rwbt}`,
|
||||
@ -114,10 +114,12 @@ onLoad(async (options) => {
|
||||
},
|
||||
componentProps: {
|
||||
type: "textarea",
|
||||
placeholder: rwflx.value[i].rwfl == "fwb" ? "请输入富文本内容" : "请输入内容"
|
||||
},
|
||||
})
|
||||
} else if (rwflx.value[i].rwfl == "dxsx" || rwflx.value[i].rwfl == "dxxz") {
|
||||
let options = rwflx.value[i].remark.split(";");
|
||||
// 同时支持中文分号(;)和英文分号(;)分割选项
|
||||
let options = rwflx.value[i].remark.split(/[;;]/);
|
||||
let range = [];
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
range.push({
|
||||
|
||||
@ -101,6 +101,23 @@ interface RwInfo {
|
||||
[key: string]: any; // 允许其他属性
|
||||
}
|
||||
|
||||
interface RwlxItem {
|
||||
id: string;
|
||||
rwbt?: string;
|
||||
rwfl?: string;
|
||||
rwbs?: number;
|
||||
remark?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface RwzxqdItem {
|
||||
id?: string;
|
||||
rwlxId?: string;
|
||||
rwzxqdtx?: string;
|
||||
wjmc?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const formData: any = ref({})
|
||||
const messageId = ref<string>('');
|
||||
const jsId = ref<string>(''); // 教师ID
|
||||
@ -121,9 +138,10 @@ const messageDetail = ref<MessageDetail | null>({
|
||||
comments: 12
|
||||
});
|
||||
const isLoading = ref(false);
|
||||
const rwflx: any = ref([])
|
||||
const rwflx = ref<RwlxItem[]>([])
|
||||
const rw = ref<RwInfo>({})
|
||||
const schema = ref<FormsSchema[]>([])
|
||||
const rwzxqds = ref<RwzxqdItem[]>([])
|
||||
const {getUser} = useUserStore()
|
||||
|
||||
async function saveRwZx() {
|
||||
@ -153,8 +171,8 @@ async function performSubmit() {
|
||||
let fileNames = '';
|
||||
if (rwflx.value[i].rwfl == "sctp" || rwflx.value[i].rwfl == "scsp" || rwflx.value[i].rwfl == "scwd") {
|
||||
// 收集上传文件的URL和文件名
|
||||
const fileUrls = [];
|
||||
const names = [];
|
||||
const fileUrls: string[] = [];
|
||||
const names: string[] = [];
|
||||
|
||||
if (rwflx.value[i].rwfl == "sctp") {
|
||||
// 图片上传
|
||||
@ -217,7 +235,7 @@ async function performSubmit() {
|
||||
}
|
||||
}
|
||||
// 查找对应的执行记录ID
|
||||
const existingRecord = rwzxqds.value.find(item => item.rwlxId === fieldId);
|
||||
const existingRecord = rwzxqds.value.find((item: RwzxqdItem) => item.rwlxId === fieldId);
|
||||
const recordId = existingRecord ? existingRecord.id : undefined;
|
||||
|
||||
result.push({
|
||||
@ -260,7 +278,6 @@ const getFileFormat = (fileName: string) => {
|
||||
return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
|
||||
};
|
||||
|
||||
const rwzxqds = ref([])
|
||||
onLoad(async (options) => {
|
||||
console.log('页面加载参数:', options);
|
||||
|
||||
@ -280,7 +297,7 @@ onLoad(async (options) => {
|
||||
uni.removeStorageSync('taskSubmitData');
|
||||
} catch (error) {
|
||||
console.error('解析全局存储参数失败:', error);
|
||||
taskId = options.rwId || options.id || '';
|
||||
taskId = (options?.rwId || options?.id) || '';
|
||||
}
|
||||
} else {
|
||||
// 兼容原有的URL参数方式(向后兼容)
|
||||
@ -293,12 +310,12 @@ onLoad(async (options) => {
|
||||
console.log('解析URL参数成功:', { taskId, rwId: params.rwId, id: params.id, jsId: jsId.value, executionId: executionId.value });
|
||||
} catch (error) {
|
||||
console.error('解析URL参数失败:', error);
|
||||
taskId = options.rwId || options.id || '';
|
||||
taskId = (options?.rwId || options?.id) || '';
|
||||
}
|
||||
} else if (options && (options.rwId || options.id)) {
|
||||
} else if (options && (options.rwId || options.id)) {
|
||||
// 优先使用 rwId 参数,如果没有再使用 id 参数
|
||||
taskId = options.rwId || options.id;
|
||||
console.log('直接使用URL参数:', { taskId, rwId: options.rwId, id: options.id });
|
||||
taskId = (options?.rwId || options?.id) || '';
|
||||
console.log('直接使用URL参数:', { taskId, rwId: options?.rwId, id: options?.id });
|
||||
}
|
||||
}
|
||||
|
||||
@ -337,7 +354,7 @@ onLoad(async (options) => {
|
||||
formData.value[`${fieldId}_fileList`] = [];
|
||||
}
|
||||
// 根据任务类型配置上传组件
|
||||
let componentConfig = {};
|
||||
let componentConfig: Partial<FormsSchema> = {};
|
||||
|
||||
if (rwflx.value[i].rwfl == "sctp") {
|
||||
// 上传图片
|
||||
@ -393,7 +410,7 @@ onLoad(async (options) => {
|
||||
schema.value.push({
|
||||
field: schemaField,
|
||||
label: `${rwflx.value[i].rwbt}`,
|
||||
required: rwflx.value[i].rwbs,
|
||||
required: !!rwflx.value[i].rwbs,
|
||||
itemProps: {
|
||||
labelPosition: "top",
|
||||
},
|
||||
@ -406,12 +423,34 @@ onLoad(async (options) => {
|
||||
label: rwflx.value[i].rwbt,
|
||||
component: componentConfig.component
|
||||
});
|
||||
} else if (rwflx.value[i].rwfl == "fwb") {
|
||||
// 富文本类型 - 使用富文本编辑器
|
||||
schema.value.push({
|
||||
field: `${rwflx.value[i].id}`,
|
||||
label: `${rwflx.value[i].rwbt}`,
|
||||
component: "BasicEditor", // 使用富文本编辑器组件
|
||||
required: !!rwflx.value[i].rwbs,
|
||||
itemProps: {
|
||||
labelPosition: "top",
|
||||
},
|
||||
componentProps: {
|
||||
placeholder: "请输入富文本内容,支持插入图片"
|
||||
},
|
||||
})
|
||||
|
||||
// 调试:打印富文本类型的 schema
|
||||
console.log(`富文本类型 schema ${i + 1}:`, {
|
||||
field: rwflx.value[i].id,
|
||||
label: rwflx.value[i].rwbt,
|
||||
component: "BasicEditor"
|
||||
});
|
||||
} else if (rwflx.value[i].rwfl == "text") {
|
||||
// 普通文本类型 - 使用 BasicInput
|
||||
schema.value.push({
|
||||
field: `${rwflx.value[i].id}`,
|
||||
label: `${rwflx.value[i].rwbt}`,
|
||||
component: "BasicInput",
|
||||
required: rwflx.value[i].rwbs,
|
||||
required: !!rwflx.value[i].rwbs,
|
||||
itemProps: {
|
||||
labelPosition: "top",
|
||||
},
|
||||
@ -428,7 +467,8 @@ onLoad(async (options) => {
|
||||
component: "BasicInput"
|
||||
});
|
||||
} else if (rwflx.value[i].rwfl == "dxsx" || rwflx.value[i].rwfl == "dxxz") {
|
||||
let options = rwflx.value[i].remark.split(";");
|
||||
// 同时支持中文分号(;)和英文分号(;)分割选项
|
||||
let options = (rwflx.value[i].remark || '').split(/[;;]/);
|
||||
let range = [];
|
||||
for (let j = 0; j < options.length; j++) {
|
||||
range.push({
|
||||
@ -439,7 +479,7 @@ onLoad(async (options) => {
|
||||
field: `${rwflx.value[i].id}`,
|
||||
label: `${rwflx.value[i].rwbt}`,
|
||||
component: "BasicPicker",
|
||||
required: rwflx.value[i].rwbs,
|
||||
required: !!rwflx.value[i].rwbs,
|
||||
itemProps: {
|
||||
labelPosition: "top",
|
||||
},
|
||||
@ -483,9 +523,9 @@ onLoad(async (options) => {
|
||||
|
||||
// 根据rwzxqdtx字段控制提交按钮显示:如果有任意一个rwzxqdtx字段有值,则不显示提交按钮
|
||||
if (rwzxqds.value && rwzxqds.value.length > 0) {
|
||||
const hasExecutedData = rwzxqds.value.some(item => item.rwzxqdtx && item.rwzxqdtx.trim() !== '');
|
||||
const hasExecutedData = rwzxqds.value.some((item: RwzxqdItem) => item.rwzxqdtx && item.rwzxqdtx.trim() !== '');
|
||||
showSubmitButton.value = !hasExecutedData; // 有值不显示,没值才显示
|
||||
console.log('检查rwzxqdtx字段:', rwzxqds.value.map(item => ({ rwlxId: item.rwlxId, rwzxqdtx: item.rwzxqdtx })));
|
||||
console.log('检查rwzxqdtx字段:', rwzxqds.value.map((item: RwzxqdItem) => ({ rwlxId: item.rwlxId, rwzxqdtx: item.rwzxqdtx })));
|
||||
console.log('是否有已执行数据:', hasExecutedData, '是否显示提交按钮:', showSubmitButton.value);
|
||||
} else {
|
||||
// 如果没有执行清单数据,默认显示提交按钮(新任务)
|
||||
@ -496,23 +536,23 @@ onLoad(async (options) => {
|
||||
// 处理执行清单数据回显
|
||||
if (rwzxqds.value && rwzxqds.value.length > 0) {
|
||||
console.log('开始处理执行清单数据回显,rwzxqds.value:', rwzxqds.value);
|
||||
const showData = {};
|
||||
const showData: Record<string, any> = {};
|
||||
|
||||
for (let i = 0; i < rwzxqds.value.length; i++) {
|
||||
const record = rwzxqds.value[i];
|
||||
const record: RwzxqdItem = rwzxqds.value[i];
|
||||
const rwlxId = record.rwlxId;
|
||||
const rwzxqdtx = record.rwzxqdtx;
|
||||
|
||||
// 查找对应的任务类型
|
||||
const taskType = rwflx.value.find(item => item.id === rwlxId);
|
||||
const taskType = rwflx.value.find((item: RwlxItem) => item.id === rwlxId);
|
||||
|
||||
if (taskType) {
|
||||
if (taskType && rwlxId) {
|
||||
if (taskType.rwfl === "sctp") {
|
||||
// 图片上传类型 - 解析URL和文件名并创建图片对象
|
||||
if (rwzxqdtx) {
|
||||
const urls = rwzxqdtx.split(',').filter(Boolean);
|
||||
const names = record.wjmc ? record.wjmc.split(',').filter(Boolean) : [];
|
||||
const images = urls.map((url, index) => ({
|
||||
const images = urls.map((url: string, index: number) => ({
|
||||
url: imagUrl(url.trim()),
|
||||
name: names[index] || url.split('/').pop() || 'image.jpg',
|
||||
tempPath: undefined
|
||||
@ -524,7 +564,7 @@ onLoad(async (options) => {
|
||||
if (rwzxqdtx) {
|
||||
const urls = rwzxqdtx.split(',').filter(Boolean);
|
||||
const names = record.wjmc ? record.wjmc.split(',').filter(Boolean) : [];
|
||||
const videos = urls.map((url, index) => ({
|
||||
const videos = urls.map((url: string, index: number) => ({
|
||||
url: imagUrl(url.trim()),
|
||||
name: names[index] || url.split('/').pop() || 'video.mp4',
|
||||
tempPath: undefined
|
||||
@ -536,7 +576,7 @@ onLoad(async (options) => {
|
||||
if (rwzxqdtx) {
|
||||
const urls = rwzxqdtx.split(',').filter(Boolean);
|
||||
const names = record.wjmc ? record.wjmc.split(',').filter(Boolean) : [];
|
||||
const files = urls.map((url, index) => ({
|
||||
const files = urls.map((url: string, index: number) => ({
|
||||
url: imagUrl(url.trim()),
|
||||
name: names[index] || url.split('/').pop() || 'document',
|
||||
tempPath: undefined,
|
||||
@ -547,7 +587,9 @@ onLoad(async (options) => {
|
||||
}
|
||||
} else {
|
||||
// 文本等其他类型
|
||||
showData[rwlxId] = rwzxqdtx;
|
||||
if (rwlxId) {
|
||||
showData[rwlxId] = rwzxqdtx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -558,7 +600,7 @@ onLoad(async (options) => {
|
||||
// 更新 schema 中上传组件的初始值
|
||||
for (let i = 0; i < schema.value.length; i++) {
|
||||
const schemaItem = schema.value[i];
|
||||
if (schemaItem.component === "ImageVideoUpload" && schemaItem.field) {
|
||||
if ((schemaItem.component === "ImageVideoUpload" || schemaItem.component === "BasicEditor") && schemaItem.field) {
|
||||
const fieldName = schemaItem.field;
|
||||
|
||||
if (fieldName.includes('_imageList') && formData.value[fieldName]) {
|
||||
|
||||
2013
src/pages/vr/shooting/detail.vue
Normal file
2013
src/pages/vr/shooting/detail.vue
Normal file
File diff suppressed because it is too large
Load Diff
598
src/pages/vr/shooting/index.vue
Normal file
598
src/pages/vr/shooting/index.vue
Normal file
@ -0,0 +1,598 @@
|
||||
<template>
|
||||
<view class="vr-shooting-list-page">
|
||||
<!-- 顶部搜索栏 -->
|
||||
<view class="search-header">
|
||||
<u-search
|
||||
v-model="searchKeyword"
|
||||
placeholder="搜索位置名称、楼栋、房间号"
|
||||
:show-action="false"
|
||||
@search="handleSearch"
|
||||
@clear="handleSearch"
|
||||
></u-search>
|
||||
</view>
|
||||
|
||||
<!-- 筛选栏 -->
|
||||
<view class="filter-bar">
|
||||
<view class="filter-item" @click="showBuildingPicker = true">
|
||||
<text class="filter-label">楼栋:</text>
|
||||
<text class="filter-value">{{ selectedBuildingName || '全部' }}</text>
|
||||
<u-icon name="arrow-down" size="14" color="#666"></u-icon>
|
||||
</view>
|
||||
<view class="filter-item" @click="showTypePicker = true">
|
||||
<text class="filter-label">类型:</text>
|
||||
<text class="filter-value">{{ selectedTypeName || '全部' }}</text>
|
||||
<u-icon name="arrow-down" size="14" color="#666"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 列表内容 -->
|
||||
<scroll-view scroll-y class="list-content" @scrolltolower="loadMore" :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="refreshList" :style="{ width: '100%' }">
|
||||
<view v-if="locationList.length > 0" class="location-list">
|
||||
<view
|
||||
v-for="item in locationList"
|
||||
:key="item.id"
|
||||
class="location-item"
|
||||
@click="editLocation(item)"
|
||||
>
|
||||
<!-- 缩略图 -->
|
||||
<view class="item-thumbnail">
|
||||
<image
|
||||
v-if="item.thumbnailUrl || item.imageUrl"
|
||||
:src="getImageUrl(item.thumbnailUrl || item.imageUrl)"
|
||||
mode="aspectFill"
|
||||
class="thumbnail-image"
|
||||
></image>
|
||||
<view v-else class="thumbnail-placeholder">
|
||||
<u-icon name="image" size="40" color="#ccc"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 内容信息 -->
|
||||
<view class="item-content">
|
||||
<view class="item-header">
|
||||
<text class="item-name">{{ item.locationName }}</text>
|
||||
<view class="item-type" :class="getTypeClass(item.locationType)">
|
||||
{{ getLocationTypeName(item.locationType) }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="item-info">
|
||||
<view v-if="item.buildingName" class="info-text">
|
||||
<u-icon name="home" size="14" color="#999"></u-icon>
|
||||
<text>{{ item.buildingName }}</text>
|
||||
</view>
|
||||
<view v-if="item.floorNum !== null && item.floorNum !== undefined" class="info-text">
|
||||
<u-icon name="list" size="14" color="#999"></u-icon>
|
||||
<text>{{ item.floorNum }}F</text>
|
||||
</view>
|
||||
<view v-if="item.roomNum" class="info-text">
|
||||
<u-icon name="grid" size="14" color="#999"></u-icon>
|
||||
<text>{{ item.roomNum }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="item.description" class="item-desc">
|
||||
{{ item.description }}
|
||||
</view>
|
||||
<view class="item-footer">
|
||||
<text class="item-time">{{ formatTime(item.createdTime) }}</text>
|
||||
<view class="item-status" :class="item.status === 'A' ? 'status-active' : 'status-inactive'">
|
||||
{{ item.status === 'A' ? '启用' : '禁用' }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="item-actions" @click.stop>
|
||||
<u-icon name="arrow-right" size="20" color="#999"></u-icon>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<view v-else class="empty-state">
|
||||
<u-empty
|
||||
mode="data"
|
||||
:text="searchKeyword || selectedBuildingId || selectedType ? '暂无匹配数据' : '暂无VR位置数据'"
|
||||
></u-empty>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view v-if="hasMore && locationList.length > 0" class="load-more">
|
||||
<u-loading-icon v-if="loading"></u-loading-icon>
|
||||
<text class="load-more-text">{{ loading ? '加载中...' : '上拉加载更多' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 没有更多 -->
|
||||
<view v-if="!hasMore && locationList.length > 0" class="no-more">
|
||||
<text>没有更多数据了</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部新增按钮 -->
|
||||
<view class="footer-add-btn">
|
||||
<u-button
|
||||
text="新增VR位置"
|
||||
type="primary"
|
||||
icon="plus"
|
||||
@click="addLocation"
|
||||
></u-button>
|
||||
</view>
|
||||
|
||||
<!-- 楼栋选择器 -->
|
||||
<u-picker
|
||||
:show="showBuildingPicker"
|
||||
:columns="[buildingPickerList]"
|
||||
keyName="buildingName"
|
||||
@confirm="onBuildingPickerConfirm"
|
||||
@cancel="showBuildingPicker = false"
|
||||
></u-picker>
|
||||
|
||||
<!-- 类型选择器 -->
|
||||
<u-picker
|
||||
:show="showTypePicker"
|
||||
:columns="[typePickerList]"
|
||||
keyName="label"
|
||||
@confirm="onTypePickerConfirm"
|
||||
@cancel="showTypePicker = false"
|
||||
></u-picker>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { getVRLocations, getAllBuildings } from '@/api/vr/index';
|
||||
import { imagUrl } from '@/utils';
|
||||
|
||||
// 搜索关键词
|
||||
const searchKeyword = ref('');
|
||||
|
||||
// 筛选条件
|
||||
const selectedBuildingId = ref<string>('');
|
||||
const selectedBuildingName = ref<string>('');
|
||||
const selectedType = ref<string>('');
|
||||
const selectedTypeName = ref<string>('');
|
||||
|
||||
// 列表数据
|
||||
const locationList = ref<any[]>([]);
|
||||
const pageNum = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const refreshing = ref(false);
|
||||
const hasMore = ref(true);
|
||||
|
||||
// 选择器
|
||||
const showBuildingPicker = ref(false);
|
||||
const showTypePicker = ref(false);
|
||||
const buildingPickerList = ref<any[]>([]);
|
||||
const typePickerList = ref([
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '室内', value: 'indoor' },
|
||||
{ label: '室外', value: 'outdoor' },
|
||||
]);
|
||||
|
||||
// 获取图片URL
|
||||
const getImageUrl = (url: string) => {
|
||||
if (!url) return '';
|
||||
return imagUrl(url);
|
||||
};
|
||||
|
||||
// 获取位置类型名称
|
||||
const getLocationTypeName = (type: string) => {
|
||||
const typeMap: Record<string, string> = {
|
||||
indoor: '室内',
|
||||
outdoor: '室外',
|
||||
};
|
||||
return typeMap[type] || '未知';
|
||||
};
|
||||
|
||||
// 获取类型样式类
|
||||
const getTypeClass = (type: string) => {
|
||||
return type === 'indoor' ? 'type-indoor' : 'type-outdoor';
|
||||
};
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (time: string) => {
|
||||
if (!time) return '';
|
||||
const date = new Date(time);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
// 加载列表数据
|
||||
const loadList = async (reset = false) => {
|
||||
if (loading.value) return;
|
||||
|
||||
if (reset) {
|
||||
pageNum.value = 1;
|
||||
locationList.value = [];
|
||||
hasMore.value = true;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const params: any = {
|
||||
status: 'A',
|
||||
pageNum: pageNum.value,
|
||||
pageSize: pageSize.value,
|
||||
};
|
||||
|
||||
// 搜索关键词
|
||||
if (searchKeyword.value) {
|
||||
params.keyword = searchKeyword.value;
|
||||
}
|
||||
|
||||
// 楼栋筛选
|
||||
if (selectedBuildingId.value) {
|
||||
params.buildingId = selectedBuildingId.value;
|
||||
}
|
||||
|
||||
// 类型筛选
|
||||
if (selectedType.value) {
|
||||
params.locationType = selectedType.value;
|
||||
}
|
||||
|
||||
const res: any = await getVRLocations(params);
|
||||
|
||||
if (res && res.rows) {
|
||||
const newList = res.rows || [];
|
||||
if (reset) {
|
||||
locationList.value = newList;
|
||||
} else {
|
||||
locationList.value.push(...newList);
|
||||
}
|
||||
|
||||
total.value = res.total || 0;
|
||||
hasMore.value = locationList.value.length < total.value;
|
||||
|
||||
if (newList.length < pageSize.value) {
|
||||
hasMore.value = false;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 加载失败静默处理
|
||||
uni.showToast({
|
||||
title: '加载失败',
|
||||
icon: 'none',
|
||||
});
|
||||
} finally {
|
||||
loading.value = false;
|
||||
refreshing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多
|
||||
const loadMore = () => {
|
||||
if (!hasMore.value || loading.value) return;
|
||||
pageNum.value++;
|
||||
loadList(false);
|
||||
};
|
||||
|
||||
// 刷新列表
|
||||
const refreshList = () => {
|
||||
refreshing.value = true;
|
||||
loadList(true);
|
||||
};
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
loadList(true);
|
||||
};
|
||||
|
||||
// 加载楼栋列表(用于筛选)
|
||||
const loadBuildingList = async () => {
|
||||
try {
|
||||
const res: any = await getAllBuildings();
|
||||
const dataList = res?.rows || res?.records || [];
|
||||
buildingPickerList.value = [
|
||||
{ id: '', buildingName: '全部' },
|
||||
...dataList.map((item: any) => ({
|
||||
id: item.id,
|
||||
buildingName: item.buildingName,
|
||||
})),
|
||||
];
|
||||
} catch (error) {
|
||||
// 加载失败静默处理
|
||||
}
|
||||
};
|
||||
|
||||
// 楼栋选择确认
|
||||
const onBuildingPickerConfirm = (e: any) => {
|
||||
const selected = e.value[0];
|
||||
selectedBuildingId.value = selected.id || '';
|
||||
selectedBuildingName.value = selected.buildingName || '全部';
|
||||
showBuildingPicker.value = false;
|
||||
loadList(true);
|
||||
};
|
||||
|
||||
// 类型选择确认
|
||||
const onTypePickerConfirm = (e: any) => {
|
||||
const selected = e.value[0];
|
||||
selectedType.value = selected.value || '';
|
||||
selectedTypeName.value = selected.label || '全部';
|
||||
showTypePicker.value = false;
|
||||
loadList(true);
|
||||
};
|
||||
|
||||
// 新增位置
|
||||
const addLocation = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/vr/shooting/detail',
|
||||
});
|
||||
};
|
||||
|
||||
// 编辑位置
|
||||
const editLocation = (item: any) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/vr/shooting/detail?id=${item.id}`,
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadList(true);
|
||||
loadBuildingList();
|
||||
});
|
||||
|
||||
// 页面显示时刷新列表(从其他页面返回时)
|
||||
onShow(() => {
|
||||
loadList(true);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vr-shooting-list-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-bottom: 120rpx; // 为底部按钮留出空间
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
background: #fff;
|
||||
padding: 20rpx 30rpx;
|
||||
border-bottom: 1rpx solid #eee;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
padding: 20rpx 30rpx;
|
||||
border-bottom: 1rpx solid #eee;
|
||||
gap: 30rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.filter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
padding: 10rpx 20rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 8rpx;
|
||||
cursor: pointer;
|
||||
|
||||
.filter-label {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.filter-value {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-content {
|
||||
flex: 1;
|
||||
padding: 20rpx 30rpx;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.location-list {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.location-item {
|
||||
display: flex;
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.item-thumbnail {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 12rpx;
|
||||
overflow: hidden;
|
||||
margin-right: 24rpx;
|
||||
flex-shrink: 0;
|
||||
background: #f5f5f5;
|
||||
|
||||
.thumbnail-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.thumbnail-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.item-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.item-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12rpx;
|
||||
|
||||
.item-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-type {
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
margin-left: 12rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.type-indoor {
|
||||
background: #e6f7ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
&.type-outdoor {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-info {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 12rpx;
|
||||
|
||||
.info-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
font-size: 24rpx;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 12rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.item-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.item-time {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.item-status {
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 22rpx;
|
||||
|
||||
&.status-active {
|
||||
background: #f6ffed;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
&.status-inactive {
|
||||
background: #fff7e6;
|
||||
color: #faad14;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.item-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 12rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 100rpx 0;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 30rpx 0;
|
||||
gap: 12rpx;
|
||||
|
||||
.load-more-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.no-more {
|
||||
text-align: center;
|
||||
padding: 30rpx 0;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.footer-add-btn {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 24rpx 30rpx;
|
||||
background: #fff;
|
||||
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
|
||||
z-index: 100;
|
||||
// 安全区域适配
|
||||
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
</style>
|
||||
|
||||
801
src/pages/vr/view/index.vue
Normal file
801
src/pages/vr/view/index.vue
Normal file
@ -0,0 +1,801 @@
|
||||
<template>
|
||||
<view class="vr-view-page">
|
||||
<!-- 位置导航栏 -->
|
||||
<view class="location-nav" v-if="showNav">
|
||||
<scroll-view scroll-x class="nav-scroll">
|
||||
<view
|
||||
v-for="location in locationList"
|
||||
:key="location.id"
|
||||
class="nav-item"
|
||||
:class="{ active: currentLocationId === location.id }"
|
||||
@click="switchLocation(location.id)"
|
||||
>
|
||||
<text class="nav-icon">{{ getLocationIcon(location.locationType) }}</text>
|
||||
<text class="nav-name">{{ location.locationName }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- VR查看器容器 -->
|
||||
<view class="vr-container" :style="{ height: showNav ? 'calc(100vh - 100rpx)' : '100vh' }">
|
||||
<!-- H5环境:使用Pannellum VR查看器 -->
|
||||
<!-- #ifdef H5 -->
|
||||
<view v-if="currentLocation && currentLocation.imageUrlFull" id="pannellum-container" class="vr-viewer"></view>
|
||||
<view v-else class="vr-placeholder">
|
||||
<text class="placeholder-text">暂无VR全景图</text>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
|
||||
<!-- APP环境:使用web-view加载VR查看器 -->
|
||||
<!-- #ifdef APP-PLUS -->
|
||||
<view v-if="currentLocation && currentLocation.imageUrlFull" class="vr-webview-container">
|
||||
<web-view :src="vrViewerUrl" class="vr-webview" @message="handleWebViewMessage"></web-view>
|
||||
</view>
|
||||
<view v-else class="vr-placeholder">
|
||||
<text class="placeholder-text">暂无VR全景图</text>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
|
||||
<!-- 位置信息卡片 -->
|
||||
<view class="location-info-card" v-if="currentLocation">
|
||||
<view class="card-header">
|
||||
<text class="location-name">{{ currentLocation.locationName }}</text>
|
||||
<text class="location-type">{{ getLocationTypeName(currentLocation.locationType) }}</text>
|
||||
</view>
|
||||
<view class="card-body">
|
||||
<text class="info-text" v-if="currentLocation.buildingName">
|
||||
楼栋:{{ currentLocation.buildingName }}
|
||||
</text>
|
||||
<text class="info-text" v-if="currentLocation.floorNum !== null && currentLocation.floorNum !== undefined">
|
||||
楼层:{{ currentLocation.floorNum }}F
|
||||
</text>
|
||||
<text class="info-text" v-if="currentLocation.roomNum">
|
||||
房间:{{ currentLocation.roomNum }}
|
||||
</text>
|
||||
<text class="info-text" v-if="currentLocation.description">
|
||||
{{ currentLocation.description }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 控制按钮 -->
|
||||
<view class="control-buttons">
|
||||
<view class="control-btn" @click="toggleNav">
|
||||
<text class="btn-icon">📍</text>
|
||||
<text class="btn-text">位置</text>
|
||||
</view>
|
||||
<view class="control-btn" @click="showLocationList">
|
||||
<text class="btn-icon">🗺️</text>
|
||||
<text class="btn-text">地图</text>
|
||||
</view>
|
||||
<view class="control-btn" @click="shareLocation">
|
||||
<text class="btn-icon">🔗</text>
|
||||
<text class="btn-text">分享</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 位置列表弹窗 -->
|
||||
<u-popup v-model="showLocationListPopup" mode="bottom" height="70%">
|
||||
<view class="location-list-popup">
|
||||
<view class="popup-header">
|
||||
<text class="popup-title">选择位置</text>
|
||||
<text class="popup-close" @click="showLocationListPopup = false">✕</text>
|
||||
</view>
|
||||
<scroll-view scroll-y class="popup-content">
|
||||
<!-- 按楼栋分组 -->
|
||||
<view
|
||||
v-for="building in buildingGroups"
|
||||
:key="building.id"
|
||||
class="building-group"
|
||||
>
|
||||
<view class="group-header" @click="toggleBuilding(building.id)">
|
||||
<text class="group-icon">🏢</text>
|
||||
<text class="group-name">{{ building.name }}</text>
|
||||
<text class="group-arrow">{{ building.expanded ? '▼' : '▶' }}</text>
|
||||
</view>
|
||||
<view v-if="building.expanded" class="group-content">
|
||||
<view
|
||||
v-for="location in building.locations"
|
||||
:key="location.id"
|
||||
class="location-item"
|
||||
:class="{ active: currentLocationId === location.id }"
|
||||
@click="selectLocation(location)"
|
||||
>
|
||||
<text class="item-icon">{{ getLocationIcon(location.locationType) }}</text>
|
||||
<view class="item-info">
|
||||
<text class="item-name">{{ location.locationName }}</text>
|
||||
<text class="item-desc" v-if="location.roomNum">{{ location.roomNum }}</text>
|
||||
</view>
|
||||
<text class="item-arrow">→</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</u-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch, nextTick, onUnmounted } from 'vue';
|
||||
import { getAllVRLocations, getVRLocationDetail, getVRLocationLinks, getAllBuildings } from '@/api/vr/index';
|
||||
import { imagUrl } from '@/utils';
|
||||
|
||||
// 状态
|
||||
const showNav = ref(true);
|
||||
const currentLocationId = ref<string | null>(null);
|
||||
const locationList = ref<any[]>([]);
|
||||
const currentLocation = ref<any>(null);
|
||||
const showLocationListPopup = ref(false);
|
||||
const buildingGroups = ref<any[]>([]);
|
||||
const vrViewerUrl = ref('');
|
||||
let pannellumViewer: any = null;
|
||||
|
||||
// 获取位置列表
|
||||
const loadLocationList = async () => {
|
||||
try {
|
||||
const res: any = await getAllVRLocations({ status: 'A' });
|
||||
// 后端返回的数据在 rows 字段中,records 是总数
|
||||
if (res && res.rows && Array.isArray(res.rows)) {
|
||||
locationList.value = res.rows as any[];
|
||||
|
||||
// 获取楼栋信息并关联到位置数据
|
||||
await enrichLocationWithBuildingInfo();
|
||||
|
||||
// 按楼栋分组
|
||||
groupByBuilding();
|
||||
|
||||
// 默认选择第一个位置
|
||||
if (locationList.value.length > 0) {
|
||||
switchLocation(locationList.value[0].id);
|
||||
}
|
||||
} else {
|
||||
locationList.value = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载位置列表失败', error);
|
||||
uni.showToast({
|
||||
title: '加载失败',
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 关联楼栋信息到位置数据
|
||||
const enrichLocationWithBuildingInfo = async () => {
|
||||
try {
|
||||
// 获取所有楼栋
|
||||
const buildingRes: any = await getAllBuildings();
|
||||
// 后端返回的数据在 rows 字段中,records 是总数
|
||||
if (buildingRes && buildingRes.rows && Array.isArray(buildingRes.rows)) {
|
||||
const buildingMap = new Map<string, any>();
|
||||
buildingRes.rows.forEach((building: any) => {
|
||||
buildingMap.set(building.id, building);
|
||||
});
|
||||
|
||||
// 为每个位置添加楼栋名称
|
||||
locationList.value.forEach((location: any) => {
|
||||
if (location.buildingId) {
|
||||
const building = buildingMap.get(location.buildingId);
|
||||
if (building) {
|
||||
location.buildingName = building.buildingName;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载楼栋信息失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 按楼栋分组
|
||||
const groupByBuilding = () => {
|
||||
const groups: Record<string, any> = {};
|
||||
|
||||
locationList.value.forEach((location) => {
|
||||
// 通过buildingId关联楼栋名称,如果没有buildingId则归为"其他"
|
||||
const buildingId = location.buildingId;
|
||||
const buildingName = location.buildingName || '其他';
|
||||
const groupKey = buildingId || 'other';
|
||||
|
||||
if (!groups[groupKey]) {
|
||||
groups[groupKey] = {
|
||||
id: buildingId || 'other',
|
||||
name: buildingName,
|
||||
locations: [],
|
||||
expanded: false,
|
||||
};
|
||||
}
|
||||
groups[groupKey].locations.push(location);
|
||||
});
|
||||
|
||||
buildingGroups.value = Object.values(groups);
|
||||
};
|
||||
|
||||
// 切换楼栋展开/收起
|
||||
const toggleBuilding = (buildingId: string) => {
|
||||
const building = buildingGroups.value.find((b) => b.id === buildingId);
|
||||
if (building) {
|
||||
building.expanded = !building.expanded;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化Pannellum VR查看器(H5环境)
|
||||
const initPannellumViewer = async (imageUrl: string, links?: any[]) => {
|
||||
// #ifdef H5
|
||||
try {
|
||||
// 动态加载Pannellum CSS和JS
|
||||
await loadPannellumScripts();
|
||||
|
||||
await nextTick();
|
||||
const container = document.getElementById('pannellum-container');
|
||||
if (!container) {
|
||||
console.error('VR查看器容器未找到');
|
||||
return;
|
||||
}
|
||||
|
||||
// 销毁旧的查看器
|
||||
if (pannellumViewer) {
|
||||
try {
|
||||
pannellumViewer.destroy();
|
||||
} catch (e) {
|
||||
console.warn('销毁旧查看器失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 配置热点
|
||||
const hotspots: any[] = [];
|
||||
if (links && links.length > 0) {
|
||||
console.log('配置热点,链接数量:', links.length);
|
||||
links.forEach((link: any) => {
|
||||
const locationId = link.toLocationId; // 保存到局部变量,避免闭包问题
|
||||
console.log('添加热点:', {
|
||||
locationId,
|
||||
label: link.hotspotLabel,
|
||||
pitch: link.hotspotPositionY,
|
||||
yaw: link.hotspotPositionX,
|
||||
});
|
||||
hotspots.push({
|
||||
pitch: link.hotspotPositionY || 0,
|
||||
yaw: link.hotspotPositionX || 0,
|
||||
type: 'info',
|
||||
text: link.hotspotLabel || '前往',
|
||||
// 使用clickHandlerFunc处理点击事件(Pannellum的正确属性)
|
||||
clickHandlerFunc: (event: any) => {
|
||||
console.log('🔥 点击热点,跳转到位置:', locationId);
|
||||
if (locationId) {
|
||||
switchLocation(locationId);
|
||||
} else {
|
||||
console.warn('⚠️ 位置ID为空');
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
console.log('热点配置完成,总数:', hotspots.length);
|
||||
} else {
|
||||
console.log('⚠️ 没有热点数据');
|
||||
}
|
||||
|
||||
// 初始化Pannellum
|
||||
// @ts-ignore
|
||||
const pannellumLib = (window as any).pannellum;
|
||||
if (!pannellumLib) {
|
||||
throw new Error('Pannellum库未加载');
|
||||
}
|
||||
pannellumViewer = pannellumLib.viewer(container, {
|
||||
type: 'equirectangular',
|
||||
panorama: imageUrl,
|
||||
autoLoad: true,
|
||||
autoRotate: 0,
|
||||
compass: true,
|
||||
showControls: true,
|
||||
keyboardZoom: true,
|
||||
mouseZoom: true,
|
||||
hfov: 100,
|
||||
minHfov: 50,
|
||||
maxHfov: 120,
|
||||
hotSpots: hotspots,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('初始化VR查看器失败', error);
|
||||
uni.showToast({
|
||||
title: 'VR查看器加载失败',
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
// #endif
|
||||
};
|
||||
|
||||
// 加载Pannellum脚本(H5环境)
|
||||
const loadPannellumScripts = (): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// #ifdef H5
|
||||
if (typeof window === 'undefined') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已加载
|
||||
if ((window as any).pannellum) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载CSS
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.css';
|
||||
document.head.appendChild(link);
|
||||
|
||||
// 加载JS
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.js';
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error('Pannellum加载失败'));
|
||||
document.head.appendChild(script);
|
||||
// #endif
|
||||
|
||||
// #ifndef H5
|
||||
resolve();
|
||||
// #endif
|
||||
});
|
||||
};
|
||||
|
||||
// 切换位置
|
||||
const switchLocation = async (locationId: string) => {
|
||||
try {
|
||||
const res: any = await getVRLocationDetail(locationId);
|
||||
if (res.resultCode === 1) {
|
||||
currentLocation.value = res.data as any;
|
||||
currentLocationId.value = locationId;
|
||||
|
||||
// 处理图片路径:将相对路径转换为完整URL
|
||||
if (currentLocation.value.imageUrl) {
|
||||
currentLocation.value.imageUrlFull = imagUrl(currentLocation.value.imageUrl);
|
||||
}
|
||||
if (currentLocation.value.thumbnailUrl) {
|
||||
currentLocation.value.thumbnailUrlFull = imagUrl(currentLocation.value.thumbnailUrl);
|
||||
}
|
||||
|
||||
// 获取位置连接(热点)
|
||||
const linksRes: any = await getVRLocationLinks(locationId);
|
||||
// 后端返回的数据在 rows 字段中,records 是总数
|
||||
if (linksRes && linksRes.rows && Array.isArray(linksRes.rows)) {
|
||||
currentLocation.value.links = linksRes.rows;
|
||||
}
|
||||
|
||||
// H5环境:初始化Pannellum VR查看器
|
||||
// #ifdef H5
|
||||
if (currentLocation.value.imageUrlFull) {
|
||||
await nextTick();
|
||||
await initPannellumViewer(currentLocation.value.imageUrlFull, currentLocation.value.links);
|
||||
}
|
||||
// #endif
|
||||
|
||||
// APP环境:构建VR查看器URL
|
||||
// #ifdef APP-PLUS
|
||||
if (currentLocation.value.imageUrlFull) {
|
||||
const imageUrlEncoded = encodeURIComponent(currentLocation.value.imageUrlFull);
|
||||
// 构建热点配置数组
|
||||
const hotspotsArray: string[] = [];
|
||||
if (currentLocation.value.links && currentLocation.value.links.length > 0) {
|
||||
currentLocation.value.links.forEach((link: any) => {
|
||||
const locationId = link.toLocationId || '';
|
||||
const label = (link.hotspotLabel || '前往').replace(/"/g, '\\"');
|
||||
// 使用clickHandlerFunc而不是onClick
|
||||
hotspotsArray.push(`{pitch:${link.hotspotPositionY || 0},yaw:${link.hotspotPositionX || 0},type:"info",text:"${label}",clickHandlerFunc:function(event){if(window.uni&&window.uni.postMessage){window.uni.postMessage({data:{action:"switchLocation",locationId:"${locationId}"}});}}}`);
|
||||
});
|
||||
}
|
||||
const hotspotsStr = '[' + hotspotsArray.join(',') + ']';
|
||||
// 构建VR查看器HTML(APP环境使用web-view)
|
||||
const scriptContent = 'const imageUrl = decodeURIComponent("' + imageUrlEncoded + '");const hotspots = ' + hotspotsStr + ';window.pannellum.viewer("panorama",{type:"equirectangular",panorama:imageUrl,autoLoad:true,compass:true,showControls:true,hotSpots:hotspots});';
|
||||
const viewerHtml = '<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>VR全景查看</title><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.css"><style>body { margin: 0; padding: 0; } #panorama { width: 100vw; height: 100vh; }</style></head><body><div id="panorama"></div><script src="https://cdn.jsdelivr.net/npm/pannellum@2.5.6/build/pannellum.js"></script><script>' + scriptContent + '</script></body></html>';
|
||||
vrViewerUrl.value = 'data:text/html;charset=utf-8,' + encodeURIComponent(viewerHtml);
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载位置详情失败', error);
|
||||
uni.showToast({
|
||||
title: '加载失败',
|
||||
icon: 'none',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 选择位置
|
||||
const selectLocation = (location: any) => {
|
||||
switchLocation(location.id);
|
||||
showLocationListPopup.value = false;
|
||||
};
|
||||
|
||||
// 切换导航栏
|
||||
const toggleNav = () => {
|
||||
showNav.value = !showNav.value;
|
||||
};
|
||||
|
||||
// 显示位置列表
|
||||
const showLocationList = () => {
|
||||
showLocationListPopup.value = true;
|
||||
};
|
||||
|
||||
// 分享位置
|
||||
// 处理web-view消息(APP环境)
|
||||
const handleWebViewMessage = (event: any) => {
|
||||
// #ifdef APP-PLUS
|
||||
try {
|
||||
const message = event.detail.data[0];
|
||||
if (message && message.action === 'switchLocation' && message.locationId) {
|
||||
console.log('收到web-view消息,跳转到位置:', message.locationId);
|
||||
switchLocation(message.locationId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理web-view消息失败', error);
|
||||
}
|
||||
// #endif
|
||||
};
|
||||
|
||||
const shareLocation = () => {
|
||||
if (!currentLocation.value) return;
|
||||
|
||||
// 处理分享图片路径:使用完整URL
|
||||
const shareImageUrl = currentLocation.value.thumbnailUrlFull
|
||||
|| currentLocation.value.imageUrlFull
|
||||
|| imagUrl(currentLocation.value.thumbnailUrl || currentLocation.value.imageUrl || '');
|
||||
|
||||
uni.share({
|
||||
provider: 'weixin',
|
||||
scene: 'WXSceneSession',
|
||||
type: 0,
|
||||
href: `https://your-domain/vr?locationId=${currentLocationId.value}`,
|
||||
title: `VR全景 - ${currentLocation.value.locationName}`,
|
||||
summary: currentLocation.value.description || '',
|
||||
imageUrl: shareImageUrl,
|
||||
success: () => {
|
||||
uni.showToast({
|
||||
title: '分享成功',
|
||||
icon: 'success',
|
||||
});
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('分享失败', err);
|
||||
uni.showToast({
|
||||
title: '分享失败',
|
||||
icon: 'none',
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// 获取位置图标
|
||||
const getLocationIcon = (type: string) => {
|
||||
const iconMap: Record<string, string> = {
|
||||
building: '🏢',
|
||||
floor: '🏛️',
|
||||
indoor: '🏠',
|
||||
outdoor: '🌳',
|
||||
classroom: '📚',
|
||||
activity: '🎨',
|
||||
preview: '👁️',
|
||||
office: '💼',
|
||||
laboratory: '🔬',
|
||||
meeting: '🤝',
|
||||
other: '📍',
|
||||
};
|
||||
return iconMap[type] || '📍';
|
||||
};
|
||||
|
||||
// 获取位置类型名称
|
||||
const getLocationTypeName = (type: string) => {
|
||||
const nameMap: Record<string, string> = {
|
||||
building: '楼栋',
|
||||
floor: '楼层',
|
||||
indoor: '室内',
|
||||
outdoor: '室外',
|
||||
classroom: '教室',
|
||||
activity: '活动室',
|
||||
preview: '预览室',
|
||||
office: '办公室',
|
||||
laboratory: '实验室',
|
||||
meeting: '会议室',
|
||||
other: '其他',
|
||||
};
|
||||
return nameMap[type] || '未知';
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadLocationList();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理VR查看器
|
||||
// #ifdef H5
|
||||
if (pannellumViewer) {
|
||||
try {
|
||||
pannellumViewer.destroy();
|
||||
} catch (e) {
|
||||
console.warn('清理VR查看器失败', e);
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vr-view-page {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: relative;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.location-nav {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 100rpx;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.nav-scroll {
|
||||
white-space: nowrap;
|
||||
height: 100%;
|
||||
padding: 0 20rpx;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 10rpx 20rpx;
|
||||
margin-right: 20rpx;
|
||||
border-radius: 8rpx;
|
||||
transition: all 0.3s;
|
||||
|
||||
.nav-icon {
|
||||
font-size: 32rpx;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.nav-name {
|
||||
font-size: 20rpx;
|
||||
color: #fff;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(102, 126, 234, 0.8);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vr-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: #000;
|
||||
|
||||
// H5环境:Pannellum查看器
|
||||
// #ifdef H5
|
||||
.vr-viewer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
:deep(#pannellum-container) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
|
||||
// APP环境:web-view
|
||||
// #ifdef APP-PLUS
|
||||
.vr-webview-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.vr-webview {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
// #endif
|
||||
|
||||
.vr-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.placeholder-text {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.location-info-card {
|
||||
position: absolute;
|
||||
bottom: 120rpx;
|
||||
left: 20rpx;
|
||||
right: 20rpx;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16rpx;
|
||||
padding: 20rpx;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15rpx;
|
||||
|
||||
.location-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.location-type {
|
||||
font-size: 24rpx;
|
||||
color: #667eea;
|
||||
background: #f0f4ff;
|
||||
padding: 5rpx 15rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.card-body {
|
||||
.info-text {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
margin-bottom: 10rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
position: absolute;
|
||||
bottom: 20rpx;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
gap: 30rpx;
|
||||
z-index: 100;
|
||||
|
||||
.control-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 15rpx 25rpx;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
border-radius: 50rpx;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.btn-icon {
|
||||
font-size: 40rpx;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
font-size: 22rpx;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.location-list-popup {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 30rpx;
|
||||
border-bottom: 1rpx solid #eee;
|
||||
|
||||
.popup-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.popup-close {
|
||||
font-size: 40rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
flex: 1;
|
||||
padding: 20rpx;
|
||||
}
|
||||
|
||||
.building-group {
|
||||
margin-bottom: 30rpx;
|
||||
|
||||
.group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 15rpx;
|
||||
|
||||
.group-icon {
|
||||
font-size: 32rpx;
|
||||
margin-right: 15rpx;
|
||||
}
|
||||
|
||||
.group-name {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.group-arrow {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.group-content {
|
||||
padding-left: 20rpx;
|
||||
}
|
||||
|
||||
.location-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 20rpx;
|
||||
background: #fff;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 15rpx;
|
||||
border: 2rpx solid transparent;
|
||||
transition: all 0.3s;
|
||||
|
||||
.item-icon {
|
||||
font-size: 40rpx;
|
||||
margin-right: 20rpx;
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
|
||||
.item-name {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
margin-bottom: 5rpx;
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.item-arrow {
|
||||
font-size: 32rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&.active {
|
||||
border-color: #667eea;
|
||||
background: #f0f4ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
BIN
src/static/base/home/vrps.png
Normal file
BIN
src/static/base/home/vrps.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.5 KiB |
BIN
src/static/base/home/vrzs.png
Normal file
BIN
src/static/base/home/vrzs.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
Loading…
x
Reference in New Issue
Block a user