灵活用工项目

FeTaskPublishSheet.vue 30KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984
  1. <template>
  2. <view class="fe-bottom-sheet fe-bottom-sheet--task-publish" :class="{ 'fe-bottom-sheet--active': active }">
  3. <view class="fe-sheet-handle" />
  4. <view class="fe-sheet-header">
  5. <view class="fe-sheet-title">✈ 任务发布</view>
  6. <view class="fe-sheet-close" @tap="onClose">✕</view>
  7. </view>
  8. <view v-if="sourceTitle" class="fe-sheet-source tp-source">🔗 {{ sourceTitle }}</view>
  9. <view v-if="loading" class="tp-empty">
  10. <text class="tp-empty__text">加载任务信息中...</text>
  11. </view>
  12. <view v-else-if="loadError" class="tp-empty">
  13. <text class="tp-empty__text">{{ loadError }}</text>
  14. <view class="fe-btn fe-btn--primary fe-btn--sm" @tap="loadData">重试</view>
  15. </view>
  16. <template v-else>
  17. <scroll-view scroll-y class="fe-sheet-body tp-body" :show-scrollbar="false">
  18. <view class="fe-sheet-body__inner">
  19. <view class="tp-hero">
  20. <view class="tp-hero__icon">
  21. <text class="tp-hero__icon-text">✈</text>
  22. </view>
  23. <text class="tp-hero__title">{{ heroTitle }}</text>
  24. <text class="tp-hero__desc">{{ statusDesc }}</text>
  25. </view>
  26. <!-- 任务信息:多工种逗号拼接,单卡展示 -->
  27. <view class="tp-section">
  28. <text class="tp-section__title">任务信息</text>
  29. <view class="tp-card">
  30. <view class="tp-grid">
  31. <view class="tp-grid__cell">
  32. <text class="tp-grid__label">岗位类型</text>
  33. <text class="tp-grid__value">{{ taskInfoSummary.jobTypeText }}</text>
  34. </view>
  35. <view class="tp-grid__cell">
  36. <text class="tp-grid__label">用工人数</text>
  37. <text class="tp-grid__value">{{ taskInfoSummary.workerCountText }}</text>
  38. </view>
  39. <view class="tp-grid__cell">
  40. <text class="tp-grid__label">工作地点</text>
  41. <text class="tp-grid__value">{{ taskInfoSummary.locationText }}</text>
  42. </view>
  43. <view class="tp-grid__cell">
  44. <text class="tp-grid__label">{{ taskInfoSummary.durationLabel }}</text>
  45. <text class="tp-grid__value">{{ taskInfoSummary.durationText }}</text>
  46. </view>
  47. </view>
  48. </view>
  49. </view>
  50. <!-- 工作时间:按工种多组 -->
  51. <view class="tp-section">
  52. <text class="tp-section__title">工作时间</text>
  53. <view
  54. v-for="(g, gi) in groups"
  55. :key="g.timeKey"
  56. class="tp-card"
  57. >
  58. <text v-if="groups.length > 1" class="tp-card__badge">{{ g.workType || ('工种 ' + (gi + 1)) }}</text>
  59. <view class="tp-field">
  60. <text class="tp-field__label">开始时间 <text v-if="!readonly" class="tp-required">*</text></text>
  61. <view class="tp-datetime-row">
  62. <picker
  63. v-if="!readonly"
  64. class="tp-datetime-row__item"
  65. mode="date"
  66. :value="g.startDate"
  67. @change="onStartDateByIndex(gi, $event)"
  68. >
  69. <view class="tp-input" :class="{ 'tp-input--placeholder': !g.startDate }">
  70. <text class="tp-input__icon">📅</text>
  71. <text class="tp-input__text">{{ g.startDateDisplay }}</text>
  72. </view>
  73. </picker>
  74. <view v-else class="tp-datetime-row__item">
  75. <view class="tp-input tp-input--readonly">
  76. <text class="tp-input__icon">📅</text>
  77. <text class="tp-input__text">{{ g.startDateDisplay }}</text>
  78. </view>
  79. </view>
  80. <picker
  81. v-if="!readonly"
  82. class="tp-datetime-row__item"
  83. mode="time"
  84. :value="g.startTime || '08:00'"
  85. @change="onStartTimeByIndex(gi, $event)"
  86. >
  87. <view class="tp-input" :class="{ 'tp-input--placeholder': !g.startTime }">
  88. <text class="tp-input__icon">⏱</text>
  89. <text class="tp-input__text">{{ g.startTimeDisplay }}</text>
  90. </view>
  91. </picker>
  92. <view v-else class="tp-datetime-row__item">
  93. <view class="tp-input tp-input--readonly">
  94. <text class="tp-input__icon">⏱</text>
  95. <text class="tp-input__text">{{ g.startTimeDisplay }}</text>
  96. </view>
  97. </view>
  98. </view>
  99. </view>
  100. <view class="tp-field">
  101. <text class="tp-field__label">结束时间 <text v-if="!readonly" class="tp-required">*</text></text>
  102. <view class="tp-datetime-row">
  103. <picker
  104. v-if="!readonly"
  105. class="tp-datetime-row__item"
  106. mode="date"
  107. :value="g.endDate"
  108. @change="onEndDateByIndex(gi, $event)"
  109. >
  110. <view class="tp-input" :class="{ 'tp-input--placeholder': !g.endDate }">
  111. <text class="tp-input__icon">📅</text>
  112. <text class="tp-input__text">{{ g.endDateDisplay }}</text>
  113. </view>
  114. </picker>
  115. <view v-else class="tp-datetime-row__item">
  116. <view class="tp-input tp-input--readonly">
  117. <text class="tp-input__icon">📅</text>
  118. <text class="tp-input__text">{{ g.endDateDisplay }}</text>
  119. </view>
  120. </view>
  121. <picker
  122. v-if="!readonly"
  123. class="tp-datetime-row__item"
  124. mode="time"
  125. :value="g.endTime || '18:00'"
  126. @change="onEndTimeByIndex(gi, $event)"
  127. >
  128. <view class="tp-input" :class="{ 'tp-input--placeholder': !g.endTime }">
  129. <text class="tp-input__icon">⏱</text>
  130. <text class="tp-input__text">{{ g.endTimeDisplay }}</text>
  131. </view>
  132. </picker>
  133. <view v-else class="tp-datetime-row__item">
  134. <view class="tp-input tp-input--readonly">
  135. <text class="tp-input__icon">⏱</text>
  136. <text class="tp-input__text">{{ g.endTimeDisplay }}</text>
  137. </view>
  138. </view>
  139. </view>
  140. </view>
  141. </view>
  142. </view>
  143. <view v-if="readonly" class="tp-gate-tip tp-gate-tip--ok">
  144. <text class="tp-gate-tip__text tp-gate-tip__text--ok">任务已发布,当前仅可查看</text>
  145. </view>
  146. <view v-else-if="!canPublish" class="tp-gate-tip">
  147. <text class="tp-gate-tip__text">{{ gateTip }}</text>
  148. </view>
  149. <view v-else-if="!scheduleComplete" class="tp-gate-tip">
  150. <text class="tp-gate-tip__text">请为每个工种选择开始/结束的日期与时间</text>
  151. </view>
  152. </view>
  153. </scroll-view>
  154. <view class="fe-sheet-footer">
  155. <template v-if="readonly">
  156. <view class="fe-btn fe-btn--primary fe-btn--lg fe-btn--full" @tap="onClose">关闭</view>
  157. </template>
  158. <template v-else>
  159. <view class="fe-btn fe-btn--secondary fe-btn--lg" @tap="onClose">取消</view>
  160. <view
  161. v-if="confirmDisabled"
  162. class="fe-btn fe-btn--primary fe-btn--lg fe-btn--disabled-look"
  163. >{{ confirmLabel }}</view>
  164. <view
  165. v-else
  166. class="fe-btn fe-btn--primary fe-btn--lg"
  167. @tap="onConfirm"
  168. >{{ confirmLabel }}</view>
  169. </template>
  170. </view>
  171. </template>
  172. </view>
  173. </template>
  174. <script>
  175. import { showToast } from '@/common/chat-data.js'
  176. import { getDraftDetail, getDraftWorkflowProgress } from '@/api/draft.js'
  177. import { getOrderPublishTask, publishOrderTask } from '@/api/order.js'
  178. import {
  179. normalizeWorkerGroups,
  180. formatWorkDurationUnit,
  181. splitDateTimeParts,
  182. formatDateTimeDisplay,
  183. normalizeDateYmd,
  184. mapDraftDetailToCard
  185. } from '@/utils/draft.js'
  186. import { isTaskPublishStep } from '@/utils/process-card.js'
  187. import { mapRegistrationBatchDetail } from '@/utils/registration-batch.js'
  188. /** 规范化 HH:mm */
  189. function normalizeTimeHm(timeStr) {
  190. if (timeStr == null || timeStr === '') return ''
  191. const m = String(timeStr).trim().match(/^(\d{1,2}):(\d{2})/)
  192. if (!m) return ''
  193. const hh = String(Math.min(23, Math.max(0, Number(m[1])))).padStart(2, '0')
  194. return `${hh}:${m[2]}`
  195. }
  196. /** 拼成接口常用日期时间:YYYY-MM-DD HH:mm:ss(日期、时间均必填) */
  197. function joinDateTimePayload(dateStr, timeStr) {
  198. const d = normalizeDateYmd(dateStr)
  199. const t = normalizeTimeHm(timeStr)
  200. if (!d || !t) return ''
  201. return `${d} ${t}:00`
  202. }
  203. function pad2(n) {
  204. return String(n).padStart(2, '0')
  205. }
  206. function formatLocalYmd(d) {
  207. if (!d || Number.isNaN(d.getTime())) return ''
  208. return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`
  209. }
  210. function parseLocalYmd(ymd) {
  211. const s = normalizeDateYmd(ymd)
  212. if (!s) return null
  213. const d = new Date(`${s.replace(/-/g, '/')} 00:00:00`)
  214. return Number.isNaN(d.getTime()) ? null : d
  215. }
  216. /** 默认开始日期:明天 */
  217. function tomorrowYmd() {
  218. const d = new Date()
  219. d.setHours(0, 0, 0, 0)
  220. d.setDate(d.getDate() + 1)
  221. return formatLocalYmd(d)
  222. }
  223. /**
  224. * 按工期算结束日期(含首日):
  225. * - 天:开始日 + (工期天数 - 1)
  226. * - 月:开始日加工期月数后再减 1 天
  227. */
  228. function calcEndDateByDuration(startYmd, workDays, settlementMode) {
  229. const start = parseLocalYmd(startYmd)
  230. if (!start) return ''
  231. const n = Number(workDays)
  232. const count = !Number.isNaN(n) && n > 0 ? Math.floor(n) : 1
  233. const unit = formatWorkDurationUnit(settlementMode)
  234. const end = new Date(start.getTime())
  235. if (unit === '月') {
  236. end.setMonth(end.getMonth() + count)
  237. end.setDate(end.getDate() - 1)
  238. } else {
  239. end.setDate(end.getDate() + (count - 1))
  240. }
  241. return formatLocalYmd(end)
  242. }
  243. function modalConfirm(title, content) {
  244. return new Promise((resolve) => {
  245. uni.showModal({
  246. title: title || '提示',
  247. content: content || '',
  248. success: (res) => resolve(!!(res && res.confirm)),
  249. fail: () => resolve(false)
  250. })
  251. })
  252. }
  253. export default {
  254. name: 'FeTaskPublishSheet',
  255. props: {
  256. active: { type: Boolean, default: false },
  257. draftId: { type: [Number, String], default: null },
  258. orderId: { type: [Number, String], default: null },
  259. /** 流程已完成:仅查看,禁止发布 */
  260. readonly: { type: Boolean, default: false }
  261. },
  262. data() {
  263. return {
  264. loading: false,
  265. loadError: '',
  266. publishing: false,
  267. sourceTitle: '',
  268. groups: [],
  269. statusDesc: '请确认任务信息后发布',
  270. orderIdInner: null,
  271. /** 流程接口任务发布步骤:pending | active | done */
  272. taskStepStatus: 'pending',
  273. /** 已登记 < 计划人数,发布时需 allow_understaffed */
  274. understaffed: false,
  275. registeredCount: 0,
  276. plannedCount: 0
  277. }
  278. },
  279. computed: {
  280. heroTitle() {
  281. return this.readonly ? '任务发布详情' : '确认发布任务'
  282. },
  283. canPublish() {
  284. return this.taskStepStatus === 'active'
  285. },
  286. scheduleComplete() {
  287. const list = this.groups || []
  288. if (!list.length) return false
  289. for (let i = 0; i < list.length; i++) {
  290. const g = list[i]
  291. if (!normalizeDateYmd(g.startDate) || !normalizeTimeHm(g.startTime)) return false
  292. if (!normalizeDateYmd(g.endDate) || !normalizeTimeHm(g.endTime)) return false
  293. }
  294. return true
  295. },
  296. gateTip() {
  297. if (this.taskStepStatus === 'done') return '任务已发布'
  298. if (this.taskStepStatus !== 'active') return '当前步骤尚未开始,请等待流程推进到任务发布'
  299. return ''
  300. },
  301. confirmDisabled() {
  302. return this.readonly || !this.canPublish || !this.scheduleComplete || this.publishing || this.loading
  303. },
  304. confirmLabel() {
  305. if (this.readonly) return '已发布'
  306. if (this.publishing) return '发布中...'
  307. return '确认发布'
  308. },
  309. /** 任务信息汇总:多工种逗号拼接,单卡展示 */
  310. taskInfoSummary() {
  311. const list = this.groups || []
  312. if (!list.length) {
  313. return {
  314. jobTypeText: '—',
  315. workerCountText: '—',
  316. locationText: '—',
  317. durationLabel: '工作天数',
  318. durationText: '—'
  319. }
  320. }
  321. const types = []
  322. const counts = []
  323. const locations = []
  324. const durations = []
  325. let hasMonth = false
  326. let hasDay = false
  327. for (let i = 0; i < list.length; i++) {
  328. const g = list[i]
  329. const type = String(g.workType || '').trim() || '待确认'
  330. types.push(type)
  331. if (g.workerCount != null && g.workerCount !== '') {
  332. counts.push(`${type}*${g.workerCount}人`)
  333. }
  334. const loc = String(g.workLocation || '').trim()
  335. if (loc && locations.indexOf(loc) < 0) locations.push(loc)
  336. const unit = formatWorkDurationUnit(g.settlementMode)
  337. if (unit === '月') hasMonth = true
  338. else hasDay = true
  339. if (g.workDays != null && g.workDays !== '') {
  340. durations.push(`${type}*${g.workDays}${unit}`)
  341. }
  342. }
  343. let durationLabel = '工作天数'
  344. if (hasMonth && !hasDay) durationLabel = '工作月数'
  345. else if (hasMonth && hasDay) durationLabel = '工作工期'
  346. return {
  347. jobTypeText: types.join(',') || '—',
  348. workerCountText: counts.length ? counts.join(',') : '—',
  349. locationText: locations.join(',') || '—',
  350. durationLabel,
  351. durationText: durations.length ? durations.join(',') : '—'
  352. }
  353. }
  354. },
  355. watch: {
  356. active(val) {
  357. if (val) this.loadData()
  358. },
  359. draftId() {
  360. if (this.active) this.loadData()
  361. },
  362. orderId() {
  363. if (this.active) this.loadData()
  364. },
  365. readonly() {
  366. if (this.active) this.loadData()
  367. }
  368. },
  369. methods: {
  370. onClose() {
  371. this.$emit('close')
  372. },
  373. pickField(raw, camel, snake) {
  374. if (!raw || typeof raw !== 'object') return null
  375. if (raw[camel] != null && raw[camel] !== '') return raw[camel]
  376. if (raw[snake] != null && raw[snake] !== '') return raw[snake]
  377. return null
  378. },
  379. /** GET publish-task → 工种行 */
  380. mapPublishDetailWorkItems(raw) {
  381. const items = (raw && (raw.workItems || raw.work_items)) || []
  382. const list = Array.isArray(items) ? items : []
  383. const groups = list.map((item, idx) => ({
  384. id: item.id != null ? item.id : null,
  385. groupId: this.pickField(item, 'groupNo', 'group_no') || `g_${idx}`,
  386. workType: this.pickField(item, 'jobType', 'job_type') || '待确认',
  387. workLocation: this.pickField(item, 'workLocation', 'work_location') || '待确认',
  388. workerCount: this.pickField(item, 'workerCount', 'worker_count'),
  389. workDays: this.pickField(item, 'workDays', 'work_days'),
  390. settlementMode: this.pickField(item, 'settlementCycle', 'settlement_cycle') || '',
  391. workStartDate: this.pickField(item, 'workStartDate', 'work_start_date') || '',
  392. workEndDate: this.pickField(item, 'workEndDate', 'work_end_date') || ''
  393. }))
  394. return this.buildGroupRows(groups, { viewMode: true })
  395. },
  396. buildGroupRows(groups, options = {}) {
  397. const viewMode = options.viewMode === true
  398. const emptyDate = viewMode ? '—' : '选择日期'
  399. const emptyTime = viewMode ? '—' : '选择时间'
  400. const list = Array.isArray(groups) ? groups : []
  401. return list.map((g, idx) => {
  402. const startParts = splitDateTimeParts(g.workStartDate)
  403. const endParts = splitDateTimeParts(g.workEndDate)
  404. const unit = formatWorkDurationUnit(g.settlementMode)
  405. const days = g.workDays
  406. let startDate = normalizeDateYmd(startParts.date)
  407. let endDate = normalizeDateYmd(endParts.date)
  408. // 接口返回若含时间,统一赋成 HH:mm;编辑态无时间时用默认
  409. let startTime = startParts.time ? normalizeTimeHm(startParts.time) : ''
  410. let endTime = endParts.time ? normalizeTimeHm(endParts.time) : ''
  411. if (!viewMode) {
  412. if (!startDate) startDate = tomorrowYmd()
  413. if (!startTime) startTime = '08:00'
  414. if (!endTime) endTime = '18:00'
  415. if (!endDate) endDate = calcEndDateByDuration(startDate, days, g.settlementMode)
  416. }
  417. return {
  418. rowKey: g.groupId || `info_${idx}`,
  419. timeKey: `time_${g.groupId || idx}`,
  420. workItemId: g.id != null ? g.id : null,
  421. groupId: g.groupId || `g_${idx}`,
  422. workType: g.workType || '待确认',
  423. workLocation: g.workLocation || '待确认',
  424. workerCount: g.workerCount,
  425. workerCountText: g.workerCount != null ? `${g.workerCount}人` : '—',
  426. durationLabel: unit === '月' ? '工作月数' : '工作天数',
  427. durationText: days != null ? `${days}${unit}` : '—',
  428. workDays: days,
  429. settlementMode: g.settlementMode || '',
  430. startDate,
  431. startTime,
  432. endDate,
  433. endTime,
  434. startDateDisplay: startDate || emptyDate,
  435. endDateDisplay: endDate || emptyDate,
  436. startTimeDisplay: startTime || emptyTime,
  437. endTimeDisplay: endTime || emptyTime,
  438. startDisplay: formatDateTimeDisplay(startDate, startTime),
  439. endDisplay: formatDateTimeDisplay(endDate, endTime)
  440. }
  441. })
  442. },
  443. async resolveOrderIdFromDraft() {
  444. if (this.orderId != null && this.orderId !== '') return this.orderId
  445. if (this.draftId == null || this.draftId === '') return null
  446. try {
  447. const draftRaw = await getDraftDetail(this.draftId, { showError: false })
  448. if (!draftRaw) return null
  449. return draftRaw.orderId != null && draftRaw.orderId !== ''
  450. ? draftRaw.orderId
  451. : (draftRaw.order_id != null && draftRaw.order_id !== '' ? draftRaw.order_id : null)
  452. } catch (e) {
  453. return null
  454. }
  455. },
  456. /** 已完成「查看发布」:GET /orders/{id}/publish-task */
  457. async loadPublishDetail() {
  458. this.loading = true
  459. this.loadError = ''
  460. this.understaffed = false
  461. try {
  462. const orderId = await this.resolveOrderIdFromDraft()
  463. this.orderIdInner = orderId
  464. if (orderId == null || orderId === '') {
  465. this.loadError = '缺少订单信息'
  466. this.groups = []
  467. return
  468. }
  469. const raw = await getOrderPublishTask(orderId, { showError: false })
  470. const title = this.pickField(raw, 'title', 'title') || '用工任务'
  471. const orderNo = this.pickField(raw, 'orderNo', 'order_no') || ''
  472. this.sourceTitle = orderNo ? `${title} ${orderNo}` : title
  473. this.groups = this.mapPublishDetailWorkItems(raw || {})
  474. const planned = Number(this.pickField(raw, 'plannedCount', 'planned_count') || 0)
  475. const registered = Number(this.pickField(raw, 'registeredCount', 'registered_count') || 0)
  476. this.plannedCount = planned
  477. this.registeredCount = registered
  478. this.understaffed = planned > 0 && registered < planned
  479. this.registrationDone = true
  480. const msg = (raw && raw.message) || ''
  481. if (msg) {
  482. this.statusDesc = msg
  483. } else if (planned > 0) {
  484. this.statusDesc = `已登记 ${registered}/${planned},任务已发布`
  485. } else {
  486. this.statusDesc = '任务已发布,当前仅可查看'
  487. }
  488. } catch (e) {
  489. this.loadError = (e && (e.msg || e.message)) || '任务发布详情加载失败'
  490. this.groups = []
  491. showToast(this.loadError)
  492. } finally {
  493. this.loading = false
  494. }
  495. },
  496. syncGroupDisplay(gi) {
  497. const g = this.groups[gi]
  498. if (!g) return
  499. const startDate = normalizeDateYmd(g.startDate)
  500. const endDate = normalizeDateYmd(g.endDate)
  501. const startTime = normalizeTimeHm(g.startTime)
  502. const endTime = normalizeTimeHm(g.endTime)
  503. this.$set(g, 'startDate', startDate)
  504. this.$set(g, 'endDate', endDate)
  505. this.$set(g, 'startTime', startTime)
  506. this.$set(g, 'endTime', endTime)
  507. this.$set(g, 'startDateDisplay', startDate || '选择日期')
  508. this.$set(g, 'endDateDisplay', endDate || '选择日期')
  509. this.$set(g, 'startTimeDisplay', startTime || '选择时间')
  510. this.$set(g, 'endTimeDisplay', endTime || '选择时间')
  511. this.$set(g, 'startDisplay', formatDateTimeDisplay(startDate, startTime))
  512. this.$set(g, 'endDisplay', formatDateTimeDisplay(endDate, endTime))
  513. },
  514. onStartDateByIndex(gi, e) {
  515. if (this.readonly) return
  516. const v = normalizeDateYmd(e && e.detail ? e.detail.value : '')
  517. const g = this.groups[gi]
  518. if (!g) return
  519. this.$set(g, 'startDate', v)
  520. // 改开始日时,按工期重算结束日
  521. const end = calcEndDateByDuration(v, g.workDays, g.settlementMode)
  522. if (end) this.$set(g, 'endDate', end)
  523. if (!normalizeTimeHm(g.startTime)) this.$set(g, 'startTime', '08:00')
  524. if (!normalizeTimeHm(g.endTime)) this.$set(g, 'endTime', '18:00')
  525. this.syncGroupDisplay(gi)
  526. },
  527. onStartTimeByIndex(gi, e) {
  528. if (this.readonly) return
  529. const v = normalizeTimeHm(e && e.detail ? e.detail.value : '')
  530. const g = this.groups[gi]
  531. if (!g) return
  532. this.$set(g, 'startTime', v)
  533. this.syncGroupDisplay(gi)
  534. },
  535. onEndDateByIndex(gi, e) {
  536. if (this.readonly) return
  537. const v = normalizeDateYmd(e && e.detail ? e.detail.value : '')
  538. const g = this.groups[gi]
  539. if (!g) return
  540. this.$set(g, 'endDate', v)
  541. this.syncGroupDisplay(gi)
  542. },
  543. onEndTimeByIndex(gi, e) {
  544. if (this.readonly) return
  545. const v = normalizeTimeHm(e && e.detail ? e.detail.value : '')
  546. const g = this.groups[gi]
  547. if (!g) return
  548. this.$set(g, 'endTime', v)
  549. this.syncGroupDisplay(gi)
  550. },
  551. validateSchedule() {
  552. const list = this.groups || []
  553. if (!list.length) return '暂无工种信息,无法发布'
  554. for (let i = 0; i < list.length; i++) {
  555. const g = list[i]
  556. const prefix = list.length > 1 ? `「${g.workType || ('工种' + (i + 1))}」` : ''
  557. if (!normalizeDateYmd(g.startDate)) return `${prefix}请选择开始日期`
  558. if (!normalizeTimeHm(g.startTime)) return `${prefix}请选择开始时间`
  559. if (!normalizeDateYmd(g.endDate)) return `${prefix}请选择结束日期`
  560. if (!normalizeTimeHm(g.endTime)) return `${prefix}请选择结束时间`
  561. const start = joinDateTimePayload(g.startDate, g.startTime)
  562. const end = joinDateTimePayload(g.endDate, g.endTime)
  563. if (start && end && start > end) return `${prefix}结束时间不能早于开始时间`
  564. }
  565. return ''
  566. },
  567. resolveTaskStepStatus(workflowRaw) {
  568. const detail = mapRegistrationBatchDetail(workflowRaw || {})
  569. const steps = detail.steps || []
  570. const task = steps.find((s) => isTaskPublishStep(s))
  571. if (!task) return 'pending'
  572. const s = String(task.status || 'pending').toLowerCase()
  573. if (s === 'done' || s === 'completed' || s === 'finish' || s === 'finished') return 'done'
  574. if (s === 'active' || s === 'current' || s === 'in_progress' || s === 'processing') return 'active'
  575. return 'pending'
  576. },
  577. buildStatusDesc(workflowRaw) {
  578. const detail = mapRegistrationBatchDetail(workflowRaw || {})
  579. const current = (detail.progress && detail.progress.current) || 0
  580. const total = (detail.progress && detail.progress.total) || 0
  581. if (this.canPublish) {
  582. if (total > 0 && current >= total) return `${total}人已全部登记,可发布任务`
  583. if (total > 0 && current < total) {
  584. return `已登记 ${current}/${total},未招满发布需确认缺编`
  585. }
  586. return '流程已到任务发布,可确认发布'
  587. }
  588. if (this.taskStepStatus === 'done') return '任务已发布'
  589. if (total > 0) return `已登记 ${current}/${total},等待流程推进到任务发布`
  590. return '等待流程推进到任务发布'
  591. },
  592. syncStaffingFromWorkflow(workflowRaw) {
  593. const detail = mapRegistrationBatchDetail(workflowRaw || {})
  594. const current = (detail.progress && detail.progress.current) || 0
  595. const total = (detail.progress && detail.progress.total) || 0
  596. this.registeredCount = current
  597. this.plannedCount = total
  598. this.understaffed = total > 0 && current < total
  599. },
  600. /**
  601. * TaskPublishRequest:
  602. * { work_items: [{ id?, group_no?, job_type?, work_start_date, work_end_date }], allow_understaffed? }
  603. * work_start_date / work_end_date:YYYY-MM-DD HH:mm:ss(日期+时间拼成一个字段)
  604. */
  605. buildPublishPayload(allowUnderstaffed = false) {
  606. const workItems = (this.groups || []).map((g) => {
  607. const start = joinDateTimePayload(g.startDate, g.startTime)
  608. const end = joinDateTimePayload(g.endDate, g.endTime)
  609. const item = {
  610. work_start_date: start,
  611. work_end_date: end
  612. }
  613. if (g.workItemId != null && g.workItemId !== '') {
  614. item.id = Number(g.workItemId)
  615. }
  616. if (g.groupId) {
  617. item.group_no = String(g.groupId)
  618. }
  619. if (g.workType && g.workType !== '待确认') {
  620. item.job_type = String(g.workType)
  621. }
  622. return item
  623. })
  624. return {
  625. work_items: workItems,
  626. allow_understaffed: !!allowUnderstaffed
  627. }
  628. },
  629. async loadData() {
  630. // 已完成「查看发布」走任务发布详情接口
  631. if (this.readonly) {
  632. await this.loadPublishDetail()
  633. return
  634. }
  635. if (this.draftId == null || this.draftId === '') {
  636. this.loadError = '缺少草稿信息'
  637. this.groups = []
  638. return
  639. }
  640. this.loading = true
  641. this.loadError = ''
  642. this.understaffed = false
  643. this.registeredCount = 0
  644. this.plannedCount = 0
  645. try {
  646. const draftRaw = await getDraftDetail(this.draftId, { showError: false })
  647. const card = mapDraftDetailToCard(draftRaw || {})
  648. const groups = normalizeWorkerGroups(draftRaw || {})
  649. this.groups = this.buildGroupRows(groups.length ? groups : (card.workerGroups || []))
  650. const batchNo = (draftRaw && (draftRaw.draftNo || draftRaw.draft_no)) || card.draftNo || ''
  651. const title = (card && card.title) || (draftRaw && draftRaw.title) || '用工任务'
  652. this.sourceTitle = batchNo ? `${title} ${batchNo}` : title
  653. // 订单 ID 仅从草稿详情 order_id / orderId 取值
  654. const orderId = draftRaw && (draftRaw.orderId != null && draftRaw.orderId !== ''
  655. ? draftRaw.orderId
  656. : (draftRaw.order_id != null && draftRaw.order_id !== '' ? draftRaw.order_id : null))
  657. this.orderIdInner = orderId
  658. let workflowRaw = null
  659. try {
  660. workflowRaw = await getDraftWorkflowProgress(this.draftId, { showError: false })
  661. } catch (e) {
  662. workflowRaw = null
  663. }
  664. this.syncStaffingFromWorkflow(workflowRaw)
  665. this.taskStepStatus = this.resolveTaskStepStatus(workflowRaw)
  666. this.statusDesc = this.buildStatusDesc(workflowRaw)
  667. } catch (e) {
  668. this.loadError = (e && (e.msg || e.message)) || '任务信息加载失败'
  669. this.groups = []
  670. showToast(this.loadError)
  671. } finally {
  672. this.loading = false
  673. }
  674. },
  675. async onConfirm() {
  676. if (this.readonly) {
  677. showToast('任务已发布,仅可查看')
  678. return
  679. }
  680. if (this.confirmDisabled) return
  681. if (!this.canPublish) {
  682. showToast(this.gateTip || '暂不可发布')
  683. return
  684. }
  685. const orderId = this.orderIdInner
  686. if (orderId == null || orderId === '') {
  687. showToast('缺少订单信息,暂无法发布')
  688. return
  689. }
  690. const scheduleErr = this.validateSchedule()
  691. if (scheduleErr) {
  692. showToast(scheduleErr)
  693. return
  694. }
  695. let allowUnderstaffed = false
  696. if (this.understaffed) {
  697. const tip = `已登记 ${this.registeredCount}/${this.plannedCount},未招满。是否缺编发布?`
  698. const ok = await modalConfirm('确认缺编发布', tip)
  699. if (!ok) return
  700. allowUnderstaffed = true
  701. } else {
  702. const ok = await modalConfirm('确认发布', '确认后将发布用工任务,是否继续?')
  703. if (!ok) return
  704. }
  705. this.publishing = true
  706. try {
  707. const res = await publishOrderTask(
  708. orderId,
  709. this.buildPublishPayload(allowUnderstaffed),
  710. { showError: true }
  711. )
  712. showToast((res && (res.message || res.msg)) || '任务已发布')
  713. const nextOrderId = (res && (res.orderId != null ? res.orderId : res.order_id)) || orderId
  714. this.$emit('success', {
  715. orderId: nextOrderId,
  716. draftId: this.draftId,
  717. result: res
  718. })
  719. this.onClose()
  720. } catch (e) {
  721. if (e && e.__handled) return
  722. } finally {
  723. this.publishing = false
  724. }
  725. }
  726. }
  727. }
  728. </script>
  729. <style lang="scss" scoped>
  730. .fe-bottom-sheet--task-publish {
  731. max-height: 88vh;
  732. }
  733. .fe-sheet-header {
  734. display: flex;
  735. align-items: center;
  736. justify-content: space-between;
  737. }
  738. .fe-sheet-close {
  739. width: 56rpx;
  740. height: 56rpx;
  741. border-radius: 50%;
  742. background: $fe-gray-100;
  743. color: $fe-muted;
  744. font-size: 28rpx;
  745. display: flex;
  746. align-items: center;
  747. justify-content: center;
  748. }
  749. .tp-source {
  750. padding: 0 40rpx 8rpx;
  751. }
  752. .tp-body {
  753. max-height: 58vh;
  754. }
  755. .tp-empty {
  756. padding: 80rpx 32rpx;
  757. text-align: center;
  758. }
  759. .tp-empty__text {
  760. display: block;
  761. font-size: 28rpx;
  762. color: #94A3B8;
  763. margin-bottom: 24rpx;
  764. }
  765. .tp-hero {
  766. display: flex;
  767. flex-direction: column;
  768. align-items: center;
  769. padding: 12rpx 0 28rpx;
  770. }
  771. .tp-hero__icon {
  772. width: 112rpx;
  773. height: 112rpx;
  774. border-radius: 50%;
  775. background: #FEF3C7;
  776. display: flex;
  777. align-items: center;
  778. justify-content: center;
  779. margin-bottom: 20rpx;
  780. }
  781. .tp-hero__icon-text {
  782. font-size: 52rpx;
  783. line-height: 1;
  784. }
  785. .tp-hero__title {
  786. font-size: 34rpx;
  787. font-weight: 700;
  788. color: $fe-foreground;
  789. margin-bottom: 8rpx;
  790. }
  791. .tp-hero__desc {
  792. font-size: 24rpx;
  793. color: $fe-muted;
  794. text-align: center;
  795. line-height: 1.5;
  796. padding: 0 24rpx;
  797. }
  798. .tp-section {
  799. margin-bottom: 28rpx;
  800. }
  801. .tp-section__title {
  802. display: block;
  803. font-size: 28rpx;
  804. font-weight: 700;
  805. color: $fe-foreground;
  806. margin-bottom: 16rpx;
  807. }
  808. .tp-card {
  809. background: $fe-gray-50;
  810. border-radius: $fe-radius-md;
  811. padding: 24rpx;
  812. margin-bottom: 16rpx;
  813. border: 1rpx solid $fe-border-light;
  814. box-sizing: border-box;
  815. }
  816. .tp-card__badge {
  817. display: block;
  818. font-size: 22rpx;
  819. font-weight: 600;
  820. color: $fe-primary;
  821. margin-bottom: 16rpx;
  822. }
  823. .tp-grid {
  824. display: flex;
  825. flex-wrap: wrap;
  826. }
  827. .tp-grid__cell {
  828. width: 50%;
  829. box-sizing: border-box;
  830. padding: 10rpx 12rpx 10rpx 0;
  831. }
  832. .tp-grid__label {
  833. display: block;
  834. font-size: 22rpx;
  835. color: $fe-muted;
  836. margin-bottom: 6rpx;
  837. }
  838. .tp-grid__value {
  839. display: block;
  840. font-size: 28rpx;
  841. font-weight: 700;
  842. color: $fe-foreground;
  843. word-break: break-all;
  844. }
  845. .tp-field {
  846. margin-bottom: 20rpx;
  847. }
  848. .tp-field:last-child {
  849. margin-bottom: 0;
  850. }
  851. .tp-field__label {
  852. display: block;
  853. font-size: 24rpx;
  854. color: $fe-muted;
  855. margin-bottom: 10rpx;
  856. }
  857. .tp-required {
  858. color: #EF4444;
  859. font-weight: 700;
  860. }
  861. .tp-datetime-row {
  862. display: flex;
  863. align-items: stretch;
  864. gap: 16rpx;
  865. width: 100%;
  866. box-sizing: border-box;
  867. }
  868. .tp-datetime-row__item {
  869. flex: 1;
  870. min-width: 0;
  871. width: 0;
  872. }
  873. .tp-input {
  874. display: flex;
  875. align-items: center;
  876. gap: 8rpx;
  877. min-height: 80rpx;
  878. padding: 0 16rpx;
  879. background: $fe-surface;
  880. border: 1rpx solid $fe-border;
  881. border-radius: $fe-radius-md;
  882. box-sizing: border-box;
  883. }
  884. .tp-input__icon {
  885. font-size: 28rpx;
  886. line-height: 1;
  887. flex-shrink: 0;
  888. }
  889. .tp-input__text {
  890. flex: 1;
  891. min-width: 0;
  892. font-size: 26rpx;
  893. font-weight: 600;
  894. color: $fe-foreground;
  895. overflow: hidden;
  896. text-overflow: ellipsis;
  897. white-space: nowrap;
  898. }
  899. .tp-input--placeholder .tp-input__text {
  900. color: #94A3B8;
  901. font-weight: 500;
  902. }
  903. .tp-gate-tip {
  904. padding: 16rpx 20rpx;
  905. background: rgba(245, 158, 11, 0.1);
  906. border-radius: $fe-radius-md;
  907. margin-bottom: 12rpx;
  908. }
  909. .tp-gate-tip--ok {
  910. background: rgba(16, 185, 129, 0.1);
  911. }
  912. .tp-gate-tip__text {
  913. font-size: 24rpx;
  914. color: #B45309;
  915. line-height: 1.5;
  916. }
  917. .tp-gate-tip__text--ok {
  918. color: #047857;
  919. }
  920. .tp-input--readonly {
  921. background: $fe-gray-100;
  922. }
  923. .fe-btn--disabled-look {
  924. opacity: 0.42;
  925. pointer-events: none;
  926. }
  927. </style>