Files
QSY-lvjuxcx/pkgService/room_inspection/room_inspection.vue
liangjian 6e721ccc7e refactor: 国际化文本迁移至全局 locales/messages.js
将组件级 i18n 对象重构为统一由全局 locales/messages.js 管理,确保中英文语言切换时正确显示对应文本。

- 新增 utils/i18n.js 国际化工具函数
- 更新 store/$t.mixin.js 翻译混入逻辑
- 迁移 pkgService、pkgBooking、pkgAccount、pkgExplore、pages 等共 58 个组件的中英文文本至全局消息表
2026-07-24 13:43:41 +08:00

1053 lines
28 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">{{ $tr('验房') }}</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">{{ $tr('房间号') }}</text>
<text class="info-value font-mono">{{ activeInspectionDetail.roomNo || '--' }}</text>
</view>
<view class="info-cell">
<text class="info-label">{{ $tr('验房类型') }}</text>
<text class="info-value">{{ activeInspectionDetail.typeText }}</text>
</view>
<view class="info-cell full-width">
<text class="info-label">{{ $tr('发起时间') }}</text>
<text class="info-value font-mono">{{ activeInspectionDetail.createTimeText }}</text>
</view>
</view>
<!-- Remarks -->
<view class="remarks-wrap">
<textarea
class="remarks-input"
:value="remarksDraft"
:placeholder="$tr('可填写本次验房的补充说明')"
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">{{ $tr('检查项') }}</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 || $tr('未命名设备') }}</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">{{ $tr('必查') }}</text>
</view>
<view class="item-body">
<view class="row-flex">
<view class="col-half">
<text class="field-label">{{ $tr('应有数量') }}</text>
<text class="field-value font-mono">{{ item.expectedQuantityText }}</text>
</view>
<view class="col-half">
<text class="field-label">{{ $tr('实测数量') }}</text>
<input
class="field-input font-mono"
type="number"
:value="item.checkedQuantityDraft"
:placeholder="$tr('请输入数量')"
@input="updateItemField(index, 'checkedQuantityDraft', $event.detail.value)"
/>
</view>
</view>
<view class="field-row mt-20">
<text class="field-label">{{ $tr('检查结果') }}</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="$tr('请描述异常、缺失或损坏情况')"
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">{{ $tr('暂无检查项') }}</text>
<text class="empty-desc">{{ $tr('当前验房单还没有检查项明细。') }}</text>
</view>
</view>
<view v-if="!loading && !inspectionList.length" class="empty-state">
<text class="empty-title">{{ $tr('暂无验房单') }}</text>
<text class="empty-desc">{{ $tr('当前订单还没有可查看的验房记录。') }}</text>
</view>
<view v-if="activeInspectionDetail" class="bottom-action">
<view class="btn-submit" @click="submitInspection">
{{ submitting ? $tr('提交中...') : $tr('提交验房') }}
</view>
</view>
<view v-if="loading" class="global-loading">
<tn-loading mode="circle" color="#0F172A" size="48"></tn-loading>
<text class="loading-text">{{ $tr('正在同步验房数据...') }}</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: {
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.$tr('请先完成所有检查项结果选择'),
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.$tr('验房单已提交'),
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.$tr('提交失败'),
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.$tr('正在同步验房数据...'),
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.$tr('图片上传失败'))
}
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.$tr('图片上传失败'),
icon: 'none'
})
} finally {
uni.hideLoading()
}
},
fail: () => {
uni.showToast({
title: this.$tr('选择图片失败'),
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>