Commit 9b30ab8cacdb5d3bcc79ee4b39db40269e8fb6d3
1 parent
993d98fa
新增快速工单,原版
Showing
18 changed files
with
1547 additions
and
170 deletions
App.vue
api/common.js
| 1 | + | |
| 2 | +import { post, get } from '@/common/utils/request'; | |
| 3 | + | |
| 4 | + | |
| 5 | +/** | |
| 6 | + * 根据经纬度获取道路接口 | |
| 7 | + * @param {Object} params {mobile, password, code} | |
| 8 | + * @returns {Promise} | |
| 9 | + */ | |
| 10 | +export const getRoadListByLatLng = (params) => { | |
| 11 | + return get('/app-api/bpm/garden/workorder/listRoadInfo', params); | |
| 12 | +}; | |
| 13 | + | |
| 14 | +// export const fileUpload = (params) => { | |
| 15 | +// return post('/app-api/infra/file/upload', params); | |
| 16 | +// }; | |
| 17 | + export const fileUpload = '/app-api/infra/file/upload' | |
| 18 | + | |
| 19 | + | |
| 20 | + | ... | ... |
api/quick-order/quick-order.js
0 → 100644
| 1 | +import { post, get } from '@/common/utils/request'; | |
| 2 | + | |
| 3 | +// work_name | |
| 4 | +/** | |
| 5 | + * 快速工单 所有工单列表接口 | |
| 6 | + * @param {Object} params {mobile, password, code} | |
| 7 | + * @returns {Promise} | |
| 8 | + */ | |
| 9 | +export const workorderPage = (params) => { | |
| 10 | + return get('/app-api/bpm/garden/workorder/page', params); | |
| 11 | +}; | |
| 12 | + | |
| 13 | + | |
| 14 | +/** | |
| 15 | + * 快速工单 创建 | |
| 16 | + * @returns {Promise} | |
| 17 | + */ | |
| 18 | +export const createQuick = (data) => { | |
| 19 | + return post('/app-api/bpm/garden/workorder/createQuick',data); | |
| 20 | +}; | |
| 21 | + | |
| 22 | +/** | |
| 23 | + * 快速工单 获取详情 | |
| 24 | + * @returns {Promise} | |
| 25 | + */ | |
| 26 | +export const inspectionPlanDetail = (params) => { | |
| 27 | + return get('/app-api/bpm/garden/workorder/get',params); | |
| 28 | +}; | ... | ... |
api/upload.js
| 1 | + | |
| 2 | +import globalConfig from '@/common/config/global'; | |
| 3 | +import cache from '@/common/utils/cache'; | |
| 4 | + | |
| 5 | +const fileUpload = '/app-api/infra/file/upload' | |
| 6 | + | |
| 7 | +export const uploadFilePromise = (url) => { | |
| 8 | + return new Promise((resolve, reject) => { | |
| 9 | + let a = uni.uploadFile({ | |
| 10 | + url: `${globalConfig.api.baseUrl}${fileUpload}`, | |
| 11 | + filePath: url, | |
| 12 | + name: 'file', | |
| 13 | + formData: { | |
| 14 | + user: 'test', | |
| 15 | + }, | |
| 16 | + success: (res) => { | |
| 17 | + setTimeout(() => { | |
| 18 | + resolve(res.data.data); | |
| 19 | + }, 1000); | |
| 20 | + }, | |
| 21 | + }); | |
| 22 | + }); | |
| 23 | +}; | |
| 0 | 24 | \ No newline at end of file | ... | ... |
api/user.js
common/config/global.js
common/utils/upload.js
| 1 | +// @/common/utils/upload.ts | |
| 1 | 2 | import globalConfig from '@/common/config/global'; |
| 2 | 3 | import cache from '@/common/utils/cache'; |
| 4 | +import { useUserStore } from '@/pinia/user'; | |
| 5 | +import { fileUpload } from '@/api/common'; | |
| 3 | 6 | |
| 4 | -export const uploadImages = (files, options = {}) => { | |
| 5 | - const opts = { count: 9, sizeType: ['original', 'compressed'], sourceType: ['album', 'camera'], showLoading: true, ...options }; | |
| 6 | - const token = cache.get(globalConfig.cache.tokenKey); | |
| 7 | +// 统一的多文件上传方法(单/多图都走这个) | |
| 8 | +export const uploadImages = async (options) => { | |
| 9 | + const { | |
| 10 | + filePaths, | |
| 11 | + fileKey = 'file', | |
| 12 | + header = {}, | |
| 13 | + ignoreError = true // 忽略单张失败,继续上传其他 | |
| 14 | + } = options; | |
| 7 | 15 | |
| 8 | - if (!token) { | |
| 9 | - uni.showToast({ title: '请先登录', icon: 'none' }); | |
| 10 | - return Promise.reject('未登录'); | |
| 16 | + if (!filePaths || filePaths.length === 0) { | |
| 17 | + uni.showToast({title: '请选择要上传的图片', icon: 'none'}); | |
| 18 | + return []; | |
| 11 | 19 | } |
| 12 | 20 | |
| 13 | - if (files.length > opts.count) { | |
| 14 | - uni.showToast({ title: `最多上传${opts.count}张`, icon: 'none' }); | |
| 15 | - return Promise.reject('超出数量'); | |
| 16 | - } | |
| 17 | - | |
| 18 | - if (opts.showLoading) uni.showLoading({ title: '上传中...' }); | |
| 21 | + const userStore = useUserStore() | |
| 22 | + const defaultHeader = { | |
| 23 | + 'Content-Type': 'multipart/form-data', | |
| 24 | + 'Authorization': 'Bearer ' + (userStore.token || cache.get(globalConfig.cache.tokenKey) || '') | |
| 25 | + }; | |
| 26 | + const finalHeader = {...defaultHeader, ...header}; | |
| 27 | + const resultUrls = []; // 最终返回的URL数组 | |
| 19 | 28 | |
| 20 | - const uploadPromises = files.map(filePath => { | |
| 21 | - return new Promise((resolve, reject) => { | |
| 22 | - uni.uploadFile({ | |
| 23 | - url: globalConfig.api.uploadUrl, | |
| 24 | - filePath, | |
| 25 | - name: 'file', | |
| 26 | - header: { 'Authorization': `Bearer ${token}` }, | |
| 27 | - formData: opts.formData || {}, | |
| 28 | - success: (res) => { | |
| 29 | - const data = JSON.parse(res.data); | |
| 30 | - if (data.code === 200) resolve(data.data); | |
| 31 | - else { | |
| 32 | - uni.showToast({ title: data.msg || '上传失败', icon: 'none' }); | |
| 33 | - reject(data); | |
| 29 | + // 遍历上传(单/多图统一遍历逻辑) | |
| 30 | + for (let i = 0; i < filePaths.length; i++) { | |
| 31 | + try { | |
| 32 | + const url = await new Promise((resolve, reject) => { | |
| 33 | + uni.uploadFile({ | |
| 34 | + url: `${globalConfig.api.baseUrl}${fileUpload}`, | |
| 35 | + filePath: filePaths[i], | |
| 36 | + name: fileKey, | |
| 37 | + header: finalHeader, | |
| 38 | + success: (res) => { | |
| 39 | + try { | |
| 40 | + const data = JSON.parse(res.data); | |
| 41 | + if (data.code === 0) { | |
| 42 | + resolve(data.data); // 接口返回的URL在data.data中 | |
| 43 | + } else { | |
| 44 | + reject(new Error(data.msg || '上传失败')); | |
| 45 | + } | |
| 46 | + } catch (e) { | |
| 47 | + reject(new Error('解析返回数据失败')); | |
| 48 | + } | |
| 49 | + }, | |
| 50 | + fail: (err) => { | |
| 51 | + reject(new Error(err.errMsg || '网络错误')); | |
| 34 | 52 | } |
| 35 | - }, | |
| 36 | - fail: (err) => { | |
| 37 | - uni.showToast({ title: '上传失败', icon: 'none' }); | |
| 38 | - reject(err); | |
| 39 | - } | |
| 53 | + }); | |
| 40 | 54 | }); |
| 41 | - }); | |
| 42 | - }); | |
| 43 | - | |
| 44 | - return Promise.all(uploadPromises) | |
| 45 | - .then(results => { | |
| 46 | - if (opts.showLoading) uni.hideLoading(); | |
| 47 | - return results; | |
| 48 | - }) | |
| 49 | - .catch(err => { | |
| 50 | - if (opts.showLoading) uni.hideLoading(); | |
| 51 | - return Promise.reject(err); | |
| 52 | - }); | |
| 53 | -}; | |
| 55 | + resultUrls.push(url); // 成功的URL加入数组 | |
| 56 | + } catch (err) { | |
| 57 | + console.error(`第${i+1}张图片上传失败:`, err); | |
| 58 | + if (!ignoreError) break; // 不忽略错误则终止上传 | |
| 59 | + } | |
| 60 | + } | |
| 54 | 61 | |
| 55 | -export const chooseAndUploadImages = (options = {}) => { | |
| 56 | - const opts = { count: 9, sizeType: ['original', 'compressed'], sourceType: ['album', 'camera'], ...options }; | |
| 57 | - return new Promise((resolve, reject) => { | |
| 58 | - uni.chooseImage({ | |
| 59 | - count: opts.count, | |
| 60 | - sizeType: opts.sizeType, | |
| 61 | - sourceType: opts.sourceType, | |
| 62 | - success: (res) => { | |
| 63 | - uploadImages(res.tempFilePaths, opts).then(resolve).catch(reject); | |
| 64 | - }, | |
| 65 | - fail: reject | |
| 66 | - }); | |
| 67 | - }); | |
| 62 | + return resultUrls; // 始终返回数组(单图返回长度1,多图返回对应长度,无成功则返回空数组) | |
| 68 | 63 | }; |
| 69 | 64 | \ No newline at end of file | ... | ... |
components/upload-image/upload-image.vue
| 1 | 1 | <template> |
| 2 | - <view class="upload-image"> | |
| 3 | - <view class="image-list"> | |
| 4 | - <view class="image-item" v-for="(item, index) in imageList" :key="index"> | |
| 5 | - <image class="image" :src="item.url || item" mode="aspectFill"></image> | |
| 6 | - <view class="delete-btn" @click="deleteImage(index)"> | |
| 7 | - <u-icon name="close" color="#fff"></u-icon> | |
| 8 | - </view> | |
| 9 | - </view> | |
| 10 | - <view class="upload-btn" v-if="imageList.length < maxCount" @click="chooseImage"> | |
| 11 | - <u-icon name="plus" size="40" color="#999"></u-icon> | |
| 12 | - <view class="upload-text">上传图片</view> | |
| 2 | + <view class="upload-images-container"> | |
| 3 | + <!-- 已上传图片预览(uView Album组件) --> | |
| 4 | + <up-album | |
| 5 | + v-if="imageList.length > 0" | |
| 6 | + :list="imageList" | |
| 7 | + :max-count="maxCount" | |
| 8 | + :show-delete="showDelete" | |
| 9 | + @delete="handleDelete" | |
| 10 | + @preview="handlePreview" | |
| 11 | + :border-radius="8" | |
| 12 | + :column="column" | |
| 13 | + :gap="15" | |
| 14 | + ></up-album> | |
| 15 | + | |
| 16 | + <!-- 上传按钮 --> | |
| 17 | + <up-upload | |
| 18 | + class="upload-btn" | |
| 19 | + :disabled="imageList.length >= maxCount" | |
| 20 | + :multiple="true" | |
| 21 | + :max-count="maxCount - imageList.length" | |
| 22 | + @after-read="handleAfterRead" | |
| 23 | + :previewFullImage="true" | |
| 24 | + > | |
| 25 | + <view class="upload-btn-inner"> | |
| 26 | + <up-icon name="plus" color="#999" size="24"></up-icon> | |
| 27 | + <text class="upload-tips" v-if="imageList.length < maxCount">上传图片</text> | |
| 28 | + <text class="upload-tips" v-else>已达上限</text> | |
| 13 | 29 | </view> |
| 14 | - </view> | |
| 15 | - <view class="tips" v-if="tips">{{ tips }}</view> | |
| 30 | + </up-upload> | |
| 16 | 31 | </view> |
| 17 | 32 | </template> |
| 18 | 33 | |
| 19 | -<script setup> | |
| 20 | -import { ref, defineProps, defineEmits, watch } from 'vue'; | |
| 21 | -import { chooseAndUploadImages } from '@/common/utils/upload'; | |
| 34 | +<script setup lang="ts"> | |
| 35 | +import { ref, watch, defineProps, defineEmits, withDefaults } from 'vue'; | |
| 36 | +import { uploadImages } from '@/common/utils/upload.js'; | |
| 37 | + | |
| 38 | + | |
| 39 | +// 定义Props | |
| 40 | +const props = withDefaults( | |
| 41 | + defineProps<{ | |
| 42 | + // 已上传图片列表(双向绑定) | |
| 43 | + value: string[]; | |
| 44 | + // 最大上传数量 | |
| 45 | + maxCount?: number; | |
| 46 | + // 是否显示删除按钮 | |
| 47 | + showDelete?: boolean; | |
| 48 | + // 相册列数 | |
| 49 | + column?: number; | |
| 50 | + // 上传文件key名 | |
| 51 | + fileKey?: string; | |
| 52 | + // 自定义请求头(覆盖默认) | |
| 53 | + header?: Record<string, string>; | |
| 54 | + // 上传失败是否继续(多图时) | |
| 55 | + ignoreError?: boolean; | |
| 56 | + }>(), | |
| 57 | + { | |
| 58 | + maxCount: 9, | |
| 59 | + showDelete: true, | |
| 60 | + column: 3, | |
| 61 | + fileKey: 'file', | |
| 62 | + ignoreError: true | |
| 63 | + } | |
| 64 | +); | |
| 22 | 65 | |
| 23 | -const props = defineProps({ | |
| 24 | - maxCount: { type: Number, default: 9 }, | |
| 25 | - modelValue: { type: Array, default: () => [] }, | |
| 26 | - tips: { type: String, default: '最多上传9张图片' }, | |
| 27 | - showTips: { type: Boolean, default: true } | |
| 28 | -}); | |
| 66 | +// 定义Emits | |
| 67 | +const emit = defineEmits<{ | |
| 68 | + // 双向绑定更新图片列表 | |
| 69 | + (e: 'update:value', val: string[]): void; | |
| 70 | + // 单张/多张上传成功 | |
| 71 | + (e: 'upload-success', urls: string[]): void; | |
| 72 | + // 上传失败 | |
| 73 | + (e: 'upload-error', err: any): void; | |
| 74 | + // 删除图片 | |
| 75 | + (e: 'delete-image', index: number): void; | |
| 76 | + // 预览图片 | |
| 77 | + (e: 'preview-image', index: number): void; | |
| 78 | +}>(); | |
| 29 | 79 | |
| 30 | -const emit = defineEmits(['update:modelValue', 'change']); | |
| 31 | -const imageList = ref([...props.modelValue]); | |
| 80 | +// 内部维护的图片列表 | |
| 81 | +const imageList = ref([...props.value]); | |
| 32 | 82 | |
| 33 | -watch(() => props.modelValue, (newVal) => imageList.value = [...newVal], { deep: true }); | |
| 83 | +// 监听父组件传入的value变化 | |
| 84 | +watch( | |
| 85 | + () => props.value, | |
| 86 | + (newVal) => { | |
| 87 | + imageList.value = [...newVal]; | |
| 88 | + }, | |
| 89 | + { immediate: true } | |
| 90 | +); | |
| 91 | + | |
| 92 | +// 监听内部列表变化,同步给父组件 | |
| 93 | +watch( | |
| 94 | + () => imageList.value, | |
| 95 | + (newVal) => { | |
| 96 | + emit('update:value', [...newVal]); | |
| 97 | + }, | |
| 98 | + { deep: true } | |
| 99 | +); | |
| 100 | + | |
| 101 | +/** | |
| 102 | + * 选择图片后触发(上传核心逻辑) | |
| 103 | + */ | |
| 104 | +const handleAfterRead = async (event: any) => { | |
| 105 | + // 获取选择的图片临时路径 | |
| 106 | + const filePaths = event.file.map((item: any) => item.url); | |
| 34 | 107 | |
| 35 | -const chooseImage = async () => { | |
| 36 | 108 | try { |
| 37 | - const res = await chooseAndUploadImages({ count: props.maxCount - imageList.value.length }); | |
| 38 | - imageList.value = [...imageList.value, ...res]; | |
| 39 | - emit('update:modelValue', imageList.value); | |
| 40 | - emit('change', imageList.value); | |
| 109 | + uni.showLoading({ title: '上传中...' }); | |
| 110 | + // 调用公共上传方法 | |
| 111 | + const uploadUrls = await uploadImages({ | |
| 112 | + filePaths, | |
| 113 | + fileKey: props.fileKey, | |
| 114 | + header: props.header, | |
| 115 | + ignoreError: props.ignoreError | |
| 116 | + }); | |
| 117 | + | |
| 118 | + if (uploadUrls.length > 0) { | |
| 119 | + // 合并到已上传列表 | |
| 120 | + imageList.value = [...imageList.value, ...uploadUrls]; | |
| 121 | + // 通知父组件上传成功 | |
| 122 | + emit('upload-success', uploadUrls); | |
| 123 | + uni.showToast({ title: `成功上传${uploadUrls.length}张图片`, icon: 'success' }); | |
| 124 | + } | |
| 41 | 125 | } catch (err) { |
| 42 | - console.error('上传失败:', err); | |
| 126 | + emit('upload-error', err); | |
| 127 | + console.error('图片上传失败:', err); | |
| 128 | + } finally { | |
| 129 | + uni.hideLoading(); | |
| 43 | 130 | } |
| 44 | 131 | }; |
| 45 | 132 | |
| 46 | -const deleteImage = (index) => { | |
| 133 | +/** | |
| 134 | + * 删除图片 | |
| 135 | + */ | |
| 136 | +const handleDelete = (index: number) => { | |
| 47 | 137 | imageList.value.splice(index, 1); |
| 48 | - emit('update:modelValue', imageList.value); | |
| 49 | - emit('change', imageList.value); | |
| 138 | + emit('delete-image', index); | |
| 139 | + uni.showToast({ title: '删除成功', icon: 'success' }); | |
| 140 | +}; | |
| 141 | + | |
| 142 | +/** | |
| 143 | + * 预览图片 | |
| 144 | + */ | |
| 145 | +const handlePreview = (index: number) => { | |
| 146 | + emit('preview-image', index); | |
| 147 | + // 原生预览(可选) | |
| 148 | + uni.previewImage({ | |
| 149 | + urls: imageList.value, | |
| 150 | + current: imageList.value[index] | |
| 151 | + }); | |
| 50 | 152 | }; |
| 51 | 153 | </script> |
| 52 | 154 | |
| 53 | -<style scoped> | |
| 54 | -.upload-image { width: 100%; } | |
| 55 | -.image-list { display: flex; flex-wrap: wrap; gap: 10rpx; } | |
| 56 | -.image-item { position: relative; width: 200rpx; height: 200rpx; border-radius: 8rpx; overflow: hidden; } | |
| 57 | -.image { width: 100%; height: 100%; } | |
| 58 | -.delete-btn { position: absolute; top: 0; right: 0; width: 40rpx; height: 40rpx; background: rgba(0,0,0,0.5); border-radius: 50%; display: flex; align-items: center; justify-content: center; } | |
| 59 | -.upload-btn { width: 200rpx; height: 200rpx; border: 1rpx dashed #ddd; border-radius: 8rpx; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #f9f9f9; } | |
| 60 | -.upload-text { font-size: 24rpx; color: #999; margin-top: 10rpx; } | |
| 61 | -.tips { font-size: 24rpx; color: #999; margin-top: 10rpx; } | |
| 155 | +<style lang="scss" scoped> | |
| 156 | +.upload-images-container { | |
| 157 | + padding: 10rpx 0; | |
| 158 | +} | |
| 159 | + | |
| 160 | +// 上传按钮样式 | |
| 161 | +.upload-btn { | |
| 162 | + margin-top: 15rpx; | |
| 163 | + width: 200rpx; | |
| 164 | + height: 200rpx; | |
| 165 | + border: 1px dashed #e5e5e5; | |
| 166 | + border-radius: 8rpx; | |
| 167 | + display: flex; | |
| 168 | + align-items: center; | |
| 169 | + justify-content: center; | |
| 170 | + background-color: #f9f9f9; | |
| 171 | + | |
| 172 | + .upload-btn-inner { | |
| 173 | + display: flex; | |
| 174 | + flex-direction: column; | |
| 175 | + align-items: center; | |
| 176 | + justify-content: center; | |
| 177 | + } | |
| 178 | + | |
| 179 | + .upload-tips { | |
| 180 | + font-size: 24rpx; | |
| 181 | + color: #999; | |
| 182 | + margin-top: 10rpx; | |
| 183 | + } | |
| 184 | +} | |
| 185 | + | |
| 186 | +// 适配Album组件样式 | |
| 187 | +:deep(.u-album__item) { | |
| 188 | + border-radius: 8rpx !important; | |
| 189 | + overflow: hidden; | |
| 190 | +} | |
| 191 | + | |
| 192 | +:deep(.u-album__delete) { | |
| 193 | + background-color: rgba(0, 0, 0, 0.5) !important; | |
| 194 | + border-radius: 50% !important; | |
| 195 | + width: 40rpx !important; | |
| 196 | + height: 40rpx !important; | |
| 197 | + top: 0 !important; | |
| 198 | + right: 0 !important; | |
| 199 | +} | |
| 62 | 200 | </style> |
| 63 | 201 | \ No newline at end of file | ... | ... |
main.js
| ... | ... | @@ -4,6 +4,8 @@ import uviewPlus from '@/uni_modules/uview-plus' |
| 4 | 4 | // 导入 Pinia 实例(你的 stores/index.js 导出的 pinia) |
| 5 | 5 | import pinia from '@/pinia/index' |
| 6 | 6 | import EmptyView from '@/components/empty-view/empty-view.vue'; |
| 7 | +import UploadImage from '@/components/upload-image/upload-image.vue'; | |
| 8 | + | |
| 7 | 9 | // #ifdef VUE3 |
| 8 | 10 | import { createSSRApp } from 'vue' |
| 9 | 11 | |
| ... | ... | @@ -17,6 +19,7 @@ export function createApp() { |
| 17 | 19 | // 4. 注册 Pinia(核心:在 app 挂载前注册) |
| 18 | 20 | app.use(pinia) |
| 19 | 21 | app.component('EmptyView', EmptyView) |
| 22 | + app.component('UploadImage', UploadImage) | |
| 20 | 23 | // 5. 返回 app + pinia(可选,便于调试) |
| 21 | 24 | return { |
| 22 | 25 | app, | ... | ... |
manifest.json
| ... | ... | @@ -55,6 +55,17 @@ |
| 55 | 55 | "urlCheck" : false |
| 56 | 56 | }, |
| 57 | 57 | "usingComponents" : true, |
| 58 | + "permission" : { | |
| 59 | + "scope.userLocation" : { | |
| 60 | + "desc" : "定位" | |
| 61 | + } | |
| 62 | + }, | |
| 63 | + "requiredPrivateInfos" : [ | |
| 64 | + "getLocation", | |
| 65 | + "chooseLocation", | |
| 66 | + "startLocationUpdate", | |
| 67 | + "onLocationChange" | |
| 68 | + ], | |
| 58 | 69 | "mergeVirtualHostAttributes" : true |
| 59 | 70 | }, |
| 60 | 71 | "mp-alipay" : { | ... | ... |
pages-sub/daily/patrol-manage/patrol-plan/index.vue
| ... | ... | @@ -32,7 +32,7 @@ |
| 32 | 32 | ></up-search> |
| 33 | 33 | </view> |
| 34 | 34 | |
| 35 | - <!-- 修复点1:绑定total + 修正ref名称 + 移除auto=false(改用默认auto=true更易控制) --> | |
| 35 | + | |
| 36 | 36 | <z-paging |
| 37 | 37 | ref="paging" |
| 38 | 38 | v-model="planList" |
| ... | ... | @@ -98,9 +98,9 @@ const searchValue = ref(''); |
| 98 | 98 | // 分页相关 |
| 99 | 99 | const pageNo = ref(1); |
| 100 | 100 | const pageSize = ref(10); |
| 101 | -const total = ref(0); | |
| 101 | + | |
| 102 | 102 | const planList = ref([]); |
| 103 | -const paging = ref(null); // 修复点2:ref名称与模板中一致 | |
| 103 | +const paging = ref(null); | |
| 104 | 104 | // 养护级别/计划类型映射 |
| 105 | 105 | const levelMap = { |
| 106 | 106 | 11: '一级养护', |
| ... | ... | @@ -119,8 +119,6 @@ const handleTabChange = (val) => { |
| 119 | 119 | console.log(activeTab.value) |
| 120 | 120 | activeTab.value = val.id |
| 121 | 121 | searchValue.value = '' |
| 122 | - // paging.value.reload() | |
| 123 | - // queryList(pageNo, pageSize) | |
| 124 | 122 | paging.value.reload() |
| 125 | 123 | }; |
| 126 | 124 | // 搜索/清空搜索 |
| ... | ... | @@ -133,7 +131,7 @@ const handleSearchClear = () => { |
| 133 | 131 | // searchValue.value = '' |
| 134 | 132 | paging.value.reload() |
| 135 | 133 | }; |
| 136 | -// 加载数据(核心修复) | |
| 134 | +// 加载数据 | |
| 137 | 135 | const queryList = async (pageNo, pageSize) => { |
| 138 | 136 | try { |
| 139 | 137 | const params = { | ... | ... |
pages-sub/daily/quick-order/add-order.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <view class="u-page"> | |
| 3 | + <!-- 工单表单容器 --> | |
| 4 | + <view class="work-order-form-content commonPageLRpadding"> | |
| 5 | + <!-- uview-plus表单(Vue3 适配) --> | |
| 6 | + <up-form | |
| 7 | + label-position="left" | |
| 8 | + :model="workOrderForm" | |
| 9 | + ref="workOrderFormRef" | |
| 10 | + labelWidth="220rpx" | |
| 11 | + :rules="workOrderFormRules" | |
| 12 | + > | |
| 13 | + <!-- 1. 工单位置(地图选择) --> | |
| 14 | + <up-form-item | |
| 15 | + label="工单位置" | |
| 16 | + prop="workLocation" | |
| 17 | + border-bottom | |
| 18 | + required | |
| 19 | + @click="chooseWorkLocation(); hideKeyboard()" | |
| 20 | + > | |
| 21 | + <up-input | |
| 22 | + v-model="workOrderForm.workLocation" | |
| 23 | + border="none" | |
| 24 | + readonly | |
| 25 | + suffix-icon="map-fill" | |
| 26 | + placeholder="点击选择工单位置" | |
| 27 | + ></up-input> | |
| 28 | + </up-form-item> | |
| 29 | + | |
| 30 | + <!-- 2. 道路名称(下拉框:位置选择后才可点击) --> | |
| 31 | + <up-form-item | |
| 32 | + label="道路名称" | |
| 33 | + prop="roadName" | |
| 34 | + border-bottom | |
| 35 | + required | |
| 36 | + @click="workOrderForm.workLocation ? (showRoadName = true, hideKeyboard()) : uni.showToast({title: '请先选择工单位置', icon: 'none'})" | |
| 37 | + > | |
| 38 | + <up-input | |
| 39 | + v-model="workOrderForm.roadName" | |
| 40 | + disabled | |
| 41 | + disabled-color="#ffffff" | |
| 42 | + placeholder="请先选择工单位置" | |
| 43 | + border="none" | |
| 44 | + :placeholder-style="workOrderForm.workLocation ? '' : 'color:#999;'" | |
| 45 | + ></up-input> | |
| 46 | + <template #right> | |
| 47 | + <up-icon name="arrow-right" size="16" :color="workOrderForm.workLocation ? '#333' : '#999'"></up-icon> | |
| 48 | + </template> | |
| 49 | + </up-form-item> | |
| 50 | + | |
| 51 | + <!-- 3. 工单名称(下拉框) --> | |
| 52 | + <up-form-item | |
| 53 | + label="工单名称" | |
| 54 | + prop="orderName" | |
| 55 | + border-bottom | |
| 56 | + required | |
| 57 | + @click="showOrderName = true; hideKeyboard()" | |
| 58 | + > | |
| 59 | + <up-input | |
| 60 | + v-model="workOrderForm.orderName" | |
| 61 | + disabled | |
| 62 | + disabled-color="#ffffff" | |
| 63 | + placeholder="请选择工单名称" | |
| 64 | + border="none" | |
| 65 | + ></up-input> | |
| 66 | + <template #right> | |
| 67 | + <up-icon name="arrow-right" size="16"></up-icon> | |
| 68 | + </template> | |
| 69 | + </up-form-item> | |
| 70 | + | |
| 71 | + <!-- 4. 情况描述(文本域) --> | |
| 72 | + <up-form-item | |
| 73 | + label="情况描述" | |
| 74 | + prop="problemDesc" | |
| 75 | + required | |
| 76 | + > | |
| 77 | + <up-textarea | |
| 78 | + placeholder="请输入情况描述(最多150字)" | |
| 79 | + v-model="workOrderForm.problemDesc" | |
| 80 | + count | |
| 81 | + maxlength="150" | |
| 82 | + rows="4" | |
| 83 | + ></up-textarea> | |
| 84 | + </up-form-item> | |
| 85 | + </up-form> | |
| 86 | + | |
| 87 | + <!-- 道路名称下拉弹窗 --> | |
| 88 | + <up-action-sheet | |
| 89 | + :show="showRoadName" | |
| 90 | + :actions="roadNameList" | |
| 91 | + title="请选择道路名称" | |
| 92 | + @close="showRoadName = false" | |
| 93 | + @select="handleRoadNameSelect" | |
| 94 | + ></up-action-sheet> | |
| 95 | + | |
| 96 | + <!-- 工单名称下拉弹窗 --> | |
| 97 | + <up-action-sheet | |
| 98 | + :show="showOrderName" | |
| 99 | + :actions="orderNameList" | |
| 100 | + title="请选择工单名称" | |
| 101 | + @close="showOrderName = false" | |
| 102 | + @select="handleOrderNameSelect" | |
| 103 | + ></up-action-sheet> | |
| 104 | + </view> | |
| 105 | + | |
| 106 | + <!-- 图片分类标签 --> | |
| 107 | + <up-tabs :list="imgTabList" @click="switchImgTab"></up-tabs> | |
| 108 | + | |
| 109 | + <!-- 图片上传组件(根据选中标签显示对应列表) --> | |
| 110 | + <view class="img-upload-wrap" v-if="activeImgTab === '1'"> | |
| 111 | + <up-form-item label="问题照片" prop="problemImgs" required> | |
| 112 | + <up-upload | |
| 113 | + :file-list="problemImgsList" | |
| 114 | + @after-read="(event) => uploadImgs(event, 'problemImgsList')" | |
| 115 | + @delete="(event) => deleteImg(event, 'problemImgsList')" | |
| 116 | + multiple | |
| 117 | + :max-count="3" | |
| 118 | + upload-text="选择问题照片" | |
| 119 | + ></up-upload> | |
| 120 | + </up-form-item> | |
| 121 | + </view> | |
| 122 | + <view class="img-upload-wrap" v-else> | |
| 123 | + <up-form-item label="完成照片" prop="completeImgs" required> | |
| 124 | + <up-upload | |
| 125 | + :file-list="completeImgsList" | |
| 126 | + @after-read="(event) => uploadImgs(event, 'completeImgsList')" | |
| 127 | + @delete="(event) => deleteImg(event, 'completeImgsList')" | |
| 128 | + multiple | |
| 129 | + :max-count="3" | |
| 130 | + :sizeType="['compressed']" | |
| 131 | + upload-text="选择完成照片" | |
| 132 | + ></up-upload> | |
| 133 | + </up-form-item> | |
| 134 | + </view> | |
| 135 | + | |
| 136 | + <!-- 处理结果描述(独立显示) --> | |
| 137 | + <view class="handle-result-wrap"> | |
| 138 | + <up-form-item | |
| 139 | + label="处理结果" | |
| 140 | + prop="handleResultDesc" | |
| 141 | + required | |
| 142 | + > | |
| 143 | + <up-textarea | |
| 144 | + placeholder="请输入处理结果描述(最多200字)" | |
| 145 | + v-model="workOrderForm.handleResultDesc" | |
| 146 | + count | |
| 147 | + maxlength="200" | |
| 148 | + rows="4" | |
| 149 | + ></up-textarea> | |
| 150 | + </up-form-item> | |
| 151 | + </view> | |
| 152 | + | |
| 153 | + <!-- 底部提交按钮 --> | |
| 154 | + <view class="fixed-bottom-btn-wrap"> | |
| 155 | + <up-button | |
| 156 | + type="primary" | |
| 157 | + text="提交工单" | |
| 158 | + @click="submitWorkOrder" | |
| 159 | + :loading="isSubmitting" | |
| 160 | + :disabled="isSubmitting" | |
| 161 | + ></up-button> | |
| 162 | + </view> | |
| 163 | + </view> | |
| 164 | +</template> | |
| 165 | + | |
| 166 | +<script setup lang="ts"> | |
| 167 | +import { ref, reactive, onMounted } from 'vue' | |
| 168 | +import type { UniFormRef, UniActionSheetSelectEvent, UploadFile, UploadDeleteEvent } from '@/uni_modules/uview-plus/types' | |
| 169 | +// 导入接口 | |
| 170 | +import { getRoadListByLatLng } from '@/api/common' | |
| 171 | +// 导入统一的多文件上传方法 | |
| 172 | +import { uploadImages } from '@/common/utils/upload'; | |
| 173 | +import { createQuick } from '@/api/quick-order/quick-order' | |
| 174 | + | |
| 175 | +// ========== 基础变量定义(语义化命名) ========== | |
| 176 | +// 图片分类标签列表 | |
| 177 | +const imgTabList = ref([ | |
| 178 | + { name: '问题照片', id: '1' }, | |
| 179 | + { name: '完成照片', id: '2' } | |
| 180 | +]) | |
| 181 | +// 激活的图片标签(默认选中问题照片) | |
| 182 | +const activeImgTab = ref('1') | |
| 183 | +// 问题照片列表 | |
| 184 | +const problemImgsList = ref<UploadFile[]>([]) | |
| 185 | +// 完成照片列表 | |
| 186 | +const completeImgsList = ref<UploadFile[]>([]) | |
| 187 | +// 表单Ref(语义化:工单表单) | |
| 188 | +const workOrderFormRef = ref<UniFormRef>(null) | |
| 189 | +// 提交加载状态 | |
| 190 | +const isSubmitting = ref(false) | |
| 191 | +// 弹窗显隐控制 | |
| 192 | +const showRoadName = ref(false) | |
| 193 | +const showOrderName = ref(false) | |
| 194 | +// 下拉列表数据 | |
| 195 | +const roadNameList = ref<any[]>([]) | |
| 196 | +const orderNameList = ref([ | |
| 197 | + { name: '绿地卫生', code: 'ORDER001' }, | |
| 198 | + { name: '设施维修', code: 'ORDER002' }, | |
| 199 | + { name: '垃圾清理', code: 'ORDER003' } | |
| 200 | +]) | |
| 201 | + | |
| 202 | +// ========== 工单表单数据(语义化字段名) ========== | |
| 203 | +const workOrderForm = reactive({ | |
| 204 | + roadId: 0, // 道路ID | |
| 205 | + roadName: '', // 道路名称 | |
| 206 | + workLocation: '', // 工单位置 | |
| 207 | + orderName: '', // 工单名称 | |
| 208 | + problemDesc: '', // 情况描述 | |
| 209 | + handleResultDesc: '', // 处理结果描述 | |
| 210 | + lat: 0, // 纬度 | |
| 211 | + lon: 0 // 经度 | |
| 212 | +}) | |
| 213 | + | |
| 214 | +// ========== 表单校验规则(修复trigger + 语义化) ========== | |
| 215 | +const workOrderFormRules = reactive({ | |
| 216 | + workLocation: [ | |
| 217 | + { type: 'string', required: true, message: '请选择工单位置', trigger: 'change' } | |
| 218 | + ], | |
| 219 | + roadName: [ | |
| 220 | + { type: 'string', required: true, message: '请选择道路名称', trigger: 'change' } | |
| 221 | + ], | |
| 222 | + orderName: [ | |
| 223 | + { type: 'string', required: true, message: '请选择工单名称', trigger: 'change' } | |
| 224 | + ], | |
| 225 | + problemDesc: [ | |
| 226 | + { type: 'string', required: true, message: '请输入情况描述', trigger: 'change' }, | |
| 227 | + { type: 'string', min: 3, max: 150, message: '情况描述需3-150字', trigger: 'change' } | |
| 228 | + ], | |
| 229 | + problemImgs: [ | |
| 230 | + { required: true, message: '请上传问题照片', trigger: 'change' } | |
| 231 | + ], | |
| 232 | + completeImgs: [ | |
| 233 | + { required: true, message: '请上传完成照片', trigger: 'change' } | |
| 234 | + ], | |
| 235 | + handleResultDesc: [ | |
| 236 | + { type: 'string', required: true, message: '请输入处理结果描述', trigger: 'change' }, | |
| 237 | + { type: 'string', min: 3, max: 200, message: '处理结果需3-200字', trigger: 'change' } | |
| 238 | + ] | |
| 239 | +}) | |
| 240 | + | |
| 241 | +// ========== 方法定义(语义化命名 + 修复逻辑) ========== | |
| 242 | +/** | |
| 243 | + * 删除图片 | |
| 244 | + * @param event 删除事件 | |
| 245 | + * @param type 图片类型(problemImgsList/completeImgsList) | |
| 246 | + */ | |
| 247 | +const deleteImg = (event: UploadDeleteEvent, type: string) => { | |
| 248 | + console.log('删除图片事件:', event, '类型:', type) | |
| 249 | + if (type === 'problemImgsList') { | |
| 250 | + problemImgsList.value.splice(event.index, 1) | |
| 251 | + } else if (type === 'completeImgsList') { | |
| 252 | + completeImgsList.value.splice(event.index, 1) | |
| 253 | + } | |
| 254 | + uni.showToast({ title: '图片删除成功', icon: 'success' }) | |
| 255 | +} | |
| 256 | + | |
| 257 | +/** | |
| 258 | + * 上传图片(单/多图统一逻辑) | |
| 259 | + * @param event 上传事件 | |
| 260 | + * @param type 图片类型(problemImgsList/completeImgsList) | |
| 261 | + */ | |
| 262 | +const uploadImgs = async (event: { file: UploadFile | UploadFile[] }, type: string) => { | |
| 263 | + console.log('上传图片事件:', event, '类型:', type) | |
| 264 | + // 统一处理为数组格式 | |
| 265 | + const fileList = Array.isArray(event.file) ? event.file : [event.file] | |
| 266 | + const targetImgList = type === 'problemImgsList' ? problemImgsList : completeImgsList | |
| 267 | + | |
| 268 | + // 1. 提取文件路径 | |
| 269 | + const filePaths = fileList.map(item => item.url) | |
| 270 | + // 2. 添加上传中临时项 | |
| 271 | + const tempItems: UploadFile[] = fileList.map(item => ({ | |
| 272 | + ...item, | |
| 273 | + status: 'uploading' as const, | |
| 274 | + message: '上传中' | |
| 275 | + })) | |
| 276 | + const startIndex = targetImgList.value.length | |
| 277 | + targetImgList.value.push(...tempItems) | |
| 278 | + | |
| 279 | + try { | |
| 280 | + // 3. 调用统一上传方法 | |
| 281 | + const uploadResultUrls = await uploadImages({ | |
| 282 | + filePaths: filePaths, | |
| 283 | + ignoreError: true | |
| 284 | + }) | |
| 285 | + console.log('上传成功的URL列表:', uploadResultUrls) | |
| 286 | + | |
| 287 | + // 4. 更新成功状态 | |
| 288 | + uploadResultUrls.forEach((url, index) => { | |
| 289 | + if (targetImgList.value[startIndex + index]) { | |
| 290 | + targetImgList.value.splice(startIndex + index, 1, { | |
| 291 | + ...fileList[index], | |
| 292 | + status: 'success' as const, | |
| 293 | + message: '', | |
| 294 | + url: url | |
| 295 | + }) | |
| 296 | + } | |
| 297 | + }) | |
| 298 | + | |
| 299 | + // 5. 处理失败项 | |
| 300 | + if (uploadResultUrls.length < fileList.length) { | |
| 301 | + const failCount = fileList.length - uploadResultUrls.length | |
| 302 | + for (let i = uploadResultUrls.length; i < fileList.length; i++) { | |
| 303 | + if (targetImgList.value[startIndex + i]) { | |
| 304 | + targetImgList.value.splice(startIndex + i, 1, { | |
| 305 | + ...fileList[i], | |
| 306 | + status: 'failed' as const, | |
| 307 | + message: '上传失败' | |
| 308 | + }) | |
| 309 | + } | |
| 310 | + } | |
| 311 | + uni.showToast({ title: `成功上传${uploadResultUrls.length}张,失败${failCount}张`, icon: 'none' }) | |
| 312 | + } else { | |
| 313 | + uni.showToast({ title: `成功上传${fileList.length}张图片`, icon: 'success' }) | |
| 314 | + } | |
| 315 | + } catch (err) { | |
| 316 | + console.error('图片上传失败:', err) | |
| 317 | + // 标记所有为失败 | |
| 318 | + for (let i = 0; i < fileList.length; i++) { | |
| 319 | + if (targetImgList.value[startIndex + i]) { | |
| 320 | + targetImgList.value.splice(startIndex + i, 1, { | |
| 321 | + ...fileList[i], | |
| 322 | + status: 'failed' as const, | |
| 323 | + message: '上传失败' | |
| 324 | + }) | |
| 325 | + } | |
| 326 | + } | |
| 327 | + uni.showToast({ title: '图片上传失败,请重试', icon: 'none' }) | |
| 328 | + } | |
| 329 | +} | |
| 330 | + | |
| 331 | +/** | |
| 332 | + * 切换图片标签 | |
| 333 | + * @param item 标签项 | |
| 334 | + */ | |
| 335 | +const switchImgTab = (item: { name: string; id: string }) => { | |
| 336 | + console.log('切换图片标签:', item) | |
| 337 | + activeImgTab.value = item.id | |
| 338 | +} | |
| 339 | + | |
| 340 | +/** | |
| 341 | + * 选择工单位置(地图)- 核心修改:清空道路名称 | |
| 342 | + */ | |
| 343 | +const chooseWorkLocation = () => { | |
| 344 | + if (isSubmitting.value) return | |
| 345 | + | |
| 346 | + uni.authorize({ | |
| 347 | + scope: 'scope.userLocation', | |
| 348 | + success: () => { | |
| 349 | + uni.chooseLocation({ | |
| 350 | + success: async (res) => { | |
| 351 | + // ========== 核心新增:清空道路名称相关数据 ========== | |
| 352 | + workOrderForm.roadName = '' // 清空道路名称 | |
| 353 | + workOrderForm.roadId = 0 // 清空道路ID | |
| 354 | + roadNameList.value = [] // 清空道路名称列表 | |
| 355 | + | |
| 356 | + // 更新工单位置 | |
| 357 | + workOrderForm.workLocation = res.name | |
| 358 | + workOrderForm.lat = res.latitude | |
| 359 | + workOrderForm.lon = res.longitude | |
| 360 | + | |
| 361 | + // 触发工单位置和道路名称的表单校验更新 | |
| 362 | + workOrderFormRef.value?.validateField('workLocation') | |
| 363 | + workOrderFormRef.value?.validateField('roadName') | |
| 364 | + | |
| 365 | + try { | |
| 366 | + uni.showLoading({ title: '获取道路名称中...' }) | |
| 367 | + const roadRes = await getRoadListByLatLng({ | |
| 368 | + companyCode: 'sls', | |
| 369 | + latitude: res.latitude, | |
| 370 | + longitude: res.longitude | |
| 371 | + }) | |
| 372 | + uni.hideLoading() | |
| 373 | + | |
| 374 | + if (Array.isArray(roadRes)) { | |
| 375 | + roadNameList.value = roadRes.map((item: any) => ({ | |
| 376 | + name: item.roadName || '', | |
| 377 | + code: item.roadCode || '', | |
| 378 | + id: item.roadId || 0 | |
| 379 | + })) | |
| 380 | + } else { | |
| 381 | + roadNameList.value = [{ name: '未查询到道路名称', code: '', id: 0 }] | |
| 382 | + uni.showToast({ title: '未查询到该位置的道路信息', icon: 'none' }) | |
| 383 | + } | |
| 384 | + } catch (err) { | |
| 385 | + uni.hideLoading() | |
| 386 | + console.error('获取道路名称失败:', err) | |
| 387 | + uni.showToast({ title: '获取道路名称失败,请重试', icon: 'none' }) | |
| 388 | + roadNameList.value = [{ name: '获取失败,请重新选择位置', code: '', id: 0 }] | |
| 389 | + } | |
| 390 | + }, | |
| 391 | + fail: (err) => { | |
| 392 | + console.error('选择位置失败:', err) | |
| 393 | + uni.showToast({ title: '选择位置失败:' + err.errMsg, icon: 'none' }) | |
| 394 | + } | |
| 395 | + }) | |
| 396 | + }, | |
| 397 | + fail: (err) => { | |
| 398 | + console.error('位置授权失败:', err) | |
| 399 | + uni.showModal({ | |
| 400 | + title: '权限提示', | |
| 401 | + content: '需要获取您的位置权限才能选择工单位置,请前往设置开启', | |
| 402 | + confirmText: '去设置', | |
| 403 | + cancelText: '取消', | |
| 404 | + success: (res) => { | |
| 405 | + if (res.confirm) { | |
| 406 | + uni.openSetting({ | |
| 407 | + success: (settingRes) => { | |
| 408 | + if (settingRes.authSetting['scope.userLocation']) { | |
| 409 | + uni.showToast({ title: '权限已开启,请重新选择位置', icon: 'none' }) | |
| 410 | + } | |
| 411 | + } | |
| 412 | + }) | |
| 413 | + } | |
| 414 | + } | |
| 415 | + }) | |
| 416 | + } | |
| 417 | + }) | |
| 418 | +} | |
| 419 | + | |
| 420 | +/** | |
| 421 | + * 选择道路名称 | |
| 422 | + * @param e 选择事件 | |
| 423 | + */ | |
| 424 | +const handleRoadNameSelect = (e: UniActionSheetSelectEvent) => { | |
| 425 | + console.log('选择道路名称:', e) | |
| 426 | + workOrderForm.roadName = e.name | |
| 427 | + workOrderForm.roadId = e.code | |
| 428 | + showRoadName.value = false | |
| 429 | + workOrderFormRef.value?.validateField('roadName') | |
| 430 | +} | |
| 431 | + | |
| 432 | +/** | |
| 433 | + * 选择工单名称 | |
| 434 | + * @param e 选择事件 | |
| 435 | + */ | |
| 436 | +const handleOrderNameSelect = (e: UniActionSheetSelectEvent) => { | |
| 437 | + workOrderForm.orderName = e.name | |
| 438 | + showOrderName.value = false | |
| 439 | + workOrderFormRef.value?.validateField('orderName') | |
| 440 | +} | |
| 441 | + | |
| 442 | +/** | |
| 443 | + * 隐藏键盘 | |
| 444 | + */ | |
| 445 | +const hideKeyboard = () => { | |
| 446 | + uni.hideKeyboard() | |
| 447 | +} | |
| 448 | + | |
| 449 | +/** | |
| 450 | + * 提取图片URL数组 | |
| 451 | + * @param imgList 图片列表 | |
| 452 | + * @returns URL数组 | |
| 453 | + */ | |
| 454 | +const getImgUrlList = (imgList: UploadFile[]) => { | |
| 455 | + console.log('提取图片URL:', imgList) | |
| 456 | + return imgList.filter(item => item.status === 'success').map(item => item.url) | |
| 457 | +} | |
| 458 | + | |
| 459 | +/** | |
| 460 | + * 校验上传图片(兜底校验) | |
| 461 | + * @returns 是否校验通过 | |
| 462 | + */ | |
| 463 | +const validateUploadImgs = () => { | |
| 464 | + // 修复:补充返回值,避免函数无返回 | |
| 465 | + if (activeImgTab.value === '1') { | |
| 466 | + const hasValidImgs = problemImgsList.value.some(item => item.status === 'success') | |
| 467 | + if (!hasValidImgs) { | |
| 468 | + uni.showToast({ title: '请上传至少1张问题照片', icon: 'none' }) | |
| 469 | + return false | |
| 470 | + } | |
| 471 | + } else { | |
| 472 | + const hasValidcompleteImgs = completeImgsList.value.some(item => item.status === 'success') | |
| 473 | + if (!hasValidcompleteImgs) { | |
| 474 | + uni.showToast({ title: '请上传至少1张完成照片', icon: 'none' }) | |
| 475 | + return false | |
| 476 | + } | |
| 477 | + } | |
| 478 | + return true | |
| 479 | +} | |
| 480 | + | |
| 481 | +/** | |
| 482 | + * 提交工单(修复核心:await位置 + 表单Ref名称错误) | |
| 483 | + */ | |
| 484 | +const submitWorkOrder = async () => { | |
| 485 | + if (isSubmitting.value) { | |
| 486 | + uni.showToast({ title: '提交中,请稍候', icon: 'none' }) | |
| 487 | + return | |
| 488 | + } | |
| 489 | + | |
| 490 | + try { | |
| 491 | + isSubmitting.value = true | |
| 492 | + | |
| 493 | + // 修复1:兜底校验图片 | |
| 494 | + if (!validateUploadImgs()) { | |
| 495 | + isSubmitting.value = false | |
| 496 | + return | |
| 497 | + } | |
| 498 | + | |
| 499 | + // 修复2:使用正确的表单Ref名称(workOrderFormRef 而非 uFormRef) | |
| 500 | + // 修复3:将validate转为Promise形式,配合async/await | |
| 501 | + const valid = await new Promise((resolve) => { | |
| 502 | + workOrderFormRef.value?.validate((isValid) => { | |
| 503 | + resolve(isValid) | |
| 504 | + }) | |
| 505 | + }) | |
| 506 | + | |
| 507 | + if (!valid) { | |
| 508 | + uni.showToast({ title: '表单校验失败,请检查必填项', icon: 'none' }) | |
| 509 | + isSubmitting.value = false | |
| 510 | + return | |
| 511 | + } | |
| 512 | + | |
| 513 | + // 构造提交数据 | |
| 514 | + const submitData = { | |
| 515 | + roadId: workOrderForm.roadId, | |
| 516 | + roadName: workOrderForm.roadName, | |
| 517 | + imgs: getImgUrlList(problemImgsList.value), // 问题照片URL数组 | |
| 518 | + longRangeImgList: getImgUrlList(completeImgsList.value), // 完成照片URL数组 | |
| 519 | + remark: activeImgTab.value === '2' ? workOrderForm.handleResultDesc : workOrderForm.problemDesc, | |
| 520 | + latLonType: 2, | |
| 521 | + lat: workOrderForm.lat, | |
| 522 | + lon: workOrderForm.lon, | |
| 523 | + lonLatAddress: workOrderForm.workLocation, | |
| 524 | + pressingType: 2, | |
| 525 | + orderName: workOrderForm.orderName, | |
| 526 | + sourceId: 1, | |
| 527 | + sourceName: '园林', | |
| 528 | + thirdWorkNo: '' | |
| 529 | + } | |
| 530 | + | |
| 531 | + console.log('提交工单数据:', submitData) | |
| 532 | + // 修复4:await 现在在async函数中,可正常使用 | |
| 533 | + await createQuick(submitData) | |
| 534 | + uni.showToast({ title: '工单提交成功', icon: 'success' }) | |
| 535 | + | |
| 536 | + // 可选:提交成功后重置表单 | |
| 537 | + // problemImgsList.value = [] | |
| 538 | + // completeImgsList.value = [] | |
| 539 | + // Object.assign(workOrderForm, { | |
| 540 | + // roadId: 0, roadName: '', workLocation: '', orderName: '', | |
| 541 | + // problemDesc: '', handleResultDesc: '', lat: 0, lon: 0 | |
| 542 | + // }) | |
| 543 | + // workOrderFormRef.value?.clearValidate() | |
| 544 | + | |
| 545 | + } catch (error) { | |
| 546 | + console.error('提交工单失败:', error) | |
| 547 | + uni.showToast({ title: '提交失败,请重试', icon: 'none' }) | |
| 548 | + } finally { | |
| 549 | + isSubmitting.value = false | |
| 550 | + } | |
| 551 | +} | |
| 552 | + | |
| 553 | +// ========== 页面挂载初始化 ========== | |
| 554 | +onMounted(() => { | |
| 555 | + setTimeout(() => { | |
| 556 | + if (workOrderFormRef.value && !workOrderFormRef.value.rules) { | |
| 557 | + workOrderFormRef.value.setRules(workOrderFormRules) | |
| 558 | + console.log('工单表单规则初始化完成') | |
| 559 | + } | |
| 560 | + }, 200) | |
| 561 | +}) | |
| 562 | +</script> | |
| 563 | + | |
| 564 | +<style lang="scss" scoped> | |
| 565 | +// 全局页面样式 | |
| 566 | +.u-page { | |
| 567 | + background-color: #f5f5f5; | |
| 568 | + min-height: 100vh; | |
| 569 | + padding-bottom: 100rpx; // 给底部按钮留空间 | |
| 570 | +} | |
| 571 | + | |
| 572 | +// 工单表单容器 | |
| 573 | +.work-order-form-content { | |
| 574 | + background: #fff; | |
| 575 | + padding: 20rpx; | |
| 576 | + box-sizing: border-box; | |
| 577 | + margin-bottom: 20rpx; | |
| 578 | +} | |
| 579 | + | |
| 580 | +// 图片上传区域 | |
| 581 | +.img-upload-wrap { | |
| 582 | + background: #fff; | |
| 583 | + padding: 20rpx; | |
| 584 | + margin-bottom: 20rpx; | |
| 585 | + | |
| 586 | + :deep(.u-upload) { | |
| 587 | + --u-upload-item-width: 200rpx; | |
| 588 | + --u-upload-item-height: 200rpx; | |
| 589 | + } | |
| 590 | + | |
| 591 | + :deep(.u-form-item) { | |
| 592 | + margin-bottom: 0; | |
| 593 | + } | |
| 594 | +} | |
| 595 | + | |
| 596 | +// 处理结果描述区域 | |
| 597 | +.handle-result-wrap { | |
| 598 | + background: #fff; | |
| 599 | + padding: 0 20rpx 20rpx; | |
| 600 | + margin-bottom: 20rpx; | |
| 601 | + border-top: 1px solid #f0f0f0; | |
| 602 | + | |
| 603 | + :deep(.u-form-item) { | |
| 604 | + --u-form-item-label-font-size: 28rpx; | |
| 605 | + --u-form-item-content-font-size: 28rpx; | |
| 606 | + } | |
| 607 | + | |
| 608 | + :deep(.u-textarea) { | |
| 609 | + --u-textarea-font-size: 28rpx; | |
| 610 | + padding: 10rpx 0; | |
| 611 | + } | |
| 612 | +} | |
| 613 | + | |
| 614 | +// 提交按钮区域 | |
| 615 | +.fixed-bottom-btn-wrap { // 修复:原样式类名是submit-btn-wrap,与模板不一致 | |
| 616 | + position: fixed; | |
| 617 | + bottom: 0; | |
| 618 | + left: 0; | |
| 619 | + right: 0; | |
| 620 | + padding: 20rpx; | |
| 621 | + background: #fff; | |
| 622 | + box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05); | |
| 623 | + | |
| 624 | + :deep(.u-button) { | |
| 625 | + width: 100%; | |
| 626 | + height: 80rpx; | |
| 627 | + line-height: 80rpx; | |
| 628 | + font-size: 32rpx; | |
| 629 | + border-radius: 8rpx; | |
| 630 | + } | |
| 631 | +} | |
| 632 | + | |
| 633 | +// ========== 修复Label折行样式 ========== | |
| 634 | +:deep(.u-form-item__label) { | |
| 635 | + white-space: nowrap; // 强制不换行 | |
| 636 | + text-overflow: ellipsis; // 超出省略 | |
| 637 | + overflow: hidden; // 隐藏超出部分 | |
| 638 | + line-height: 1.4; // 行高适配 | |
| 639 | +} | |
| 640 | + | |
| 641 | +// 表单基础样式适配 | |
| 642 | +:deep(.u-form-item) { | |
| 643 | + --u-form-item-label-font-size: 28rpx; | |
| 644 | + --u-form-item-content-font-size: 28rpx; | |
| 645 | + margin-bottom: 10rpx; | |
| 646 | +} | |
| 647 | + | |
| 648 | +:deep(.u-input) { | |
| 649 | + --u-input-font-size: 28rpx; | |
| 650 | +} | |
| 651 | + | |
| 652 | +:deep(.u-textarea) { | |
| 653 | + --u-textarea-font-size: 28rpx; | |
| 654 | + padding: 10rpx 0; | |
| 655 | +} | |
| 656 | + | |
| 657 | +:deep(.u-action-sheet) { | |
| 658 | + z-index: 9999 !important; | |
| 659 | +} | |
| 660 | + | |
| 661 | +:deep(.u-tabs) { | |
| 662 | + background: #fff; | |
| 663 | +} | |
| 664 | +</style> | |
| 0 | 665 | \ No newline at end of file | ... | ... |
pages-sub/daily/quick-order/index.vue
| 1 | -<script setup lang="ts"> | |
| 1 | +<template> | |
| 2 | + <view class="work-order-page"> | |
| 3 | + <!-- 顶部固定搜索栏 --> | |
| 4 | + <up-sticky> | |
| 5 | + <view class="search-header"> | |
| 6 | + <!-- 左侧下拉框 --> | |
| 7 | + <view class="dropdown-wrap"> | |
| 8 | + <up-dropdown ref="uDropdownRef" @open="open" @close="close"> | |
| 9 | + <up-dropdown-item | |
| 10 | + v-model="selectedSortValue" | |
| 11 | + title="距离" | |
| 12 | + :options="sortOptions" | |
| 13 | + @change="handleSortChange" | |
| 14 | + /> | |
| 15 | + </up-dropdown> | |
| 16 | + </view> | |
| 2 | 17 | |
| 3 | -</script> | |
| 18 | + <!-- 右侧搜索框 --> | |
| 19 | + <view class="search-input-wrap"> | |
| 20 | + <up-search | |
| 21 | + v-model="searchValue" | |
| 22 | + placeholder="请输入关键字" | |
| 23 | + @search="handleSearch" | |
| 24 | + bg-color="#f5f5f5" | |
| 25 | + :clearabled="false" | |
| 26 | + :show-action="true" | |
| 27 | + actionText="搜索" | |
| 28 | + :animation="true" | |
| 29 | + @custom="handleSearch" | |
| 30 | + /> | |
| 31 | + </view> | |
| 32 | + </view> | |
| 33 | + </up-sticky> | |
| 4 | 34 | |
| 5 | -<template> | |
| 35 | + <!-- 列表容器 --> | |
| 36 | + <z-paging | |
| 37 | + ref="paging" | |
| 38 | + v-model="orderList" | |
| 39 | + @query="queryList" | |
| 40 | + :top="100" | |
| 41 | + :bottom="120" | |
| 42 | + > | |
| 43 | + <template #empty> | |
| 44 | + <view class="empty-tip">暂无工单数据</view> | |
| 45 | + </template> | |
| 46 | + | |
| 47 | + <!-- 修复:新增列表容器,配置顶部内边距 --> | |
| 48 | + <view class="order-list"> | |
| 49 | + <view class="order-card" v-for="item in orderList" :key="item.orderNo"> | |
| 50 | + <view class="order-item up-line-1">工单编号:{{ item.orderNo }}</view> | |
| 51 | + <view class="order-item up-line-1">工单位置:{{ item.roadName || '未填写' }}</view> | |
| 52 | + <view class="order-item up-line-1">工单名称:{{ item.orderName || '未填写' }}</view> | |
| 53 | + <view class="order-item up-line-1">情况描述:{{ item.remark || '无' }}</view> | |
| 54 | + <view class="order-footer"> | |
| 55 | + <view class="submit-time up-line-1">提交时间:{{ timeFormat(item.createTime,'yyyy-mm-dd') }}</view> | |
| 56 | + <up-button | |
| 57 | + type="primary" | |
| 58 | + size="mini" | |
| 59 | + @click="handleDetail(item)" | |
| 60 | + :style="{ width: '80px', height: '28px', fontSize: '14px', borderRadius: 4 }" | |
| 61 | + > | |
| 62 | + 工单详情 | |
| 63 | + </up-button> | |
| 64 | + </view> | |
| 65 | + </view> | |
| 66 | + </view> | |
| 67 | + </z-paging> | |
| 6 | 68 | |
| 69 | + <!-- 修复:补充底部按钮样式层级 --> | |
| 70 | + <view class="fixed-bottom-btn-wrap"> | |
| 71 | + <up-button type="primary" size="large" @click="handleAddOrder"> | |
| 72 | + 新增工单 | |
| 73 | + </up-button> | |
| 74 | + </view> | |
| 75 | + </view> | |
| 7 | 76 | </template> |
| 8 | 77 | |
| 9 | -<style scoped> | |
| 78 | +<script setup> | |
| 79 | +import { ref } from 'vue'; | |
| 80 | +import { workorderPage } from "@/api/quick-order/quick-order"; | |
| 81 | +import { timeFormat } from '@/uni_modules/uview-plus'; | |
| 82 | +// ========== 修复1:声明所有核心响应式变量 ========== | |
| 83 | +// 顶部/底部高度(rpx) | |
| 84 | +const headerHeight = 100; | |
| 85 | +const bottomBtnHeight = 120; | |
| 86 | +// 排序相关 | |
| 87 | +const selectedSortValue = ref(1); | |
| 88 | +const sortOptions = ref([ | |
| 89 | + { label: '默认排序', value: 1 }, | |
| 90 | + { label: '距离优先', value: 2 } | |
| 91 | +]); | |
| 92 | +// 分页相关(核心:声明orderList) | |
| 93 | +const pageNo = ref(1); | |
| 94 | +const pageSize = ref(10); | |
| 95 | +const paging = ref(null); | |
| 96 | +const orderList = ref([]); // 修复:新增列表变量 | |
| 97 | +// 搜索相关 | |
| 98 | +const searchValue = ref(''); | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | +// ========== 修复3:适配z-paging回调参数 ========== | |
| 103 | +const queryList = async (pageNo,pageSize) => { | |
| 104 | + try { | |
| 105 | + // 修复:z-paging的query回调参数是对象 {pageNo, pageSize} | |
| 106 | + const apiParams = { | |
| 107 | + searchContent: searchValue.value.trim() || '', | |
| 108 | + pageNo: pageNo, | |
| 109 | + pageSize: pageSize, | |
| 110 | + type:1 // 1 位置 2 工单名称 3 情况描述 4 工单编号 | |
| 111 | + }; | |
| 112 | + console.log('请求参数:', apiParams); | |
| 113 | + const res = await workorderPage(apiParams); | |
| 114 | + console.log('接口返回:', res); | |
| 115 | + | |
| 116 | + | |
| 117 | + paging.value.complete(res.list); | |
| 118 | + } catch (error) { | |
| 119 | + console.error('加载失败:', error); | |
| 120 | + paging.value?.complete(false); | |
| 121 | + uni.showToast({ title: '加载失败,请重试', icon: 'none' }); | |
| 122 | + } | |
| 123 | +}; | |
| 124 | + | |
| 125 | +// ========== 其他方法补充 ========== | |
| 126 | +const handleSortChange = (val) => { | |
| 127 | + console.log('排序变更:', val); | |
| 128 | + paging.value?.reload(); // 排序后刷新列表 | |
| 129 | +}; | |
| 130 | + | |
| 131 | +const handleSearch = (val) => { | |
| 132 | + console.log('搜索内容:', val); | |
| 133 | + searchValue.value = val; | |
| 134 | + paging.value?.reload(); // 搜索后刷新列表 | |
| 135 | +}; | |
| 136 | + | |
| 137 | +const handleDetail = (item) => { | |
| 138 | + console.log('工单详情:', item); | |
| 139 | + uni.navigateTo({ | |
| 140 | + url: `/pages-sub/daily/quick-order/order-detail?id=${item.id}` | |
| 141 | + }); | |
| 142 | +}; | |
| 143 | + | |
| 144 | +const handleAddOrder = () => { | |
| 145 | + uni.navigateTo({ | |
| 146 | + url: '/pages-sub/daily/quick-order/add-order' | |
| 147 | + }); | |
| 148 | +}; | |
| 149 | + | |
| 150 | +// 空方法(避免下拉框报错) | |
| 151 | +const open = () => {}; | |
| 152 | +const close = () => {}; | |
| 153 | +</script> | |
| 154 | + | |
| 155 | +<style scoped lang="scss"> | |
| 156 | +// 修复:页面基础样式 | |
| 157 | +.work-order-page { | |
| 158 | + min-height: 100vh; | |
| 159 | + background-color: #f5f5f5; | |
| 160 | + padding-bottom: env(safe-area-inset-bottom); | |
| 161 | + box-sizing: border-box; | |
| 162 | +} | |
| 163 | + | |
| 164 | +// 顶部搜索栏 | |
| 165 | +.search-header { | |
| 166 | + display: flex; | |
| 167 | + align-items: center; | |
| 168 | + padding: 20rpx; | |
| 169 | + background-color: #fff; | |
| 170 | + border-bottom: 1px solid #f0f0f0; | |
| 171 | + height: v-bind(headerHeight + 'rpx'); | |
| 172 | + box-sizing: border-box; | |
| 173 | + | |
| 174 | + .dropdown-wrap { | |
| 175 | + flex: 1; | |
| 176 | + margin-right: 20rpx; | |
| 177 | + } | |
| 178 | + | |
| 179 | + .search-input-wrap { | |
| 180 | + flex: 2; | |
| 181 | + } | |
| 182 | +} | |
| 183 | + | |
| 184 | +// 修复:列表容器样式(核心) | |
| 185 | +.order-list { | |
| 186 | + padding: 120rpx 20rpx 20rpx; // 顶部内边距避开搜索栏 | |
| 187 | + box-sizing: border-box; | |
| 188 | +} | |
| 189 | + | |
| 190 | +// 空状态样式 | |
| 191 | +.empty-tip { | |
| 192 | + text-align: center; | |
| 193 | + padding: 100rpx 0; | |
| 194 | + color: #999; | |
| 195 | + font-size: 28rpx; | |
| 196 | +} | |
| 197 | + | |
| 198 | +// 工单卡片样式 | |
| 199 | +.order-card { | |
| 200 | + background-color: #fff; | |
| 201 | + border-radius: 12rpx; | |
| 202 | + padding: 24rpx; | |
| 203 | + margin-bottom: 20rpx; | |
| 204 | + box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05); | |
| 205 | + | |
| 206 | + .order-item { | |
| 207 | + font-size: 28rpx; | |
| 208 | + color: #333; | |
| 209 | + line-height: 48rpx; | |
| 210 | + margin-bottom: 8rpx; | |
| 211 | + } | |
| 212 | + | |
| 213 | + .order-footer { | |
| 214 | + display: flex; | |
| 215 | + justify-content: space-between; | |
| 216 | + align-items: center; | |
| 217 | + margin-top: 12rpx; | |
| 218 | + | |
| 219 | + .submit-time { | |
| 220 | + font-size: 26rpx; | |
| 221 | + color: #666; | |
| 222 | + line-height: 40rpx; | |
| 223 | + } | |
| 224 | + } | |
| 225 | +} | |
| 10 | 226 | |
| 11 | 227 | </style> |
| 12 | 228 | \ No newline at end of file | ... | ... |
pages-sub/daily/quick-order/order-detail.vue
0 → 100644
| 1 | +<template> | |
| 2 | + <view class="u-page"> | |
| 3 | + | |
| 4 | + | |
| 5 | + <!-- 页面级加载组件(up-loading-page,uview-plus标准组件) --> | |
| 6 | + <up-loading-page | |
| 7 | + v-if="loading" | |
| 8 | + :loading="true" | |
| 9 | + title="加载中..." | |
| 10 | + color="#3c9cff" | |
| 11 | + ></up-loading-page> | |
| 12 | + | |
| 13 | + <!-- 内容容器(仅在非加载状态显示) --> | |
| 14 | + <view v-else class="content-wrap"> | |
| 15 | + <!-- 单元格分组(核心uview组件) --> | |
| 16 | + <up-cell-group :border="false" inset style="margin: 20rpx;" > | |
| 17 | + <!-- 1. 工单编号 --> | |
| 18 | + <up-cell | |
| 19 | + title="工单编号" | |
| 20 | + :value="orderDetail.orderNo || '--'" | |
| 21 | + class="up-line-1" | |
| 22 | + align="middle" | |
| 23 | + ></up-cell> | |
| 24 | + | |
| 25 | + <!-- 2. 工单位置 --> | |
| 26 | + <up-cell | |
| 27 | + title="工单位置" | |
| 28 | + :value="orderDetail.roadName || '--'" | |
| 29 | + class="up-line-1" | |
| 30 | + align="middle" | |
| 31 | + ></up-cell> | |
| 32 | + | |
| 33 | + <!-- 3. 工单名称 --> | |
| 34 | + <up-cell | |
| 35 | + title="工单名称" | |
| 36 | + :value="orderDetail.orderName || '--'" | |
| 37 | + class="up-line-1" | |
| 38 | + align="middle" | |
| 39 | + ></up-cell> | |
| 40 | + | |
| 41 | + <!-- 4. 情况描述 --> | |
| 42 | + <up-cell | |
| 43 | + title="情况描述" | |
| 44 | + :value="orderDetail.remark || '--'" | |
| 45 | + class="up-line-1" | |
| 46 | + align="middle" | |
| 47 | + ></up-cell> | |
| 48 | + | |
| 49 | + <!-- 5. 问题照片 --> | |
| 50 | + <up-cell title="问题照片" align="top"> | |
| 51 | + <view class="cell-content-wrap"> | |
| 52 | + <up-album | |
| 53 | + v-if="orderDetail.imgs && orderDetail.imgs.length" | |
| 54 | + :list="formatAlbumList(orderDetail.imgs)" | |
| 55 | + :column="3" | |
| 56 | + :max-count="9" | |
| 57 | + :preview-full-image="true" | |
| 58 | + disabled | |
| 59 | + style="width: 100%;" | |
| 60 | + ></up-album> | |
| 61 | + <text v-else class="empty-text">暂无问题照片</text> | |
| 62 | + </view> | |
| 63 | + </up-cell> | |
| 64 | + | |
| 65 | + <!-- 6. 完成照片 --> | |
| 66 | + <up-cell title="完成照片" align="top"> | |
| 67 | + <view class="cell-content-wrap"> | |
| 68 | + <up-album | |
| 69 | + v-if="orderDetail.longRangeImgList && orderDetail.longRangeImgList.length" | |
| 70 | + :list="formatAlbumList(orderDetail.longRangeImgList)" | |
| 71 | + :column="3" | |
| 72 | + :max-count="9" | |
| 73 | + :preview-full-image="true" | |
| 74 | + disabled | |
| 75 | + style="width: 100%;" | |
| 76 | + ></up-album> | |
| 77 | + <text v-else class="empty-text">暂无完成照片</text> | |
| 78 | + </view> | |
| 79 | + </up-cell> | |
| 80 | + | |
| 81 | + <!-- 7. 处理结果 --> | |
| 82 | + <up-cell | |
| 83 | + title="处理结果" | |
| 84 | + :value="orderDetail.handleResultDesc || '--'" | |
| 85 | + class="up-line-1" | |
| 86 | + align="middle" | |
| 87 | + :border="false" | |
| 88 | + ></up-cell> | |
| 89 | + </up-cell-group> | |
| 90 | + | |
| 91 | + <!-- 空状态(原生样式,替代up-empty) --> | |
| 92 | + <view v-if="!Object.keys(orderDetail).length" class="empty-wrap"> | |
| 93 | + <text class="empty-icon">📄</text> | |
| 94 | + <text class="empty-text">暂无工单详情</text> | |
| 95 | + <text class="empty-subtext">请检查工单ID是否正确</text> | |
| 96 | + </view> | |
| 97 | + </view> | |
| 98 | + </view> | |
| 99 | +</template> | |
| 100 | + | |
| 101 | +<script setup lang="ts"> | |
| 102 | +import { ref, reactive } from 'vue'; | |
| 103 | +import { inspectionPlanDetail } from "@/api/quick-order/quick-order"; | |
| 104 | + | |
| 105 | +import { onLoad, onShow } from '@dcloudio/uni-app'; | |
| 106 | +// 状态管理 | |
| 107 | +const loading = ref(true); | |
| 108 | +const orderDetail = ref({}); | |
| 109 | + | |
| 110 | +/** | |
| 111 | + * 格式化相册列表(适配up-album) | |
| 112 | + */ | |
| 113 | +const formatAlbumList = (imgUrls: string[]) => { | |
| 114 | + return imgUrls.map(url => ({ | |
| 115 | + url, | |
| 116 | + thumb: url, | |
| 117 | + name: `图片-${Date.now()}` | |
| 118 | + })); | |
| 119 | +}; | |
| 120 | + | |
| 121 | +/** | |
| 122 | + * 获取工单详情 | |
| 123 | + */ | |
| 124 | +const getOrderDetail = async (id: string) => { | |
| 125 | + try { | |
| 126 | + loading.value = true; | |
| 127 | + const res = await inspectionPlanDetail({ id }); | |
| 128 | + console.log('接口返回:', res) | |
| 129 | + orderDetail.value = res | |
| 130 | + | |
| 131 | + } catch (error) { | |
| 132 | + console.error('获取工单详情失败:', error); | |
| 133 | + uni.showToast({ title: '加载失败,请重试', icon: 'none' }); | |
| 134 | + } finally { | |
| 135 | + loading.value = false; | |
| 136 | + } | |
| 137 | +}; | |
| 138 | + | |
| 139 | +// 页面加载 | |
| 140 | +onLoad((options) => { | |
| 141 | + const { id } = options; | |
| 142 | + if (id) { | |
| 143 | + getOrderDetail(id); | |
| 144 | + } else { | |
| 145 | + loading.value = false; | |
| 146 | + uni.showToast({ title: '缺少工单ID', icon: 'none' }); | |
| 147 | + } | |
| 148 | +}); | |
| 149 | +</script> | |
| 150 | + | |
| 151 | +<style scoped lang="scss"> | |
| 152 | + | |
| 153 | + | |
| 154 | +// 内容容器(原生滚动) | |
| 155 | +.content-wrap { | |
| 156 | + background: #fff; | |
| 157 | + width: 100%; | |
| 158 | + box-sizing: border-box; | |
| 159 | + overflow-y: auto; | |
| 160 | +} | |
| 161 | + | |
| 162 | +// 单元格内容包裹层 | |
| 163 | +.cell-content-wrap { | |
| 164 | + width: 100%; | |
| 165 | + padding: 10rpx 0; | |
| 166 | + box-sizing: border-box; | |
| 167 | +} | |
| 168 | + | |
| 169 | +// 空文本样式 | |
| 170 | +.empty-text { | |
| 171 | + color: #999; | |
| 172 | + font-size: 28rpx; | |
| 173 | + line-height: 80rpx; | |
| 174 | + display: block; | |
| 175 | + text-align: left; | |
| 176 | +} | |
| 177 | + | |
| 178 | +// 优化uview组件样式 | |
| 179 | +:deep(.up-cell-group) { | |
| 180 | + --u-cell-group-background-color: #fff; | |
| 181 | + --u-cell-group-border-radius: 12rpx; | |
| 182 | + --u-cell-group-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.05); | |
| 183 | +} | |
| 184 | +:deep(.up-cell) { | |
| 185 | + --u-cell-title-font-size: 28rpx; | |
| 186 | + --u-cell-value-font-size: 28rpx; | |
| 187 | + --u-cell-title-color: #666; | |
| 188 | + --u-cell-value-color: #333; | |
| 189 | + --u-cell-padding: 20rpx 15rpx; | |
| 190 | + --u-cell-border-color: #f5f5f5; | |
| 191 | +} | |
| 192 | +:deep(.up-album__item) { | |
| 193 | + margin: 10rpx 5rpx; | |
| 194 | + border-radius: 8rpx; | |
| 195 | +} | |
| 196 | + | |
| 197 | +// 原生空状态样式 | |
| 198 | +.empty-wrap { | |
| 199 | + margin-top: 200rpx; | |
| 200 | + text-align: center; | |
| 201 | +} | |
| 202 | +.empty-icon { | |
| 203 | + font-size: 80rpx; | |
| 204 | + display: block; | |
| 205 | + margin-bottom: 20rpx; | |
| 206 | + color: #ddd; | |
| 207 | +} | |
| 208 | +.empty-text { | |
| 209 | + font-size: 28rpx; | |
| 210 | + color: #999; | |
| 211 | + display: block; | |
| 212 | +} | |
| 213 | +.empty-subtext { | |
| 214 | + font-size: 24rpx; | |
| 215 | + color: #ccc; | |
| 216 | + margin-top: 10rpx; | |
| 217 | + display: block; | |
| 218 | +} | |
| 219 | + | |
| 220 | +// 适配up-loading-page样式 | |
| 221 | +:deep(.up-loading-page) { | |
| 222 | + margin-top: 100rpx; | |
| 223 | +} | |
| 224 | +</style> | |
| 0 | 225 | \ No newline at end of file | ... | ... |
pages-sub/daily/quick-order/order-list.vue
0 → 100644
pages.json
| ... | ... | @@ -70,6 +70,16 @@ |
| 70 | 70 | "style": { "navigationBarTitleText": "快速工单" } |
| 71 | 71 | }, |
| 72 | 72 | { |
| 73 | + "path": "quick-order/add-order", | |
| 74 | + "style": { "navigationBarTitleText": "新增快速工单" } | |
| 75 | + }, | |
| 76 | + { | |
| 77 | + "path": "quick-order/order-detail", | |
| 78 | + "style": { "navigationBarTitleText": "快速工单详情" } | |
| 79 | + }, | |
| 80 | + | |
| 81 | + | |
| 82 | + { | |
| 73 | 83 | "path": "12345-order/index", |
| 74 | 84 | "style": { "navigationBarTitleText": "12345工单" } |
| 75 | 85 | }, | ... | ... |
pages/workbench/index.vue
| 1 | 1 | <template> |
| 2 | 2 | <!-- 外层容器:包含蓝色块 + 原始内容 --> |
| 3 | 3 | <view class="workbench-container"> |
| 4 | + <!-- 加载页:覆盖整个容器,渲染完成后隐藏 --> | |
| 5 | + <up-loading-page | |
| 6 | + v-if="loading" | |
| 7 | + loading-text="加载中..." | |
| 8 | + color="#577ee3" | |
| 9 | + style="position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 9999;" | |
| 10 | + ></up-loading-page> | |
| 11 | + | |
| 4 | 12 | <!-- 蓝色装饰块:#577ee3 --> |
| 5 | 13 | <view class="blue-decor-block"></view> |
| 6 | 14 | |
| ... | ... | @@ -8,7 +16,7 @@ |
| 8 | 16 | <view class="content-wrap"> |
| 9 | 17 | <!-- uview-plus空状态组件 --> |
| 10 | 18 | <u-empty |
| 11 | - v-if="!moduleList.length" | |
| 19 | + v-if="!moduleList.length && !loading" | |
| 12 | 20 | mode="list" |
| 13 | 21 | text="暂无菜单数据" |
| 14 | 22 | color="#999" |
| ... | ... | @@ -16,15 +24,13 @@ |
| 16 | 24 | ></u-empty> |
| 17 | 25 | |
| 18 | 26 | <!-- 菜单卡片列表(修复header插槽语法,恢复标题显示) --> |
| 19 | - <view v-else class="menu-card-wrap"> | |
| 20 | - | |
| 27 | + <view v-else-if="!loading" class="menu-card-wrap"> | |
| 21 | 28 | <up-card |
| 22 | 29 | :title-size="18" |
| 23 | 30 | v-for="(parentModule, index) in moduleList" |
| 24 | 31 | :key="parentModule.id" |
| 25 | 32 | :title="parentModule.name" |
| 26 | 33 | > |
| 27 | - | |
| 28 | 34 | <template #body> |
| 29 | 35 | <view> |
| 30 | 36 | <up-grid |
| ... | ... | @@ -57,7 +63,7 @@ |
| 57 | 63 | </template> |
| 58 | 64 | |
| 59 | 65 | <script setup lang="ts"> |
| 60 | -// 原始代码完全保留 | |
| 66 | +// 原始代码完全保留,新增loading状态 | |
| 61 | 67 | import {ref, nextTick} from 'vue'; |
| 62 | 68 | import { onShow } from '@dcloudio/uni-app'; |
| 63 | 69 | import { useUserStore } from '@/pinia/user'; |
| ... | ... | @@ -82,6 +88,8 @@ interface MenuItem { |
| 82 | 88 | children: MenuItem[]; |
| 83 | 89 | } |
| 84 | 90 | |
| 91 | +// 新增:加载状态(默认开启) | |
| 92 | +const loading = ref(true); | |
| 85 | 93 | const userStore = useUserStore(); |
| 86 | 94 | const moduleList = ref<MenuItem[]>([]); |
| 87 | 95 | |
| ... | ... | @@ -96,15 +104,27 @@ const headClick = () => { |
| 96 | 104 | |
| 97 | 105 | onShow(async () => { |
| 98 | 106 | try { |
| 107 | + // 重置加载状态 | |
| 108 | + loading.value = true; | |
| 109 | + | |
| 99 | 110 | const rawMenuData = userStore.moduleListInfo || cache.get(globalConfig.cache.moduleListKey); |
| 100 | - // const rawMenuData = userStore.moduleListInfo as MenuItem[]; | |
| 101 | 111 | const menuData = rawMenuData || []; |
| 102 | 112 | moduleList.value = menuData; |
| 113 | + | |
| 114 | + // 关键:等待DOM完全渲染后再隐藏加载页 | |
| 103 | 115 | await nextTick(); |
| 116 | + // 额外延迟(可选,确保视觉更流畅) | |
| 117 | + setTimeout(() => { | |
| 118 | + loading.value = false; | |
| 119 | + }, 300); | |
| 120 | + | |
| 104 | 121 | console.log('菜单数据:', moduleList.value); |
| 105 | 122 | } catch (error) { |
| 106 | 123 | console.error('获取菜单数据失败:', error); |
| 107 | 124 | moduleList.value = []; |
| 125 | + // 出错时也隐藏加载页 | |
| 126 | + await nextTick(); | |
| 127 | + loading.value = false; | |
| 108 | 128 | } |
| 109 | 129 | }); |
| 110 | 130 | |
| ... | ... | @@ -179,4 +199,9 @@ const handleMenuClick = (item: MenuItem) => { |
| 179 | 199 | text-align: center; |
| 180 | 200 | margin-top: 10rpx; |
| 181 | 201 | } |
| 202 | + | |
| 203 | +/* 加载页样式优化(可选) */ | |
| 204 | +:deep(.up-loading-page) { | |
| 205 | + background-color: rgba(255, 255, 255, 0.9); | |
| 206 | +} | |
| 182 | 207 | </style> |
| 183 | 208 | \ No newline at end of file | ... | ... |
pinia/user.js
| 1 | 1 | import { defineStore } from 'pinia'; |
| 2 | 2 | import cache from '@/common/utils/cache'; |
| 3 | 3 | import globalConfig from '@/common/config/global'; |
| 4 | -import { login, getUserInfo, logout, moduleList } from '@/api/user'; | |
| 4 | +import { login, getUserInfo, logout, moduleList, getSimpleDictDataList } from '@/api/user'; | |
| 5 | 5 | |
| 6 | 6 | export const useUserStore = defineStore('user', { |
| 7 | - // 1. 状态定义(修正 userId 类型 + 字段名) | |
| 7 | + // 修复1:删除重复的 state 定义,只保留根层级的 state | |
| 8 | 8 | state: () => ({ |
| 9 | - token: '', | |
| 10 | - userInfo: {}, | |
| 11 | - userId: '', | |
| 12 | - moduleListInfo: '', | |
| 13 | - expireTime: 0, | |
| 14 | - logoutLoading: false // 防重锁移到这里 | |
| 9 | + token: cache.get(globalConfig.cache.tokenKey) || '', // 初始值从缓存取(兼容持久化) | |
| 10 | + userInfo: cache.get(globalConfig.cache.userInfoKey) || {}, | |
| 11 | + userId: cache.get(globalConfig.cache.userIdKey) || '', | |
| 12 | + moduleListInfo: cache.get(globalConfig.cache.moduleListKey) || '', | |
| 13 | + expireTime: cache.get(globalConfig.cache.expireTimeKey) || 0, | |
| 14 | + dictData: cache.get(globalConfig.cache.dictDataKey) || {}, | |
| 15 | + logoutLoading: false | |
| 15 | 16 | }), |
| 16 | 17 | |
| 17 | - // 2. 计算属性(保持不变) | |
| 18 | 18 | getters: { |
| 19 | 19 | isLogin: (state) => { |
| 20 | 20 | if (!state.token) return false; |
| ... | ... | @@ -26,7 +26,6 @@ export const useUserStore = defineStore('user', { |
| 26 | 26 | permissions: (state) => state.userInfo.permissions || [] |
| 27 | 27 | }, |
| 28 | 28 | |
| 29 | - // 3. 核心动作(修正语法错误 + 登录后自动调用户信息接口) | |
| 30 | 29 | actions: { |
| 31 | 30 | async login(params) { |
| 32 | 31 | try { |
| ... | ... | @@ -37,36 +36,40 @@ export const useUserStore = defineStore('user', { |
| 37 | 36 | throw new Error('登录失败,未获取到令牌'); |
| 38 | 37 | } |
| 39 | 38 | |
| 40 | - // 仅更新 Pinia state(持久化插件会自动同步到 storage) | |
| 39 | + // 更新 Pinia state | |
| 41 | 40 | this.token = accessToken; |
| 42 | 41 | this.expireTime = expiresTime; |
| 43 | 42 | this.userId = userId; |
| 44 | 43 | this.userInfo = {}; |
| 45 | 44 | |
| 46 | - // 移除手动 cache.set() 代码 | |
| 47 | - // cache.set(globalConfig.cache.tokenKey, accessToken); | |
| 48 | - // cache.set(globalConfig.cache.expireTimeKey, expiresTime); | |
| 49 | - // cache.set(globalConfig.cache.userIdKey, userId); | |
| 50 | - | |
| 51 | - // 等待 Pinia 持久化同步(可选,50ms 足够) | |
| 45 | + // 等待 Pinia 持久化同步 | |
| 52 | 46 | await new Promise(resolve => setTimeout(resolve, 50)); |
| 53 | 47 | |
| 54 | 48 | // 获取用户信息 |
| 55 | 49 | const userInfo = await this.getUserInfo(); |
| 56 | 50 | this.userInfo = userInfo; |
| 57 | - // 移除 cache.set(globalConfig.cache.userInfoKey, userInfo); | |
| 58 | 51 | |
| 59 | 52 | // 获取模块列表 |
| 60 | 53 | let moduleListInfo = null; |
| 61 | 54 | try { |
| 62 | 55 | moduleListInfo = await this.getModuleList(); |
| 63 | 56 | this.moduleListInfo = moduleListInfo; |
| 64 | - // 移除 cache.set(globalConfig.cache.moduleListKey, moduleListInfo); | |
| 65 | 57 | } catch (moduleErr) { |
| 66 | 58 | console.warn('获取模块列表失败(不影响登录):', moduleErr); |
| 67 | 59 | uni.showToast({ title: '获取模块列表失败,可正常登录', icon: 'none' }); |
| 68 | 60 | } |
| 69 | 61 | |
| 62 | + // 修复2:调用字典接口时,不直接抛错(避免阻断登录) | |
| 63 | + try { | |
| 64 | + const dictRes = await this.getAndSaveDictData(); | |
| 65 | + // 仅接口返回成功时才更新字典 | |
| 66 | + this.dictData = dictRes || {}; | |
| 67 | + console.log('字典数据获取成功:', this.dictData); | |
| 68 | + } catch (dictErr) { | |
| 69 | + console.warn('获取字典失败(不影响登录):', dictErr); | |
| 70 | + uni.showToast({ title: '获取字典失败,可正常使用', icon: 'none' }); | |
| 71 | + } | |
| 72 | + | |
| 70 | 73 | return { ...res, userInfo, moduleListInfo }; |
| 71 | 74 | } catch (err) { |
| 72 | 75 | console.error('登录流程失败:', err); |
| ... | ... | @@ -74,23 +77,46 @@ export const useUserStore = defineStore('user', { |
| 74 | 77 | } |
| 75 | 78 | }, |
| 76 | 79 | |
| 80 | + // 修复3:重构字典获取方法(加 Token 校验 + 强制携带 Token + 宽松错误处理) | |
| 81 | + async getAndSaveDictData() { | |
| 82 | + // 前置校验:无登录态直接返回,不请求接口 | |
| 83 | + if (!this.isLogin) { | |
| 84 | + console.warn('未登录,跳过字典获取'); | |
| 85 | + return { code: -1, msg: '未登录' }; | |
| 86 | + } | |
| 87 | + | |
| 88 | + try { | |
| 89 | + // 强制携带 Token(和 getModuleList 保持一致,避免拦截器同步延迟) | |
| 90 | + const res = await getSimpleDictDataList( | |
| 91 | + {}, // 接口入参(按需传,比如 dictType: ['level']) | |
| 92 | + { header: { 'Authorization': `Bearer ${this.token}` } } | |
| 93 | + ); | |
| 94 | + | |
| 95 | + // 校验接口返回码(核心:避免非 0 码数据存入) | |
| 96 | + if (res.code !== 0) { | |
| 97 | + console.warn('字典接口返回失败:', res.msg); | |
| 98 | + return res; // 返回错误信息,但不抛错 | |
| 99 | + } | |
| 100 | + return res; | |
| 101 | + } catch (err) { | |
| 102 | + // 修复4:宽松错误处理,只打印日志,不抛错(避免阻断登录) | |
| 103 | + console.error('字典接口请求异常:', err); | |
| 104 | + return { code: -2, msg: '接口请求异常:' + (err.message || '网络错误') }; | |
| 105 | + } | |
| 106 | + }, | |
| 107 | + | |
| 77 | 108 | async getModuleList() { |
| 78 | 109 | try { |
| 79 | - // 前置校验:无 Token 直接抛错,避免无效请求 | |
| 80 | 110 | if (!this.token) { |
| 81 | 111 | throw new Error('未获取到登录令牌,无法获取模块列表'); |
| 82 | 112 | } |
| 83 | 113 | |
| 84 | - // 强制携带 Token(覆盖请求工具的自动携带,避免缓存同步延迟) | |
| 85 | 114 | const res = await moduleList({}, { |
| 86 | - header: { | |
| 87 | - 'Authorization': `Bearer ${this.token}` | |
| 88 | - } | |
| 115 | + header: { 'Authorization': `Bearer ${this.token}` } | |
| 89 | 116 | }); |
| 90 | 117 | return res; |
| 91 | 118 | } catch (err) { |
| 92 | 119 | console.error('获取用户菜单失败:', err); |
| 93 | - // 区分 401 错误:仅登录态失效时抛错,避免触发 logout 循环 | |
| 94 | 120 | if (err?.data?.code === 401 || err?.message.includes('401')) { |
| 95 | 121 | throw new Error('登录态已过期,请重新登录'); |
| 96 | 122 | } else { |
| ... | ... | @@ -101,7 +127,6 @@ export const useUserStore = defineStore('user', { |
| 101 | 127 | |
| 102 | 128 | async getUserInfo() { |
| 103 | 129 | try { |
| 104 | - // 调用用户信息接口(此时 token 已存入缓存,请求工具会自动携带) | |
| 105 | 130 | const res = await getUserInfo(); |
| 106 | 131 | return res; |
| 107 | 132 | } catch (err) { |
| ... | ... | @@ -109,6 +134,7 @@ export const useUserStore = defineStore('user', { |
| 109 | 134 | throw new Error('获取用户信息失败,请重新登录'); |
| 110 | 135 | } |
| 111 | 136 | }, |
| 137 | + | |
| 112 | 138 | logout() { |
| 113 | 139 | const pages = getCurrentPages(); |
| 114 | 140 | if (pages.length === 0) return; |
| ... | ... | @@ -119,16 +145,12 @@ export const useUserStore = defineStore('user', { |
| 119 | 145 | .split('?')[0]; |
| 120 | 146 | |
| 121 | 147 | if (currentPageRoute === loginPath) { |
| 122 | - // 仅清空 Pinia state(持久化插件自动同步到 storage) | |
| 123 | 148 | this.token = ''; |
| 124 | 149 | this.userInfo = {}; |
| 125 | 150 | this.userId = ''; |
| 126 | 151 | this.moduleListInfo = ''; |
| 127 | 152 | this.expireTime = 0; |
| 128 | - | |
| 129 | - // 移除手动 cache.remove() 代码 | |
| 130 | - // cache.remove(globalConfig.cache.tokenKey); | |
| 131 | - // ... 其他 cache.remove 都删除 | |
| 153 | + this.dictData = {}; | |
| 132 | 154 | return; |
| 133 | 155 | } |
| 134 | 156 | |
| ... | ... | @@ -141,16 +163,14 @@ export const useUserStore = defineStore('user', { |
| 141 | 163 | } catch (err) { |
| 142 | 164 | console.error('退出登录接口调用失败:', err); |
| 143 | 165 | } finally { |
| 144 | - // 清空 state | |
| 145 | 166 | this.token = ''; |
| 146 | 167 | this.userInfo = {}; |
| 147 | 168 | this.userId = ''; |
| 148 | 169 | this.moduleListInfo = ''; |
| 170 | + this.dictData = {}; | |
| 149 | 171 | this.expireTime = 0; |
| 150 | 172 | this.logoutLoading = false; |
| 151 | 173 | |
| 152 | - // 移除手动 cache.remove() 代码 | |
| 153 | - | |
| 154 | 174 | uni.redirectTo({ |
| 155 | 175 | url: globalConfig.router.loginPath |
| 156 | 176 | }); |
| ... | ... | @@ -160,19 +180,8 @@ export const useUserStore = defineStore('user', { |
| 160 | 180 | logoutWithLock(); |
| 161 | 181 | }, |
| 162 | 182 | |
| 163 | -// 新增:状态中添加 logoutLoading 防重锁 | |
| 164 | - state: () => ({ | |
| 165 | - token: cache.get(globalConfig.cache.tokenKey) || '', | |
| 166 | - userInfo: cache.get(globalConfig.cache.userInfoKey) || {}, | |
| 167 | - userId: cache.get(globalConfig.cache.userIdKey) || '', | |
| 168 | - moduleListInfo: cache.get(globalConfig.cache.moduleListKey) || '', | |
| 169 | - expireTime: cache.get(globalConfig.cache.expireTimeKey) || 0, | |
| 170 | - logoutLoading: false // 新增:退出登录防重锁 | |
| 171 | - }), | |
| 172 | - | |
| 173 | 183 | checkLogin() { |
| 174 | 184 | if (!this.isLogin) { |
| 175 | - // 先判断是否已在登录页,避免重复跳转 | |
| 176 | 185 | const pages = getCurrentPages(); |
| 177 | 186 | if (pages.length === 0) return false; |
| 178 | 187 | |
| ... | ... | @@ -192,38 +201,36 @@ export const useUserStore = defineStore('user', { |
| 192 | 201 | } |
| 193 | 202 | }, |
| 194 | 203 | |
| 195 | - // 4. 持久化配置(修正:persist 应放在 store 根层级,非 actions 内) | |
| 196 | 204 | persist: { |
| 197 | 205 | enabled: true, |
| 198 | - key: 'user_store', // 自定义存储键名(默认是 store 名 'user') | |
| 206 | + key: 'user_store', | |
| 199 | 207 | storage: { |
| 200 | 208 | getItem: (key) => uni.getStorageSync(key), |
| 201 | 209 | setItem: (key, value) => uni.setStorageSync(key, value), |
| 202 | 210 | removeItem: (key) => uni.removeStorageSync(key) |
| 203 | 211 | }, |
| 204 | - // 自定义序列化:将 state 拆分为原有的 jcss_xxx 键(可选) | |
| 205 | 212 | serializer: { |
| 206 | 213 | serialize: (state) => { |
| 207 | - // 拆分为多个独立键(和原 cache 格式对齐) | |
| 208 | 214 | uni.setStorageSync(globalConfig.cache.tokenKey, state.token); |
| 209 | 215 | uni.setStorageSync(globalConfig.cache.userIdKey, state.userId); |
| 210 | 216 | uni.setStorageSync(globalConfig.cache.expireTimeKey, state.expireTime); |
| 211 | 217 | uni.setStorageSync(globalConfig.cache.userInfoKey, state.userInfo); |
| 212 | 218 | uni.setStorageSync(globalConfig.cache.moduleListKey, state.moduleListInfo); |
| 213 | - return state; // 返回完整 state(兼容 Pinia 默认逻辑) | |
| 219 | + uni.setStorageSync(globalConfig.cache.dictDataKey, state.dictData); | |
| 220 | + return state; | |
| 214 | 221 | }, |
| 215 | 222 | deserialize: (value) => { |
| 216 | - // 从多个独立键恢复 state | |
| 217 | 223 | return { |
| 218 | 224 | token: uni.getStorageSync(globalConfig.cache.tokenKey) || '', |
| 219 | 225 | userId: uni.getStorageSync(globalConfig.cache.userIdKey) || '', |
| 220 | 226 | expireTime: uni.getStorageSync(globalConfig.cache.expireTimeKey) || 0, |
| 221 | 227 | userInfo: uni.getStorageSync(globalConfig.cache.userInfoKey) || {}, |
| 222 | 228 | moduleListInfo: uni.getStorageSync(globalConfig.cache.moduleListKey) || '', |
| 229 | + dictData: uni.getStorageSync(globalConfig.cache.dictDataKey) || {}, | |
| 223 | 230 | logoutLoading: false |
| 224 | 231 | }; |
| 225 | 232 | } |
| 226 | 233 | }, |
| 227 | - paths: [] // 序列化自定义后,paths 可留空 | |
| 234 | + paths: [] | |
| 228 | 235 | } |
| 229 | 236 | }); |
| 230 | 237 | \ No newline at end of file | ... | ... |