Files
yxe-mp/pages/index/index.vue
T
weicw-dshsa aff1e52e3e feat(自主填报): 拆分驻店/跑店填报并新增跑店填报页
「自主填报」原本只有一种表单。本次拆成两种类型:

1. 新增选择填报类型页 packageUser/self-report/select
   - 按设计稿:hero 页头 + 两张类型卡(驻店填报 / 跑店填报)+ 确认选择
   - 入口(首页、我的)由直接进 create 改为先进 select

2. 原自主填报页改为「驻店填报」
   - 仅改导航标题,表单内容不变

3. 新增跑店填报页 packageUser/self-report/visit
   - 复制现有跑店/巡店任务(pages/task/detail 的 cateId=4 分支)的填报页并改造:
     保留巡店列表与下方内容,去掉列表上方的任务卡片/总进度/任务详情;
     打卡、摆台照、销量、小票、门店评分一并不保留
   - 巡店列表内「连锁筛选」「门店筛选」是两个并列的独立输入框,
     各自弹各自的选择器(不串联),样式与驻店填报逐项对齐;
     换连锁会清空已选门店
   - 每条巡店可新增/删除,独立获取定位 + 打卡拍照(最多 9 张)
   - 定位不强制(巡店任务里是强制的,这里放开)
   - 底部「保存草稿」+「提交填报」,草稿交互与驻店填报一致:
     进页面询问是否加载、提交成功后清除;storage key 与驻店分开
   - 驳回后重新提交复用同一页面(mode=resubmit)

4. 填报记录页加「驻店 / 跑店」类型切换
   - 两表分表查询,各自分页;跑店卡片展示门店数、巡店列表(门店/定位/照片)
   - 连锁与门店关键词筛选落到巡店列表的每一行

5. 药审核加「跑店填报」类型
   - 新增 AuditVisitReportItem,展开时按 id 拉详情(列表不下发 store_list)
   - 跑店没有销量小计,审核通过需手填奖金,故补奖金输入弹层

顺带重构:
- 门店解析(扁平列表 / 连锁嵌套两种结构的兼容、门店明细不入页面 data
  的性能处理)抽到 util/store-picker,create 与 visit 共用一份,
  避免两页各自维护导致解析口径不一致
2026-10-11 22:50:15 +08:00

1402 lines
35 KiB
Vue

<template>
<view class="home-page">
<view class="top-wrap">
<AppNavbar class="top-navbar" title="药小二" title-size="28rpx" gap="40rpx"/>
<view class="search-bar top-search">
<u-search showAction animation v-model="searchQuery" bgColor="white"
searchIcon="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/16f54dd102cacccfb5085533009b70fc.png"/>
</view>
<view v-if="bannersEnabled && banners.length" class="banner-area">
<swiper class="banner-swiper" autoplay :interval="4200" indicator-dots indicator-color="#bfeee4"
indicator-active-color="#1ac5a6">
<swiper-item v-for="item in banners" :key="item.id">
<image class="banner-img" :src="item.image" mode="aspectFill"/>
</swiper-item>
</swiper>
</view>
<!-- <view class="notice-bar">-->
<!-- <text class="notice-label">奖励:</text>-->
<!-- <swiper class="notice-swiper" vertical circular autoplay :interval="3200" :duration="400">-->
<!-- <swiper-item v-for="(notice, idx) in notices" :key="idx" class="notice-item">-->
<!-- <text class="notice-text">{{ notice }}</text>-->
<!-- </swiper-item>-->
<!-- </swiper>-->
<!-- <view class="notice-more">-->
<!-- <text class="notice-more-icon">></text>-->
<!-- </view>-->
<!-- </view>-->
<view class="self-report-entry">
<view class="entry-btn primary" @tap="openSelfReportCreate">
<u-icon name="edit-pen" size="18" color="#ffffff"/>
<text>自主填报</text>
</view>
<view class="entry-btn" @tap="openSelfReportRecord">
<u-icon name="order" size="18" color="#d9661f"/>
<text>填报记录</text>
</view>
</view>
</view>
<view class="nav-wrap">
<view class="tabs">
<view v-for="tab in tabs" :key="tab" :class="['tab', activeTab === tab ? 'active' : '']"
@tap="activeTab = tab">
<view class="tab-main">
<image v-if="tab === '新任务'" class="tab-icon"
src="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/9c6c98843c2e30247355d86ea47552aa.png"
mode="aspectFit"/>
<image v-if="tab === '待执行'" class="tab-icon"
src="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260127/f5fbb2e4be31bcc830156f174e7931b8.png"
mode="aspectFit"/>
<image v-if="tab === '已完成'" class="tab-icon"
src="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/eebe6469eb153413dc881f513063f821.png"
mode="aspectFit"/>
<text class="tab-text">{{ tab }}</text>
</view>
<view v-if="tab === '待执行' && pendingCount" class="tab-badge">{{ pendingCount }}</view>
<view v-if="activeTab === tab" class="tab-underline"/>
</view>
</view>
<view class="category-wrap">
<scroll-view class="category-bar" scroll-x show-scrollbar="false">
<view v-for="cat in categories" :key="cat" :class="['category-chip', category === cat.id ? 'active' : '']"
@tap="changeCate(cat.id)">
{{ cat.title }}
</view>
</scroll-view>
</view>
</view>
<view class="board">
<view class="filter-row">
<view class="location" @tap="toggleCity">
<u-icon name="map"/>
<text>{{ currentCity }}</text>
</view>
<view class="sort-options">
<view v-for="sort in sortOptions" :key="sort" :class="['sort-item', sortType === sort ? 'active' : '']"
@tap="sortType = sort">
<text class="sort-text">{{ sort }}</text>
<text class="sort-arrow">▼</text>
</view>
</view>
</view>
<view class="task-list">
<view v-if="getFilteredTasks().length === 0" class="empty">
<u-empty
icon="https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260126/ddcc1bbd561d97128ed19407b07e299b.png"
text="暂无符合条件的任务"/>
</view>
<view v-for="task in getFilteredTasks()" :key="task.id" class="task-card">
<view class="task-head">
<view class="date-row">
<text class="date">{{ formatTaskDateText(task) }}</text>
<view class="relative">{{ formatTaskDateSubText(task) }}</view>
<view v-if="task.cate && task.cate.title" class="cate-tag">{{ task.cate.title }}</view>
</view>
<view v-if="!isManualSalesTask(task)" class="reward">
<text class="currency">¥</text>
<text class="reward-number">{{ calReward(task) }}</text>
</view>
</view>
<view class="task-title-row">
<text class="task-title">{{ task.merchant.name }}</text>
<view v-if="task.chainText" class="chain-pill">{{ task.chainText }}</view>
<view :class="['type-pill', 'type-blue']">{{ task.cate.title }}</view>
</view>
<text class="addr">{{ task.title }}</text>
<view class="task-meta" v-if="task.merchant">
<text class="addr">{{ task.merchant.province }}{{ task.merchant.city }}{{
task.merchant.region
}}{{ task.merchant.address }}
</text>
<view class="distance">
<u-icon name="map"/>
<text>{{ task.distance }}</text>
</view>
</view>
<view v-if="task.userTask" class="total-progress-card">
<view class="total-progress-head">
<text class="total-progress-title">总进度</text>
<text class="total-progress-value">{{ totalProgressText(task) }}</text>
</view>
<view class="total-progress-bar">
<view class="total-progress-fill" :style="{ width: `${totalProgressPercent(task)}%` }"></view>
</view>
</view>
<view v-if="task.userTask && [1, 2, 3].includes(task.userTask.status)" class="audit-box">
<view class="flex flex-col justify-start items-start">
<view :class="['audit-status', auditClass(task.userTask.status)]">订单状态:
{{ auditLabel(task.userTask.status) }}
</view>
<text v-if="task.userTask.auditReason" class="audit-reason">原因: {{ task.userTask.auditReason }}</text>
</view>
<view class="info-action" @tap="showDetails(task)">查看详情</view>
</view>
<view class="actions" v-if="task.status === 0">
<view class="ghost-action" @tap="openDetailPopup(task)">查看详情</view>
<view class="primary-action" @tap="takeOver(task)">接单</view>
<!-- <view v-if="task.status === 'pending'" class="primary-action full" @tap="goExecute(task)">去执行</view>-->
</view>
<view class="actions" v-if="task.status === 1 && task.userTask && task.userTask.status === 0">
<view class="ghost-action" @tap="openDetailPopup(task)">查看详情</view>
<view class="action-btn" @tap="showDetails(task)">去执行</view>
</view>
</view>
</view>
</view>
<u-popup :show="detailPopupVisible" mode="bottom" round="36rpx" close-on-click-overlay @close="closeDetailPopup">
<view class="detail-popup" v-if="detailTask">
<view class="detail-popup-head">
<text class="detail-popup-title">任务详情</text>
<view class="detail-popup-close" @tap="closeDetailPopup">
<u-icon name="close" size="36rpx" color="#7a8495"/>
</view>
</view>
<view class="detail-merchant-card">
<view v-if="detailTask.chainText" class="detail-merchant-row">
<text class="detail-merchant-label">所属连锁</text>
<text class="detail-merchant-value">{{ detailTask.chainText }}</text>
</view>
<view v-if="detailTask.merchant && detailTask.merchant.name" class="detail-merchant-row">
<text class="detail-merchant-label">任务门店</text>
<text class="detail-merchant-value">{{ detailTask.merchant.name }}</text>
</view>
</view>
<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">{{ detailTask.detail }}</text>
</view>
<!-- <view class="detail-section" v-if="detailTask.cateId !== 4">-->
<!-- <view class="detail-section-title">-->
<!-- <view class="detail-section-dot orange"></view>-->
<!-- <text>重点爆破品种</text>-->
<!-- </view>-->
<!-- <view class="detail-section-card">{{-->
<!-- detailTask.productList.map(item => item.product.title).join('、')-->
<!-- }}-->
<!-- </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">{{ detailTask.policy }}</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">{{ formatTaskExecutePeriod(detailTask) }}</view>
</view>
</view>
</u-popup>
<AreaPicker
:show="showAreaPicker"
:default-province="currentProvince"
:default-city="currentCity"
@close="showAreaPicker = false"
@confirm="onAreaConfirm"
/>
<Login ref="Login" @done="handleLoginDone"/>
</view>
</template>
<script>
import {addUserTaskReq, taskCateListReq, taskDoneListReq, taskListReq} from "@/api/task";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import "dayjs/locale/zh-cn";
import {calcDistanceMeters, formatDistance, getLocationAction} from "@/util";
import {swiperListReq} from "@/api/common";
import AppNavbar from "@/components/AppNavbar.vue";
import AreaPicker from "@/components/AreaPicker.vue";
import Login from "@/components/Login.vue";
import {buildSharePath, getUserInfo} from "@/util/user";
dayjs.extend(relativeTime);
dayjs.locale("zh-cn");
export default {
components: {AppNavbar, AreaPicker, Login},
data() {
return {
bannersEnabled: true,
banners: [],
tabs: ['新任务', '待执行', '已完成'],
activeTab: '待执行',
searchQuery: '',
categories: [],
category: null,
sortOptions: ['综合排序', '距我最近', '热度最高'],
sortType: '综合排序',
currentCity: '定位中',
currentProvince: '',
showAreaPicker: false,
notices: [
// '李强已完成业绩 ¥12,000',
// '黄敏新接单 3 个门店任务',
// '周杰完成活动爆破业绩 ¥18,500',
// '团队今日累计业绩 ¥65,300'
],
tasks: [],
lat: null,
lng: null,
detailPopupVisible: false,
detailTask: null,
pendingLoginUrl: ''
}
},
computed: {
activeStatus() {
if (this.activeTab === '新任务') return 'new';
if (this.activeTab === '待执行') return 'pending';
return 'completed';
},
pendingCount() {
return this.tasks.filter(t => t.status === 'pending').length;
},
},
watch: {
activeTab() {
this.getTaskList()
}
},
async onShow() {
await this.refreshPageData()
},
async onPullDownRefresh() {
try {
await this.refreshPageData()
} finally {
uni.stopPullDownRefresh()
}
},
methods: {
async refreshPageData() {
const res = await getLocationAction().catch(() => {
uni.showToast({
title: '请允许位置授权',
icon: 'none'
})
})
if (res) {
this.lat = res.latitude
this.lng = res.longitude
this.currentCity = res.city_str
this.currentProvince = res.province_str || ''
this.updateTaskDistances()
}
await this.getSwiperList()
await this.getCateList()
await this.getNoticeList()
await this.getTaskList()
},
async getNoticeList() {
const {data} = await taskDoneListReq()
this.notices = data.map(item => {
return `${item.user.nickname}已完成业绩 ¥${item.reward}`
})
},
async getSwiperList() {
const {data} = await swiperListReq()
this.banners = data
},
async getCateList() {
const {data} = await taskCateListReq({
filter: true
});
this.categories = [{id: null, title: '全部'}, ...data]
},
async getTaskList() {
const params = {
province: this.currentProvince,
city: this.currentCity,
notExpired: true
}
if (this.category) params.cateId = this.category;
params.userTaskStatus = this.getUserTaskStatus()
const {data} = await taskListReq(params);
this.tasks = data.map(task => Object.assign({}, task, {
chainText: this.getTaskChainText(task)
}))
this.updateTaskDistances()
},
changeCate(id) {
this.category = id;
this.getTaskList()
},
toggleCity() {
this.showAreaPicker = true
},
onAreaConfirm({provinceName, cityName}) {
this.currentProvince = provinceName
this.currentCity = cityName
this.showAreaPicker = false
this.getTaskList()
},
getUserTaskStatus() {
if (this.activeTab === '新任务') return -1
if (this.activeTab === '待执行') return 0
return 1
},
distanceToNumber(distance) {
if (!distance) return 0;
if (distance.indexOf('km') > -1) return parseFloat(distance);
if (distance.indexOf('m') > -1) return parseFloat(distance) / 1000;
return parseFloat(distance);
},
formatRelativeDate(value) {
if (!value) return '';
const date = dayjs(value);
return date.isValid() ? date.fromNow() : value;
},
formatTaskDateText(task) {
const isPendingTab = this.activeTab === '待执行'
const isNewTab = this.activeTab === '新任务'
const startTime = task && task.taskStartTime ? task.taskStartTime : ''
const taskDate = task && task.date ? task.date : ''
if (isPendingTab && startTime) {
const date = dayjs(startTime)
if (date.isValid()) return date.format('MM月DD日')
}
if (isNewTab && taskDate) {
const date = dayjs(taskDate)
if (date.isValid()) return date.format('MM月DD日')
}
return task && task.date ? task.date : ''
},
formatTaskDateSubText(task) {
const isPendingTab = this.activeTab === '待执行'
const isNewTab = this.activeTab === '新任务'
const startTime = task && task.taskStartTime ? task.taskStartTime : ''
const taskDate = task && task.date ? task.date : ''
if (isPendingTab && startTime) {
const date = dayjs(startTime)
if (date.isValid()) {
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
const weekday = weekdays[date.day()] || ''
const daysAfter = this.getDaysAfterToday(date)
const daysText = daysAfter < 0 ? '已过期' : (daysAfter === 0 ? '今天' : `${daysAfter}天后`)
return `${weekday} ${daysText}`.trim()
}
}
if (isNewTab && taskDate) {
const date = dayjs(taskDate)
if (date.isValid()) {
const weekdays = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
return weekdays[date.day()] || ''
}
}
return this.formatRelativeDate(task && task.date ? task.date : '')
},
getDaysAfterToday(dateValue) {
const target = dayjs(dateValue).startOf('day')
if (!target.isValid()) return 0
const today = dayjs().startOf('day')
return target.diff(today, 'day')
},
updateTaskDistances() {
if (!Number.isFinite(this.lat) || !Number.isFinite(this.lng)) return;
this.tasks = this.tasks.map(task => {
const merchant = task.merchant || {};
const lat = Number(merchant.lat);
const lng = Number(merchant.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return task;
const meters = calcDistanceMeters(this.lat, this.lng, lat, lng);
return Object.assign({}, task, {
distance: formatDistance(meters)
});
});
},
getFilteredTasks(status) {
let list = this.tasks;
if (this.category) {
list = list.filter(item => item.cateId === this.category);
}
if (this.searchQuery) {
const keyword = this.searchQuery.trim();
list = list.filter(item => item.merchant.name.indexOf(keyword) > -1 || item.title.indexOf(keyword) > -1);
}
if (this.sortType === '热度最高') {
list = [...list].sort((a, b) => this.rewardToNumber(b) - this.rewardToNumber(a));
}
if (this.sortType === '距我最近') {
list = [...list].sort((a, b) => this.distanceToNumber(a.distance) - this.distanceToNumber(b.distance));
}
return list;
},
calReward(item) {
let reward = parseFloat(item.reward)
if (item.extraReward) reward += parseFloat(item.extraReward)
return reward.toFixed(2)
},
rewardToNumber(item) {
if (this.isManualSalesTask(item)) return 0;
const reward = Number(item && item.reward);
const extraReward = Number(item && item.extraReward);
return (Number.isFinite(reward) ? reward : 0) + (Number.isFinite(extraReward) ? extraReward : 0);
},
auditLabel(status) {
if (status === 2) return '审核通过';
if (status === 3) return '审核不通过';
return '待审核';
},
auditClass(status) {
if (status === 2) return 'audit-success';
if (status === 3) return 'audit-danger';
return 'audit-warn';
},
parseJsonMaybe(value) {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'string') {
try {
return JSON.parse(value);
} catch (err) {
return value;
}
}
return value;
},
safeNumber(value) {
const num = Number(value);
return Number.isFinite(num) ? num : 0;
},
getTaskProductList(task) {
const raw = task && task.productList ? task.productList : [];
const parsed = this.parseJsonMaybe(raw);
return Array.isArray(parsed) ? parsed : [];
},
getTaskSalesGroups(task) {
const userTask = task && task.userTask ? task.userTask : {};
const raw = userTask.salesGroups || userTask.sales_groups || task.salesGroups || task.sales_groups || [];
const parsed = this.parseJsonMaybe(raw);
return Array.isArray(parsed) ? parsed : [];
},
calcTotalTaskNum(task) {
const list = this.getTaskProductList(task);
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 = this.getTaskSalesGroups(task);
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(task) {
if (task.cateId === 4) {
return task.userTask.storeList ? JSON.parse(task.userTask.storeList).length : 0;
}
const groups = this.getTaskSalesGroups(task);
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);
},
totalProgressPercent(task) {
let totalTaskNum = this.calcTotalTaskNum(task);
if (task.cateId === 4) {
totalTaskNum = task.storeNum
}
if (!totalTaskNum) return 0;
const totalQty = this.calcTotalQty(task);
return Math.min(100, Math.round((totalQty / totalTaskNum) * 100));
},
totalProgressText(task) {
let totalTaskNum = this.calcTotalTaskNum(task);
if (task.cateId === 4) {
totalTaskNum = task.storeNum
}
const totalQty = this.calcTotalQty(task);
return `${totalQty}/${totalTaskNum || 0}`;
},
isManualSalesTask(task) {
if (!task || task.cateId === 4) return false;
const list = Array.isArray(task.productList) ? task.productList : [];
return list.length === 0;
},
getTaskChainText(task) {
const merchant = task && task.merchant ? task.merchant : {};
const chain = merchant.chain || merchant.chainName || merchant.chainAbb || task.chain || task.chainName || '';
return String(chain || '').trim();
},
showDetails(task) {
let str = `reward=${task.reward}&target=${task.cate.title}&id=${task.id}`
if (task.merchant) str += `&title=${encodeURIComponent(task.merchant.name)}`
uni.navigateTo({
url: `/pages/task/detail?${str}`
})
},
async takeOver(task) {
const res = await addUserTaskReq({
taskId: task.id
}).catch(e => {
uni.showToast({
title: e.message,
icon: 'none'
})
})
if (res) {
uni.showToast({
title: '接单成功',
icon: 'none'
})
this.activeTab = '待执行'
}
},
openDetailPopup(task) {
this.detailTask = task ? Object.assign({}, task, {
chainText: task.chainText || this.getTaskChainText(task)
}) : {};
this.detailPopupVisible = true;
},
openSelfReportCreate() {
// 「自主填报」先选填报类型(驻店填报 / 跑店填报),再进对应表单页
this.jumpWithLogin('/packageUser/self-report/select')
},
openSelfReportRecord() {
this.jumpWithLogin('/packageUser/self-report/records')
},
jumpWithLogin(url) {
if (!getUserInfo().token) {
this.pendingLoginUrl = url
this.$refs.Login.open()
return
}
uni.navigateTo({url})
},
handleLoginDone() {
if (!this.pendingLoginUrl) return
const url = this.pendingLoginUrl
this.pendingLoginUrl = ''
uni.navigateTo({url})
},
closeDetailPopup() {
this.detailPopupVisible = false;
},
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')
},
formatTaskExecutePeriod(task) {
const source = task || {}
const start = this.trimSecond(source.taskStartTime || '')
const end = this.trimSecond(source.taskEndTime || '')
if (start && end) return `${start} 至 ${end}`
if (start) return `${start} 至 -`
if (end) return `- 至 ${end}`
return '暂无时间段'
},
},
onShareTimeline() {
return {
title: '药动销'
}
},
onShareAppMessage() {
return {
title: '药动销',
path: buildSharePath('/pages/index/index')
}
}
}
</script>
<style lang="scss" scoped>
.home-page {
background: #f5f5f5;
min-height: 100vh;
padding-bottom: 40rpx;
}
.top-wrap {
position: relative;
padding: 0 0 32rpx;
}
.top-navbar {
margin-bottom: 20rpx;
}
.top-search {
margin-bottom: 20rpx;
}
.search-bar {
width: calc(100vw - 70rpx);
z-index: 9999;
position: absolute;
display: flex;
align-items: center;
background: #ffffff;
border-radius: 999rpx;
padding: 5rpx 18rpx;
margin: 0 20rpx;
top: 170rpx;
box-shadow: 0 8rpx 18rpx rgba(0, 0, 0, 0.06);
}
.search-icon {
width: 32rpx;
height: 32rpx;
margin-right: 12rpx;
}
.search-btn {
font-size: 26rpx;
font-weight: 700;
color: #111827;
padding: 6rpx 12rpx;
width: calc(100% - 40rpx);
}
.banner-area {
padding: 0;
overflow: hidden;
border-radius: 0;
//box-shadow: 0 12rpx 24rpx rgba(0, 0, 0, 0.08);
}
.banner-swiper {
height: 320rpx;
}
.banner-img {
width: 100%;
height: 320rpx;
display: block;
}
.board {
margin-top: -5rpx;
background: #f5f5f5;
border-radius: 28rpx 28rpx 0 0;
overflow: hidden;
}
.tabs {
display: flex;
padding: 10rpx 24rpx 18rpx;
//background: #f3fffb;
gap: 12rpx;
}
.tab {
flex: 1;
text-align: center;
padding: 12rpx 0;
color: #6b7280;
font-weight: 700;
position: relative;
font-size: 30rpx;
}
.tab-main {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 10rpx 18rpx;
border-radius: 999rpx;
}
.tab-text {
line-height: 1;
}
.tab-icon {
width: 28rpx;
height: 28rpx;
}
.tab.active .tab-main {
background: #2fc6a7;
color: #ffffff;
}
.tab-badge {
position: absolute;
top: 4rpx;
right: 16rpx;
min-width: 30rpx;
padding: 4rpx 10rpx;
background: #ff3b30;
color: #fff;
border-radius: 999rpx;
font-size: 20rpx;
border: 2rpx solid #ffffff;
}
.tab-underline {
position: absolute;
bottom: 3rpx;
left: 50%;
width: 16rpx;
height: 16rpx;
background: #2fc6a7;
transform: translateX(-50%) rotate(45deg);
border-radius: 4rpx;
}
.tab.active {
color: #d9661f;
}
.search-input {
flex: 1;
font-size: 26rpx;
color: #0f2f52;
}
.search-placeholder {
color: #a1aabc;
}
.category-bar {
white-space: nowrap;
}
.notice-bar {
margin: 20rpx 24rpx 10rpx;
background: #ffffff;
border-radius: 999rpx;
padding: 12rpx 16rpx;
display: flex;
align-items: center;
gap: 10rpx;
box-shadow: 0 8rpx 16rpx rgba(0, 0, 0, 0.06);
}
.notice-label {
font-size: 24rpx;
font-weight: 700;
color: #2fc6a7;
}
.notice-swiper {
height: 48rpx;
flex: 1;
}
.notice-item {
display: flex;
align-items: center;
}
.notice-text {
font-size: 24rpx;
color: #111827;
font-weight: 500;
}
.notice-more {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
border: 1rpx solid #2fc6a7;
display: flex;
align-items: center;
justify-content: center;
color: #2fc6a7;
}
.notice-more-icon {
font-size: 22rpx;
line-height: 1;
}
.self-report-entry {
margin: 18rpx 24rpx 0;
display: flex;
gap: 14rpx;
}
.entry-btn {
flex: 1;
min-height: 76rpx;
border-radius: 20rpx;
border: 1rpx solid #f2a75e;
background: #fff4de;
color: #d9661f;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
font-size: 26rpx;
font-weight: 700;
}
.entry-btn.primary {
background: linear-gradient(to right, #f97316, #ef4444);
border-color: transparent;
color: #ffffff;
}
.category-wrap {
padding: 4rpx 24rpx 18rpx;
//background: #f3fffb;
}
.category-chip {
display: inline-flex;
align-items: center;
padding: 10rpx 20rpx;
margin-right: 16rpx;
border-radius: 999rpx;
font-size: 28rpx;
background: transparent;
color: #1f2937;
border: 1rpx solid transparent;
}
.category-chip.active {
background: #ff8a00;
color: #fff;
border-color: #ff8a00;
}
.filter-row {
margin: 0 24rpx 8rpx;
padding: 12rpx 0;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: space-between;
color: #5c6c84;
}
.location {
display: flex;
align-items: center;
gap: 8rpx;
font-weight: 700;
color: #111827;
}
.sort-options {
display: flex;
gap: 12rpx;
font-size: 26rpx;
color: #2fc6a7;
}
.sort-item {
display: inline-flex;
align-items: center;
gap: 6rpx;
padding: 8rpx 14rpx;
border-radius: 999rpx;
border: 1rpx solid #2fc6a7;
color: #2fc6a7;
font-size: 22rpx;
}
.sort-item.active {
background: #2fc6a7;
color: #ffffff;
}
.sort-arrow {
font-size: 18rpx;
line-height: 1;
}
.task-list {
padding: 12rpx 24rpx 32rpx;
background: #f5f5f5;
}
.task-card {
background: #ffffff;
border-radius: 24rpx;
padding: 22rpx 20rpx;
margin-bottom: 18rpx;
border: 1rpx solid #eef2f7;
box-shadow: 0 8rpx 20rpx rgba(37, 99, 235, 0.06);
}
.task-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.date-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.date {
font-weight: 700;
color: #1f2937;
font-size: 28rpx;
}
.relative {
padding: 6rpx 14rpx;
border-radius: 999rpx;
background: #fff7ed;
color: #f97316;
font-size: 22rpx;
border: 1rpx solid #fdba74;
}
.cate-tag {
padding: 6rpx 14rpx;
border-radius: 999rpx;
background: #eff6ff;
color: #2563eb;
font-size: 22rpx;
border: 1rpx solid #93c5fd;
}
.reward {
display: flex;
align-items: baseline;
font-weight: 800;
color: #ff3b30;
}
.currency {
font-size: 24rpx;
margin-right: 6rpx;
}
.reward-number {
font-size: 34rpx;
}
.task-title-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 12rpx;
gap: 12rpx;
}
.task-title {
font-size: 30rpx;
font-weight: 700;
color: #0f172a;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chain-pill {
max-width: 220rpx;
padding: 6rpx 14rpx;
border-radius: 999rpx;
background: #ecfdf5;
color: #059669;
border: 1rpx solid #a7f3d0;
font-size: 22rpx;
font-weight: 700;
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 0;
}
.type-pill {
display: none;
}
.type-blue {
background: linear-gradient(135deg, #2563eb, #1d4ed8);
}
.type-purple {
background: linear-gradient(135deg, #7c3aed, #6d28d9);
}
.type-orange {
background: linear-gradient(135deg, #f97316, #ea580c);
}
.task-meta {
margin-top: 10rpx;
display: flex;
justify-content: space-between;
align-items: center;
color: #6b7280;
font-size: 24rpx;
gap: 12rpx;
}
.addr {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.distance {
display: flex;
align-items: center;
gap: 6rpx;
padding: 6rpx 10rpx;
border-radius: 12rpx;
color: #4b5563;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 12rpx;
}
.tag {
padding: 8rpx 14rpx;
border-radius: 999rpx;
background: #f0f4ff;
color: var(--brand-primary);
font-size: 22rpx;
font-weight: 600;
border: 1rpx solid #d8e2ff;
}
.audit-box {
margin-top: 14rpx;
padding: 14rpx;
background: #f8fafc;
border-radius: 14rpx;
border: 1rpx solid #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
}
.audit-status {
display: inline-flex;
align-items: center;
padding: 8rpx 14rpx;
border-radius: 999rpx;
font-size: 22rpx;
font-weight: 700;
}
.audit-success {
background: #ecfdf3;
color: #15803d;
border: 1rpx solid #bbf7d0;
}
.audit-danger {
background: #fef2f2;
color: #dc2626;
border: 1rpx solid #fecdd3;
}
.audit-warn {
background: #fffbeb;
color: #d97706;
border: 1rpx solid #fde68a;
}
.audit-reason {
display: block;
margin-top: 8rpx;
color: #dc2626;
font-size: 22rpx;
}
.total-progress-card {
margin-top: 14rpx;
padding: 12rpx 14rpx;
background: #f8fafc;
border-radius: 14rpx;
border: 1rpx solid #e5e7eb;
}
.total-progress-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10rpx;
}
.total-progress-title {
font-size: 26rpx;
font-weight: 700;
color: #0f172a;
}
.total-progress-value {
font-size: 22rpx;
color: #64748b;
}
.total-progress-bar {
height: 10rpx;
border-radius: 999rpx;
background: #e2e8f0;
overflow: hidden;
}
.total-progress-fill {
height: 100%;
background: linear-gradient(90deg, #2fc6a7, #a1ded0);
}
.actions {
margin-top: 16rpx;
display: flex;
gap: 12rpx;
}
.primary-action,
.action-btn,
.info-action,
.ghost-action {
text-align: center;
padding: 18rpx 16rpx;
border-radius: 999rpx;
font-weight: 800;
font-size: 26rpx;
}
.primary-action {
flex: 2;
background: #2fc6a7;
color: #ffffff;
box-shadow: 0 8rpx 18rpx rgba(47, 198, 167, 0.25);
}
.ghost-action {
flex: 1;
background: #ffffff;
color: #2fc6a7;
border: 1rpx solid #2fc6a7;
}
.info-action {
width: 100rpx;
padding: 10rpx 20rpx;
font-size: 24rpx;
border: 1px solid rgb(75, 85, 99);
color: rgb(75, 85, 99);
}
.action-btn {
flex: 1;
background: linear-gradient(to right, #f97316, #ef4444);
color: #ffffff;
border: none;
box-shadow: 0 8rpx 18rpx #ffedd5;
}
.primary-action.full,
.ghost-action.full {
flex: 1;
}
.empty {
padding: 120rpx 0;
text-align: center;
color: #9ca3af;
}
.empty-icon {
font-size: 64rpx;
margin-bottom: 16rpx;
}
.empty-text {
font-size: 26rpx;
}
.detail-popup {
padding: 36rpx 32rpx 28rpx;
max-height: 78vh;
overflow: auto;
}
.detail-popup-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 26rpx;
}
.detail-popup-title {
font-size: 38rpx;
font-weight: 800;
color: #0f172a;
}
.detail-popup-close {
width: 68rpx;
height: 68rpx;
border-radius: 50%;
background: #f1f5f9;
display: flex;
align-items: center;
justify-content: center;
}
.detail-merchant-card {
margin-bottom: 22rpx;
background: #f8fafc;
border: 1rpx solid #e5e7eb;
border-radius: 20rpx;
padding: 18rpx 20rpx;
}
.detail-merchant-row {
display: flex;
align-items: center;
gap: 18rpx;
min-width: 0;
}
.detail-merchant-row + .detail-merchant-row {
margin-top: 14rpx;
}
.detail-merchant-label {
flex-shrink: 0;
width: 132rpx;
color: #64748b;
font-size: 24rpx;
}
.detail-merchant-value {
flex: 1;
min-width: 0;
color: #0f172a;
font-size: 28rpx;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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;
}
.nav-wrap {
background: url("https://yaodongxiao.oss-cn-guangzhou.aliyuncs.com/20260127/a0fceca8f9e439969e0e0a9a799b4db2.png");
background-size: cover;
background-position: bottom center;
border-radius: 20rpx;
padding: 10rpx;
//position: absolute;
//left: 20rpx;
//top: 635rpx;
//width: calc(100vw - 60rpx);
margin: 0 20rpx 20rpx;
}
.detail-icon {
width: 44rpx;
height: 44rpx;
border-radius: 12rpx;
//background: #d1fae5;
display: flex;
align-items: center;
justify-content: center;
}
.detail-block-title {
font-size: 30rpx;
font-weight: 700;
color: #10b981;
}
.detail-block-text {
font-size: 26rpx;
color: #059669;
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: #10b981;
}
.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;
}
</style>