Files
QSY-lvjuxcx/pkgService/room_inspection/room_inspection.vue
liangjian 445bd850f4 feat: 新增工单中心、房间巡查、失物招领等业务模块
- 新增工单中心(ticket_center)与工单工作台(ticket_workbench)页面
- 新增房间巡查(room_inspection)与失物招领发布(lost_found_publish)页面
- 预约看样流程新增房型/楼栋/楼层选择器组件
- 新增工具模块: booking-entry、privacy、reservation-flow
- api/index.js 补充楼层列表、看样提交等接口
- locales/messages.js 补充多语言文案
- manifest.json 开启微信小程序隐私检查
- 完善登录、注册、申请详情、账单等相关页面交互
2026-07-24 09:35:46 +08:00

1131 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="app-page">
<tn-nav-bar :isBack="true" :bottomShadow="false" backgroundColor="#FFFFFF">
<!-- Empty slot for title to keep it clean, or use default -->
</tn-nav-bar>
<!-- Header -->
<view class="page-header" :style="{ paddingTop: vuex_custom_bar_height ? 'calc(' + vuex_custom_bar_height + 'px + 32rpx)' : '32rpx' }">
<view class="title-wrap">
<text class="title-zh">{{ t('pageTitle') }}</text>
<text class="title-en">Inspection</text>
</view>
<view v-if="activeInspection" class="status-tag">
{{ activeInspection.statusText }}
</view>
</view>
<!-- Inspection Tabs (Only show if > 1) -->
<view v-if="inspectionList.length > 1" class="tab-wrap">
<scroll-view scroll-x class="tab-scroll" :show-scrollbar="false">
<view class="tab-list">
<view
v-for="item in inspectionList"
:key="item.inspectionNo"
class="tab-item"
:class="{ 'tab-item--active': item.inspectionNo === activeInspectionNo }"
@click="switchInspection(item)"
>
<text class="tab-type">{{ item.typeText }}</text>
<text class="tab-room font-mono">{{ item.roomNo || '--' }}</text>
</view>
</view>
</scroll-view>
</view>
<view v-if="activeInspectionDetail" class="content-wrap">
<!-- High Density Info Grid -->
<view class="info-grid">
<view class="info-cell">
<text class="info-label">{{ t('roomNoLabel') }}</text>
<text class="info-value font-mono">{{ activeInspectionDetail.roomNo || '--' }}</text>
</view>
<view class="info-cell">
<text class="info-label">{{ t('inspectionTypeLabel') }}</text>
<text class="info-value">{{ activeInspectionDetail.typeText }}</text>
</view>
<view class="info-cell full-width">
<text class="info-label">{{ t('inspectionTimeLabel') }}</text>
<text class="info-value font-mono">{{ activeInspectionDetail.createTimeText }}</text>
</view>
</view>
<!-- Remarks -->
<view class="remarks-wrap">
<textarea
class="remarks-input"
:value="remarksDraft"
:placeholder="t('inspectionRemarksPlaceholder')"
maxlength="300"
auto-height
@input="handleRemarksInput"
/>
</view>
<!-- Items -->
<view v-if="activeInspectionDetail.items.length" class="items-wrap">
<view class="section-title">
<text class="section-title-zh">{{ t('inspectionItemsTitle') }}</text>
<text class="section-title-en">Items</text>
</view>
<view class="item-list">
<view
v-for="(item, index) in activeInspectionDetail.items"
:key="item.itemId || index"
class="inspect-item"
>
<view class="item-header">
<view class="item-title-col">
<text class="item-name">{{ item.deviceName || t('deviceFallback') }}</text>
<text class="item-meta" v-if="item.assetSourceText || item.brandSnapshot">
{{ [item.assetSourceText, item.brandSnapshot].filter(Boolean).join(' / ') }}
</text>
</view>
<text v-if="item.isRequiredSnapshot" class="item-required">{{ t('requiredTag') }}</text>
</view>
<view class="item-body">
<view class="row-flex">
<view class="col-half">
<text class="field-label">{{ t('expectedQuantityLabel') }}</text>
<text class="field-value font-mono">{{ item.expectedQuantityText }}</text>
</view>
<view class="col-half">
<text class="field-label">{{ t('checkedQuantityLabel') }}</text>
<input
class="field-input font-mono"
type="number"
:value="item.checkedQuantityDraft"
:placeholder="t('checkedQuantityPlaceholder')"
@input="updateItemField(index, 'checkedQuantityDraft', $event.detail.value)"
/>
</view>
</view>
<view class="field-row mt-20">
<text class="field-label">{{ t('inspectionResultLabel') }}</text>
<view class="radio-group">
<view
v-for="option in inspectionResultOptions"
:key="option.key"
class="radio-btn"
:class="{ 'radio-btn--active': String(item.resultDraft) === String(option.key) }"
@click="updateItemField(index, 'resultDraft', option.key)"
>
{{ option.value }}
</view>
</view>
</view>
<view class="field-row mt-20">
<textarea
class="field-textarea"
:value="item.problemDetailDraft"
:placeholder="t('problemDetailPlaceholder')"
maxlength="300"
auto-height
@input="updateItemField(index, 'problemDetailDraft', $event.detail.value)"
/>
</view>
<view class="field-row mt-20">
<view class="photo-uploader">
<view
v-for="(url, imageIndex) in item.imageUrlsDraft"
:key="`${item.itemId || index}-${imageIndex}`"
class="photo-item"
>
<image
class="photo-img"
:src="resolveImageUrl(url)"
mode="aspectFill"
@click="previewImages(item.imageUrlsDraft, imageIndex)"
/>
<view class="photo-del" @click.stop="removeImage(index, imageIndex)">×</view>
</view>
<view class="photo-add" @click="chooseImages(index)">
<text class="photo-add-icon">+</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<view v-else-if="!loading" class="empty-state">
<text class="empty-title">{{ t('emptyItemsTitle') }}</text>
<text class="empty-desc">{{ t('emptyItemsDesc') }}</text>
</view>
</view>
<view v-if="!loading && !inspectionList.length" class="empty-state">
<text class="empty-title">{{ t('emptyTitle') }}</text>
<text class="empty-desc">{{ t('emptyDesc') }}</text>
</view>
<view v-if="activeInspectionDetail" class="bottom-action">
<view class="btn-submit" @click="submitInspection">
{{ submitting ? t('submitting') : t('submitAction') }}
</view>
</view>
<view v-if="loading" class="global-loading">
<tn-loading mode="circle" color="#0F172A" size="48"></tn-loading>
<text class="loading-text">{{ t('loadingDesc') }}</text>
</view>
</view>
</template>
<script>
import {
roomInspectionDetail,
roomInspectionMyPage,
roomInspectionSubmit,
uploadCommonFile
} from '@/api/index.js'
import { fetchEnums, getEnumLabel, getEnumOptions } from '@/utils/enum-store'
import { addFilePrefix } from '@/utils/common'
import { getAuthToken } from '@/utils/app-state'
function getResponsePayload(response) {
const payload = response && response.data ? response.data : response
if (typeof payload !== 'string') {
return payload
}
const text = payload.trim()
if (!text) {
return payload
}
if (!((text.startsWith('{') && text.endsWith('}')) || (text.startsWith('[') && text.endsWith(']')))) {
return payload
}
try {
return JSON.parse(text)
} catch (error) {
return payload
}
}
function getArrayPayload(response) {
const payload = getResponsePayload(response)
if (Array.isArray(payload)) {
return payload
}
if (payload && Array.isArray(payload.data)) {
return payload.data
}
if (payload && Array.isArray(payload.items)) {
return payload.items
}
return []
}
function getObjectPayload(response) {
const payload = getResponsePayload(response)
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
return payload
}
return {}
}
function formatDateTime(value) {
if (!value) {
return '--'
}
const text = String(value).replace('T', ' ')
return text.length > 16 ? text.slice(0, 16) : text
}
function resolveUploadedFileUrl(uploadResult) {
const candidates = [
uploadResult && uploadResult.data && uploadResult.data.url,
uploadResult && uploadResult.data && uploadResult.data.fileUrl,
uploadResult && uploadResult.data && uploadResult.data.path,
uploadResult && uploadResult.data && uploadResult.data.filePath,
uploadResult && uploadResult.data,
uploadResult && uploadResult.url,
uploadResult && uploadResult.fileUrl,
uploadResult && uploadResult.path,
uploadResult && uploadResult.filePath,
typeof uploadResult === 'string' ? uploadResult : ''
]
return candidates.find(item => typeof item === 'string' && item) || ''
}
export default {
data() {
return {
loading: false,
submitting: false,
orderNo: '',
inspectionResultOptions: [],
inspectionList: [],
activeInspectionNo: '',
detailMap: {},
remarksDraft: ''
}
},
watch: {
language() {
this.detailMap = {}
this.loadPage(true)
}
},
computed: {
language() {
return this.$getLanguage()
},
activeInspection() {
return this.inspectionList.find(item => item.inspectionNo === this.activeInspectionNo) || null
},
activeInspectionDetail() {
return this.detailMap[this.activeInspectionNo] || null
}
},
onLoad(options = {}) {
this.orderNo = String(options.orderNo || '').trim()
this.activeInspectionNo = String(options.inspectionNo || '').trim()
this.loadPage(true)
},
onPullDownRefresh() {
this.loadPage(true).finally(() => {
uni.stopPullDownRefresh()
})
},
methods: {
t(key) {
const map = {
zh: {
pageTitle: '验房',
pageDesc: '按当前订单查看并提交验房结果',
inspectionListTitle: '验房记录',
roomNoLabel: '房间号',
inspectionTypeLabel: '验房类型',
inspectionStatusLabel: '当前状态',
inspectionTimeLabel: '发起时间',
inspectionRemarksLabel: '备注',
inspectionRemarksPlaceholder: '可填写本次验房的补充说明',
inspectionItemsTitle: '检查项',
inspectionItemsDesc: '请根据现场情况逐项核对',
deviceFallback: '未命名设备',
requiredTag: '必查',
expectedQuantityLabel: '应有数量',
inspectionResultLabel: '检查结果',
checkedQuantityLabel: '实测数量',
checkedQuantityPlaceholder: '请输入数量',
problemDetailLabel: '问题说明',
problemDetailPlaceholder: '请描述异常、缺失或损坏情况',
photosLabel: '现场照片',
addPhoto: '添加',
submitAction: '提交验房',
loadingDesc: '正在同步验房数据...',
emptyTitle: '暂无验房单',
emptyDesc: '当前订单还没有可查看的验房记录。',
emptyItemsTitle: '暂无检查项',
emptyItemsDesc: '当前验房单还没有检查项明细。',
submitSuccess: '验房单已提交',
submitFailed: '提交失败',
submitting: '提交中...',
missingInspectionNo: '未获取到验房单',
uploadFailed: '图片上传失败',
chooseImageFailed: '选择图片失败',
resultRequired: '请先完成所有检查项结果选择'
},
en: {
pageTitle: 'Inspection',
pageDesc: 'Review and submit inspection results for the current order',
inspectionListTitle: 'Inspection Records',
roomNoLabel: 'Room No.',
inspectionTypeLabel: 'Inspection Type',
inspectionStatusLabel: 'Status',
inspectionTimeLabel: 'Created At',
inspectionRemarksLabel: 'Remarks',
inspectionRemarksPlaceholder: 'Add notes for this inspection',
inspectionItemsTitle: 'Inspection Items',
inspectionItemsDesc: 'Check each item according to the on-site condition',
deviceFallback: 'Unnamed Device',
requiredTag: 'Required',
expectedQuantityLabel: 'Expected Qty',
inspectionResultLabel: 'Result',
checkedQuantityLabel: 'Checked Qty',
checkedQuantityPlaceholder: 'Enter quantity',
problemDetailLabel: 'Problem Detail',
problemDetailPlaceholder: 'Describe any abnormal, missing, or damaged condition',
photosLabel: 'Photos',
addPhoto: 'Add',
submitAction: 'Submit',
loadingDesc: 'Syncing inspection data...',
emptyTitle: 'No inspections',
emptyDesc: 'No inspection record is available for the current order.',
emptyItemsTitle: 'No items',
emptyItemsDesc: 'No inspection item is available for this inspection.',
submitSuccess: 'Inspection submitted',
submitFailed: 'Failed to submit',
submitting: 'Submitting...',
missingInspectionNo: 'Missing inspection record',
uploadFailed: 'Failed to upload image',
chooseImageFailed: 'Failed to choose image',
resultRequired: 'Please complete the result selection for all items first'
}
}
const lang = this.language === 'en' ? 'en' : 'zh'
return (map[lang] && map[lang][key]) || key
},
syncInspectionResultOptions() {
this.inspectionResultOptions = this.getEnumOptionsWithAliases([
'InspectionItemResultEnum',
'INSPECTIONITEMRESULTENUM'
])
},
getCheckTypeLabel(value, fallback = '') {
return getEnumLabel('CheckTypeEnum', value, fallback, this.language)
},
getInspectionStatusLabel(value, fallback = '') {
return getEnumLabel('InspectionStatusEnum', value, fallback, this.language)
},
getInspectionItemResultLabel(value, fallback = '') {
return this.getEnumLabelWithAliases([
'InspectionItemResultEnum',
'INSPECTIONITEMRESULTENUM'
], value, fallback)
},
getAssetSourceLabel(value, fallback = '') {
return getEnumLabel('RoomAssetSourceTypeEnum', value, fallback, this.language)
},
getEnumOptionsWithAliases(codes = []) {
const codeList = Array.isArray(codes) ? codes : [codes]
for (let index = 0; index < codeList.length; index += 1) {
const code = String(codeList[index] || '').trim()
if (!code) {
continue
}
const options = getEnumOptions(code, this.language)
if (Array.isArray(options) && options.length) {
return options
}
}
return []
},
getEnumLabelWithAliases(codes = [], value = '', fallback = '') {
const codeList = Array.isArray(codes) ? codes : [codes]
for (let index = 0; index < codeList.length; index += 1) {
const code = String(codeList[index] || '').trim()
if (!code) {
continue
}
const label = getEnumLabel(code, value, '', this.language)
if (label !== '') {
return label
}
}
return fallback
},
normalizeListItem(item = {}) {
const inspectionNo = String(item.no || item.inspectionNo || '').trim()
return {
inspectionNo,
roomNo: item.roomNo || '',
type: item.type,
status: item.status,
createTime: item.createTime || '',
createTimeText: formatDateTime(item.createTime),
typeText: this.getCheckTypeLabel(item.type, item.typeDes || '--'),
statusText: this.getInspectionStatusLabel(item.status, item.statusDes || '--')
}
},
normalizeDetail(detail = {}) {
const inspectionNo = String(detail.no || detail.inspectionNo || '').trim()
const items = Array.isArray(detail.items)
? [...detail.items]
.sort((a, b) => Number(a.sort || 0) - Number(b.sort || 0))
.map(item => ({
itemId: item.id,
deviceName: item.deviceName || '',
brandSnapshot: item.brandSnapshot || '',
isRequiredSnapshot: !!item.isRequiredSnapshot,
expectedQuantityText: item.expectedQuantity === null || item.expectedQuantity === undefined ? '--' : String(item.expectedQuantity),
checkedQuantityDraft: item.checkedQuantity === null || item.checkedQuantity === undefined ? '' : String(item.checkedQuantity),
resultDraft: item.result === null || item.result === undefined ? '' : String(item.result),
problemDetailDraft: item.problemDetail || '',
imageUrlsDraft: Array.isArray(item.imageUrls) ? item.imageUrls.filter(Boolean) : [],
assetSourceText: this.getAssetSourceLabel(item.assetSourceType, ''),
resultText: this.getInspectionItemResultLabel(item.result, item.resultDes || '')
}))
: []
return {
inspectionNo,
roomNo: detail.roomNo || '',
typeText: this.getCheckTypeLabel(detail.type, detail.typeDes || '--'),
statusText: this.getInspectionStatusLabel(detail.status, detail.statusDes || '--'),
createTimeText: formatDateTime(detail.createTime),
remarksDraft: detail.remarks || '',
items
}
},
async loadPage(force = false) {
this.loading = true
try {
await fetchEnums(force, this.language).catch(() => {})
this.syncInspectionResultOptions()
await this.fetchInspectionList()
if (!this.activeInspectionNo && this.inspectionList.length) {
this.activeInspectionNo = this.inspectionList[0].inspectionNo
}
if (this.activeInspectionNo) {
await this.fetchInspectionDetail(this.activeInspectionNo, force)
}
} finally {
this.loading = false
}
},
async fetchInspectionList() {
if (!this.orderNo) {
this.inspectionList = []
this.activeInspectionNo = ''
return
}
const response = await roomInspectionMyPage({
orderNo: this.orderNo
}, this.language)
const list = getArrayPayload(response)
.map(item => this.normalizeListItem(item))
.filter(item => item.inspectionNo)
.sort((prev, next) => new Date(next.createTime || 0).getTime() - new Date(prev.createTime || 0).getTime())
this.inspectionList = list
if (!list.some(item => item.inspectionNo === this.activeInspectionNo)) {
this.activeInspectionNo = list.length ? list[0].inspectionNo : ''
}
},
async fetchInspectionDetail(inspectionNo = '', force = false) {
const targetNo = String(inspectionNo || '').trim()
if (!targetNo) {
return
}
if (!force && this.detailMap[targetNo]) {
this.remarksDraft = this.detailMap[targetNo].remarksDraft || ''
return
}
const response = await roomInspectionDetail({
inspectionNo: targetNo
}, this.language)
const normalized = this.normalizeDetail(getObjectPayload(response))
this.$set(this.detailMap, targetNo, normalized)
this.remarksDraft = normalized.remarksDraft || ''
},
async switchInspection(item = {}) {
const inspectionNo = String(item.inspectionNo || '').trim()
if (!inspectionNo || inspectionNo === this.activeInspectionNo) {
return
}
this.activeInspectionNo = inspectionNo
await this.fetchInspectionDetail(inspectionNo)
},
handleRemarksInput(event) {
const value = event && event.detail ? event.detail.value : ''
this.remarksDraft = value
if (this.activeInspectionDetail) {
this.activeInspectionDetail.remarksDraft = value
}
},
updateItemField(index, field, value) {
const detail = this.activeInspectionDetail
if (!detail || !Array.isArray(detail.items) || !detail.items[index]) {
return
}
this.$set(detail.items[index], field, value)
},
buildSubmitItems() {
const detail = this.activeInspectionDetail
if (!detail) {
return []
}
return (Array.isArray(detail.items) ? detail.items : [])
.filter(item => item && item.itemId)
.map(item => {
const resultText = String(item.resultDraft || '').trim()
const checkedQuantityText = String(item.checkedQuantityDraft || '').trim()
return {
itemId: item.itemId,
result: resultText === '' ? null : Number(resultText),
checkedQuantity: checkedQuantityText === '' ? null : Number(checkedQuantityText),
problemDetail: String(item.problemDetailDraft || '').trim(),
imageUrls: Array.isArray(item.imageUrlsDraft) ? item.imageUrlsDraft.filter(Boolean) : []
}
})
},
validateBeforeSubmit() {
const items = this.buildSubmitItems()
const hasMissingResult = items.some(item => item.result === null || Number.isNaN(item.result))
if (hasMissingResult) {
uni.showToast({
title: this.t('resultRequired'),
icon: 'none'
})
return false
}
return true
},
async submitInspection() {
const inspectionNo = String(this.activeInspectionNo || '').trim()
if (!inspectionNo || this.submitting) {
return
}
if (!this.validateBeforeSubmit()) {
return
}
this.submitting = true
try {
await roomInspectionSubmit({
inspectionNo,
remarks: this.remarksDraft,
items: this.buildSubmitItems()
}, this.language)
uni.showToast({
title: this.t('submitSuccess'),
icon: 'success'
})
setTimeout(() => {
uni.$emit('reservation-flow-force-refresh', {
source: 'room-inspection-submit',
orderNo: this.orderNo,
inspectionNo
})
uni.navigateBack()
}, 500)
} catch (error) {
uni.showToast({
title: (error && (error.message || error.msg)) || this.t('submitFailed'),
icon: 'none'
})
} finally {
this.submitting = false
}
},
chooseImages(index) {
uni.chooseImage({
count: 9,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const tempFilePaths = Array.isArray(res.tempFilePaths) ? res.tempFilePaths : []
if (!tempFilePaths.length) {
return
}
uni.showLoading({
title: this.t('loadingDesc'),
mask: true
})
try {
const uploadedUrls = []
for (let fileIndex = 0; fileIndex < tempFilePaths.length; fileIndex += 1) {
const uploadResult = await uploadCommonFile(
tempFilePaths[fileIndex],
{},
'file',
{
...(getAuthToken() ? { Authorization: `Bearer ${getAuthToken()}` } : {})
}
)
const remoteUrl = resolveUploadedFileUrl(uploadResult)
if (!remoteUrl) {
throw new Error(this.t('uploadFailed'))
}
uploadedUrls.push(addFilePrefix(remoteUrl))
}
const detail = this.activeInspectionDetail
if (!detail || !Array.isArray(detail.items) || !detail.items[index]) {
return
}
const nextUrls = (detail.items[index].imageUrlsDraft || []).concat(uploadedUrls)
this.$set(detail.items[index], 'imageUrlsDraft', nextUrls)
} catch (error) {
uni.showToast({
title: (error && (error.message || error.msg)) || this.t('uploadFailed'),
icon: 'none'
})
} finally {
uni.hideLoading()
}
},
fail: () => {
uni.showToast({
title: this.t('chooseImageFailed'),
icon: 'none'
})
}
})
},
removeImage(itemIndex, imageIndex) {
const detail = this.activeInspectionDetail
if (!detail || !Array.isArray(detail.items) || !detail.items[itemIndex]) {
return
}
const nextUrls = (detail.items[itemIndex].imageUrlsDraft || []).filter((_, index) => index !== imageIndex)
this.$set(detail.items[itemIndex], 'imageUrlsDraft', nextUrls)
},
previewImages(urls, currentIndex = 0) {
const safeUrls = Array.isArray(urls) ? urls.filter(Boolean).map(item => this.resolveImageUrl(item)) : []
if (!safeUrls.length) {
return
}
uni.previewImage({
urls: safeUrls,
current: safeUrls[currentIndex] || safeUrls[0]
})
},
resolveImageUrl(url = '') {
return addFilePrefix(url)
}
}
}
</script>
<style scoped lang="scss">
.app-page {
min-height: 100vh;
background-color: #F8FAFC;
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
.page-header {
padding: 32rpx 32rpx 24rpx;
background: #FFFFFF;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.title-wrap {
display: flex;
flex-direction: column;
}
.title-zh {
font-size: 40rpx;
font-weight: 600;
color: #0F172A;
line-height: 1.2;
}
.title-en {
font-size: 22rpx;
font-weight: 500;
color: #94A3B8;
margin-top: 8rpx;
text-transform: uppercase;
letter-spacing: 1rpx;
}
.status-tag {
padding: 8rpx 16rpx;
background: #F1F5F9;
color: #475569;
border-radius: 8rpx;
font-size: 24rpx;
font-weight: 500;
}
.tab-wrap {
background: #FFFFFF;
border-top: 1rpx solid #F1F5F9;
padding: 0 32rpx 24rpx;
}
.tab-scroll {
width: 100%;
white-space: nowrap;
}
.tab-list {
display: inline-flex;
gap: 16rpx;
}
.tab-item {
padding: 16rpx 24rpx;
background: #F8FAFC;
border-radius: 12rpx;
border: 1rpx solid #E2E8F0;
display: flex;
flex-direction: column;
min-width: 180rpx;
}
.tab-item--active {
background: #F0F9FF;
border-color: #BAE6FD;
}
.tab-item--active .tab-type {
color: #0284C7;
}
.tab-type {
font-size: 26rpx;
font-weight: 600;
color: #334155;
}
.tab-room {
font-size: 22rpx;
color: #64748B;
margin-top: 4rpx;
}
.content-wrap {
padding: 24rpx 32rpx;
}
/* Info Grid */
.info-grid {
display: flex;
flex-wrap: wrap;
background: #FFFFFF;
border-radius: 16rpx;
border: 1rpx solid #E2E8F0;
overflow: hidden;
margin-bottom: 24rpx;
}
.info-cell {
width: 50%;
padding: 20rpx 24rpx;
display: flex;
flex-direction: column;
box-sizing: border-box;
border-bottom: 1rpx solid #E2E8F0;
border-right: 1rpx solid #E2E8F0;
}
.info-cell:nth-child(even) {
border-right: none;
}
.info-cell.full-width {
width: 100%;
border-right: none;
border-bottom: none;
}
.info-cell:last-child {
border-bottom: none;
}
.info-label {
font-size: 22rpx;
color: #64748B;
margin-bottom: 8rpx;
}
.info-value {
font-size: 26rpx;
color: #0F172A;
font-weight: 600;
}
/* Remarks */
.remarks-wrap {
background: #FFFFFF;
border-radius: 16rpx;
border: 1rpx solid #E2E8F0;
padding: 20rpx 24rpx;
margin-bottom: 32rpx;
}
.remarks-input {
width: 100%;
min-height: 80rpx;
font-size: 26rpx;
color: #0F172A;
line-height: 1.5;
}
/* Section Title */
.section-title {
display: flex;
align-items: baseline;
margin-bottom: 20rpx;
padding-left: 8rpx;
}
.section-title-zh {
font-size: 32rpx;
font-weight: 600;
color: #0F172A;
margin-right: 12rpx;
}
.section-title-en {
font-size: 20rpx;
font-weight: 500;
color: #94A3B8;
text-transform: uppercase;
}
/* Inspect Items */
.item-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.inspect-item {
background: #FFFFFF;
border-radius: 16rpx;
border: 1rpx solid #E2E8F0;
overflow: hidden;
}
.item-header {
padding: 20rpx 24rpx;
background: #F8FAFC;
border-bottom: 1rpx solid #E2E8F0;
display: flex;
justify-content: space-between;
align-items: flex-start;
}
.item-title-col {
display: flex;
flex-direction: column;
flex: 1;
padding-right: 20rpx;
}
.item-name {
font-size: 28rpx;
font-weight: 600;
color: #0F172A;
line-height: 1.4;
}
.item-meta {
font-size: 22rpx;
color: #64748B;
margin-top: 6rpx;
}
.item-required {
padding: 4rpx 12rpx;
background: #FEF3C7;
color: #0369A1;
border-radius: 6rpx;
font-size: 20rpx;
font-weight: 500;
flex-shrink: 0;
margin-top: 4rpx;
}
.item-body {
padding: 24rpx;
}
.row-flex {
display: flex;
gap: 24rpx;
}
.col-half {
flex: 1;
display: flex;
flex-direction: column;
}
.field-label {
font-size: 22rpx;
color: #64748B;
margin-bottom: 12rpx;
}
.field-value {
font-size: 28rpx;
font-weight: 600;
color: #0F172A;
height: 64rpx;
display: flex;
align-items: center;
}
.field-input {
height: 64rpx;
background: #F8FAFC;
border: 1rpx solid #E2E8F0;
border-radius: 8rpx;
padding: 0 20rpx;
font-size: 28rpx;
color: #0F172A;
}
.mt-20 {
margin-top: 20rpx;
}
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.radio-btn {
padding: 12rpx 28rpx;
background: #F1F5F9;
border-radius: 8rpx;
font-size: 24rpx;
color: #475569;
font-weight: 500;
border: 1rpx solid transparent;
}
.radio-btn--active {
background: #F0F9FF;
color: #0284C7;
border-color: #BAE6FD;
}
.field-textarea {
width: 100%;
min-height: 80rpx;
background: #F8FAFC;
border: 1rpx solid #E2E8F0;
border-radius: 8rpx;
padding: 16rpx 20rpx;
font-size: 26rpx;
color: #0F172A;
box-sizing: border-box;
line-height: 1.5;
}
.photo-uploader {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.photo-item,
.photo-add {
width: 140rpx;
height: 140rpx;
border-radius: 12rpx;
overflow: hidden;
position: relative;
}
.photo-item {
background: #F1F5F9;
border: 1rpx solid #E2E8F0;
}
.photo-img {
width: 100%;
height: 100%;
}
.photo-del {
position: absolute;
top: 6rpx;
right: 6rpx;
width: 32rpx;
height: 32rpx;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
color: #FFFFFF;
font-size: 28rpx;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.photo-add {
background: #F8FAFC;
border: 1rpx dashed #CBD5E1;
display: flex;
align-items: center;
justify-content: center;
}
.photo-add-icon {
font-size: 48rpx;
color: #94A3B8;
font-weight: 300;
}
/* Empty State */
.empty-state {
padding: 80rpx 0;
display: flex;
flex-direction: column;
align-items: center;
}
.empty-title {
font-size: 28rpx;
font-weight: 600;
color: #334155;
}
.empty-desc {
font-size: 24rpx;
color: #94A3B8;
margin-top: 12rpx;
}
/* Bottom Action */
.bottom-action {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 24rpx 32rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
background: #FFFFFF;
border-top: 1rpx solid #E2E8F0;
z-index: 100;
}
.btn-submit {
height: 88rpx;
background: #0F172A;
color: #FFFFFF;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 30rpx;
font-weight: 600;
box-shadow: 0 8rpx 16rpx rgba(15, 23, 42, 0.15);
}
.btn-submit:active {
opacity: 0.9;
transform: translateY(2rpx);
}
/* Global Loading */
.global-loading {
position: fixed;
inset: 0;
background: rgba(255, 255, 255, 0.8);
backdrop-filter: blur(4px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 999;
}
.loading-text {
margin-top: 24rpx;
font-size: 26rpx;
color: #475569;
font-weight: 500;
}
.font-mono {
font-family: "DIN Alternate", "SF Pro Text", SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace;
}
</style>