This commit is contained in:
hebo 2025-11-23 19:34:40 +08:00
parent 6161a2fa8d
commit 85f74332c0
22 changed files with 5689 additions and 607 deletions

View File

@ -31,6 +31,16 @@ export const findAllZw = () => {

293
src/api/vr/index.ts Normal file
View 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);
},
});
});
};

View 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>

View File

@ -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"/>

View File

@ -31,6 +31,8 @@ type Component =
| 'BasicNjBjPicker'
| 'BasicNjBjSelect'
| 'BasicXsPicker'
| 'BasicEditor'
| 'ImageVideoUpload'
interface FormsSchema {
field?: string,

View File

@ -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"

View File

@ -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": {

View File

@ -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",
},
],
},
],
},
{

View File

@ -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('党政职务'),
//

View File

@ -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[] => {

View File

@ -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;
}
}
}
}

View File

@ -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)}`,
// idID
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
};
// IDID
@ -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;
}
}
}
}

View File

@ -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>

View File

@ -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({

View File

@ -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>

View File

@ -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({

View File

@ -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) => {
// rwzxqdtxrwzxqdtx
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]) {

File diff suppressed because it is too large Load Diff

View 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
View 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) => {
// buildingIdbuildingId""
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 VRH5
const initPannellumViewer = async (imageUrl: string, links?: any[]) => {
// #ifdef H5
try {
// Pannellum CSSJS
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 || '前往',
// 使clickHandlerFuncPannellum
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
};
// PannellumH5
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;
}
// H5Pannellum VR
// #ifdef H5
if (currentLocation.value.imageUrlFull) {
await nextTick();
await initPannellumViewer(currentLocation.value.imageUrlFull, currentLocation.value.links);
}
// #endif
// APPVRURL
// #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, '\\"');
// 使clickHandlerFunconClick
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(',') + ']';
// VRHTMLAPP使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-viewAPP
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;
// H5Pannellum
// #ifdef H5
.vr-viewer {
width: 100%;
height: 100%;
:deep(#pannellum-container) {
width: 100%;
height: 100%;
}
}
// #endif
// APPweb-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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB