提交代码

This commit is contained in:
zlwu committed 2025-10-24 22:20:29 +08:00
1 parent aacdbdad81
commit a0e5c9a024
11 files changed
+6086 -26

No files matched your search

+1
View File
@@ -73,6 +73,7 @@
"@vue/shared": "^3.0.0",
"core-js": "^3.8.3",
"crypto-js": "^4.2.0",
"dayjs": "^1.11.18",
"flyio": "^0.6.2",
"jsencrypt": "^3.3.2",
"uview-ui": "2.0.38",
+35
View File
@@ -0,0 +1,35 @@
// 字典数据API - 重新导出common/dict.js中的方法以保持兼容性
import {
getDicts,
getDict,
useDict,
getDictLabel,
getDictValue,
hasDictValue,
refreshDict,
getDictTagType,
} from '@/common/dict.js';
// 重新导出所有字典相关方法
export {
getDicts,
getDict,
useDict,
getDictLabel,
getDictValue,
hasDictValue,
refreshDict,
getDictTagType,
};
// 默认导出
export default {
getDicts,
getDict,
useDict,
getDictLabel,
getDictValue,
hasDictValue,
refreshDict,
getDictTagType,
};
+413
View File
@@ -0,0 +1,413 @@
# 移动端API调用标准规范
> 📌 **重要提示**: 移动端与Web端的API调用方式完全不同,请严格遵守本规范!
## 🚫 常见错误
### ❌ 不要这样做(Web端方式)
```javascript
// 错误1:使用 this.$http
const res = await this.$http.get('/api/path');
const res = await this.$http.post('/api/path', data);
const res = await this.$http.upload('/api/upload', options);
// 错误2:使用 getDicts
import { getDicts } from '@/api/system/dict/data.js';
const res = await getDicts('dict_type');
this.dictData = res.data; // Web端返回格式
// 错误3:使用 this.$u.toast
this.$u.toast('提示信息');
```
## ✅ 正确做法(移动端方式)
### 1. HTTP请求
```javascript
// 导入
import { request } from '@/common/request.js';
// GET请求(无参数)
const res = await request({
url: '/api/path',
method: 'GET',
});
// GET请求(带参数)
const res = await request({
url: '/api/path',
method: 'GET',
params: {
key1: 'value1',
key2: 'value2'
},
});
// POST请求
const res = await request({
url: '/api/path',
method: 'POST',
data: {
key1: 'value1',
key2: 'value2'
},
});
// PUT请求
const res = await request({
url: '/api/path',
method: 'PUT',
data: { ... },
});
// DELETE请求
const res = await request({
url: '/api/path',
method: 'DELETE',
});
```
### 2. 字典数据获取
```javascript
// 导入
import { getDict, getDictLabel, getDictTagType } from '@/common/dict.js';
// 获取字典数据(直接返回数组)
const dictData = await getDict('dict_type'); // 返回: [{label, value, ...}, ...]
// 批量获取字典
const [dict1, dict2] = await Promise.all([
getDict('dict_type_1'),
getDict('dict_type_2'),
]);
// 获取字典标签
const label = getDictLabel(dictData, '1'); // 返回: '标签名'
// 获取字典Tag类型(用于u-tag组件)
const tagType = getDictTagType(dictData, '1'); // 返回: 'success', 'warning', 'error' 等
```
### 3. 字典展示(u-tag)
```vue
<template>
<!-- 基础用法 -->
<u-tag :type="getDictTagType(xg_dict_type, value)">
{{ getDictLabel(xg_dict_type, value) }}
</u-tag>
<!-- 带降级处理 -->
<u-tag :type="getDictTagType(xg_dict_type, value, tagTypeFallback)">
{{ getDictLabel(xg_dict_type, value) }}
</u-tag>
<!-- 选择器显示 -->
<u-picker
:show="showPicker"
:columns="dictColumns"
keyName="text"
@confirm="handleConfirm"
@cancel="showPicker = false"
></u-picker>
</template>
<script>
import { getDict, getDictLabel, getDictTagType } from '@/common/dict.js';
export default {
data() {
return {
xg_dict_type: [],
value: '1',
showPicker: false,
// 降级映射(当字典未加载时使用)
tagTypeFallback: {
'1': 'success',
'2': 'warning',
'3': 'error',
},
};
},
computed: {
// 转换为选择器格式
dictColumns() {
return [
this.xg_dict_type.map(item => ({
text: item.label,
value: item.value,
}))
];
},
},
async mounted() {
// 加载字典
this.xg_dict_type = await getDict('xg_dict_type');
},
methods: {
getDictLabel,
getDictTagType,
handleConfirm(e) {
if (e && e.value && e.value.length > 0) {
this.value = e.value[0].value;
}
this.showPicker = false;
},
},
};
</script>
```
### 4. 文件上传
```javascript
// 导入
import { uploadFile } from '@/utils/upload.js';
// 选择并上传图片
uni.chooseImage({
count: 1,
success: async (res) => {
const filePath = res.tempFilePaths[0];
// 上传文件
const result = await uploadFile(filePath, (progress) => {
console.log('上传进度:', progress);
// 更新进度条
this.uploadProgress = progress;
});
if (result && result.code === 200 && result.data) {
// 上传成功
this.ossId = result.data.ossId;
this.fileUrl = result.data.url;
}
}
});
```
### 5. Toast提示
```javascript
// 普通提示
uni.showToast({
title: '提示信息',
icon: 'none'
});
// 成功提示
uni.showToast({
title: '操作成功',
icon: 'success'
});
// 失败提示
uni.showToast({
title: '操作失败',
icon: 'error'
});
// 加载中
uni.showLoading({
title: '加载中...',
mask: true
});
// 关闭加载
uni.hideLoading();
```
## 📊 完整示例
```vue
<template>
<view class="page-container">
<!-- 字典展示 -->
<u-tag :type="getDictTagType(xg_status_dict, formData.status)">
{{ getDictLabel(xg_status_dict, formData.status) }}
</u-tag>
<!-- 字典选择器 -->
<u-form-item label="状态" prop="status">
<view @click="showStatusPicker = true">
<u-input
:value="getDictLabel(xg_status_dict, formData.status)"
placeholder="请选择状态"
readonly
suffix-icon="arrow-down"
/>
</view>
<u-picker
:show="showStatusPicker"
:columns="statusColumns"
keyName="text"
@confirm="handleStatusConfirm"
@cancel="showStatusPicker = false"
></u-picker>
</u-form-item>
<!-- 提交按钮 -->
<u-button type="primary" @click="handleSubmit">提交</u-button>
</view>
</template>
<script>
import { request } from '@/common/request.js';
import { getDict, getDictLabel, getDictTagType } from '@/common/dict.js';
import { uploadFile } from '@/utils/upload.js';
export default {
data() {
return {
formData: {
title: '',
status: '1',
},
xg_status_dict: [],
showStatusPicker: false,
};
},
computed: {
statusColumns() {
return [
this.xg_status_dict.map(item => ({
text: item.label,
value: item.value,
}))
];
},
},
async mounted() {
await this.init();
},
methods: {
getDictLabel,
getDictTagType,
async init() {
// 加载字典
this.xg_status_dict = await getDict('xg_status');
// 加载数据
await this.loadData();
},
async loadData() {
try {
const res = await request({
url: '/api/getData',
method: 'GET',
params: { id: this.id },
});
if (res.code === 200) {
this.formData = res.data;
}
} catch (error) {
console.error('加载数据失败:', error);
uni.showToast({
title: '加载失败',
icon: 'none'
});
}
},
handleStatusConfirm(e) {
if (e && e.value && e.value.length > 0) {
this.formData.status = e.value[0].value;
}
this.showStatusPicker = false;
},
async handleSubmit() {
try {
uni.showLoading({ title: '提交中...', mask: true });
const res = await request({
url: '/api/submit',
method: 'POST',
data: this.formData,
});
uni.hideLoading();
if (res.code === 200) {
uni.showToast({
title: '提交成功',
icon: 'success'
});
setTimeout(() => {
uni.navigateBack();
}, 1500);
} else {
uni.showToast({
title: res.msg || '提交失败',
icon: 'none'
});
}
} catch (error) {
uni.hideLoading();
console.error('提交失败:', error);
uni.showToast({
title: '提交失败',
icon: 'error'
});
}
},
},
};
</script>
```
## 🔑 核心差异对照表
| 功能 | Web端 | 移动端 |
|------|-------|--------|
| **HTTP请求** | `this.$http.get('/api')` | `request({ url: '/api', method: 'GET' })` |
| **字典获取** | `getDicts('type')` 返回 `{data:[]}` | `getDict('type')` 返回 `[...]` |
| **字典标签** | `selectDictLabel(dict, value)` | `getDictLabel(dict, value)` |
| **Tag类型** | 手动映射 | `getDictTagType(dict, value)` |
| **文件上传** | `this.$http.upload()` | `uploadFile(path, callback)` |
| **Toast提示** | `this.$u.toast('msg')` | `uni.showToast({ title: 'msg' })` |
| **加载提示** | `this.$u.loading.show()` | `uni.showLoading({ title: '...' })` |
## 📝 注意事项
1. **导入路径固定**:
- HTTP请求: `@/common/request.js`
- 字典方法: `@/common/dict.js`
- 文件上传: `@/utils/upload.js`
2. **字典数据格式**:
- 移动端 `getDict()` 直接返回数组,不需要 `.data`
- 字典项格式: `{ label, value, elTagType, elTagClass, remark }`
3. **Tag类型自动转换**:
- `danger` → `error`
- `default` → `primary`
4. **文件上传进度**:
- 进度回调返回 0-100 的数字
- Web端可能是 `progress.progress`,移动端直接是数字
5. **错误处理**:
- 统一使用 `try-catch`
- 错误时使用 `uni.showToast` 提示
## 🔗 相关文档
- `mobile/src/common/request.js` - HTTP请求源码
- `mobile/src/common/dict.js` - 字典方法源码
- `mobile/src/utils/upload.js` - 文件上传源码
- `.cursor/cursorrules-mobile.md` - 移动端开发规范
---
**最后更新**: 2025-01-24
**适用范围**: 移动端工程(Vue 2.6.14 + uni-app 2.x)
+3 -1
View File
@@ -12,6 +12,7 @@ export const ENCRYPT_CONFIG = {
TENANT_ID: '000000',
// 加密请求头
ENCRYPT_HEADER: 'encrypt-key',
//TODO 开发环境需要修改为false
// 是否启用加密
ENCRYPT_ENABLED: true,
};
@@ -36,8 +37,9 @@ export const REQUEST_CONFIG = {
? 'http://localhost/login?redirect=/index'
: 'https://hzdyjs.camasort.com/login?redirect=/index', // 生产环境前端域名
//TODO 生产环境需要修改为true
// 是否启用加密
encryptEnabled: true,
encryptEnabled: false,
// 防重复提交间隔时间(ms)
repeatSubmitInterval: 500,
};
+31 -24
View File
@@ -200,6 +200,13 @@
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/xgss/wsjc/wsjcMainAdd",
"style": {
"navigationBarTitleText": "宿舍卫生检查",
"navigationStyle": "custom"
}
},
{
"path": "pages/xgss/xssswsjc/xssswsjc",
"style": {
@@ -303,30 +310,30 @@
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/xljk/reservation/index",
"style": {
"navigationBarTitleText": "心理咨询预约",
"navigationBarBackgroundColor": "#0051d9",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/xljk/my-appointments/index",
"style": {
"navigationBarTitleText": "我的预约",
"navigationBarBackgroundColor": "#0051d9",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/xljk/reservation/appointment-form",
"style": {
"navigationBarTitleText": "预约咨询",
"navigationBarBackgroundColor": "#0051d9",
"navigationBarTextStyle": "white"
}
}
{
"path": "pages/xljk/reservation/index",
"style": {
"navigationBarTitleText": "心理咨询预约",
"navigationBarBackgroundColor": "#0051d9",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/xljk/my-appointments/index",
"style": {
"navigationBarTitleText": "我的预约",
"navigationBarBackgroundColor": "#0051d9",
"navigationBarTextStyle": "white"
}
},
{
"path": "pages/xljk/reservation/appointment-form",
"style": {
"navigationBarTitleText": "预约咨询",
"navigationBarBackgroundColor": "#0051d9",
"navigationBarTextStyle": "white"
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
@@ -0,0 +1,210 @@
# 宿舍卫生检查组件说明
## 组件列表
### 1. SingleRoomCheck.vue - 单宿舍检查组件
**功能说明:**
- 移动端单个宿舍的卫生检查表单组件
- 与web端 `DormHygieneCheckSingleDialog.vue` 功能完全一致
- 用于嵌入在弹窗中进行单个宿舍的详细检查
**主要功能模块:**
#### 1.1 宿舍整体情况
- 违纪类型选择(从API加载)
- 宿舍扣分输入(0-100分)
- 卫生等级选择(字典:`xg_dorm_hygiene_grade`)
- 检查附件上传(支持图片,最多15个)
#### 1.2 宿舍学生情况
- 学生基本信息展示(姓名、学号、性别、床位、班级、专业)
- 学生评分项:
- 个人扣分(0-100分)
- 出勤状态(字典:`xg_student_attendance_status`)
- 学生评级(字典:`xg_dorm_hygiene_grade`)
- 请假信息展示(如果有请假记录)
- 违纪记录录入与管理
**Props:**
```javascript
{
roomInfo: Object, // 房间信息(必填)
checkInfo: Object, // 检查基础信息(必填)
existingData: Object // 编辑时的现有数据(可选)
}
```
**Events:**
```javascript
{
submit: (data) => {}, // 保存检查结果
cancel: () => {} // 取消操作
}
```
**API接口:**
- `GET /disciplineType/disciplineType/listByAreaType` - 加载违纪类型
- `GET /dormBedStudent/dormBedStudent/getRoomStudents` - 加载房间学生信息
- `GET /studentLeave/studentLeave/queryByCheckDate` - 查询学生请假信息
- `POST /dormHygieneCheck/dormHygieneCheck/saveCompleteData` - 保存完整检查数据
- `POST /resource/oss/upload` - 上传附件
**字典类型:**
- `xg_dorm_hygiene_grade` - 卫生等级
- `xg_student_attendance_status` - 出勤状态
---
### 2. ViolationRecordDialog.vue - 违纪记录弹窗
**功能说明:**
- 学生违纪记录的录入和编辑弹窗
- 支持新增和编辑两种模式
**主要字段:**
- 违纪类型(从API加载)
- 扣分分数(自动填入最低扣分)
- 违纪日期
- 违纪地点
- 违纪描述
- 处理意见
**Props:**
```javascript
{
visible: Boolean, // 弹窗显示状态
studentInfo: Object, // 学生信息
violationData: Object // 违纪数据(编辑模式)
}
```
**Events:**
```javascript
{
confirm: (data) => {}, // 确认保存
close: () => {} // 关闭弹窗
}
```
**API接口:**
- `GET /disciplineType/disciplineType/listByAreaType` - 加载违纪类型
---
## 使用示例
### 在 wsjcMainAdd.vue 中使用 SingleRoomCheck
```vue
<template>
<u-popup
:show="showRoomCheckPopup"
mode="bottom"
:round="20"
closeable
:safe-area-inset-bottom="true"
@close="handlePopupClose"
>
<view class="popup-container">
<view class="popup-header">
<text class="popup-title">
{{ currentRoom ? currentRoom.roomName : '' }} - 卫生检查
</text>
</view>
<scroll-view scroll-y class="popup-scroll" :style="{ height: popupHeight + 'px' }">
<SingleRoomCheck
v-if="showRoomCheckPopup && currentRoom"
:room-info="currentRoom"
:check-info="formData"
:existing-data="currentEditData"
@submit="handleSingleCheckSubmit"
@cancel="handlePopupClose"
/>
</scroll-view>
</view>
</u-popup>
</template>
<script>
import SingleRoomCheck from './components/SingleRoomCheck.vue';
export default {
components: {
SingleRoomCheck,
},
// ...
};
</script>
```
---
## 开发规范
### 1. API调用
- 统一使用 `mobile/src/common/request.js` 中的 `request` 方法
- 附件上传使用 `smartUploadFile` 方法(自动适配微信环境)
### 2. 字典数据
- 使用 `mobile/src/common/dict.js` 中的方法
- `getDict(dictType)` - 获取字典数据
- `getDictLabel(dictData, value)` - 获取字典标签
- `getDictTagType(dictData, value)` - 获取u-tag类型
### 3. 样式规范
- 使用uview-ui 2.x组件
- 字典数据用 `<u-tag>` 展示
- 遵循移动端UI设计规范
### 4. 数据提交
- 与web端API接口完全一致
- 参数名和数据结构保持一致
- 返回数据格式相同
---
## 技术栈
- Vue 2.6.14
- uni-app 2.x
- uview-ui 2.0.38
- dayjs(日期处理)
---
## 注意事项
1. **附件上传兼容性**
- 移动端附件功能兼容微信小程序
- 参考 `wsjcAdd.vue` 的附件实现
- 使用 `smartUploadFile` 方法自动适配环境
2. **字典数据加载**
- 组件初始化时自动加载字典数据
- 使用缓存机制,避免重复请求
3. **请假信息自动填充**
- 加载学生信息时自动查询请假记录
- 有请假记录的学生自动设置出勤状态为"请假"
4. **违纪类型自动扣分**
- 选择违纪类型后自动填入最低扣分
- 用户可手动修改
5. **数据验证**
- 扣分范围:0-100分
- 必填字段验证
- 表单提交前统一验证
---
## 更新日志
**2025-01-24**
- 创建 SingleRoomCheck.vue 组件
- 创建 ViolationRecordDialog.vue 组件
- 实现与web端完全一致的功能
- 适配移动端UI和交互方式
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -450,7 +450,7 @@ export default {
// 新增检查
handleAdd() {
uni.navigateTo({
url: '/pages/xgss/wsjc/wsjcAdd?type=add',
url: '/pages/xgss/wsjc/wsjcMainAdd',
});
},
+971
View File
@@ -0,0 +1,971 @@
<template>
<view class="page-container">
<!-- 顶部导航 -->
<u-navbar
title="宿舍卫生检查"
:border-bottom="false"
bg-color="#0051d9"
:titleStyle="{ color: '#fff' }"
>
<view slot="left" @click="goBack">
<u-icon name="arrow-left" color="#fff" size="22"></u-icon>
</view>
</u-navbar>
<!-- 加载状态 -->
<u-loading-page :loading="pageLoading" loading-text="加载中..."></u-loading-page>
<!-- 表单内容 -->
<view v-if="!pageLoading" class="content-container">
<!-- 基础信息卡片 -->
<view class="info-card">
<view class="card-title">检查基础信息</view>
<u-form ref="formRef" :model="formData" :rules="rules" label-width="100">
<!-- 检查标题 -->
<u-form-item label="检查标题" prop="checkTitle" required>
<u-input v-model="formData.checkTitle" placeholder="请输入检查标题" />
</u-form-item>
<!-- 检查日期 -->
<u-form-item label="检查日期" prop="checkDate" required>
<view @click="showDatePicker = true" style="width: 100%">
<u-input
v-model="checkDateDisplay"
placeholder="请选择检查日期"
readonly
border="none"
suffix-icon="arrow-down"
suffix-icon-style="color: #c8c9cc;"
style="pointer-events: none"
/>
</view>
<u-datetime-picker
:show="showDatePicker"
v-model="checkDateValue"
mode="datetime"
@confirm="handleDateConfirm"
@cancel="showDatePicker = false"
></u-datetime-picker>
</u-form-item>
<!-- 检查员 -->
<u-form-item label="检查员" prop="checkerName" required>
<u-input v-model="formData.checkerName" placeholder="检查员姓名" readonly />
</u-form-item>
<!-- 检查类型 -->
<u-form-item label="检查类型" prop="checkType" required>
<view @click="showCheckTypePicker = true" style="width: 100%">
<u-input
v-model="checkTypeDisplay"
placeholder="请选择检查类型"
readonly
border="none"
suffix-icon="arrow-down"
suffix-icon-style="color: #c8c9cc;"
style="pointer-events: none"
/>
</view>
<u-picker
:show="showCheckTypePicker && xg_dorm_hygiene_check_type.length > 0"
:columns="checkTypeColumns"
:defaultIndex="checkTypeDefaultIndex"
keyName="text"
@confirm="handleCheckTypeConfirm"
@cancel="showCheckTypePicker = false"
></u-picker>
</u-form-item>
<!-- 检查级别 -->
<u-form-item label="检查级别" prop="checkLevel" required>
<view @click="showCheckLevelPicker = true" style="width: 100%">
<u-input
v-model="checkLevelDisplay"
placeholder="请选择检查级别"
readonly
border="none"
suffix-icon="arrow-down"
suffix-icon-style="color: #c8c9cc;"
style="pointer-events: none"
/>
</view>
<u-picker
:show="showCheckLevelPicker && xg_dorm_hygiene_check_level.length > 0"
:columns="checkLevelColumns"
:defaultIndex="checkLevelDefaultIndex"
keyName="text"
@confirm="handleCheckLevelConfirm"
@cancel="showCheckLevelPicker = false"
></u-picker>
</u-form-item>
<!-- 校区 -->
<u-form-item label="校区" prop="campusId" required>
<view @click="showCampusPicker = true" style="width: 100%">
<u-input
v-model="formData.campusName"
placeholder="请选择校区"
readonly
border="none"
suffix-icon="arrow-down"
suffix-icon-style="color: #c8c9cc;"
style="pointer-events: none"
/>
</view>
<u-picker
:show="showCampusPicker"
:columns="campusColumns"
keyName="text"
@confirm="handleCampusConfirm"
@cancel="showCampusPicker = false"
></u-picker>
</u-form-item>
<!-- 宿舍楼 -->
<u-form-item label="宿舍楼" prop="buildingId" required>
<view @click="handleBuildingClick" style="width: 100%">
<u-input
v-model="formData.buildingName"
placeholder="请选择宿舍楼"
readonly
border="none"
suffix-icon="arrow-down"
suffix-icon-style="color: #c8c9cc;"
:disabled="!formData.campusId"
style="pointer-events: none"
/>
</view>
<u-picker
:show="showBuildingPicker"
:columns="buildingColumns"
keyName="text"
@confirm="handleBuildingConfirm"
@cancel="showBuildingPicker = false"
></u-picker>
</u-form-item>
</u-form>
</view>
<!-- 房间列表区域 -->
<view v-if="formData.buildingId && floorRooms.length > 0" class="rooms-section">
<view class="rooms-header">
<text class="rooms-title">宿舍房间列表</text>
<view class="rooms-stats">
<text>总房间: {{ totalRoomsCount }}</text>
<text style="margin-left: 20rpx">已检查: {{ completedRoomsCount }}</text>
</view>
</view>
<view class="hint-text">
<u-icon name="info-circle" color="#ff9900" size="16"></u-icon>
<text style="margin-left: 10rpx; color: #ff9900">点击房间卡片开始检查</text>
</view>
<!-- 楼层和房间展示 -->
<view class="floors-container">
<view
v-for="floorData in floorRooms"
:key="floorData.floor"
class="floor-section"
>
<view class="floor-header">
<view class="floor-name">{{ floorData.floor }}楼</view>
<view class="floor-stats">
已检查: {{ getFloorCompletedCount(floorData.floor) }} /
{{ floorData.rooms.length }}
</view>
</view>
<view class="rooms-grid">
<view
v-for="room in floorData.rooms"
:key="room.id"
class="room-card"
:class="{ completed: completedRooms.has(room.id) }"
@click="handleRoomClick(room)"
>
<view class="room-card-content">
<view class="room-name">{{ room.roomName }}</view>
<view class="room-code">
{{ room.roomCode || room.roomName }}
</view>
<view class="room-status">
<u-icon
v-if="completedRooms.has(room.id)"
name="checkmark-circle-fill"
color="#19be6b"
size="18"
></u-icon>
<u-icon
v-else
name="calendar"
color="#c8c9cc"
size="18"
></u-icon>
</view>
</view>
<view class="room-hint">
<text v-if="completedRooms.has(room.id)" class="completed-text">
已检查
</text>
<text v-else class="pending-text">点击检查</text>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view
v-if="formData.buildingId && floorRooms.length === 0 && !roomsLoading"
class="empty-container"
>
<u-empty text="该宿舍楼暂无房间信息" mode="data"></u-empty>
</view>
</view>
<!-- 单个房间检查弹窗 -->
<u-popup
:show="showRoomCheckPopup"
mode="bottom"
:round="20"
closeable
:safe-area-inset-bottom="true"
@close="handlePopupClose"
>
<view class="popup-container">
<view class="popup-header">
<text class="popup-title">
{{ currentRoom ? currentRoom.roomName : '' }} - 卫生检查
</text>
</view>
<scroll-view scroll-y class="popup-scroll" :style="{ height: popupHeight + 'px' }">
<SingleRoomCheck
v-if="showRoomCheckPopup && currentRoom"
:room-info="currentRoom"
:check-info="formData"
:existing-data="currentEditData"
@submit="handleSingleCheckSubmit"
@cancel="handlePopupClose"
/>
</scroll-view>
</view>
</u-popup>
</view>
</template>
<script>
import { mapState } from 'vuex';
import { getDict, getDictLabel, getDictTagType } from '@/common/dict.js';
import { request } from '@/common/request.js';
import dayjs from 'dayjs';
import SingleRoomCheck from './components/SingleRoomCheck.vue';
export default {
name: 'WsjcMainAdd',
components: {
SingleRoomCheck,
},
data() {
return {
pageLoading: true,
roomsLoading: false,
// 表单数据
formData: {
checkTitle: '',
checkDate: '',
checkerId: '',
checkerName: '',
checkType: '1',
checkLevel: '1',
campusId: '',
campusName: '',
buildingId: '',
buildingName: '',
},
// 验证规则
rules: {
checkTitle: [{ required: true, message: '请输入检查标题', trigger: 'blur' }],
checkDate: [{ required: true, message: '请选择检查日期', trigger: 'change' }],
checkerName: [{ required: true, message: '请选择检查员', trigger: 'change' }],
checkType: [{ required: true, message: '请选择检查类型', trigger: 'change' }],
checkLevel: [{ required: true, message: '请选择检查级别', trigger: 'change' }],
campusId: [{ required: true, message: '请选择校区', trigger: 'change' }],
buildingId: [{ required: true, message: '请选择宿舍楼', trigger: 'change' }],
},
// Picker显示状态
showDatePicker: false,
showCheckTypePicker: false,
showCheckLevelPicker: false,
showCampusPicker: false,
showBuildingPicker: false,
showRoomCheckPopup: false,
// 日期时间
checkDateValue: Date.now(),
checkDateDisplay: '',
// 字典数据
xg_dorm_hygiene_check_type: [],
xg_dorm_hygiene_check_level: [],
// 选项数据
campusOptions: [],
buildingOptions: [],
// 房间相关数据
floorRooms: [],
allRooms: [],
completedRooms: new Set(),
roomCheckIds: new Map(),
// 当前操作的房间
currentRoom: null,
currentEditData: null,
// 弹窗高度
popupHeight: 600,
};
},
computed: {
...mapState(['userInfo']),
// 检查类型列
checkTypeColumns() {
return [
this.xg_dorm_hygiene_check_type.map((item) => ({
text: item.label,
value: item.value,
})),
];
},
// 检查类型默认索引
checkTypeDefaultIndex() {
const index = this.xg_dorm_hygiene_check_type.findIndex(
(item) => item.value === this.formData.checkType,
);
return [index >= 0 ? index : 0];
},
// 检查类型显示文本
checkTypeDisplay() {
const item = this.xg_dorm_hygiene_check_type.find(
(d) => d.value === this.formData.checkType,
);
return item ? item.label : '';
},
// 检查级别列
checkLevelColumns() {
return [
this.xg_dorm_hygiene_check_level.map((item) => ({
text: item.label,
value: item.value,
})),
];
},
// 检查级别默认索引
checkLevelDefaultIndex() {
const index = this.xg_dorm_hygiene_check_level.findIndex(
(item) => item.value === this.formData.checkLevel,
);
return [index >= 0 ? index : 0];
},
// 检查级别显示文本
checkLevelDisplay() {
const item = this.xg_dorm_hygiene_check_level.find(
(d) => d.value === this.formData.checkLevel,
);
return item ? item.label : '';
},
// 校区列
campusColumns() {
return [this.campusOptions.map((item) => ({ text: item.campusName, value: item.id }))];
},
// 宿舍楼列
buildingColumns() {
return [
this.buildingOptions.map((item) => ({
text: this.getBuildingDisplayName(item),
value: item.id,
})),
];
},
// 总房间数
totalRoomsCount() {
return this.allRooms.length;
},
// 已完成房间数
completedRoomsCount() {
return this.completedRooms.size;
},
},
onLoad() {
this.initPage();
},
onReady() {
// 计算弹窗高度(屏幕高度的70%)
const systemInfo = uni.getSystemInfoSync();
this.popupHeight = systemInfo.windowHeight * 0.7;
},
methods: {
// 初始化页面
async initPage() {
try {
this.pageLoading = true;
// 初始化检查标题和日期
this.generateDefaultCheckTitle();
this.formData.checkDate = dayjs().format('YYYY-MM-DD HH:mm:ss');
this.checkDateDisplay = this.formData.checkDate;
this.checkDateValue = Date.now();
// 获取当前用户信息
await this.fetchCurrentUserInfo();
// 加载字典数据
await this.loadDicts();
// 加载校区选项
await this.loadCampusOptions();
} catch (error) {
console.error('初始化页面失败:', error);
this.$u.toast('初始化页面失败');
} finally {
this.pageLoading = false;
}
},
// 生成默认检查标题
generateDefaultCheckTitle() {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
this.formData.checkTitle = `${year}${month}${day}宿舍卫生检查`;
},
// 获取当前用户信息
async fetchCurrentUserInfo() {
try {
const res = await request({
url: '/system/user/getInfo',
method: 'GET',
});
if (res.code === 200 && res.data?.user) {
const user = res.data.user;
this.formData.checkerId = user.userId;
this.formData.checkerName = user.nickName || user.userName;
}
} catch (error) {
console.warn('获取用户信息失败:', error);
}
},
// 加载字典数据
async loadDicts() {
try {
const [checkTypeData, checkLevelData] = await Promise.all([
getDict('xg_dorm_hygiene_check_type'),
getDict('xg_dorm_hygiene_check_level'),
]);
this.xg_dorm_hygiene_check_type = checkTypeData || [];
this.xg_dorm_hygiene_check_level = checkLevelData || [];
} catch (error) {
console.error('加载字典数据失败:', error);
}
},
// 加载校区选项
async loadCampusOptions() {
try {
const res = await request({
url: '/xqxx/dormCampus/list',
method: 'GET',
});
this.campusOptions = res.rows || [];
} catch (error) {
console.error('加载校区选项失败:', error);
uni.showToast({ title: '加载校区选项失败', icon: 'none' });
}
},
// 加载宿舍楼选项
async loadBuildingOptions(campusId) {
try {
const res = await request({
url: '/sslxx/dormBuilding/list',
method: 'GET',
params: { campusId },
});
this.buildingOptions = res.rows || [];
} catch (error) {
console.error('加载宿舍楼选项失败:', error);
uni.showToast({ title: '加载宿舍楼选项失败', icon: 'none' });
}
},
// 加载房间列表
async loadRoomsByFloor(buildingId) {
try {
this.roomsLoading = true;
const res = await request({
url: '/xgss/dormRoom/list',
method: 'GET',
params: { buildingId },
});
if (res.code === 200 && res.rows) {
this.allRooms = res.rows;
// 按楼层分组
const floorMap = new Map();
res.rows.forEach((room) => {
const floor = room.floorNum || '1';
if (!floorMap.has(floor)) {
floorMap.set(floor, []);
}
floorMap.get(floor).push(room);
});
// 转换为数组并排序
this.floorRooms = Array.from(floorMap.entries())
.map(([floor, rooms]) => ({ floor, rooms }))
.sort((a, b) => parseInt(a.floor) - parseInt(b.floor));
}
} catch (error) {
console.error('加载房间失败:', error);
uni.showToast({ title: '加载房间失败', icon: 'none' });
} finally {
this.roomsLoading = false;
}
},
// 日期确认处理
handleDateConfirm(e) {
this.formData.checkDate = dayjs(e.value).format('YYYY-MM-DD HH:mm:ss');
this.checkDateDisplay = this.formData.checkDate;
this.showDatePicker = false;
},
// 检查类型确认处理
handleCheckTypeConfirm(e) {
if (e && e.value && e.value.length > 0) {
this.formData.checkType = e.value[0].value;
}
this.showCheckTypePicker = false;
},
// 检查级别确认处理
handleCheckLevelConfirm(e) {
if (e && e.value && e.value.length > 0) {
this.formData.checkLevel = e.value[0].value;
}
this.showCheckLevelPicker = false;
},
// 校区确认处理
async handleCampusConfirm(e) {
if (e && e.value && e.value.length > 0) {
const campus = this.campusOptions.find((item) => item.id === e.value[0].value);
if (campus) {
this.formData.campusId = campus.id;
this.formData.campusName = campus.campusName;
// 清空下级选项
this.formData.buildingId = '';
this.formData.buildingName = '';
this.buildingOptions = [];
this.floorRooms = [];
this.allRooms = [];
this.completedRooms.clear();
this.roomCheckIds.clear();
await this.loadBuildingOptions(campus.id);
}
}
this.showCampusPicker = false;
},
// 宿舍楼点击处理
handleBuildingClick() {
if (!this.formData.campusId) {
this.$u.toast('请先选择校区');
return;
}
this.showBuildingPicker = true;
},
// 宿舍楼确认处理
async handleBuildingConfirm(e) {
if (e && e.value && e.value.length > 0) {
const building = this.buildingOptions.find((item) => item.id === e.value[0].value);
if (building) {
this.formData.buildingId = building.id;
this.formData.buildingName = building.buildingName;
// 清空房间数据
this.floorRooms = [];
this.allRooms = [];
this.completedRooms.clear();
this.roomCheckIds.clear();
await this.loadRoomsByFloor(building.id);
}
}
this.showBuildingPicker = false;
},
// 房间点击处理
async handleRoomClick(room) {
console.log('🔍 handleRoomClick - 点击的房间数据:', room);
console.log(
'🔍 房间字段 - roomName:',
room.roomName,
', roomCode:',
room.roomCode,
', name:',
room.name,
);
// 验证基础信息是否完整
if (
!this.formData.checkTitle ||
!this.formData.checkDate ||
!this.formData.checkerName ||
!this.formData.checkType ||
!this.formData.checkLevel
) {
uni.showToast({ title: '请先完善检查基础信息', icon: 'none' });
return;
}
this.currentRoom = room;
console.log('✅ currentRoom已赋值:', this.currentRoom);
// 检查是否已有记录ID
const checkId = this.roomCheckIds.get(room.id);
if (checkId) {
// 已检查过,加载数据用于编辑
try {
const response = await request({
url: `/dormHygieneCheck/dormHygieneCheck/${checkId}`,
method: 'GET',
});
if (response.code === 200 && response.data) {
this.currentEditData = response.data;
}
} catch (error) {
console.error('查询检查记录详情失败:', error);
uni.showToast({ title: '查询检查记录失败', icon: 'none' });
return;
}
} else {
this.currentEditData = null;
}
this.showRoomCheckPopup = true;
},
// 单个房间检查提交处理
handleSingleCheckSubmit(data) {
// 标记房间为已完成
this.completedRooms.add(this.currentRoom.id);
// 保存检查记录ID
if (data.id) {
this.roomCheckIds.set(this.currentRoom.id, data.id);
}
uni.showToast({ title: `${this.currentRoom.roomName} 检查完成`, icon: 'success' });
this.showRoomCheckPopup = false;
this.currentRoom = null;
this.currentEditData = null;
},
// 弹窗关闭处理
handlePopupClose() {
this.showRoomCheckPopup = false;
this.currentRoom = null;
this.currentEditData = null;
},
// 获取楼层已完成数量
getFloorCompletedCount(floor) {
const floorData = this.floorRooms.find((f) => f.floor === floor);
if (!floorData) return 0;
return floorData.rooms.filter((room) => this.completedRooms.has(room.id)).length;
},
// 宿舍楼显示名称
getBuildingDisplayName(building) {
let displayName = building.buildingName;
if (building.buildingType) {
if (building.buildingType === '1') {
displayName = `(男)${displayName}`;
} else if (building.buildingType === '2') {
displayName = `(女)${displayName}`;
}
}
return displayName;
},
// 返回
goBack() {
console.log('🔙 goBack - 点击返回按钮');
// 检查是否有未保存的数据
if (this.completedRoomsCount > 0) {
uni.showModal({
title: '提示',
content: `已检查 ${this.completedRoomsCount} 个房间,确定要返回吗?`,
success: (res) => {
if (res.confirm) {
uni.navigateBack({
delta: 1,
fail: (err) => {
console.error('❌ navigateBack失败:', err);
// 如果navigateBack失败,尝试使用redirectTo
uni.redirectTo({
url: '/pages/xgss/wsjc/wsjc',
fail: (err2) => {
console.error('❌ redirectTo也失败:', err2);
uni.showToast({ title: '返回失败', icon: 'none' });
},
});
},
});
}
},
});
} else {
uni.navigateBack({
delta: 1,
fail: (err) => {
console.error('❌ navigateBack失败:', err);
// 如果navigateBack失败,尝试使用redirectTo
uni.redirectTo({
url: '/pages/xgss/wsjc/wsjc',
fail: (err2) => {
console.error('❌ redirectTo也失败:', err2);
uni.showToast({ title: '返回失败', icon: 'none' });
},
});
},
});
}
},
},
};
</script>
<style lang="scss" scoped>
.page-container {
width: 100%;
min-height: 100vh;
background-color: #f5f6f7;
}
.content-container {
padding: 20rpx;
padding-top: 100rpx; /* 为导航栏预留空间 */
}
.info-card {
background-color: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.card-title {
font-size: 32rpx;
font-weight: 600;
color: #303133;
margin-bottom: 20rpx;
padding-left: 20rpx;
border-left: 6rpx solid #0051d9;
}
.rooms-section {
background-color: #fff;
border-radius: 16rpx;
padding: 30rpx;
}
.rooms-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.rooms-title {
font-size: 32rpx;
font-weight: 600;
color: #303133;
}
.rooms-stats {
font-size: 24rpx;
color: #909399;
display: flex;
flex-wrap: nowrap;
white-space: nowrap;
}
.hint-text {
display: flex;
align-items: center;
padding: 16rpx;
background-color: #fff8e6;
border-radius: 8rpx;
margin-bottom: 20rpx;
font-size: 24rpx;
}
.floors-container {
margin-top: 20rpx;
}
.floor-section {
margin-bottom: 40rpx;
}
.floor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 2rpx solid #3c9cff;
margin-bottom: 20rpx;
min-height: 60rpx;
}
.floor-name {
font-size: 30rpx;
font-weight: 600;
color: #3c9cff;
flex: 1;
margin-right: 20rpx;
}
.floor-stats {
font-size: 24rpx;
color: #909399;
min-width: 240rpx;
text-align: right;
white-space: nowrap;
flex-shrink: 0;
display: inline-block;
}
.rooms-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
}
.room-card {
background-color: #fff;
border: 2rpx solid #e4e7ed;
border-radius: 12rpx;
padding: 20rpx;
position: relative;
transition: all 0.3s;
}
.room-card.completed {
border-color: #19be6b;
background-color: #f0f9ff;
}
.room-card-content {
display: flex;
flex-direction: column;
position: relative;
min-height: 80rpx;
}
.room-name {
font-size: 28rpx;
font-weight: 600;
color: #303133;
text-align: center;
padding-right: 30rpx;
display: flex;
align-items: center;
justify-content: center;
min-height: 50rpx;
}
.room-code {
font-size: 22rpx;
color: #909399;
text-align: center;
margin-top: 4rpx;
}
.room-status {
position: absolute;
top: 0;
right: 0;
height: 50rpx;
display: flex;
align-items: center;
justify-content: center;
}
.room-hint {
text-align: center;
margin-top: 8rpx;
font-size: 22rpx;
}
.completed-text {
color: #19be6b;
font-weight: 500;
}
.pending-text {
color: #909399;
}
.empty-container {
padding: 100rpx 0;
}
.popup-container {
background-color: #fff;
border-radius: 20rpx 20rpx 0 0;
}
.popup-header {
padding: 30rpx;
border-bottom: 2rpx solid #f0f0f0;
}
.popup-title {
font-size: 32rpx;
font-weight: 600;
color: #303133;
}
.popup-scroll {
overflow-y: auto;
}
</style>
+308
View File
@@ -0,0 +1,308 @@
// 文件上传工具 - 移动端适配
import { request } from '@/common/request.js';
import { REQUEST_CONFIG } from '@/common/config.js';
/**
* 获取token
*/
function getToken() {
try {
const token = uni.getStorageSync('access_token');
return token ? `Bearer ${token}` : '';
} catch (e) {
console.error('❌ 获取Token失败:', e);
return '';
}
}
/**
* 构建完整的上传URL
*/
function buildUploadUrl(url) {
let fullUrl;
if (REQUEST_CONFIG.baseURL.startsWith('/')) {
// 开发环境,baseURL是'/api',需要使用完整的服务器URL
fullUrl = `${REQUEST_CONFIG.serverUrl}${url}`;
} else {
// 生产环境,直接拼接
fullUrl = REQUEST_CONFIG.baseURL + url;
}
return fullUrl;
}
/**
* 上传单个文件
* @param {string} url - 上传接口URL
* @param {Object} options - 上传选项
* @param {string} options.filePath - 文件路径
* @param {string} options.name - 文件字段名,默认'file'
* @param {Object} options.formData - 额外的表单数据
* @param {Function} options.onProgress - 进度回调
* @returns {Promise}
*/
export function uploadFile(url, options = {}) {
return new Promise((resolve, reject) => {
const { filePath, name = 'file', formData = {}, onProgress } = options;
if (!filePath) {
return reject(new Error('文件路径不能为空'));
}
const token = getToken();
const clientid = uni.getStorageSync('clientid') || REQUEST_CONFIG.clientId;
const fullUrl = buildUploadUrl(url);
console.log('🚀 开始上传文件:', {
url: fullUrl,
filePath,
name,
});
const uploadTask = uni.uploadFile({
url: fullUrl,
filePath,
name,
formData,
header: {
authorization: token,
clientid: clientid,
},
success: (res) => {
console.log('✅ 文件上传成功:', res);
if (res.statusCode === 200) {
try {
const data = JSON.parse(res.data);
if (data.code === 200) {
resolve(data);
} else {
uni.showToast({
title: data.msg || '上传失败',
icon: 'none',
});
reject(new Error(data.msg || '上传失败'));
}
} catch (error) {
console.error('❌ 解析上传响应失败:', error);
reject(new Error('解析响应数据失败'));
}
} else {
reject(new Error(`上传失败: HTTP ${res.statusCode}`));
}
},
fail: (err) => {
console.error('❌ 文件上传失败:', err);
uni.showToast({
title: '上传失败',
icon: 'none',
});
reject(new Error(err.errMsg || '上传失败'));
},
});
// 监听上传进度
if (onProgress && typeof onProgress === 'function') {
uploadTask.onProgressUpdate((res) => {
onProgress(res);
});
}
});
}
/**
* 上传多个文件
* @param {string} url - 上传接口URL
* @param {Array} files - 文件列表
* @param {Object} options - 上传选项
* @returns {Promise<Array>}
*/
export function uploadFiles(url, files, options = {}) {
const uploadPromises = files.map((file, index) => {
return uploadFile(url, {
...options,
filePath: file.tempFilePath || file.path || file.url,
onProgress: (progress) => {
if (options.onProgress) {
options.onProgress({
...progress,
fileIndex: index,
totalFiles: files.length,
});
}
},
});
});
return Promise.all(uploadPromises);
}
/**
* 选择并上传图片
* @param {string} url - 上传接口URL
* @param {Object} options - 选择和上传选项
* @returns {Promise}
*/
export function chooseAndUploadImage(url, options = {}) {
return new Promise((resolve, reject) => {
const {
count = 1,
sizeType = ['compressed'],
sourceType = ['album', 'camera'],
...uploadOptions
} = options;
uni.chooseImage({
count,
sizeType,
sourceType,
success: (chooseRes) => {
console.log('📷 选择图片成功:', chooseRes);
if (count === 1) {
// 单张图片上传
uploadFile(url, {
...uploadOptions,
filePath: chooseRes.tempFilePaths[0],
})
.then(resolve)
.catch(reject);
} else {
// 多张图片上传
uploadFiles(url, chooseRes.tempFiles, uploadOptions)
.then(resolve)
.catch(reject);
}
},
fail: (err) => {
console.error('❌ 选择图片失败:', err);
reject(new Error(err.errMsg || '选择图片失败'));
},
});
});
}
/**
* 选择并上传文件
* @param {string} url - 上传接口URL
* @param {Object} options - 选择和上传选项
* @returns {Promise}
*/
export function chooseAndUploadFile(url, options = {}) {
return new Promise((resolve, reject) => {
const { count = 1, type = 'all', extension = [], ...uploadOptions } = options;
// #ifdef MP-WEIXIN
// 微信小程序使用 wx.chooseMessageFile
uni.chooseMessageFile({
count,
type,
extension,
success: (chooseRes) => {
console.log('📁 选择文件成功:', chooseRes);
if (count === 1) {
uploadFile(url, {
...uploadOptions,
filePath: chooseRes.tempFiles[0].path,
})
.then(resolve)
.catch(reject);
} else {
uploadFiles(url, chooseRes.tempFiles, uploadOptions)
.then(resolve)
.catch(reject);
}
},
fail: (err) => {
console.error('❌ 选择文件失败:', err);
reject(new Error(err.errMsg || '选择文件失败'));
},
});
// #endif
// #ifndef MP-WEIXIN
// H5和其他平台使用普通文件选择
uni.chooseFile({
count,
type,
extension,
success: (chooseRes) => {
console.log('📁 选择文件成功:', chooseRes);
if (count === 1) {
uploadFile(url, {
...uploadOptions,
filePath: chooseRes.tempFilePaths[0],
})
.then(resolve)
.catch(reject);
} else {
uploadFiles(url, chooseRes.tempFiles, uploadOptions)
.then(resolve)
.catch(reject);
}
},
fail: (err) => {
console.error('❌ 选择文件失败:', err);
reject(new Error(err.errMsg || '选择文件失败'));
},
});
// #endif
});
}
/**
* 压缩图片
* @param {string} src - 图片路径
* @param {Object} options - 压缩选项
* @returns {Promise}
*/
export function compressImage(src, options = {}) {
return new Promise((resolve, reject) => {
const { quality = 80, compressedWidth, compressedHeight } = options;
uni.compressImage({
src,
quality,
compressedWidth,
compressedHeight,
success: (res) => {
console.log('✅ 图片压缩成功:', res);
resolve(res.tempFilePath);
},
fail: (err) => {
console.error('❌ 图片压缩失败:', err);
reject(new Error(err.errMsg || '图片压缩失败'));
},
});
});
}
/**
* 获取图片信息
* @param {string} src - 图片路径
* @returns {Promise}
*/
export function getImageInfo(src) {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src,
success: (res) => {
resolve(res);
},
fail: (err) => {
reject(new Error(err.errMsg || '获取图片信息失败'));
},
});
});
}
// 默认导出
export default {
uploadFile,
uploadFiles,
chooseAndUploadImage,
chooseAndUploadFile,
compressImage,
getImageInfo,
};