Files
yxe-mp/pages/task/detail.vue
T
2026-09-29 15:17:13 +08:00

3571 lines
117 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="detail-page">
<view class="page-hero">
<AppNavbar title="任务详情" show-back/>
</view>
<view class="store-card">
<view class="flex justify-between items-center">
<view class="flex justify-start items-center">
<text class="mr-15">{{ task.title }}</text>
<view class="store-tag">{{ taskTag }}</view>
</view>
</view>
<view class="store-title">{{ storeName }}</view>
<view v-if="storeChainText" class="store-chain">
<text class="store-chain-label">所属连锁</text>
<text class="store-chain-value">{{ storeChainText }}</text>
</view>
<view class="store-address">
<u-icon name="map" size="16" color="rgba(255, 255, 255, 0.9)"/>
<text>{{ storeAddress }}</text>
</view>
</view>
<view class="card block total-progress-card">
<view class="total-progress-head">
<text class="total-progress-title">总进度</text>
<text class="total-progress-value">{{ totalProgressText }}</text>
</view>
<view class="total-progress-bar">
<view class="total-progress-fill" :style="{ width: `${totalProgressPercent}%` }"></view>
</view>
</view>
<view class="card block detail-info">
<view class="detail-info-head" @tap="toggleDetailExpanded">
<text class="detail-info-title">任务详情</text>
<view class="detail-info-toggle">
<text class="detail-info-toggle-text">{{ detailExpanded ? '收起' : '展开' }}</text>
<view :class="['detail-info-toggle-arrow', detailExpanded ? 'open' : '']">
<u-icon name="arrow-right" size="16" color="#8f9bb3"/>
</view>
</view>
</view>
<view v-if="detailExpanded" class="detail-info-body">
<view class="detail-requirement">
<view class="detail-block-head">
<view class="detail-icon">
<u-icon
name="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260127/a6a3db8692c2366cf3d219b8d5009b7d.png"
size="34rpx"/>
</view>
<text class="detail-block-title">任务要求</text>
</view>
<text class="detail-block-text">{{ detailRequirement }}</text>
</view>
<!-- <view class="detail-section" v-if="task.cateId !== 4">-->
<!-- <view class="detail-section-title">-->
<!-- <view class="detail-section-dot orange"></view>-->
<!-- <text>重点爆破品种</text>-->
<!-- </view>-->
<!-- <view class="detail-section-card">{{ detailFocus }}</view>-->
<!-- </view>-->
<view class="detail-section">
<view class="detail-section-title">
<view class="detail-section-dot green"></view>
<text>活动政策</text>
</view>
<view class="detail-section-card">{{ detailPolicy }}</view>
</view>
<view class="detail-section">
<view class="detail-section-title">
<view class="detail-section-dot blue"></view>
<text>任务执行时间段</text>
</view>
<view class="detail-section-card">{{ taskExecutePeriod }}</view>
</view>
<!-- <view v-if="!manualSalesMode" class="detail-section">-->
<!-- <view class="detail-section-title">-->
<!-- <view class="detail-section-dot orange"></view>-->
<!-- <text>任务奖金</text>-->
<!-- </view>-->
<!-- <view class="detail-section-card">销售奖金{{ salesBonusDisplay }}元,额外奖金{{ extraBonusDisplay }}元</view>-->
<!-- </view>-->
</view>
</view>
<view v-if="!isStoreTask" class="card block">
<view class="section-title">上班打卡(水印拍照)</view>
<view class="clock-grid">
<view class="clock-card primary">
<view class="clock-thumb" @tap="handleClockAction(0)">
<image v-if="clockPhotos[0] && clockPhotos[0].url" :src="clockPhotos[0] && clockPhotos[0].url"
mode="aspectFill" class="clock-image"/>
<view v-else class="clock-placeholder">
<u-icon name="photo" size="40" color="#52627a"/>
</view>
<view v-if="clockPhotos[0] && clockPhotos[0].url" class="clock-check">
<u-icon name="checkmark-circle-fill" size="22" color="#22c55e"/>
</view>
<view class="clock-info">
<view class="clock-time">{{ (clockPhotos[0] && clockPhotos[0].time) || '--:--' }}</view>
<view class="clock-date">{{ (clockPhotos[0] && clockPhotos[0].date) || '--' }}</view>
</view>
</view>
<view class="detail-section-card location-card" v-if="isAuditEntry && clockPhotos[0] && clockPhotos[0].lat">
<view class="location-row">
<text class="location-label">纬度:</text>
<text class="location-value">{{ clockPhotos[0].lat }}</text>
</view>
<view class="location-row">
<text class="location-label">经度:</text>
<text class="location-value">{{ clockPhotos[0].lng }}</text>
</view>
<view class="location-row">
<text class="location-label">详细地址:</text>
<text class="location-value">{{ clockPhotos[0].location }}</text>
</view>
</view>
</view>
</view>
</view>
<view v-if="isStoreTask" class="card block">
<view class="section-title-row">
<view class="section-title">巡店列表</view>
<view class="section-extra" v-if="totalTaskNum">目标{{ totalTaskNum }}家</view>
</view>
<view v-if="!storeList.length" class="audit-empty-tip">暂无巡店记录</view>
<view v-for="(item, idx) in storeList" :key="`store-${idx}`" class="visit-store-card">
<view class="visit-store-head">
<view>
<view class="visit-store-title">巡店{{ idx + 1 }}</view>
<view v-if="item.visitTime" class="visit-store-time">{{ item.visitTime }}</view>
</view>
<view
v-if="!isAuditEntry && storeList.length > 1"
class="visit-store-remove"
@tap="removeStoreItem(idx)"
>
删除
</view>
</view>
<view class="visit-store-field">
<view class="visit-store-label">选择门店</view>
<view
:class="['visit-store-picker-inner', item.merchantName ? '' : 'placeholder']"
@tap="openStoreMerchantPicker(idx)"
>
{{
item.merchantName ? `${item.chainName || '-'}/${item.merchantName}` : (merchantLoading ? '门店加载中...' : '请选择连锁和门店')
}}
</view>
</view>
<view class="visit-store-field">
<view class="visit-store-label-row">
<view class="visit-store-label">定位</view>
<view v-if="!isAuditEntry" class="visit-store-action" @tap="fetchStoreLocation(idx)">获取定位</view>
</view>
<view class="detail-section-card location-card">
<view class="location-row">
<text class="location-label">纬度:</text>
<text class="location-value">{{ formatCoordinate(item.lat) }}</text>
</view>
<view class="location-row">
<text class="location-label">经度:</text>
<text class="location-value">{{ formatCoordinate(item.lng) }}</text>
</view>
<view class="location-row">
<text class="location-label">详细地址:</text>
<text class="location-value">{{ item.location || '暂无地址' }}</text>
</view>
</view>
</view>
<view class="visit-store-field">
<view class="section-title-row">
<view class="visit-store-label">打卡拍照</view>
<view class="section-extra">最多9张</view>
</view>
<view v-if="!item.photos || item.photos.length === 0" class="photo-grid">
<view v-if="!isAuditEntry" class="photo-item add" @tap="uploadStorePhotos(idx)">
<u-icon name="photo" size="32" color="#c6ccd8"/>
</view>
<view v-else class="audit-empty-tip visit-store-empty">暂无巡店照片</view>
</view>
<view v-else class="photo-grid">
<view v-for="(photo, photoIdx) in item.photos" :key="`store-${idx}-photo-${photoIdx}`" class="photo-item">
<image :src="photo" mode="aspectFill" class="photo-image" @tap="previewStorePhotos(idx, photoIdx)"/>
<view v-if="!isAuditEntry" class="photo-remove" @tap="removeStorePhoto(idx, photoIdx)">x</view>
</view>
<view v-if="!isAuditEntry && item.photos.length < 9" class="photo-item add" @tap="uploadStorePhotos(idx)">
<u-icon name="photo" size="32" color="#c6ccd8"/>
</view>
</view>
</view>
</view>
<view
v-if="!isAuditEntry"
class="visit-store-add"
@tap="addStoreItem"
>
新增巡店
</view>
</view>
<view v-if="!isStoreTask" class="card block">
<view class="section-title-row">
<view class="section-title">门头摆台照</view>
<view class="section-extra">最多{{ boothLimit }}张</view>
</view>
<view v-if="isAuditEntry && boothPhotos.length === 0" class="audit-empty-tip">暂无门头摆台照</view>
<view v-else class="photo-grid">
<view v-for="(item, idx) in boothPhotos" :key="idx" class="photo-item">
<image :src="item" mode="aspectFill" class="photo-image" @tap="handleBoothAction(idx)"/>
<view v-if="!isAuditEntry" class="photo-remove" @tap="removeBooth(idx)">x</view>
</view>
<view v-if="!isAuditEntry && boothPhotos.length < boothLimit" class="photo-item add" @tap="uploadBooth">
<u-icon name="photo" size="32" color="#c6ccd8"/>
</view>
</view>
</view>
<view v-if="!isStoreTask" class="card block">
<view class="section-title-row sales-title-row">
<view class="section-title">销量填写</view>
<view v-if="manualSalesMode" class="section-extra">已添加{{ manualSalesItemCount }}项</view>
</view>
<view v-if="manualSalesMode && !isAuditEntry" class="sales-actions">
<view class="sales-add-btn" @tap="openProductPicker">
<u-icon name="plus" size="18" color="#ffffff"/>
<text>新增商品</text>
</view>
</view>
<view v-if="manualSalesMode && manualSalesItemCount === 0" class="audit-empty-tip">
{{ isAuditEntry ? '暂无销量商品' : '请点击“新增商品”逐个添加销量商品' }}
</view>
<block v-if="!manualSalesMode || manualSalesItemCount > 0">
<view class="sales-group" v-for="(group, gIdx) in salesGroups" :key="gIdx">
<view class="sales-head" @tap="toggleGroup(gIdx)">
<view class="sales-title">{{ group.title }}</view>
<view :class="['sales-arrow', group.open ? 'open' : '']">
<u-icon name="arrow-right" size="16" color="#ffffff"/>
</view>
</view>
<view v-if="group.open" class="sales-items">
<view class="sales-item" v-for="(item, iIdx) in group.items" :key="iIdx">
<view class="sales-main">
<!-- <view :class="['sales-check', item.checked ? 'checked' : '']" @tap.stop="toggleItem(gIdx, iIdx)">-->
<!-- <u-icon v-if="item.checked" name="checkmark" size="14" color="#ffffff"/>-->
<!-- </view>-->
<view class="sales-name">
<text>{{ item.name }}</text>
<!-- <text v-if="hasTaskIncome(item)" class="sales-target">单品奖金</text>-->
<!-- <text v-if="hasTaskIncome(item)" class="text-red text-25">¥{{ formatTaskIncome(item) }}</text>-->
</view>
<view v-if="manualSalesMode && !isAuditEntry" class="sales-remove" @tap.stop="removeManualSalesItem(gIdx, iIdx)">删除</view>
</view>
<view v-if="item.taskNum" class="sales-progress">
<view class="sales-progress-bar">
<view class="sales-progress-fill" :style="{ width: `${salesProgress(item)}%` }"></view>
</view>
<text class="sales-progress-text">{{ salesProgressText(item) }}</text>
</view>
<view class="sales-controls">
<picker class="spec-picker" mode="selector" :range="item.specOptions"
@change="onSpecChange($event, gIdx, iIdx)">
<view class="spec-pill">
<text>{{ item.spec }}</text>
</view>
</picker>
<view class="qty-box">
<input v-model="item.qty" class="qty-input" type="number" placeholder="0" @input="scheduleSave"/>
<text class="qty-unit">{{ item.unit }}</text>
</view>
</view>
</view>
</view>
</view>
</block>
</view>
<u-popup :show="showProductPicker" mode="bottom" round="28rpx" @close="closeProductPicker">
<view class="product-picker">
<view class="merchant-picker-head">
<view class="picker-action" @tap="closeProductPicker">取消</view>
<text class="picker-title">选择商品</text>
<view class="picker-action primary" @tap="closeProductPicker">完成</view>
</view>
<view class="merchant-picker-search">
<u-input v-model="salesKeyword" placeholder="搜索商品名称/厂家/规格" shape="circle"/>
</view>
<view v-if="productLoading" class="merchant-picker-loading">
<u-loading-icon mode="circle"></u-loading-icon>
</view>
<view v-else class="product-panel">
<scroll-view class="category-sidebar" scroll-y>
<view
v-for="group in filteredProductGroups"
:key="group.key"
:class="['category-item', currentProductGroupKey === group.key ? 'category-item-active' : '']"
@tap="selectProductGroup(group.key)"
>
{{ group.title }}
</view>
</scroll-view>
<scroll-view class="product-panel-main" scroll-y>
<view v-if="filteredProductGroups.length === 0" class="audit-empty-tip">暂无可选商品</view>
<view v-else-if="displayedProductOptions.length === 0" class="audit-empty-tip">该分类暂无可选商品</view>
<view v-else class="product-picker-list">
<view
v-for="item in displayedProductOptions"
:key="item._key"
class="product-picker-item"
@tap="selectProduct(item)"
>
<view class="product-picker-main">
<view class="product-picker-name">{{ item.name }}</view>
<view class="product-picker-sub">{{ item.makerAbb || '-' }} / {{ item.spec || '规格' }}</view>
<!-- <view v-if="hasTaskIncome(item)" class="product-picker-income">奖金金额:¥{{ formatTaskIncome(item) }}</view>-->
</view>
<view class="product-picker-tag">{{ isProductSelected(item) ? '已添加' : '添加' }}</view>
</view>
</view>
</scroll-view>
</view>
</view>
</u-popup>
<view v-if="!isStoreTask" class="card block">
<view class="section-title-row">
<view class="section-title tight">
小票上传
<text class="section-sub">(必须原图)</text>
</view>
<view class="section-extra">最多{{ receiptLimit }}张</view>
</view>
<template v-if="receiptPhotos.length === 0">
<view v-if="isAuditEntry" class="audit-empty-tip">暂无小票</view>
<view v-else class="receipt-upload" @tap="uploadReceipt">
<view class="receipt-placeholder">
<view class="receipt-icon">
<u-icon name="arrow-upward" size="30" color="#8f9bb3"/>
</view>
<view class="receipt-title">点击拍照上传</view>
<view class="receipt-desc">支持jpg/png,需清晰可见</view>
</view>
</view>
</template>
<view v-else class="photo-grid">
<view v-for="(item, idx) in receiptPhotos" :key="idx" class="photo-item">
<image :src="item" mode="aspectFill" class="photo-image" @tap="handleReceiptAction(idx)"/>
<view v-if="!isAuditEntry" class="photo-remove" @tap="removeReceipt(idx)">x</view>
</view>
<view v-if="!isAuditEntry && receiptPhotos.length < receiptLimit" class="photo-item add" @tap="uploadReceipt">
<u-icon name="arrow-upward" size="30" color="#c6ccd8"/>
</view>
</view>
</view>
<view class="card block">
<view class="section-title">活动小结</view>
<textarea v-model="summary" class="summary-input" placeholder="请填写活动情况" @input="scheduleSave"/>
</view>
<view v-if="!isStoreTask" class="card block">
<view class="section-title">下班打卡(水印拍照)</view>
<view class="clock-grid">
<view class="clock-card warm">
<view class="clock-thumb" @tap="handleClockAction(1)">
<image v-if="clockPhotos[1] && clockPhotos[1].url" :src="clockPhotos[1] && clockPhotos[1].url"
mode="aspectFill" class="clock-image"/>
<view v-else class="clock-placeholder">
<u-icon name="photo" size="40" color="#52627a"/>
</view>
<view v-if="clockPhotos[1] && clockPhotos[1].url" class="clock-check">
<u-icon name="checkmark-circle-fill" size="22" color="#22c55e"/>
</view>
<view class="clock-info">
<view class="clock-time">{{ (clockPhotos[1] && clockPhotos[1].time) || '--:--' }}</view>
<view class="clock-date">{{ (clockPhotos[1] && clockPhotos[1].date) || '--' }}</view>
</view>
</view>
<view class="detail-section-card location-card" v-if="isAuditEntry && clockPhotos[1] && clockPhotos[1].lat">
<view class="location-row">
<text class="location-label">纬度:</text>
<text class="location-value">{{ clockPhotos[1].lat }}</text>
</view>
<view class="location-row">
<text class="location-label">经度:</text>
<text class="location-value">{{ clockPhotos[1].lng }}</text>
</view>
<view class="location-row">
<text class="location-label">详细地址:</text>
<text class="location-value">{{ clockPhotos[1].location }}</text>
</view>
</view>
</view>
</view>
</view>
<view v-if="!isStoreTask" class="card block">
<view class="section-title-row">
<view class="section-title warm">门店评分</view>
<view class="section-extra">点击星星评分</view>
</view>
<view class="rating-row">
<u-rate v-model="rating" :count="5" :size="34" active-color="#ff8a4c" inactive-color="#e4e8ef"
@change="scheduleSave"/>
</view>
</view>
<view v-if="!isAuditEntry && (task.userTask.status === 0 || task.userTask.status === 3)" class="task-submit-bar">
<view class="primary-btn submit" @tap="submit">提交</view>
</view>
<view v-if="!isAuditEntry && (task.userTask.status === 0 || task.userTask.status === 3)" class="task-submit-placeholder"/>
<view v-if="!isAuditEntry && task.userTask.status === 1" class="text-green text-center mt-20">审核中</view>
<view v-if="!isAuditEntry && task.userTask.status === 2" class="text-green text-center mt-20">审核通过</view>
<view v-if="isAuditEntry" class="audit-action-bar">
<view v-if="canAuditApproveReject" class="audit-action-list">
<view class="audit-action-btn" @tap="handleAuditApprove">通过</view>
<view class="audit-action-btn audit-action-btn-danger" @tap="openAuditRejectPopup">驳回</view>
</view>
<view v-if="canAuditCopy" class="audit-action-list mt-12">
<view class="audit-action-btn" @tap="openAuditCopyPopup">抄送</view>
</view>
</view>
<u-popup :show="showMerchantPicker" mode="bottom" round="24rpx" @close="closeMerchantPicker">
<view class="merchant-picker">
<view class="merchant-picker-head">
<view class="picker-action" @tap="closeMerchantPicker">取消</view>
<text class="picker-title">选择连锁和门店</text>
<view class="picker-action primary" @tap="confirmMerchantPicker">确定</view>
</view>
<view class="merchant-picker-search">
<u-input v-model="merchantSearchKeyword" placeholder="搜索连锁/门店" shape="circle"
:customStyle="{border: '3px solid #ff8a4c !important'}"/>
<!-- <input-->
<!-- v-model="merchantSearchKeyword"-->
<!-- class="merchant-search-input"-->
<!-- placeholder="搜索连锁/门店"-->
<!-- placeholder-class="merchant-search-placeholder"-->
<!-- />-->
</view>
<view v-if="merchantLoading" class="merchant-picker-loading">
<u-loading-icon mode="circle"></u-loading-icon>
</view>
<picker-view
v-else
class="merchant-picker-view"
:value="merchantPickerValue"
:indicator-style="merchantIndicatorStyle"
@change="onMerchantPickerChange"
>
<picker-view-column>
<view v-for="(item, idx) in merchantChainOptions" :key="`chain-${idx}`" class="merchant-picker-item">
{{ item.text }}
</view>
</picker-view-column>
<picker-view-column>
<view v-for="(item, idx) in merchantStoreOptions" :key="`merchant-${idx}`" class="merchant-picker-item">
{{ item.text }}
</view>
</picker-view-column>
</picker-view>
</view>
</u-popup>
<Login ref="Login" @done="getUserData"/>
<view v-if="showAuditRejectPopup" class="audit-popup-mask" @tap="closeAuditRejectPopup"></view>
<view v-if="showAuditRejectPopup" class="audit-popup card" @tap.stop>
<view class="audit-popup-head">
<view class="section-title">填写拒绝原因</view>
<view class="close-btn" @tap="closeAuditRejectPopup">关闭</view>
</view>
<view class="audit-form-row">
<view class="audit-form-label">拒绝原因</view>
<input v-model="auditRejectReason" class="audit-form-input" type="text" placeholder="请输入拒绝原因"/>
</view>
<view class="primary-btn audit-submit-btn" @tap="submitAuditRejectReason">提交</view>
</view>
<view v-if="showAuditCopyPopup" class="audit-popup-mask" @tap="closeAuditCopyPopup" @touchmove.stop.prevent></view>
<view v-if="showAuditCopyPopup" class="audit-popup card" @tap.stop>
<view class="audit-popup-head">
<view class="section-title">抄送</view>
<view class="close-btn" @tap="closeAuditCopyPopup">关闭</view>
</view>
<view class="audit-copy-search-row">
<input
v-model="auditCopyKeyword"
class="audit-copy-search-input"
type="text"
placeholder="输入姓名/手机号搜索用户"
@confirm="searchAuditCopyUsers"
/>
<view class="audit-copy-search-btn" @tap="searchAuditCopyUsers">搜索</view>
</view>
<view v-if="auditCopySearching" class="audit-copy-loading">搜索中...</view>
<scroll-view v-else class="audit-copy-user-list" scroll-y>
<view
v-for="user in auditCopyUsers"
:key="`audit-copy-user-${user.id}`"
:class="['audit-copy-user-item', auditSelectedCopyUserId === user.id ? 'audit-copy-user-item-active' : '']"
@tap="auditSelectedCopyUserId = user.id"
>
<text class="audit-copy-user-name">{{ user.label }}</text>
<text class="audit-copy-user-phone">{{ user.phone || '' }}</text>
</view>
<view v-if="auditCopyUsers.length === 0" class="audit-copy-empty">暂无用户</view>
</scroll-view>
<view class="primary-btn audit-submit-btn" @tap="submitAuditCopy">确认抄送</view>
</view>
</view>
</template>
<script>
import Login from "@/components/Login.vue";
import AppNavbar from "@/components/AppNavbar.vue";
import {userInfoReq, userSearchReq} from "@/api/user";
import {getUserInfo} from "@/util/user";
import {chooseImg, getLocationOnly, calcDistanceMeters} from "@/util";
import {changeUserTaskReq, copyUserTaskReq, taskInfoReq} from "@/api/task";
import {merchantGroupByChainReq} from "@/api/shop";
import {productGroupByCateReq} from "@/api/product";
import {Base64} from 'js-base64';
import dayjs from "dayjs";
export default {
components: {Login, AppNavbar},
data() {
return {
task: {
title: '任务详情',
reward: 0,
target: ''
},
clockPhotos: [
{key: 'in', label: '上班', time: '', date: '', url: ''},
{key: 'out', label: '下班', time: '', date: '', url: ''}
],
boothPhotos: [],
boothLimit: 6,
receiptPhotos: [],
receiptLimit: 999,
summary: '',
rating: 0,
salesGroups: [],
productGroups: [],
productLoading: false,
productLoadedChainName: '',
showProductPicker: false,
salesKeyword: '',
activeProductGroupKey: '',
storeList: [],
merchantChainList: [],
merchantLoading: false,
showMerchantPicker: false,
merchantSearchKeyword: '',
merchantPickerValue: [0, 0],
activeStorePickerIndex: -1,
id: null,
isLoggedIn: false,
lat: null,
lng: null,
currentAddress: '',
detailExpanded: true,
auditEntry: false,
auditId: '',
auditTaskId: '',
auditUserId: '',
auditStatus: null,
auditSubmitting: false,
showAuditRejectPopup: false,
auditRejectReason: '',
showAuditCopyPopup: false,
auditCopyKeyword: '',
auditCopyUsers: [],
auditCopySearching: false,
auditSelectedCopyUserId: '',
autoSaveTimer: null,
autoSaving: false,
autoSavePending: false
}
},
computed: {
isAuditEntry() {
return this.auditEntry
},
isStoreTask() {
return Number(this.task && this.task.cateId) === 4
},
canAuditApproveReject() {
return this.isAuditEntry && Number(this.auditStatus) === 1
},
canAuditCopy() {
if (!this.isAuditEntry) return false
const status = Number(this.auditStatus)
return status === 1 || status === 3
},
manualSalesMode() {
if (this.isStoreTask) return false
const list = Array.isArray(this.task && this.task.productList) ? this.task.productList : []
return list.length === 0
},
manualSalesItemCount() {
return (Array.isArray(this.salesGroups) ? this.salesGroups : []).reduce((total, group) => {
const items = Array.isArray(group.items) ? group.items : []
return total + items.length
}, 0)
},
productChainName() {
const task = this.task || {}
const merchant = task && task.merchant ? task.merchant : {}
const chain = merchant.chain || merchant.chainName || merchant.chainAbb || task.chain || task.chainName || task.chainAbb || ''
return String(chain || '').trim()
},
filteredProductGroups() {
const keyword = (this.salesKeyword || '').trim().toLowerCase()
const groups = Array.isArray(this.productGroups) ? this.productGroups : []
if (!keyword) return groups
return groups
.map(group => {
const items = Array.isArray(group.items) ? group.items : []
const nextItems = items.filter(item => {
const name = String(item.name || '').toLowerCase()
const maker = String(item.makerAbb || '').toLowerCase()
const spec = String(item.spec || '').toLowerCase()
return name.includes(keyword) || maker.includes(keyword) || spec.includes(keyword)
})
return Object.assign({}, group, {items: nextItems})
})
.filter(group => Array.isArray(group.items) && group.items.length)
},
currentProductGroupKey() {
const groups = Array.isArray(this.filteredProductGroups) ? this.filteredProductGroups : []
if (!groups.length) return ''
const hasActive = groups.some(group => group.key === this.activeProductGroupKey)
return hasActive ? this.activeProductGroupKey : groups[0].key
},
displayedProductOptions() {
const groups = Array.isArray(this.filteredProductGroups) ? this.filteredProductGroups : []
const current = groups.find(group => group.key === this.currentProductGroupKey)
return current && Array.isArray(current.items) ? current.items : []
},
merchantChainOptions() {
const list = Array.isArray(this.merchantChainList) ? this.merchantChainList : []
const keyword = (this.merchantSearchKeyword || '').trim()
if (!keyword) return list.length ? list : [{id: '', text: '暂无连锁', merchants: []}]
const filtered = list
.map(chain => {
const merchants = Array.isArray(chain.merchants) ? chain.merchants : []
const chainMatch = (chain.text || '').includes(keyword)
if (chainMatch) return {...chain}
const matchedMerchants = merchants.filter(merchant => (merchant.text || '').includes(keyword))
if (!matchedMerchants.length) return null
return {
...chain,
merchants: matchedMerchants
}
})
.filter(Boolean)
return filtered.length ? filtered : [{id: '', text: '无匹配连锁', merchants: []}]
},
merchantStoreOptions() {
const chainIndex = Number(this.merchantPickerValue[0] || 0)
const currentChain = this.merchantChainOptions[chainIndex] || this.merchantChainOptions[0] || {}
const merchants = Array.isArray(currentChain.merchants) ? currentChain.merchants : []
return merchants.length ? merchants : [{id: '', text: '无门店'}]
},
merchantIndicatorStyle() {
return 'height: 44px;'
},
storeName() {
const merchant = (this.task && this.task.merchant) ? this.task.merchant : {}
return merchant.name || this.task.title || '门店名称'
},
storeChainText() {
const task = this.task || {}
const merchant = task && task.merchant ? task.merchant : {}
const chain = merchant.chain || merchant.chainName || merchant.chainAbb || task.chain || task.chainName || task.chainAbb || ''
return String(chain || '').trim()
},
storeAddress() {
const merchant = (this.task && this.task.merchant) ? this.task.merchant : {}
const fullAddress = [merchant.province, merchant.city, merchant.region, merchant.address].filter(Boolean).join('')
return fullAddress || this.task.address || '暂无地址'
},
taskTag() {
const cate = (this.task && this.task.cate) ? this.task.cate : {}
return cate.title || this.task.target || '日常驻店'
},
detailRequirement() {
return this.pickDetailValue(
['detail'],
'上下班打卡,拍摄活动照片,提交活动小结'
);
},
detailFocus() {
if (!this.task.productList || !this.task.productList.length) return ''
return this.task.productList.map(item => item.product.title).join('、')
},
detailPolicy() {
return this.pickDetailValue(
['policy', 'policyDesc', 'policyText', 'policyRemark', 'activityPolicy', 'policyDetail'],
'益血生两盒75折,骨通贴膏买二送一'
);
},
taskExecutePeriod() {
const startRaw = this.task && this.task.taskStartTime ? this.task.taskStartTime : ''
const endRaw = this.task && this.task.taskEndTime ? this.task.taskEndTime : ''
const start = this.trimSecond(startRaw)
const end = this.trimSecond(endRaw)
if (start && end) return `${start} 至 ${end}`
if (start) return `${start} 至 -`
if (end) return `- 至 ${end}`
return '暂无时间段'
},
totalTaskNum() {
return this.calcTotalTaskNum();
},
totalQty() {
return this.calcTotalQty();
},
totalProgressPercent() {
if (!this.totalTaskNum) return 0;
return Math.min(100, Math.round((this.totalQty / this.totalTaskNum) * 100));
},
totalProgressText() {
return `${this.totalQty}/${this.totalTaskNum || 0}`;
},
salesBonusDisplay() {
return this.formatAmountDisplay(this.resolveTaskAmount([
this.task && this.task.reward,
this.task && this.task.taskIncome
]))
},
extraBonusDisplay() {
return this.formatAmountDisplay(this.resolveTaskAmount([
this.task && this.task.extraIncome,
this.task && this.task.extraReward,
this.task && this.task.extra_bonus,
this.task && this.task.extraBonus
]))
}
},
watch: {
merchantSearchKeyword() {
this.merchantPickerValue = [0, 0]
}
},
async onLoad(options) {
this.auditEntry = String(options.fromAudit || '') === '1'
this.auditId = options.auditId || ''
this.auditTaskId = options.auditTaskId || ''
this.auditUserId = options.auditUserId || ''
if (options.auditStatus !== undefined && options.auditStatus !== null && options.auditStatus !== '') {
const status = Number(options.auditStatus)
this.auditStatus = Number.isFinite(status) ? status : null
}
if (options.title) this.task.title = decodeURIComponent(options.title)
if (options.reward) this.task.reward = options.reward
if (options.target) this.task.target = decodeURIComponent(options.target)
if (options.id) {
this.id = Number(options.id)
this.getInfo()
}
},
async onShow() {
if (getUserInfo().token) this.getUserData()
else {
this.$refs.Login.open()
}
if (this.isAuditEntry) return
const res = await getLocationOnly().catch(() => {
this.toast('请允许位置授权')
})
if (res) {
this.lat = res.latitude
this.lng = res.longitude
this.currentAddress = res.full || [res.province_str, res.city_str, res.area_str].filter(Boolean).join('')
this.applyNearestMerchantToAutoItems()
}
},
methods: {
calReward() {
const reward = this.resolveTaskAmount([
this.task && this.task.reward,
this.task && this.task.taskIncome
])
const extraReward = this.resolveTaskAmount([
this.task && this.task.extraIncome,
this.task && this.task.extraReward,
this.task && this.task.extra_bonus,
this.task && this.task.extraBonus
])
return (reward + extraReward).toFixed(2)
},
resolveTaskAmount(candidates = []) {
for (let i = 0; i < candidates.length; i += 1) {
const value = candidates[i]
if (value === undefined || value === null || String(value).trim() === '') continue
const num = Number(value)
if (Number.isFinite(num)) return num
}
return 0
},
formatAmountDisplay(value) {
const num = Number(value)
if (!Number.isFinite(num)) return '0'
return Number.isInteger(num) ? String(num) : num.toFixed(2)
},
async getUserData() {
const {data} = await userInfoReq()
this.isLoggedIn = true
this.user = data
},
async getInfo() {
const {data} = await taskInfoReq(this.id)
this.task = data
const applied = this.applyUserTask(data.userTask)
if (this.isStoreTask) {
await this.prepareStoreTaskData()
} else if (applied) {
this.salesGroups = this.manualSalesMode
? this.attachTaskNumToSalesGroups(this.salesGroups, data.productGroup)
: this.mergeSalesGroupsWithProductGroups(this.salesGroups, data.productGroup)
} else if (this.manualSalesMode) {
this.salesGroups = []
} else {
this.salesGroups = this.buildSalesGroups(data.productGroup)
}
if (this.isAuditEntry && (this.auditStatus === null || this.auditStatus === undefined || Number.isNaN(this.auditStatus))) {
const status = Number(data && data.userTask ? data.userTask.status : NaN)
this.auditStatus = Number.isFinite(status) ? status : null
if (data.userTask && data.userTask.lat) this.lat = data.userTask.lat
if (data.userTask && data.userTask.lng) this.lng = data.userTask.lng
if (data.userTask && data.userTask.location) this.currentAddress = data.userTask.location
}
},
applyUserTask(userTask) {
if (!userTask) return false
const storeList = this.parseJsonMaybe(userTask.storeList)
if (Array.isArray(storeList)) {
this.storeList = storeList.map(item => this.normalizeStoreItem(item))
}
const clockPhotos = this.parseJsonMaybe(userTask.clockPhotos)
if (Array.isArray(clockPhotos) && clockPhotos.length) {
this.clockPhotos = this.normalizeClockPhotos(clockPhotos)
}
const boothPhotos = this.parseJsonMaybe(userTask.boothPhotos)
if (Array.isArray(boothPhotos)) {
this.boothPhotos = boothPhotos
}
const receiptPhoto = this.parseJsonMaybe(userTask.receiptPhoto)
this.receiptPhotos = this.normalizeReceiptPhotos(receiptPhoto)
const summary = this.parseJsonMaybe(userTask.summary)
if (typeof summary === 'string') {
this.summary = summary
}
const rating = this.parseJsonMaybe(userTask.rating)
if (rating !== undefined && rating !== null && rating !== '') {
this.rating = Number(rating) || 0
}
const salesGroups = this.parseJsonMaybe(userTask.salesGroups)
if (Array.isArray(salesGroups) && salesGroups.length) {
this.salesGroups = this.attachTaskNumToSalesGroups(salesGroups)
console.log('1', this.salesGroups)
return true
}
return Array.isArray(this.storeList) && this.storeList.length > 0
},
parseJsonMaybe(value) {
if (value === null || value === undefined) return value
if (typeof value === 'string') {
const trimmed = value.trim()
if (!trimmed) return ''
try {
return JSON.parse(trimmed)
} catch (error) {
return value
}
}
return value
},
normalizeReceiptPhotos(value) {
if (!value) return []
const list = Array.isArray(value) ? value : [value]
return list
.map(item => {
if (!item) return ''
if (typeof item === 'string') return item
return item.url || item.path || item.image || ''
})
.filter(Boolean)
},
normalizeClockPhotos(list) {
const base = Array.isArray(this.clockPhotos) ? this.clockPhotos : []
if (!base.length) return list
const next = base.map((item, index) => Object.assign({}, item, list[index] || {}))
if (list.length > base.length) {
for (let i = base.length; i < list.length; i += 1) {
next.push(list[i])
}
}
return next
},
normalizeMerchant(item) {
const id = item.id ?? item.merchantId ?? item.merchant_id
const text = item.name || item.merchantName || item.merchant_name || item.title || '未命名门店'
const lat = Number(item.lat ?? item.latitude ?? item.merchantLat ?? item.merchant_lat)
const lng = Number(item.lng ?? item.longitude ?? item.merchantLng ?? item.merchant_lng)
const address = [item.province, item.city, item.region, item.area, item.address].filter(Boolean).join('')
return {
id,
text,
chainId: item.chainId ?? item.chain_id ?? '',
chainName: item.chainName || item.chain || item.chainAbb || '',
lat: Number.isFinite(lat) ? lat : null,
lng: Number.isFinite(lng) ? lng : null,
province: item.province || item.province_str || '',
city: item.city || item.city_str || '',
address
}
},
pickFirstText(source, keys = []) {
if (!source || typeof source !== 'object') return ''
for (let i = 0; i < keys.length; i += 1) {
const value = source[keys[i]]
if (value !== undefined && value !== null && String(value).trim()) {
return String(value).trim()
}
}
return ''
},
unwrapList(data) {
if (Array.isArray(data)) return data
if (!data || typeof data !== 'object') return []
const keys = ['list', 'records', 'rows', 'items', 'data', 'result', 'chainList']
for (let i = 0; i < keys.length; i += 1) {
const value = data[keys[i]]
if (Array.isArray(value)) return value
if (value && typeof value === 'object') {
const nested = this.unwrapList(value)
if (nested.length) return nested
}
}
return [data]
},
normalizeMerchantChain(item, index) {
const chainName = this.pickFirstText(item, ['chainName', 'chain', 'chainAbb', 'name', 'title']) || `连锁${index + 1}`
const rawChainId = item.chainId ?? item.chain_id ?? item.id ?? item.chainCode ?? item.chain_code ?? chainName
const chainId = rawChainId === undefined || rawChainId === null || String(rawChainId).trim() === ''
? `chain_${index}`
: rawChainId
const childCandidates = ['children', 'merchantList', 'merchants', 'shopList', 'list', 'records']
let rawChildren = []
for (let i = 0; i < childCandidates.length; i += 1) {
const list = item[childCandidates[i]]
if (Array.isArray(list) && list.length) {
rawChildren = list
break
}
}
let merchants = rawChildren
.map(child => this.normalizeMerchant(child))
.filter(merchant => merchant.id !== undefined && merchant.id !== null && String(merchant.id).trim() !== '')
.map(merchant => Object.assign({}, merchant, {chainId, chainName}))
if (!merchants.length) {
const selfMerchant = this.normalizeMerchant(item)
if (selfMerchant.id !== undefined && selfMerchant.id !== null && String(selfMerchant.id).trim() !== '') {
merchants = [Object.assign({}, selfMerchant, {chainId, chainName})]
}
}
return {
id: chainId,
text: chainName,
merchants
}
},
flattenMerchantsFromChains(chainList) {
const list = []
;(Array.isArray(chainList) ? chainList : []).forEach(chain => {
const merchants = Array.isArray(chain.merchants) ? chain.merchants : []
merchants.forEach(merchant => {
if (!merchant || merchant.id === '' || merchant.id === null || merchant.id === undefined) return
list.push(Object.assign({}, merchant, {
chainId: merchant.chainId || chain.id || '',
chainName: merchant.chainName || chain.text || ''
}))
})
})
return list
},
findNearestMerchant(list) {
if (!Number.isFinite(this.lat) || !Number.isFinite(this.lng)) return null
let nearest = null
let minDistance = Infinity
list.forEach(item => {
if (!Number.isFinite(item.lat) || !Number.isFinite(item.lng)) return
const meters = calcDistanceMeters(this.lat, this.lng, item.lat, item.lng)
if (!Number.isFinite(meters) || meters >= minDistance) return
minDistance = meters
nearest = item
})
return nearest
},
buildStoreMerchantFields(merchant = null, autoSelected = true) {
if (!merchant) {
return {
merchantId: '',
merchantName: '',
chainId: '',
chainName: '',
province: '',
city: '',
merchantLat: '',
merchantLng: '',
merchantAddress: '',
_autoSelected: autoSelected
}
}
return {
merchantId: merchant.id ?? '',
merchantName: merchant.text || '',
chainId: merchant.chainId || '',
chainName: merchant.chainName || '',
province: merchant.province || '',
city: merchant.city || '',
merchantLat: Number.isFinite(merchant.lat) ? merchant.lat : '',
merchantLng: Number.isFinite(merchant.lng) ? merchant.lng : '',
merchantAddress: merchant.address || '',
_autoSelected: autoSelected
}
},
createStoreItem(merchant = null, autoSelected = true) {
return Object.assign({
visitTime: this.formatDateTime(new Date()),
lat: '',
lng: '',
location: '',
photos: []
}, this.buildStoreMerchantFields(merchant, autoSelected))
},
normalizeStoreItem(item) {
const photos = Array.isArray(item && item.photos)
? item.photos.filter(Boolean)
: this.normalizeReceiptPhotos(item && (item.photos || item.images || item.image))
return {
merchantId: item && (item.merchantId ?? item.storeId ?? item.id) !== undefined
? (item.merchantId ?? item.storeId ?? item.id)
: '',
merchantName: item && (item.merchantName || item.storeName || item.name) ? (item.merchantName || item.storeName || item.name) : '',
chainId: item && item.chainId !== undefined ? item.chainId : '',
chainName: item && item.chainName ? item.chainName : '',
visitTime: item && (item.visitTime || item.time || item.createTime) ? (item.visitTime || item.time || item.createTime) : '',
province: item && item.province ? item.province : '',
city: item && item.city ? item.city : '',
merchantLat: item && item.merchantLat !== undefined ? item.merchantLat : '',
merchantLng: item && item.merchantLng !== undefined ? item.merchantLng : '',
merchantAddress: item && (item.merchantAddress || item.address) ? (item.merchantAddress || item.address) : '',
lat: item && item.lat !== undefined ? item.lat : '',
lng: item && item.lng !== undefined ? item.lng : '',
location: item && item.location ? item.location : '',
photos,
_autoSelected: false
}
},
sanitizeStoreList() {
const list = Array.isArray(this.storeList) ? this.storeList : []
return list.map(item => ({
merchantId: item.merchantId ?? '',
merchantName: item.merchantName || '',
chainId: item.chainId || '',
chainName: item.chainName || '',
visitTime: item.visitTime || '',
province: item.province || '',
city: item.city || '',
merchantLat: item.merchantLat !== undefined ? item.merchantLat : '',
merchantLng: item.merchantLng !== undefined ? item.merchantLng : '',
merchantAddress: item.merchantAddress || '',
lat: item.lat !== undefined ? item.lat : '',
lng: item.lng !== undefined ? item.lng : '',
location: item.location || '',
photos: Array.isArray(item.photos) ? item.photos.filter(Boolean) : []
}))
},
syncStoreMerchantMeta() {
const merchantList = this.flattenMerchantsFromChains(this.merchantChainList)
if (!Array.isArray(this.storeList) || !this.storeList.length || !merchantList.length) {
return
}
this.storeList = this.storeList.map(item => {
if (!item || item.merchantId === '' || item.merchantId === null || item.merchantId === undefined) return item
const matched = merchantList.find(merchant => String(merchant.id) === String(item.merchantId))
if (!matched) return item
return Object.assign({}, this.buildStoreMerchantFields(matched, !!item._autoSelected), item, {
chainId: item.chainId || matched.chainId || '',
chainName: item.chainName || matched.chainName || '',
merchantName: item.merchantName || matched.text || '',
province: item.province || matched.province || '',
city: item.city || matched.city || '',
merchantLat: item.merchantLat !== '' && item.merchantLat !== null && item.merchantLat !== undefined ? item.merchantLat : (Number.isFinite(matched.lat) ? matched.lat : ''),
merchantLng: item.merchantLng !== '' && item.merchantLng !== null && item.merchantLng !== undefined ? item.merchantLng : (Number.isFinite(matched.lng) ? matched.lng : ''),
merchantAddress: item.merchantAddress || matched.address || '',
_autoSelected: !!item._autoSelected
})
})
},
async getMerchantList() {
this.merchantLoading = true
try {
const {data} = await merchantGroupByChainReq({})
const list = this.unwrapList(data)
this.merchantChainList = list
.map((item, index) => this.normalizeMerchantChain(item, index))
.filter(item => Array.isArray(item.merchants) && item.merchants.length)
} catch (error) {
this.merchantChainList = []
this.toast('门店列表获取失败')
} finally {
this.merchantLoading = false
}
},
async prepareStoreTaskData() {
await this.getMerchantList()
this.syncStoreMerchantMeta()
if (!this.storeList.length) {
this.ensureDefaultStoreList()
if (!this.isAuditEntry && this.storeList.length && !this.storeList[0].location) {
await this.fetchStoreLocation(0, true)
}
return
}
this.applyNearestMerchantToAutoItems()
},
ensureDefaultStoreList() {
if (!this.isStoreTask || this.storeList.length) return
const merchantList = this.flattenMerchantsFromChains(this.merchantChainList)
const nearest = this.findNearestMerchant(merchantList)
const target = nearest || merchantList[0] || null
this.storeList = [this.createStoreItem(target, true)]
},
applyNearestMerchantToAutoItems() {
const merchantList = this.flattenMerchantsFromChains(this.merchantChainList)
if (!this.isStoreTask || !merchantList.length) return
const nearest = this.findNearestMerchant(merchantList)
const fallback = nearest || merchantList[0] || null
if (!fallback) return
this.storeList = (Array.isArray(this.storeList) ? this.storeList : []).map(item => {
if (!item) return this.createStoreItem(fallback, true)
if (!item._autoSelected) return item
return Object.assign({}, item, this.buildStoreMerchantFields(fallback, true))
})
},
getMerchantPickerIndexesByStore(item) {
const chainList = Array.isArray(this.merchantChainList) ? this.merchantChainList : []
if (!chainList.length) return [0, 0]
let chainIndex = chainList.findIndex(chain => String(chain.id) === String(item && item.chainId !== undefined ? item.chainId : ''))
if (chainIndex < 0 && item && item.merchantId) {
chainIndex = chainList.findIndex(chain => {
const merchants = Array.isArray(chain.merchants) ? chain.merchants : []
return merchants.some(merchant => String(merchant.id) === String(item.merchantId))
})
}
if (chainIndex < 0) chainIndex = 0
const merchants = Array.isArray(chainList[chainIndex].merchants) ? chainList[chainIndex].merchants : []
let merchantIndex = merchants.findIndex(merchant => String(merchant.id) === String(item && item.merchantId !== undefined ? item.merchantId : ''))
if (merchantIndex < 0) merchantIndex = 0
return [chainIndex, merchantIndex]
},
async openStoreMerchantPicker(index) {
if (this.isAuditEntry) return
if (!Array.isArray(this.merchantChainList) || !this.merchantChainList.length) {
uni.showLoading({
title: '加载门店列表中...',
})
await this.getMerchantList()
uni.hideLoading()
}
const item = this.storeList[index] || this.createStoreItem()
this.activeStorePickerIndex = index
this.merchantSearchKeyword = ''
this.merchantPickerValue = this.getMerchantPickerIndexesByStore(item)
this.showMerchantPicker = true
},
closeMerchantPicker() {
this.showMerchantPicker = false
this.activeStorePickerIndex = -1
},
onMerchantPickerChange(e) {
const value = e.detail && e.detail.value !== undefined ? e.detail.value : e.detail
const currentChainIndex = Number(this.merchantPickerValue[0] || 0)
let chainIndex = Array.isArray(value) ? Number(value[0] || 0) : Number(value || 0)
let merchantIndex = Array.isArray(value) ? Number(value[1] || 0) : 0
if (!Number.isFinite(chainIndex) || chainIndex < 0) chainIndex = 0
if (!Number.isFinite(merchantIndex) || merchantIndex < 0) merchantIndex = 0
const chain = this.merchantChainOptions[chainIndex] || this.merchantChainOptions[0] || {}
const merchants = Array.isArray(chain.merchants) ? chain.merchants : []
if (!merchants.length) {
merchantIndex = 0
} else if (chainIndex !== currentChainIndex || merchantIndex > merchants.length - 1) {
merchantIndex = 0
}
this.merchantPickerValue = [chainIndex, merchantIndex]
},
confirmMerchantPicker() {
const index = this.activeStorePickerIndex
if (index < 0) {
this.closeMerchantPicker()
return
}
const chainIndex = Number(this.merchantPickerValue[0] || 0)
const merchantIndex = Number(this.merchantPickerValue[1] || 0)
const chain = this.merchantChainOptions[chainIndex] || null
const merchants = chain && Array.isArray(chain.merchants) ? chain.merchants : []
const selected = merchants[merchantIndex] || null
if (!selected || selected.id === '' || selected.id === null || selected.id === undefined) return
const current = this.storeList[index] || this.createStoreItem()
this.$set(this.storeList, index, Object.assign({}, current, this.buildStoreMerchantFields(Object.assign({}, selected, {
chainId: selected.chainId || (chain && chain.id) || '',
chainName: selected.chainName || (chain && chain.text) || ''
}), false)))
this.closeMerchantPicker()
this.scheduleSave()
},
async addStoreItem() {
const limit = this.safeNumber(this.task && this.task.storeNum)
if (limit && this.storeList.length >= limit) {
this.toast(`最多新增${limit}家门店`)
return
}
const merchantList = this.flattenMerchantsFromChains(this.merchantChainList)
const nearest = this.findNearestMerchant(merchantList)
const target = nearest || merchantList[0] || null
this.storeList.push(this.createStoreItem(target, true))
const index = this.storeList.length - 1
await this.fetchStoreLocation(index, true)
this.scheduleSave()
},
removeStoreItem(index) {
this.storeList.splice(index, 1)
if (!this.storeList.length) {
this.ensureDefaultStoreList()
}
this.save({silent: true})
},
async fetchStoreLocation(index, silent = false) {
if (this.isAuditEntry) return
if (!silent) {
uni.showLoading({
title: '定位中'
})
}
try {
const res = await getLocationOnly()
const current = this.storeList[index] || this.createStoreItem()
this.$set(this.storeList, index, Object.assign({}, current, {
lat: res.latitude,
lng: res.longitude,
location: res.full || [res.province_str, res.city_str, res.area_str].filter(Boolean).join(' ')
}))
await this.save({silent: true})
} catch (error) {
if (!silent) {
this.toast('定位失败')
}
} finally {
if (!silent) uni.hideLoading()
}
},
async uploadStorePhotos(index) {
if (!this.isLoggedIn) return this.$refs.Login.open()
uni.showLoading({
title: '上传中'
})
const current = this.storeList[index] || this.createStoreItem()
const photos = Array.isArray(current.photos) ? current.photos : []
const remain = 9 - photos.length
if (remain <= 0) {
uni.hideLoading()
return
}
const res = await chooseImg(remain, ['camera']).catch(() => {
uni.hideLoading()
})
if (!res || !res.length) {
uni.hideLoading()
return
}
const location = await getLocationOnly().catch(() => null)
const now = new Date()
const time = this.formatTime(now)
const date = this.formatDate(now)
const address = location
? `${location.province_str}${location.city_str}${location.area_str}`
: (current.location || this.currentAddress || '当前地址')
const nextPhotos = photos.concat(
res
.map(item => item && item.path ? this.buildWatermarkImageUrl(item.path, address, date, time) : '')
.filter(Boolean)
)
const nextStore = Object.assign({}, current, {
photos: nextPhotos
})
if (location) {
nextStore.lat = location.latitude
nextStore.lng = location.longitude
nextStore.location = location.full || [location.province_str, location.city_str, location.area_str].filter(Boolean).join(' ')
}
this.$set(this.storeList, index, nextStore)
uni.hideLoading()
await this.save({silent: true})
},
removeStorePhoto(storeIndex, photoIndex) {
const current = this.storeList[storeIndex] || this.createStoreItem()
const photos = Array.isArray(current.photos) ? [...current.photos] : []
photos.splice(photoIndex, 1)
this.$set(this.storeList, storeIndex, Object.assign({}, current, {photos}))
this.save({silent: true})
},
previewStorePhotos(storeIndex, photoIndex = 0) {
const current = this.storeList[storeIndex] || {}
const photos = Array.isArray(current.photos) ? current.photos.filter(Boolean) : []
if (!photos.length) return
this.previewImages(photos, photos[photoIndex] || photos[0])
},
isStoreItemCompleted(item) {
const photos = Array.isArray(item && item.photos) ? item.photos.filter(Boolean) : []
return !!(item && item.merchantId && item.location && item.lat && item.lng && photos.length)
},
pickDetailValue(keys, fallback) {
const task = this.task || {};
for (const key of keys) {
const text = this.formatDetailValue(task[key]);
if (text) return text;
}
return fallback;
},
formatDetailValue(value) {
if (value === 0) return '0';
if (!value) return '';
if (Array.isArray(value)) {
const list = [];
value.forEach(item => {
if (!item) return;
if (typeof item === 'string') {
list.push(item);
return;
}
if (Array.isArray(item)) {
item.forEach(child => {
if (child) list.push(String(child));
});
return;
}
const children = Array.isArray(item.children) ? item.children : [];
if (children.length) {
children.forEach(child => {
const name = child.title || child.name || child.goodsName || child.label || '';
if (name) list.push(name);
});
return;
}
const name = item.title || item.name || item.label || item.goodsName || item.spec || '';
if (name) list.push(name);
});
return list.filter(Boolean).join('、');
}
if (typeof value === 'object') {
const children = Array.isArray(value.children) ? value.children : [];
if (children.length) {
return children
.map(child => child.title || child.name || child.goodsName || child.label || '')
.filter(Boolean)
.join('、');
}
const name = value.title || value.name || value.label || value.goodsName;
if (name) return String(name);
}
return String(value);
},
trimSecond(value) {
const text = value === null || value === undefined ? '' : String(value).trim()
if (!text) return ''
return text.replace(/(\d{2}:\d{2}):\d{2}\b/, '$1')
},
formatCoordinate(value) {
const num = Number(value)
return Number.isFinite(num) ? num.toFixed(6) : '暂无'
},
previewImages(urls = [], current = '') {
const list = Array.isArray(urls) ? urls.filter(Boolean) : []
if (!list.length) return
uni.previewImage({
urls: list,
current: current || list[0]
})
},
handleClockAction(index) {
if (!this.isAuditEntry) {
this.uploadClock(index)
return
}
const list = Array.isArray(this.clockPhotos)
? this.clockPhotos.map(item => item && item.url).filter(Boolean)
: []
if (!list.length) return
const current = this.clockPhotos[index] && this.clockPhotos[index].url ? this.clockPhotos[index].url : list[0]
this.previewImages(list, current)
},
handleBoothAction(index) {
if (!this.isAuditEntry) return
const list = Array.isArray(this.boothPhotos) ? this.boothPhotos.filter(Boolean) : []
if (!list.length) return
const current = list[index] || list[0]
this.previewImages(list, current)
},
handleReceiptAction(index) {
if (!this.isAuditEntry) return
const list = Array.isArray(this.receiptPhotos) ? this.receiptPhotos.filter(Boolean) : []
if (!list.length) return
const current = list[index] || list[0]
this.previewImages(list, current)
},
async uploadClock(index) {
if (!this.isLoggedIn) return this.$refs.Login.open()
uni.showLoading({
title: '上传中'
})
const res = await chooseImg(1, ['camera']).catch(() => {
uni.hideLoading()
})
const now = new Date()
const time = this.formatTime(now)
const date = this.formatDate(now)
const next = res[0] && res[0].path ? res[0].path : ''
if (next) {
const location = await getLocationOnly()
if (location) {
const address = `${location.province_str}${location.city_str}${location.area_str}`
let url = `${next}?x-oss-process=image/watermark,text_${this.makeTextBase64(address)},g_sw,color_FFFFFF,x_10,y_120`
url += `/watermark,text_${this.makeTextBase64(date)},g_sw,color_FFFFFF,x_10,y_60`
url += `/watermark,text_${this.makeTextBase64(time)},g_sw,color_FFFFFF,x_10,y_10`
this.$set(this.clockPhotos, index, Object.assign({}, this.clockPhotos[index], {
url,
time,
date
}))
}
}
uni.hideLoading()
await this.save({isClock: true, clockIndex: index, silent: true})
},
makeTextBase64(text) {
let base64 = Base64.encode(text)
// 将结果中的加号(+)替换成短划线(-)。
// 将结果中的正斜线(/)替换成下划线(_)。
// 将结果中尾部的所有等号(=)省略。
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
},
buildWatermarkImageUrl(path, address, date, time) {
let url = `${path}?x-oss-process=image/watermark,text_${this.makeTextBase64(address)},g_sw,color_FFFFFF,x_10,y_120`
url += `/watermark,text_${this.makeTextBase64(date)},g_sw,color_FFFFFF,x_10,y_60`
url += `/watermark,text_${this.makeTextBase64(time)},g_sw,color_FFFFFF,x_10,y_10`
return url
},
async uploadBooth() {
if (!this.isLoggedIn) return this.$refs.Login.open()
const remain = this.boothLimit - this.boothPhotos.length
if (remain <= 0) return
const res = await chooseImg(remain)
this.boothPhotos.push(...res.map(i => i.path))
await this.save()
},
removeBooth(idx) {
this.boothPhotos.splice(idx, 1)
this.save()
},
async uploadReceipt() {
if (!this.isLoggedIn) return this.$refs.Login.open()
const remain = this.receiptLimit - this.receiptPhotos.length
if (remain <= 0) return
const res = await chooseImg(remain)
const next = res.map(item => item.path).filter(Boolean)
if (next.length) {
this.receiptPhotos.push(...next)
}
await this.save()
},
removeReceipt(index) {
this.receiptPhotos.splice(index, 1)
this.save()
},
scheduleSave() {
if (this.isAuditEntry) return
if (!this.task || !this.task.userTask) return
if (this.task.userTask.status === 1 || this.task.userTask.status === 2) return
if (this.autoSaveTimer) clearTimeout(this.autoSaveTimer)
this.autoSaveTimer = setTimeout(() => {
this.autoSaveTimer = null
this.flushAutoSave()
}, 800)
},
async flushAutoSave() {
if (this.autoSaveTimer) {
clearTimeout(this.autoSaveTimer)
this.autoSaveTimer = null
}
if (this.isAuditEntry) return
if (!this.task || !this.task.userTask) return
if (this.task.userTask.status === 1 || this.task.userTask.status === 2) return
if (this.autoSaving) {
this.autoSavePending = true
return
}
this.autoSaving = true
try {
await this.save({silent: true})
} finally {
this.autoSaving = false
if (this.autoSavePending) {
this.autoSavePending = false
this.scheduleSave()
}
}
},
toggleGroup(index) {
const next = !this.salesGroups[index].open
this.$set(this.salesGroups, index, Object.assign({}, this.salesGroups[index], {open: next}))
},
toggleDetailExpanded() {
this.detailExpanded = !this.detailExpanded
},
toggleItem(groupIdx, itemIdx) {
const item = this.salesGroups[groupIdx].items[itemIdx]
this.$set(this.salesGroups[groupIdx].items, itemIdx, Object.assign({}, item, {checked: !item.checked}))
this.scheduleSave()
},
onSpecChange(event, groupIdx, itemIdx) {
const value = Number(event.detail.value || 0)
const item = this.salesGroups[groupIdx].items[itemIdx]
const nextSpec = item.specOptions[value] || item.spec
this.$set(this.salesGroups[groupIdx].items, itemIdx, Object.assign({}, item, {spec: nextSpec}))
this.scheduleSave()
},
toast(message, title = '提示') {
const content = message === undefined || message === null ? '' : String(message)
return new Promise(resolve => {
uni.showModal({
title,
content,
showCancel: false,
success: resolve,
fail: resolve
})
})
},
buildAuditBasePayload() {
const userTask = this.task && this.task.userTask ? this.task.userTask : {}
const id = this.auditId || userTask.id || ''
const taskId = this.auditTaskId || this.id || this.task.id || ''
const userId = this.auditUserId || userTask.userId || userTask.user_id || this.task.userId || ''
if (id === '' || id === null || id === undefined) {
this.toast('记录ID缺失')
return null
}
if (taskId === '' || taskId === null || taskId === undefined) {
this.toast('任务ID缺失')
return null
}
if (userId === '' || userId === null || userId === undefined) {
this.toast('用户ID缺失')
return null
}
return {
id,
taskId,
userId
}
},
async submitAuditAction(status, auditReason = '') {
if (!this.isAuditEntry || this.auditSubmitting) return
const base = this.buildAuditBasePayload()
if (!base) return
const payload = {
...base,
status
}
if (Number(status) === 3) {
payload.auditReason = auditReason
}
this.auditSubmitting = true
try {
await changeUserTaskReq(payload)
this.auditStatus = Number(status)
if (this.task && this.task.userTask) {
this.$set(this.task, 'userTask', Object.assign({}, this.task.userTask, {status: Number(status)}))
}
await this.toast('操作成功')
uni.navigateBack()
} catch (error) {
this.toast('操作失败')
} finally {
this.auditSubmitting = false
}
},
async handleAuditApprove() {
await this.submitAuditAction(2)
},
openAuditRejectPopup() {
this.auditRejectReason = ''
this.showAuditRejectPopup = true
},
closeAuditRejectPopup() {
this.showAuditRejectPopup = false
this.auditRejectReason = ''
},
async submitAuditRejectReason() {
const reason = (this.auditRejectReason || '').trim()
if (!reason) {
this.toast('请输入拒绝原因')
return
}
await this.submitAuditAction(3, reason)
this.closeAuditRejectPopup()
},
unwrapAuditUserList(data) {
if (Array.isArray(data)) return data
if (!data || typeof data !== 'object') return []
const keys = ['list', 'records', 'rows', 'items', 'data', 'result']
for (let i = 0; i < keys.length; i++) {
if (Array.isArray(data[keys[i]])) return data[keys[i]]
}
return []
},
normalizeAuditUser(item) {
const id = item.id ?? item.userId ?? item.uid
const nickname = item.nickname || item.nickName || ''
const realName = item.realName || item.real_name || item.name || ''
const phone = item.phone || item.mobile || item.tel || ''
const label = realName || nickname || phone || `用户${id || ''}`
return {
id,
label,
phone
}
},
openAuditCopyPopup() {
this.auditCopyKeyword = ''
this.auditCopyUsers = []
this.auditSelectedCopyUserId = ''
this.showAuditCopyPopup = true
},
closeAuditCopyPopup() {
this.showAuditCopyPopup = false
},
async searchAuditCopyUsers() {
const keyword = (this.auditCopyKeyword || '').trim()
if (!keyword) {
this.toast('请输入keyword')
return
}
this.auditCopySearching = true
try {
const {data} = await userSearchReq({keywords: keyword})
const list = this.unwrapAuditUserList(data)
this.auditCopyUsers = list
.map(item => this.normalizeAuditUser(item))
.filter(item => item.id !== undefined && item.id !== null && String(item.id).trim() !== '')
this.auditSelectedCopyUserId = this.auditCopyUsers[0] ? this.auditCopyUsers[0].id : ''
} catch (error) {
this.auditCopyUsers = []
this.toast('搜索失败')
} finally {
this.auditCopySearching = false
}
},
async submitAuditCopy() {
const targetUserId = this.auditSelectedCopyUserId
if (targetUserId === '' || targetUserId === null || targetUserId === undefined) {
this.toast('请选择用户')
return
}
const base = this.buildAuditBasePayload()
if (!base) return
try {
await copyUserTaskReq({
id: base.id,
taskId: base.taskId,
sourceUserId: base.userId,
userId: targetUserId,
isCopy: 1
})
this.toast('抄送成功')
this.closeAuditCopyPopup()
} catch (error) {
this.toast('抄送失败')
}
},
validateSubmit() {
if (this.isStoreTask) {
const list = Array.isArray(this.storeList) ? this.storeList : []
if (!list.length) {
this.toast('请至少新增一条巡店记录')
return false
}
for (let idx = 0; idx < list.length; idx += 1) {
const item = list[idx]
if (!item || !item.merchantId) {
this.toast(`请选择巡店${idx + 1}的门店`)
return false
}
if (!item.location || !item.lat || !item.lng) {
this.toast(`请完成巡店${idx + 1}定位`)
return false
}
const photos = Array.isArray(item.photos) ? item.photos.filter(Boolean) : []
if (!photos.length) {
this.toast(`请上传巡店${idx + 1}照片`)
return false
}
}
const summaryText = (this.summary || '').trim()
if (!summaryText) {
this.toast('请填写活动小结')
return false
}
const targetStoreNum = this.safeNumber(this.task && this.task.storeNum)
const completed = list.filter(item => this.isStoreItemCompleted(item)).length
if (targetStoreNum && completed < targetStoreNum) {
this.toast(`请完成${targetStoreNum}家门店巡店`)
return false
}
return true
}
const clockList = Array.isArray(this.clockPhotos) ? this.clockPhotos : []
if (!clockList[1] || !clockList[1].url) {
this.toast('请至少完成下班打卡')
return false
}
if (!this.boothPhotos || this.boothPhotos.length === 0) {
this.toast('请上传门头摆台照')
return false
}
// if (!this.receiptPhotos.length) {
// this.toast('请上传小票原图')
// return false
// }
const summaryText = (this.summary || '').trim()
if (!summaryText) {
this.toast('请填写活动小结')
return false
}
const groups = Array.isArray(this.salesGroups) ? this.salesGroups : []
if (!groups.length) {
this.toast(this.manualSalesMode ? '请至少添加一项销量商品' : '暂无销量填写项')
return false
}
let hasChecked = false
for (let gIdx = 0; gIdx < groups.length; gIdx += 1) {
const items = Array.isArray(groups[gIdx].items) ? groups[gIdx].items : []
for (let iIdx = 0; iIdx < items.length; iIdx += 1) {
const item = items[iIdx]
if (item) {
// hasChecked = true
const qty = item.qty
const emptyQty = qty === '' || qty === null || qty === undefined || (typeof qty === 'string' && qty.trim() === '')
if (emptyQty) {
this.toast(`请填写${item.name || '商品'}销量`)
return false
}
if (Number(qty) < 0) {
this.toast(`${item.name || '商品'}销量不能小于0`)
return false
}
}
}
}
// if (!hasChecked) {
// this.toast('请选择销量产品')
// return false
// }
return true
},
fillEmptySalesQtyWithZero() {
const groups = Array.isArray(this.salesGroups) ? this.salesGroups : []
groups.forEach((group, gIdx) => {
const items = Array.isArray(group.items) ? group.items : []
items.forEach((item, iIdx) => {
if (!item) return
const qty = item.qty
const emptyQty = qty === '' || qty === null || qty === undefined || (typeof qty === 'string' && qty.trim() === '')
if (!emptyQty) return
this.$set(this.salesGroups[gIdx].items, iIdx, Object.assign({}, item, {qty: 0}))
})
})
},
buildSalesGroups(list) {
const groups = Array.isArray(list) ? list : []
const {idTotals, nameTotals} = this.buildTaskNumMaps()
return groups.map((group, index) => {
const itemsRaw = Array.isArray(group.children) ? group.children : []
const items = itemsRaw.map(item => {
const specOptions = this.normalizeSpecOptions(item)
const rawSpec = item.spec || item.specification || item.standard || ''
const spec = rawSpec || (specOptions[0] || '规格')
const safeOptions = specOptions.length ? specOptions : [spec]
const taskNum = this.getTaskNumFromMaps(item, idTotals, nameTotals)
return {
productId: this.getSalesItemId(item),
name: this.getSalesItemName(item) || '药品',
spec,
specOptions: safeOptions,
qty: item.qty !== undefined && item.qty !== null
? item.qty
: (item.num !== undefined && item.num !== null ? item.num : ''),
unit: item.unit || '盒',
checked: !!item.checked,
taskNum,
taskIncome: this.resolveTaskIncome(item),
}
})
return {
title: group.makerAbb || group.title || group.name || `分组${index + 1}`,
open: true,
items
}
}).filter(group => group.items.length)
},
isProductNode(node) {
if (!node || typeof node !== 'object') return false
const product = node.product && typeof node.product === 'object' ? node.product : {}
const hasChildrenOnly = Array.isArray(node.children)
&& !this.pickFirstText(node, ['goodsName', 'goods_name', 'goodsCode', 'code', 'productCode', 'batchCode'])
&& !node.product
if (hasChildrenOnly) return false
return !!(
this.pickFirstText(node, ['goodsName', 'goods_name', 'goodsCode', 'code', 'productCode', 'batchCode']) ||
this.pickFirstText(product, ['goodsName', 'goods_name', 'goodsCode', 'code', 'productCode', 'batchCode'])
)
},
collectProductNodes(node, depth = 0) {
if (!node || depth > 4) return []
if (Array.isArray(node)) {
return node.reduce((acc, item) => acc.concat(this.collectProductNodes(item, depth + 1)), [])
}
if (typeof node !== 'object') return []
if (this.isProductNode(node)) return [node]
const keys = ['productList', 'products', 'list', 'records', 'rows', 'items', 'children', 'data']
return keys.reduce((acc, key) => {
const value = node[key]
if (Array.isArray(value) || (value && typeof value === 'object')) {
return acc.concat(this.collectProductNodes(value, depth + 1))
}
return acc
}, [])
},
buildCategoryTabs(rawData) {
const rootList = this.unwrapList(rawData)
const tabs = []
rootList.forEach((categoryNode, categoryIndex) => {
if (!categoryNode || typeof categoryNode !== 'object') return
const label = categoryNode.cate || categoryNode.makerAbb || categoryNode.title || categoryNode.name
const products = Array.isArray(categoryNode.children) ? categoryNode.children : []
if (!label || !products.length) return
tabs.push({
key: `cate_children_${categoryIndex}`,
label,
rawProducts: products
})
})
if (tabs.length) return tabs
const allProducts = this.collectProductNodes(rootList)
if (allProducts.length) {
tabs.push({
key: 'all',
label: '全部',
rawProducts: allProducts
})
}
return tabs
},
normalizeProduct(item) {
const product = item && item.product ? item.product : {}
const code = item.goodsCode || item.code || item.productCode || item.batchCode || product.code || product.goodsCode || ''
const productId = item.productId || item.id || item.goodsId || item.skuId || product.productId || product.id || ''
const name = item.goodsName || item.goods_name || item.title || item.name || product.title || product.goodsName || product.name || ''
const maker = item.makerAbb || item.maker || item.manufacturer || product.makerAbb || product.maker || product.manufacturer || ''
const spec = product.spec || item.spec || item.specification || ''
return {
productId,
goodsCode: code,
name,
makerAbb: maker,
spec,
unit: item.unit || product.unit || '盒',
taskIncome: this.resolveTaskIncome(item),
specOptions: this.normalizeSpecOptions(Object.assign({}, product, item)),
qty: ''
}
},
buildSalesGroupsFromProductData(rawData) {
const tabs = this.buildCategoryTabs(rawData)
let rowSeed = 0
return tabs.map((tab, tabIndex) => {
const items = (Array.isArray(tab.rawProducts) ? tab.rawProducts : [])
.map((rawItem, itemIndex) => {
const product = this.normalizeProduct(rawItem)
if (!product.name) return null
const specOptions = Array.isArray(product.specOptions) && product.specOptions.length
? product.specOptions
: [product.spec || '规格']
return Object.assign({}, product, {
groupTitle: tab.label || `分类${tabIndex + 1}`,
spec: product.spec || specOptions[0] || '规格',
specOptions,
_key: `sales_${tabIndex}_${itemIndex}_${rowSeed++}`
})
})
.filter(Boolean)
return {
key: tab.key || `group_${tabIndex}`,
title: tab.label || `分类${tabIndex + 1}`,
items
}
}).filter(group => group.items.length)
},
async getProductGroups() {
const chainName = this.productChainName
if (!chainName) {
this.productGroups = []
this.productLoadedChainName = ''
this.activeProductGroupKey = ''
return
}
this.productLoading = true
try {
const {data} = await productGroupByCateReq({chain: chainName, type: 0})
this.productGroups = this.buildSalesGroupsFromProductData(data)
this.productLoadedChainName = chainName
} catch (error) {
this.productGroups = []
this.productLoadedChainName = ''
uni.showToast({
title: '商品数据获取失败',
icon: 'none'
})
} finally {
this.activeProductGroupKey = this.productGroups[0] ? this.productGroups[0].key : ''
this.productLoading = false
}
},
async openProductPicker() {
if (!this.productChainName) {
uni.showToast({
title: '当前任务缺少连锁信息',
icon: 'none'
})
return
}
if (this.productLoadedChainName !== this.productChainName || !this.productGroups.length) {
await this.getProductGroups()
}
if (!this.filteredProductGroups.length) {
uni.showToast({
title: '当前连锁暂无可选商品',
icon: 'none'
})
return
}
if (!this.activeProductGroupKey) {
this.activeProductGroupKey = this.filteredProductGroups[0] ? this.filteredProductGroups[0].key : ''
}
this.showProductPicker = true
},
closeProductPicker() {
this.showProductPicker = false
},
selectProductGroup(key) {
this.activeProductGroupKey = key
},
isProductSelected(product) {
const groups = Array.isArray(this.salesGroups) ? this.salesGroups : []
return groups.some(group => {
const items = Array.isArray(group.items) ? group.items : []
return items.some(item => {
if (product.productId && item.productId) {
return String(product.productId) === String(item.productId)
}
return item.name === product.name
})
})
},
selectProduct(product) {
if (this.isProductSelected(product)) {
uni.showToast({
title: '该商品已添加',
icon: 'none'
})
return
}
const specOptions = Array.isArray(product.specOptions) && product.specOptions.length
? [...product.specOptions]
: [product.spec || '规格']
const item = Object.assign({}, product, {
groupTitle: product.groupTitle || '手动添加',
specOptions,
spec: product.spec || specOptions[0] || '规格',
qty: ''
})
const groupIndex = this.salesGroups.findIndex(group => group && group.manual)
if (groupIndex >= 0) {
const group = this.salesGroups[groupIndex]
const items = Array.isArray(group.items) ? group.items.concat(item) : [item]
this.$set(this.salesGroups, groupIndex, Object.assign({}, group, {items, open: true}))
} else {
this.salesGroups.push({
title: '手动添加',
open: true,
manual: true,
items: [item]
})
}
this.showProductPicker = false
this.salesKeyword = ''
this.scheduleSave()
},
removeManualSalesItem(groupIdx, itemIdx) {
const group = this.salesGroups[groupIdx]
if (!group || !Array.isArray(group.items)) return
const items = group.items.slice()
items.splice(itemIdx, 1)
if (items.length) {
this.$set(this.salesGroups, groupIdx, Object.assign({}, group, {items}))
} else {
this.salesGroups.splice(groupIdx, 1)
}
this.scheduleSave()
},
attachTaskNumToSalesGroups(groups, sourceGroups = []) {
const {idTotals, nameTotals} = this.buildTaskNumMaps()
const {idIncomeMap, nameIncomeMap} = this.buildTaskIncomeMaps(sourceGroups)
return groups.map(group => {
const items = Array.isArray(group.items) ? group.items : []
const nextItems = items.map(item => {
const taskNum = item && item.taskNum ? item.taskNum : this.getTaskNumFromMaps(item || {}, idTotals, nameTotals)
const taskIncome = this.hasDisplayValue(this.resolveTaskIncome(item))
? this.resolveTaskIncome(item)
: this.getTaskIncomeFromMaps(item || {}, idIncomeMap, nameIncomeMap)
return Object.assign({}, item, {taskNum, taskIncome})
})
return Object.assign({}, group, {items: nextItems})
})
},
mergeSalesGroupsWithProductGroups(savedGroups = [], sourceGroups = []) {
const saved = this.attachTaskNumToSalesGroups(Array.isArray(savedGroups) ? savedGroups : [], sourceGroups)
const base = this.buildSalesGroups(sourceGroups)
if (!base.length) return saved
if (!saved.length) return base
const matchedSavedGroupIndexes = new Set()
const merged = base.map((baseGroup, index) => {
const savedGroup = this.findSavedSalesGroup(baseGroup, saved, index, matchedSavedGroupIndexes)
if (!savedGroup) return baseGroup
const savedItemMaps = this.buildSavedSalesItemMaps(savedGroup.items)
const items = baseGroup.items.map(baseItem => {
const savedItem = this.findSavedSalesItem(baseItem, savedItemMaps)
return savedItem ? this.mergeSalesItem(baseItem, savedItem) : baseItem
})
return Object.assign({}, baseGroup, {
title: savedGroup.title || baseGroup.title,
open: savedGroup.open !== undefined ? savedGroup.open : baseGroup.open,
items
})
})
saved.forEach((group, index) => {
if (!matchedSavedGroupIndexes.has(index)) merged.push(group)
})
return this.attachTaskNumToSalesGroups(merged, sourceGroups)
},
findSavedSalesGroup(baseGroup, savedGroups, baseIndex, matchedIndexes) {
const title = String(baseGroup && baseGroup.title ? baseGroup.title : '').trim()
if (title) {
const titleIndex = savedGroups.findIndex((group, index) => {
if (matchedIndexes.has(index)) return false
return String(group && group.title ? group.title : '').trim() === title
})
if (titleIndex >= 0) {
matchedIndexes.add(titleIndex)
return savedGroups[titleIndex]
}
}
if (savedGroups[baseIndex] && !matchedIndexes.has(baseIndex)) {
matchedIndexes.add(baseIndex)
return savedGroups[baseIndex]
}
return null
},
buildSavedSalesItemMaps(items = []) {
const byId = new Map()
const byNameSpec = new Map()
const byName = new Map()
;(Array.isArray(items) ? items : []).forEach(item => {
if (!item) return
const id = this.getSalesItemId(item)
if (id !== undefined && id !== null && String(id).trim() !== '') {
byId.set(String(id), item)
}
const name = this.getSalesItemName(item)
const spec = item.spec || ''
if (name && spec) byNameSpec.set(`${name}__${spec}`, item)
if (name && !byName.has(name)) byName.set(name, item)
})
return {byId, byNameSpec, byName}
},
findSavedSalesItem(baseItem, maps) {
const id = this.getSalesItemId(baseItem)
if (id !== undefined && id !== null) {
const key = String(id)
if (maps.byId.has(key)) return maps.byId.get(key)
}
const name = this.getSalesItemName(baseItem)
const spec = baseItem && baseItem.spec ? baseItem.spec : ''
if (name && spec) {
const key = `${name}__${spec}`
if (maps.byNameSpec.has(key)) return maps.byNameSpec.get(key)
}
return name && maps.byName.has(name) ? maps.byName.get(name) : null
},
mergeSalesItem(baseItem, savedItem) {
const specOptions = Array.isArray(savedItem.specOptions) && savedItem.specOptions.length
? savedItem.specOptions
: baseItem.specOptions
return Object.assign({}, baseItem, savedItem, {
productId: baseItem.productId || savedItem.productId,
name: baseItem.name || savedItem.name,
spec: savedItem.spec || baseItem.spec,
specOptions,
unit: savedItem.unit || baseItem.unit,
qty: savedItem.qty !== undefined && savedItem.qty !== null ? savedItem.qty : baseItem.qty
})
},
getTaskNumFromMaps(item, idTotals, nameTotals) {
const id = this.getSalesItemId(item)
if (id !== undefined && id !== null) {
const total = idTotals.get(String(id))
if (total) return total
}
const name = this.getSalesItemName(item)
return name ? nameTotals.get(name) || 0 : 0
},
hasDisplayValue(value) {
return value !== undefined && value !== null && String(value).trim() !== ''
},
getSalesItemId(item) {
const product = item && item.product ? item.product : {}
return item.productId || item.id || item.goodsId || item.skuId || product.productId || product.id || ''
},
getSalesItemName(item) {
const product = item && item.product ? item.product : {}
return item.title || item.name || item.goodsName || item.productName || product.title || product.name || product.goodsName || ''
},
resolveTaskIncome(item) {
if (!item || typeof item !== 'object') return ''
const product = item.product && typeof item.product === 'object' ? item.product : {}
const candidates = [
item.taskIncome,
item.income,
item.productIncome,
item.reward,
item.bonus,
item.amount,
item.value,
product.taskIncome,
product.income,
product.productIncome,
product.reward
]
for (let i = 0; i < candidates.length; i += 1) {
const value = candidates[i]
if (this.hasDisplayValue(value)) return value
}
return ''
},
buildTaskIncomeMaps(groups) {
const idIncomeMap = new Map()
const nameIncomeMap = new Map()
;(Array.isArray(groups) ? groups : []).forEach(group => {
const items = Array.isArray(group && group.children) ? group.children : (Array.isArray(group && group.items) ? group.items : [])
items.forEach(item => {
const income = this.resolveTaskIncome(item)
if (!this.hasDisplayValue(income)) return
const id = this.getSalesItemId(item)
if (id !== undefined && id !== null && String(id).trim() !== '') {
idIncomeMap.set(String(id), income)
}
const name = this.getSalesItemName(item)
if (name) {
nameIncomeMap.set(name, income)
}
})
})
return {idIncomeMap, nameIncomeMap}
},
getTaskIncomeFromMaps(item, idIncomeMap, nameIncomeMap) {
const id = this.getSalesItemId(item)
if (id !== undefined && id !== null) {
const key = String(id)
if (idIncomeMap.has(key)) return idIncomeMap.get(key)
}
const name = this.getSalesItemName(item)
if (name && nameIncomeMap.has(name)) {
return nameIncomeMap.get(name)
}
return ''
},
hasTaskIncome(item) {
return this.hasDisplayValue(this.resolveTaskIncome(item))
},
formatTaskIncome(item) {
const value = this.resolveTaskIncome(item)
if (!this.hasDisplayValue(value)) return ''
const num = Number(value)
return Number.isFinite(num) ? num : String(value)
},
calcTotalTaskNum() {
if (this.task.cateId === 4) {
return this.safeNumber(this.task.storeNum) || (Array.isArray(this.storeList) ? this.storeList.length : 0)
}
const list = Array.isArray(this.task && this.task.productList) ? this.task.productList : []
if (list.length) {
return list.reduce((total, item) => {
const raw = item ? (item.num || item.taskNum || item.quantity || item.count || item.targetNum) : 0
return total + this.safeNumber(raw)
}, 0)
}
const map = new Map()
const groups = Array.isArray(this.salesGroups) ? this.salesGroups : []
groups.forEach(group => {
const items = Array.isArray(group.items) ? group.items : []
items.forEach(item => {
if (!item || !item.taskNum) return
const key = item.productId ? `id:${item.productId}` : `name:${item.name || ''}`
if (!key) return
const current = map.get(key) || 0
map.set(key, Math.max(current, this.safeNumber(item.taskNum)))
})
})
let total = 0
map.forEach(value => {
total += value
})
return total
},
calcTotalQty() {
if (this.isStoreTask) {
const list = Array.isArray(this.storeList) ? this.storeList : []
return list.filter(item => this.isStoreItemCompleted(item)).length
}
const groups = Array.isArray(this.salesGroups) ? this.salesGroups : []
return groups.reduce((total, group) => {
const items = Array.isArray(group.items) ? group.items : []
const sum = items.reduce((acc, item) => acc + this.safeNumber(item ? item.qty : 0), 0)
return total + sum
}, 0)
},
buildTaskNumMaps() {
const list = Array.isArray(this.task && this.task.productList) ? this.task.productList : []
const idTotals = new Map()
const nameTotals = new Map()
list.forEach(item => {
if (!item) return
const product = item.product || item.goods || {}
const rawNum = item.num || item.taskNum || item.quantity || item.count || item.targetNum
const num = this.safeNumber(rawNum)
if (!num) return
const id = item.productId || product.id || product.productId || item.goodsId || item.skuId || item.id
if (id !== undefined && id !== null) {
const key = String(id)
idTotals.set(key, (idTotals.get(key) || 0) + num)
}
const name = product.title || product.name || item.productName || item.goodsName || item.title || item.name
if (name) {
nameTotals.set(name, (nameTotals.get(name) || 0) + num)
}
})
return {idTotals, nameTotals}
},
safeNumber(value) {
const num = Number(value)
return Number.isFinite(num) ? num : 0
},
salesProgress(item) {
const num = this.safeNumber(item.taskNum)
if (!num) return 0
const qty = this.safeNumber(item.qty)
return Math.min(100, Math.round((qty / num) * 100))
},
salesProgressText(item) {
const num = this.safeNumber(item.taskNum)
if (!num) return ''
const qty = this.safeNumber(item.qty)
return `${qty}/${num}`
},
normalizeSpecOptions(item) {
const raw = item.specOptions || item.specList || item.specs || []
if (Array.isArray(raw)) {
return raw.map(value => `${value}`.trim()).filter(Boolean)
}
if (typeof raw === 'string') {
return raw.split(/[、,,]/).map(value => value.trim()).filter(Boolean)
}
return []
},
formatTime(date) {
const hour = `${date.getHours()}`.padStart(2, '0')
const minute = `${date.getMinutes()}`.padStart(2, '0')
return `${hour}:${minute}`
},
formatDate(date) {
const year = date.getFullYear()
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
return `${year}.${month}.${day}`
},
formatDateTime(date) {
return `${this.formatDate(date)} ${this.formatTime(date)}`
},
async save(options = {}) {
if (this.isAuditEntry) return
if (this.task.userTask.status === 1 || this.task.userTask.status === 2) return
let isClock = false
let clockIndex = -1
let silent = false
if (typeof options === 'boolean') {
isClock = options
} else if (options && typeof options === 'object') {
isClock = !!options.isClock
clockIndex = typeof options.clockIndex === 'number' ? options.clockIndex : -1
silent = !!options.silent
}
const summaryText = this.summary ? (this.summary || '').trim() : ''
const locationText = (this.currentAddress || '').trim()
const normalizedStoreList = this.sanitizeStoreList()
const firstStore = normalizedStoreList[0] || {}
const baseLat = this.isStoreTask ? (this.lat || firstStore.lat || null) : this.lat
const baseLng = this.isStoreTask ? (this.lng || firstStore.lng || null) : this.lng
const baseLocation = this.isStoreTask ? (locationText || firstStore.location || null) : (locationText || null)
if (this.clockPhotos.length && isClock && clockIndex > -1) {
this.clockPhotos[clockIndex].lat = this.lat
this.clockPhotos[clockIndex].lng = this.lng
this.clockPhotos[clockIndex].location = locationText
}
const hasClockPhotos = Array.isArray(this.clockPhotos) && this.clockPhotos.some(item => item && item.url)
const payload = {
id: this.task.userTask.id,
video: null,
lat: baseLat,
lng: baseLng,
rating: this.isStoreTask ? 0 : this.rating,
clockPhotos: this.isStoreTask ? null : (this.clockPhotos.length ? JSON.stringify(this.clockPhotos || []) : null),
boothPhotos: this.isStoreTask ? null : (this.boothPhotos.length ? JSON.stringify(this.boothPhotos || []) : null),
salesGroups: this.isStoreTask ? null : (this.salesGroups.length ? JSON.stringify(this.salesGroups || []) : null),
receiptPhoto: this.isStoreTask ? null : (this.receiptPhotos.length ? JSON.stringify(this.receiptPhotos || []) : null),
storeList: this.isStoreTask ? (normalizedStoreList.length ? JSON.stringify(normalizedStoreList) : null) : null,
summary: JSON.stringify(summaryText),
status: 0,
auditReason: ''
}
if (hasClockPhotos || this.isStoreTask) {
payload.location = baseLocation
}
const res = await changeUserTaskReq(payload).catch(err => {
this.toast(err.message)
if (isClock && clockIndex > -1) {
this.clockPhotos[clockIndex].url = ''
this.clockPhotos[clockIndex].time = ''
this.clockPhotos[clockIndex].date = ''
}
})
if (res && !silent) this.toast('保存成功')
if (res && isClock && clockIndex === 0) {
if (dayjs().isAfter(this.task.taskStartTime)) this.toast('您已迟到!')
}
},
async submit() {
if (this.isAuditEntry) return
if (!this.isStoreTask) {
this.fillEmptySalesQtyWithZero()
}
if (!this.validateSubmit()) return
const normalizedStoreList = this.sanitizeStoreList()
const imageList = this.isStoreTask
? normalizedStoreList.reduce((list, item) => list.concat(Array.isArray(item.photos) ? item.photos : []), [])
: [
...this.clockPhotos.filter(item => item.url).map(item => item.url),
...this.boothPhotos,
...this.receiptPhotos
]
const firstStore = normalizedStoreList[0] || {}
const submitLat = this.isStoreTask ? (this.lat || firstStore.lat || null) : this.lat
const submitLng = this.isStoreTask ? (this.lng || firstStore.lng || null) : this.lng
if (!submitLat || !submitLng) {
uni.showModal({
title: '提示',
content: '为了更好的提供服务,请开启定位服务',
success: ({confirm}) => {
if (confirm) uni.openSetting()
}
})
return
}
const summaryText = (this.summary || '').trim()
const locationText = (this.currentAddress || '').trim()
const submitLocation = this.isStoreTask ? (locationText || firstStore.location || null) : (locationText || null)
const res = await changeUserTaskReq({
id: this.task.userTask.id,
image: imageList.length ? JSON.stringify(imageList) : null,
video: null,
lat: submitLat,
lng: submitLng,
location: submitLocation,
rating: this.isStoreTask ? 0 : this.rating,
clockPhotos: this.isStoreTask ? null : JSON.stringify(this.clockPhotos || []),
boothPhotos: this.isStoreTask ? null : JSON.stringify(this.boothPhotos || []),
salesGroups: this.isStoreTask ? null : JSON.stringify(this.salesGroups || []),
receiptPhoto: this.isStoreTask ? null : JSON.stringify(this.receiptPhotos || []),
storeList: this.isStoreTask ? JSON.stringify(normalizedStoreList) : null,
summary: JSON.stringify(summaryText),
status: 1,
auditReason: ''
}).catch(e => {
this.toast(e.message)
})
if (res) {
await this.toast('提交成功,请等待审核')
uni.navigateBack()
}
}
}
}
</script>
<style lang="scss" scoped>
.detail-page {
--task-submit-bar-height: calc(108rpx + env(safe-area-inset-bottom));
min-height: 100vh;
//padding: 0 20rpx 40rpx;
background: #f5f5f5;
}
.page-hero {
//padding: 30rpx 6rpx 18rpx;
background: url('https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/462bfa04cf0eff8698884c38b9d17519.png') no-repeat top center / 100% auto;
}
.page-navbar {
padding: 0 6rpx;
}
.block {
margin: 18rpx 20rpx 0;
}
.store-card {
margin: 10rpx 20rpx 0;
background: linear-gradient(135deg, #2fc6a7, #2fc6a7);
border-radius: 24rpx;
padding: 28rpx;
color: #ffffff;
box-shadow: 0 12rpx 24rpx rgba(37, 99, 235, 0.2);
display: flex;
flex-direction: column;
gap: 12rpx;
}
.store-tag {
align-self: flex-start;
padding: 8rpx 18rpx;
border-radius: 12rpx;
font-size: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.45);
background: rgba(255, 255, 255, 0.2);
}
.store-title {
font-size: 36rpx;
font-weight: 600;
}
.store-chain {
display: inline-flex;
align-items: center;
align-self: flex-start;
max-width: 100%;
padding: 8rpx 14rpx;
border-radius: 999rpx;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.34);
gap: 10rpx;
}
.store-chain-label {
flex-shrink: 0;
font-size: 22rpx;
color: rgba(255, 255, 255, 0.78);
}
.store-chain-value {
min-width: 0;
max-width: 430rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
}
.store-address {
font-size: 26rpx;
display: flex;
align-items: center;
gap: 8rpx;
color: rgba(255, 255, 255, 0.92);
}
.detail-info-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.detail-info-toggle {
display: flex;
align-items: center;
gap: 8rpx;
}
.detail-info-toggle-text {
font-size: 24rpx;
color: #8f9bb3;
}
.detail-info-toggle-arrow {
display: flex;
align-items: center;
transform: rotate(90deg);
transition: transform 0.2s ease;
}
.detail-info-toggle-arrow.open {
transform: rotate(-90deg);
}
.detail-info-body {
margin-top: 20rpx;
}
.total-progress-card {
padding: 22rpx 24rpx;
}
.total-progress-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.total-progress-title {
font-size: 30rpx;
font-weight: 700;
color: #0f172a;
}
.total-progress-value {
font-size: 24rpx;
color: #64748b;
}
.total-progress-bar {
height: 12rpx;
border-radius: 999rpx;
background: #e2e8f0;
overflow: hidden;
}
.total-progress-fill {
height: 100%;
background: linear-gradient(90deg, #2fc6a7, #a1ded0);
}
.detail-info-title {
font-size: 34rpx;
font-weight: 700;
color: #0f172a;
}
.detail-requirement {
background: #ecfdf5;
border: 1rpx solid #a7f3d0;
border-radius: 24rpx;
padding: 20rpx 22rpx;
}
.detail-block-head {
display: flex;
align-items: center;
gap: 12rpx;
margin-bottom: 12rpx;
}
.detail-icon {
width: 44rpx;
height: 44rpx;
border-radius: 12rpx;
//background: #a1ded0;
display: flex;
align-items: center;
justify-content: center;
}
.detail-block-title {
font-size: 30rpx;
font-weight: 700;
color: #2fc6a7;
}
.detail-block-text {
font-size: 26rpx;
color: #2fc6a7;
line-height: 1.6;
}
.detail-section {
margin-top: 26rpx;
}
.detail-section-title {
display: flex;
align-items: center;
gap: 12rpx;
font-size: 30rpx;
font-weight: 700;
color: #1f2937;
}
.detail-section-dot {
width: 10rpx;
height: 32rpx;
border-radius: 999rpx;
}
.detail-section-dot.orange {
background: #f97316;
}
.detail-section-dot.green {
background: #22c55e;
}
.detail-section-dot.blue {
background: #3b82f6;
}
.detail-section-card {
margin-top: 14rpx;
background: #f8fafc;
border-radius: 18rpx;
padding: 18rpx 20rpx;
color: #4b5563;
font-size: 26rpx;
border: 1rpx solid #edf2f7;
}
.location-card {
display: flex;
flex-direction: column;
gap: 10rpx;
}
.location-row {
display: flex;
align-items: flex-start;
line-height: 1.6;
}
.location-label {
flex-shrink: 0;
color: #6b7280;
}
.location-value {
flex: 1;
word-break: break-all;
}
.section-title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16rpx;
}
.section-title-row .section-title {
margin-bottom: 0;
}
.section-extra {
color: #8b95a7;
font-size: 24rpx;
}
.section-title.tight {
margin-bottom: 16rpx;
}
.section-sub {
font-size: 24rpx;
color: #8b95a7;
}
.section-title.warm::before {
background: linear-gradient(180deg, #ff8a4c, #ffb36b);
}
.clock-grid {
display: flex;
gap: 18rpx;
}
.clock-card {
flex: 1;
}
.clock-thumb {
position: relative;
height: 220rpx;
border-radius: 20rpx;
overflow: hidden;
background: linear-gradient(180deg, #1f2c3c, #151e29);
border: 2rpx solid #2b3950;
}
.clock-card.primary .clock-thumb {
border-color: #4c7bff;
}
.clock-card.warm .clock-thumb {
border-color: #f3a45c;
}
.clock-image,
.clock-placeholder {
width: 100%;
height: 100%;
}
.clock-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: rgba(15, 23, 42, 0.25);
}
.clock-check {
position: absolute;
top: 10rpx;
right: 10rpx;
background: #ffffff;
border-radius: 50%;
padding: 2rpx;
}
.clock-info {
position: absolute;
left: 16rpx;
bottom: 16rpx;
color: #ffffff;
}
.clock-time {
font-size: 34rpx;
font-weight: 600;
}
.clock-date {
font-size: 22rpx;
opacity: 0.9;
margin-top: 4rpx;
}
.photo-grid {
display: flex;
flex-wrap: wrap;
gap: 20rpx;
}
.audit-empty-tip {
padding: 24rpx 0;
text-align: center;
color: #9aa3b2;
font-size: 24rpx;
}
.visit-store-card {
background: #f8fafc;
border: 1rpx solid #edf2f7;
border-radius: 20rpx;
padding: 20rpx;
margin-bottom: 18rpx;
}
.visit-store-card:last-of-type {
margin-bottom: 0;
}
.visit-store-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 18rpx;
}
.visit-store-title {
font-size: 30rpx;
font-weight: 700;
color: #1f2937;
}
.visit-store-time {
margin-top: 6rpx;
font-size: 22rpx;
color: #6b7280;
}
.visit-store-remove,
.visit-store-action {
font-size: 24rpx;
color: #2fc6a7;
}
.visit-store-field {
margin-bottom: 18rpx;
}
.visit-store-field:last-of-type {
margin-bottom: 0;
}
.visit-store-label-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.visit-store-label {
font-size: 26rpx;
font-weight: 600;
color: #374151;
margin-bottom: 12rpx;
}
.visit-store-picker {
width: 100%;
}
.visit-store-picker-inner {
min-height: 76rpx;
border-radius: 16rpx;
background: #ffffff;
border: 3px solid #ff8a4c;
padding: 0 18rpx;
display: flex;
align-items: center;
color: #111827;
font-size: 26rpx;
box-sizing: border-box;
}
.visit-store-picker-inner.placeholder {
color: #9aa3b2;
}
.visit-store-empty {
width: 100%;
}
.visit-store-add {
margin-top: 18rpx;
height: 76rpx;
border-radius: 16rpx;
border: 2rpx dashed #2fc6a7;
color: #2fc6a7;
background: #f3fffb;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: 600;
}
.merchant-picker {
height: 70vh;
padding: 20rpx 24rpx 30rpx;
}
.merchant-picker-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.picker-action {
font-size: 26rpx;
color: #6b7280;
}
.picker-action.primary {
color: #2fc6a7;
font-weight: 700;
}
.picker-title {
font-size: 28rpx;
font-weight: 700;
color: #1f2937;
}
.merchant-picker-search {
margin: 6rpx 0 12rpx;
}
.merchant-search-input {
width: 100%;
background: #f3f4f6;
border-radius: 12rpx;
padding: 12rpx 16rpx;
font-size: 26rpx;
color: #1f2937;
box-sizing: border-box;
}
.merchant-search-placeholder {
color: #9ca3af;
}
.merchant-picker-loading {
padding: 20rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.merchant-picker-view {
height: 90%;
}
.merchant-picker-item {
height: 44px;
line-height: 44px;
text-align: center;
font-size: 26rpx;
color: #1f2937;
}
.photo-item {
width: calc((100% - 40rpx) / 2);
height: 200rpx;
border-radius: 18rpx;
background: #f6f7fb;
position: relative;
overflow: hidden;
border: 1rpx solid #edf1f7;
display: flex;
align-items: center;
justify-content: center;
}
.photo-item.add {
border: 2rpx dashed #cfd5e1;
background: #fbfcfe;
}
.photo-image {
width: 100%;
height: 100%;
display: block;
}
.photo-remove {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 32rpx;
height: 32rpx;
background: rgba(0, 0, 0, 0.5);
color: #fff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
line-height: 1;
}
.sales-group {
border-radius: 18rpx;
background: #f7f9fc;
//padding: 0 20rpx;
margin-bottom: 18rpx;
}
.sales-group:last-of-type {
margin-bottom: 0;
}
.sales-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx;
background-color: #2fc6a7;
color: white;
border-radius: 18rpx 18rpx 0 0;
}
.sales-title {
font-size: 28rpx;
color: white;
font-weight: 600;
}
.sales-title-row {
margin-bottom: 18rpx;
}
.sales-actions {
margin-bottom: 18rpx;
}
.sales-add-btn {
width: 240rpx;
min-height: 76rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #2fc6a7, #1fb394);
box-shadow: 0 12rpx 24rpx rgba(47, 198, 167, 0.2);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10rpx;
color: #ffffff;
font-size: 26rpx;
font-weight: 700;
}
.sales-remove {
flex-shrink: 0;
color: #ef4444;
font-size: 24rpx;
}
.sales-arrow {
transition: transform 0.2s ease;
}
.sales-arrow.open {
transform: rotate(90deg);
}
.sales-items {
background: #ffffff;
border-radius: 16rpx;
padding: 10rpx 18rpx;
margin-bottom: 20rpx;
box-shadow: 0 6rpx 16rpx rgba(15, 23, 42, 0.04);
}
.sales-item {
padding: 16rpx 0;
border-bottom: 1rpx solid #edf1f7;
}
.sales-item:last-of-type {
border-bottom: none;
}
.sales-main {
display: flex;
align-items: center;
gap: 14rpx;
margin-bottom: 12rpx;
}
.sales-check {
width: 32rpx;
height: 32rpx;
border-radius: 6rpx;
border: 2rpx solid #cfd5e1;
display: flex;
align-items: center;
justify-content: center;
background: #ffffff;
}
.sales-check.checked {
background: #22c55e;
border-color: #22c55e;
}
.sales-name {
display: flex;
align-items: center;
gap: 10rpx;
font-size: 30rpx;
color: var(--text-main);
font-weight: 600;
}
.sales-target {
padding: 4rpx 12rpx;
border-radius: 999rpx;
font-size: 22rpx;
color: #f97316;
background: #fff7ed;
border: 1rpx solid #fed7aa;
}
.sales-progress {
display: flex;
align-items: center;
gap: 12rpx;
margin: 6rpx 0 12rpx;
}
.sales-progress-bar {
flex: 1;
height: 10rpx;
border-radius: 999rpx;
background: #e5e7eb;
overflow: hidden;
}
.sales-progress-fill {
height: 100%;
background: linear-gradient(90deg, #22c55e, #60a5fa);
}
.sales-progress-text {
font-size: 26rpx;
color: #6b7280;
min-width: 84rpx;
text-align: right;
}
.sales-controls {
display: flex;
align-items: center;
gap: 16rpx;
}
.spec-picker {
flex: 1;
}
.spec-pill {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10rpx;
padding: 10rpx 16rpx;
border-radius: 12rpx;
border: 1rpx solid #e2e6ef;
background: #f7f9fc;
font-size: 24rpx;
color: #4a5568;
}
.qty-box {
flex: 1;
display: flex;
align-items: center;
border: 5rpx solid #f97316;
border-radius: 12rpx;
padding: 6rpx 12rpx;
background: #ffffff;
}
.qty-input {
flex: 1;
font-size: 30rpx;
color: var(--text-main);
text-align: right;
padding-right: 12rpx;
}
.qty-unit {
font-size: 28rpx;
color: #8b95a7;
border-left: 1rpx solid #edf1f7;
padding-left: 10rpx;
}
.product-picker {
background: #ffffff;
padding-bottom: calc(env(safe-area-inset-bottom) + 12rpx);
}
.product-panel {
display: flex;
height: 62vh;
border-top: 1rpx solid #eef2f7;
}
.category-sidebar {
width: 220rpx;
background: linear-gradient(180deg, #f8fafc, #ecfdf5);
border-right: 1rpx solid #eef2f7;
}
.category-item {
min-height: 88rpx;
padding: 18rpx 16rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
color: #64748b;
font-size: 24rpx;
border-bottom: 1rpx solid rgba(226, 232, 240, 0.8);
}
.category-item-active {
background: #ffffff;
color: #059669;
font-weight: 700;
}
.product-panel-main {
flex: 1;
padding: 20rpx 24rpx 24rpx;
box-sizing: border-box;
}
.product-picker-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18rpx;
padding: 18rpx 20rpx;
border-radius: 18rpx;
background: #f8fafc;
border: 1rpx solid #e2e8f0;
}
.product-picker-item + .product-picker-item {
margin-top: 12rpx;
}
.product-picker-main {
flex: 1;
min-width: 0;
}
.product-picker-name {
color: #0f172a;
font-size: 28rpx;
font-weight: 600;
}
.product-picker-sub {
margin-top: 6rpx;
color: #64748b;
font-size: 22rpx;
}
.product-picker-income {
margin-top: 6rpx;
color: #dc2626;
font-size: 22rpx;
}
.product-picker-tag {
flex-shrink: 0;
padding: 8rpx 18rpx;
border-radius: 999rpx;
background: #dcfce7;
color: #15803d;
font-size: 22rpx;
font-weight: 700;
}
.receipt-upload {
border: 2rpx dashed #cfd5e1;
border-radius: 20rpx;
background: #f6f7fb;
padding: 30rpx;
min-height: 240rpx;
display: flex;
align-items: center;
justify-content: center;
}
.receipt-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 12rpx;
color: #8b95a7;
}
.receipt-icon {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: #e4e8ef;
display: flex;
align-items: center;
justify-content: center;
}
.receipt-title {
font-size: 28rpx;
color: #6b7280;
}
.receipt-desc {
font-size: 24rpx;
color: #9aa3b2;
}
.receipt-preview {
position: relative;
width: 100%;
height: 240rpx;
border-radius: 16rpx;
overflow: hidden;
}
.receipt-image {
width: 100%;
height: 100%;
display: block;
}
.receipt-remove {
position: absolute;
top: 10rpx;
right: 10rpx;
width: 34rpx;
height: 34rpx;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
}
.summary-input {
width: 100%;
min-height: 160rpx;
padding: 20rpx;
background: #f6f7fb;
border-radius: 16rpx;
font-size: 26rpx;
color: var(--text-main);
box-sizing: border-box;
}
.rating-row {
padding-top: 8rpx;
}
.task-submit-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
height: var(--task-submit-bar-height);
padding: 20rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
background: #ffffff;
box-shadow: 0 -4rpx 16rpx rgba(15, 23, 42, 0.08);
}
.task-submit-placeholder {
height: var(--task-submit-bar-height);
}
.submit {
width: 100%;
margin: 0;
padding: 14rpx;
height: 68rpx;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
}
.audit-action-bar {
margin: 18rpx 20rpx 0;
}
.audit-action-list {
display: flex;
gap: 12rpx;
}
.audit-action-btn {
flex: 1;
height: 68rpx;
border-radius: 14rpx;
border: 1rpx solid #2fc6a7;
color: #2fc6a7;
background: #f3fffb;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: 600;
}
.audit-action-btn-danger {
border-color: #ef4444;
color: #ef4444;
background: #fff1f2;
}
.mt-12 {
margin-top: 12rpx;
}
.audit-popup-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.45);
z-index: 90;
}
.audit-popup {
position: fixed;
left: 50%;
bottom: 40rpx;
transform: translateX(-50%);
width: 88%;
max-height: 70vh;
overflow: auto;
z-index: 100;
}
.audit-popup-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12rpx;
}
.close-btn {
color: #6b7280;
font-size: 24rpx;
}
.audit-form-row {
margin-top: 12rpx;
}
.audit-form-label {
font-size: 24rpx;
color: #4b5563;
margin-bottom: 8rpx;
}
.audit-form-input {
width: 100%;
height: 70rpx;
border-radius: 12rpx;
border: 1rpx solid #e5e7eb;
background: #f9fafb;
padding: 0 16rpx;
font-size: 26rpx;
color: #111827;
box-sizing: border-box;
}
.audit-submit-btn {
margin-top: 16rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
}
.audit-copy-search-row {
display: flex;
gap: 10rpx;
align-items: center;
}
.audit-copy-search-input {
flex: 1;
height: 70rpx;
line-height: 70rpx;
border-radius: 12rpx;
background: #f3f4f6;
padding: 0 16rpx;
font-size: 26rpx;
}
.audit-copy-search-btn {
min-width: 110rpx;
height: 70rpx;
border-radius: 12rpx;
background: #2fc6a7;
color: #ffffff;
font-size: 24rpx;
display: flex;
align-items: center;
justify-content: center;
}
.audit-copy-loading,
.audit-copy-empty {
margin-top: 14rpx;
font-size: 24rpx;
color: #6b7280;
}
.audit-copy-user-list {
margin-top: 14rpx;
max-height: 420rpx;
}
.audit-copy-user-item {
padding: 12rpx;
border-radius: 12rpx;
background: #f9fafb;
margin-bottom: 10rpx;
border: 1rpx solid #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12rpx;
}
.audit-copy-user-item-active {
border-color: #2fc6a7;
background: #ecfdf5;
}
.audit-copy-user-name {
font-size: 26rpx;
color: #111827;
}
.audit-copy-user-phone {
font-size: 22rpx;
color: #6b7280;
}
</style>