西藏巴青项目

index.vue 18KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602
  1. <template>
  2. <!-- 用药工具:顶区 up-tabs;下为三个 Tab 表单与结果区(文案 i18n),白底 -->
  3. <view :class="pageRootClass" class="tab-page mt-page" :style="pageStyle">
  4. <scroll-view scroll-y class="mt-scroll" enable-back-to-top :show-scrollbar="false">
  5. <view class="mt-tabs-wrap">
  6. <up-tabs
  7. :current="tabIndex"
  8. class="mt-tabs"
  9. :list="tabsList"
  10. key-name="name"
  11. :scrollable="false"
  12. line-color="#22C55E"
  13. :active-style="{ color: '#15803d', fontWeight: '600' }"
  14. :inactive-style="{ color: '#78716c' }"
  15. @update:current="tabIndex = $event"
  16. />
  17. </view>
  18. <!-- 查询药物 -->
  19. <view v-show="tabIndex === 0" class="mt-panel">
  20. <view class="mt-row">
  21. <text class="mt-label text-body">{{ $t('medicineToolsPage.labelDrugName') }}</text>
  22. <up-search
  23. v-model="qDrugKeyword"
  24. class="mt-search"
  25. shape="round"
  26. :placeholder="$t('medicineToolsPage.searchDrugPlaceholder')"
  27. :show-action="false"
  28. :clearabled="true"
  29. bg-color="#f5f5f5"
  30. border-color="#e8e8e8"
  31. />
  32. </view>
  33. <view class="mt-row-btn">
  34. <up-button
  35. type="primary"
  36. shape="circle"
  37. :text="$t('medicineToolsPage.btnQuery')"
  38. :loading="queryLoading"
  39. @click="onQueryDrug"
  40. />
  41. </view>
  42. <view class="mt-gap-lg" />
  43. <text class="mt-line text-body">{{ $t('medicineToolsPage.labelDrugType') }}:{{ qDisplay.type }}</text>
  44. <text class="mt-line text-body">{{ $t('medicineToolsPage.labelWithdrawal') }}:{{ qDisplay.withdrawal }}</text>
  45. <text class="mt-line text-body mt-line--block">{{ $t('medicineToolsPage.labelGuide') }}:{{ qDisplay.guide }}</text>
  46. <text class="mt-line text-body mt-line--block">{{ $t('medicineToolsPage.labelTaboo') }}:{{ qDisplay.taboo }}</text>
  47. </view>
  48. <!-- 计算休药期 -->
  49. <view v-show="tabIndex === 1" class="mt-panel">
  50. <view class="mt-row">
  51. <text class="mt-label text-body">{{ $t('medicineToolsPage.labelDrugName') }}</text>
  52. <up-search
  53. v-model="wDrugKeyword"
  54. class="mt-search"
  55. shape="round"
  56. :placeholder="$t('medicineToolsPage.searchDrugPlaceholder')"
  57. :show-action="false"
  58. :clearabled="true"
  59. bg-color="#f5f5f5"
  60. border-color="#e8e8e8"
  61. />
  62. </view>
  63. <view class="mt-row">
  64. <text class="mt-label text-body">{{ $t('medicineToolsPage.labelStopDate') }}</text>
  65. <!-- 只读 picker:不用原生 disabled input,否则小程序上点击无法冒泡到父级,日历弹不出 -->
  66. <view class="mt-date-field" role="button" @tap.stop="openStopDateCalendar">
  67. <text
  68. class="mt-date-field__txt text-body"
  69. :class="{ 'mt-date-field__txt--placeholder': !stopDateStr }"
  70. >{{ stopDateStr || $t('medicineToolsPage.pickStopDate') }}</text>
  71. <up-icon name="arrow-right" color="#78716c" :size="18" />
  72. </view>
  73. </view>
  74. <view class="mt-row-btn">
  75. <up-button
  76. type="primary"
  77. shape="circle"
  78. :text="$t('medicineToolsPage.btnCalc')"
  79. :loading="calcLoading"
  80. @click="onCalcWithdrawal"
  81. />
  82. </view>
  83. <view class="mt-gap-lg" />
  84. <text class="mt-line text-body">{{ $t('medicineToolsPage.labelWithdrawal') }}:{{ wDisplay.withdrawal }}</text>
  85. <text class="mt-line text-body mt-line--block">{{ $t('medicineToolsPage.labelEndDate') }}:{{ wDisplay.endDate }}</text>
  86. </view>
  87. <!-- 用药配伍:默认 2 行,可添加至最多 20 行;至少保留 1 行 -->
  88. <view v-show="tabIndex === 2" class="mt-panel">
  89. <view v-for="(row, idx) in compatDrugRows" :key="row.id" class="mt-row mt-row--compat">
  90. <text class="mt-label text-body">{{ $t('medicineToolsPage.labelDrugNth', { n: idx + 1 }) }}</text>
  91. <up-search
  92. v-model="row.keyword"
  93. class="mt-search"
  94. shape="round"
  95. :placeholder="$t('medicineToolsPage.searchDrugPlaceholder')"
  96. :show-action="false"
  97. :clearabled="true"
  98. bg-color="#f5f5f5"
  99. border-color="#e8e8e8"
  100. />
  101. <view
  102. v-if="compatDrugRows.length > 2"
  103. class="mt-compat-remove"
  104. role="button"
  105. @tap="removeCompatDrugRow(row.id)"
  106. >
  107. <up-icon name="trash" color="#9ca3af" :size="20" />
  108. <text class="mt-compat-remove__txt text-body">{{ $t('medicineToolsPage.btnRemoveCompatRow') }}</text>
  109. </view>
  110. </view>
  111. <view class="mt-compat-add">
  112. <up-button
  113. type="success"
  114. plain
  115. hairline
  116. size="small"
  117. :text="$t('medicineToolsPage.btnAddCompatDrug')"
  118. :disabled="compatDrugRows.length >= compatMaxRows"
  119. @click="addCompatDrugRow"
  120. />
  121. </view>
  122. <view class="mt-row-btn">
  123. <up-button
  124. type="primary"
  125. shape="circle"
  126. :text="$t('medicineToolsPage.btnCompat')"
  127. :loading="compatLoading"
  128. @click="onCompat"
  129. />
  130. </view>
  131. <view class="mt-gap-lg" />
  132. <text class="mt-line text-body">{{ $t('medicineToolsPage.labelForbidden') }}:{{ cDisplay.forbidden }}</text>
  133. <text class="mt-line text-body mt-line--block">{{ $t('medicineToolsPage.labelCompatResult') }}:{{ cDisplay.result }}</text>
  134. </view>
  135. <view class="mt-footer-spacer" />
  136. </scroll-view>
  137. <up-calendar
  138. :key="calendarRenderKey"
  139. :show="calendarShow"
  140. mode="single"
  141. :month-switch="true"
  142. :min-date="calendarMinDate"
  143. :max-date="calendarMaxDate"
  144. :default-date="calendarDefaultDate"
  145. :month-num="calendarMonthNum"
  146. color="#22C55E"
  147. :round="16"
  148. :close-on-click-overlay="true"
  149. :title="$t('medicineToolsPage.calendarTitle')"
  150. :confirm-text="$t('medicineToolsPage.calendarConfirm')"
  151. @close="calendarShow = false"
  152. @confirm="onCalendarConfirm"
  153. />
  154. </view>
  155. </template>
  156. <script>
  157. import USearch from 'uview-plus/components/u-search/u-search.vue'
  158. import UTabs from 'uview-plus/components/u-tabs/u-tabs.vue'
  159. import UButton from 'uview-plus/components/u-button/u-button.vue'
  160. import UIcon from 'uview-plus/components/u-icon/u-icon.vue'
  161. import UCalendar from 'uview-plus/components/u-calendar/u-calendar.vue'
  162. import tabPage from '@/mixins/tabPage'
  163. import pageViewport from '@/mixins/pageViewport'
  164. import { queryDrug, calculateWithdrawal, checkCompatibility } from '@/api/medication'
  165. function todayYmd() {
  166. const d = new Date()
  167. const p = (n) => String(n).padStart(2, '0')
  168. return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
  169. }
  170. /** 以「当前本地日期」为基准偏移整年,得到 YYYY-MM-DD */
  171. function addYearsFromToday(deltaYears) {
  172. const t = new Date()
  173. t.setFullYear(t.getFullYear() + deltaYears)
  174. const p = (n) => String(n).padStart(2, '0')
  175. return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`
  176. }
  177. function normalizeCalendarValue(val) {
  178. if (val == null) return ''
  179. if (typeof val === 'string') return val.length >= 10 ? val.slice(0, 10) : val
  180. if (Array.isArray(val) && val.length) {
  181. return normalizeCalendarValue(val[0])
  182. }
  183. if (typeof val === 'object' && val.date) {
  184. const d = val.date instanceof Date ? val.date : new Date(val.date)
  185. if (!Number.isNaN(d.getTime())) {
  186. const p = (n) => String(n).padStart(2, '0')
  187. return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
  188. }
  189. }
  190. return ''
  191. }
  192. /** 与 u-calendar 内 getMonths 一致:起止 YYYY-MM-DD 之间(含首尾月)的月份个数 */
  193. function countMonthsInclusive(minYmd, maxYmd) {
  194. const p = (s) => {
  195. const a = (s || '').split('-').map((x) => parseInt(x, 10))
  196. return { y: a[0], m: a[1] }
  197. }
  198. const A = p(minYmd)
  199. const B = p(maxYmd)
  200. if (!A.y || !A.m || !B.y || !B.m) return 1
  201. return (B.y - A.y) * 12 + (B.m - A.m) + 1
  202. }
  203. /** 用药配伍:最多药物行数 */
  204. const COMPAT_MAX_ROWS = 20
  205. export default {
  206. components: {
  207. 'up-search': USearch,
  208. 'up-tabs': UTabs,
  209. 'up-button': UButton,
  210. 'up-icon': UIcon,
  211. 'up-calendar': UCalendar
  212. },
  213. mixins: [tabPage, pageViewport],
  214. data() {
  215. return {
  216. navTitleKey: 'homeGrid.medicineTools',
  217. tabIndex: 0,
  218. queryLoading: false,
  219. calcLoading: false,
  220. compatLoading: false,
  221. qDrugKeyword: '',
  222. qDisplay: { type: '—', withdrawal: '—', guide: '—', taboo: '—' },
  223. wDrugKeyword: '',
  224. stopDateStr: '',
  225. calendarShow: false,
  226. calendarRenderKey: 0,
  227. wDisplay: { withdrawal: '—', endDate: '—' },
  228. compatMaxRows: COMPAT_MAX_ROWS,
  229. compatDrugUid: 2,
  230. compatDrugRows: [
  231. { id: 1, keyword: '' },
  232. { id: 2, keyword: '' }
  233. ],
  234. cDisplay: { forbidden: '—', result: '—' }
  235. }
  236. },
  237. computed: {
  238. tabsList() {
  239. return [
  240. { name: this.$t('medicineToolsPage.tabQuery'), id: 'q' },
  241. { name: this.$t('medicineToolsPage.tabWithdrawal'), id: 'w' },
  242. { name: this.$t('medicineToolsPage.tabCompat'), id: 'c' }
  243. ]
  244. },
  245. /** u-calendar 默认 monthNum=3 只会生成 3 个月;设为 min~max 之间的月数才能滚满范围 */
  246. calendarMonthNum() {
  247. const n = countMonthsInclusive(this.calendarMinDate, this.calendarMaxDate)
  248. return Math.min(240, Math.max(1, n))
  249. },
  250. calendarMinDate() {
  251. return addYearsFromToday(-2)
  252. },
  253. calendarMaxDate() {
  254. return addYearsFromToday(2)
  255. },
  256. /** 日历打开时默认高亮:已选且在范围内则用已选,否则为今天 */
  257. calendarDefaultDate() {
  258. const min = this.calendarMinDate
  259. const max = this.calendarMaxDate
  260. const s = (this.stopDateStr || '').trim()
  261. if (s && s >= min && s <= max) return s
  262. return todayYmd()
  263. }
  264. },
  265. methods: {
  266. openStopDateCalendar() {
  267. this.calendarRenderKey += 1
  268. this.calendarShow = true
  269. },
  270. onCalendarConfirm(val) {
  271. this.calendarShow = false
  272. const ymd = normalizeCalendarValue(val)
  273. if (ymd) this.stopDateStr = ymd
  274. },
  275. onQueryDrug() {
  276. const drugName = (this.qDrugKeyword || '').trim()
  277. if (!drugName) {
  278. uni.showToast({ title: this.$t('medicineToolsPage.toastEmptyDrug'), icon: 'none' })
  279. return
  280. }
  281. if (this.queryLoading) return
  282. this.queryLoading = true
  283. queryDrug({ drugName })
  284. .then((res) => {
  285. const d = res.data || {}
  286. this.qDisplay = {
  287. type: d.drugTypeName || '—',
  288. withdrawal:
  289. d.withdrawalDays != null
  290. ? this.$t('medicineToolsPage.daysTpl', { n: d.withdrawalDays })
  291. : '—',
  292. guide: d.usageGuide || '—',
  293. taboo: d.incompatibilityCompanions || '—'
  294. }
  295. })
  296. .catch(() => {
  297. this.qDisplay = { type: '—', withdrawal: '—', guide: '—', taboo: '—' }
  298. })
  299. .finally(() => {
  300. this.queryLoading = false
  301. })
  302. },
  303. onCalcWithdrawal() {
  304. const drugName = (this.wDrugKeyword || '').trim()
  305. if (!drugName) {
  306. uni.showToast({ title: this.$t('medicineToolsPage.toastEmptyDrug'), icon: 'none' })
  307. return
  308. }
  309. if (!this.stopDateStr) {
  310. uni.showToast({ title: this.$t('medicineToolsPage.toastPickDate'), icon: 'none' })
  311. return
  312. }
  313. if (this.calcLoading) return
  314. this.calcLoading = true
  315. calculateWithdrawal({ drugName, stopDate: this.stopDateStr })
  316. .then((res) => {
  317. const d = res.data || {}
  318. this.wDisplay = {
  319. withdrawal:
  320. d.withdrawalDays != null
  321. ? this.$t('medicineToolsPage.daysTpl', { n: d.withdrawalDays })
  322. : '—',
  323. endDate: d.endDate || '—'
  324. }
  325. })
  326. .catch(() => {
  327. this.wDisplay = { withdrawal: '—', endDate: '—' }
  328. })
  329. .finally(() => {
  330. this.calcLoading = false
  331. })
  332. },
  333. addCompatDrugRow() {
  334. if (this.compatDrugRows.length >= COMPAT_MAX_ROWS) {
  335. uni.showToast({ title: this.$t('medicineToolsPage.toastCompatMax', { n: COMPAT_MAX_ROWS }), icon: 'none' })
  336. return
  337. }
  338. this.compatDrugUid += 1
  339. this.compatDrugRows.push({ id: this.compatDrugUid, keyword: '' })
  340. },
  341. removeCompatDrugRow(id) {
  342. if (this.compatDrugRows.length <= 1) {
  343. uni.showToast({ title: this.$t('medicineToolsPage.toastCompatMinRows'), icon: 'none' })
  344. return
  345. }
  346. this.compatDrugRows = this.compatDrugRows.filter((r) => r.id !== id)
  347. },
  348. onCompat() {
  349. const rows = this.compatDrugRows
  350. const drugNames = []
  351. for (let i = 0; i < rows.length; i++) {
  352. const raw = (rows[i].keyword || '').trim()
  353. if (!raw) continue
  354. drugNames.push(raw)
  355. }
  356. if (drugNames.length < 2) {
  357. this.cDisplay = { forbidden: '—', result: '—' }
  358. uni.showToast({ title: this.$t('medicineToolsPage.toastCompatNeedTwoResolved'), icon: 'none' })
  359. return
  360. }
  361. if (new Set(drugNames).size !== drugNames.length) {
  362. this.cDisplay = { forbidden: '—', result: '—' }
  363. uni.showToast({ title: this.$t('medicineToolsPage.toastCompatDup'), icon: 'none' })
  364. return
  365. }
  366. if (this.compatLoading) return
  367. this.compatLoading = true
  368. const pairTasks = []
  369. for (let i = 0; i < drugNames.length; i++) {
  370. for (let j = i + 1; j < drugNames.length; j++) {
  371. pairTasks.push(
  372. checkCompatibility({ drugName1: drugNames[i], drugName2: drugNames[j] }).then((res) => ({
  373. a: drugNames[i],
  374. b: drugNames[j],
  375. data: res.data || {}
  376. }))
  377. )
  378. }
  379. }
  380. Promise.all(pairTasks)
  381. .then((results) => {
  382. const badPairs = results.filter((item) => item.data.hasIncompatibility)
  383. const forbidden = badPairs.length > 0
  384. const listStr = badPairs
  385. .map(({ a, b, data }) => {
  386. const result = (data.compatResult || '').trim()
  387. return result
  388. ? this.$t('medicineToolsPage.compatPairResultTpl', { a, b, result })
  389. : this.$t('medicineToolsPage.compatPairTpl', { a, b })
  390. })
  391. .join(';')
  392. this.cDisplay = {
  393. forbidden: forbidden
  394. ? this.$t('medicineToolsPage.forbiddenYes')
  395. : this.$t('medicineToolsPage.forbiddenNo'),
  396. result: forbidden
  397. ? this.$t('medicineToolsPage.compatMultiForbidden', { list: listStr })
  398. : this.$t('medicineToolsPage.compatMultiSafe')
  399. }
  400. })
  401. .catch(() => {
  402. this.cDisplay = { forbidden: '—', result: '—' }
  403. })
  404. .finally(() => {
  405. this.compatLoading = false
  406. })
  407. }
  408. }
  409. }
  410. </script>
  411. <style lang="scss" scoped>
  412. @import '@/styles/morandi.scss';
  413. @import '@/styles/tab-page.scss';
  414. .mt-page {
  415. display: flex;
  416. flex-direction: column;
  417. min-width: 0;
  418. width: 100%;
  419. height: 100%;
  420. min-height: 100%;
  421. overflow: hidden;
  422. box-sizing: border-box;
  423. background: #ffffff;
  424. }
  425. .mt-scroll {
  426. flex: 1;
  427. min-height: 0;
  428. min-width: 0;
  429. height: 0;
  430. box-sizing: border-box;
  431. background: #ffffff;
  432. }
  433. .mt-tabs-wrap {
  434. padding: 16rpx 24rpx 12rpx;
  435. box-sizing: border-box;
  436. background: #ffffff;
  437. border-bottom: 1rpx solid #f0f0f0;
  438. }
  439. .mt-tabs {
  440. width: 100%;
  441. min-width: 0;
  442. }
  443. .mt-panel {
  444. display: flex;
  445. flex-direction: column;
  446. gap: 20rpx;
  447. min-width: 0;
  448. padding: 20rpx 24rpx 32rpx;
  449. box-sizing: border-box;
  450. }
  451. .mt-row {
  452. display: flex;
  453. flex-direction: row;
  454. align-items: center;
  455. gap: 16rpx;
  456. min-width: 0;
  457. }
  458. .mt-label {
  459. flex-shrink: 0;
  460. width: 160rpx;
  461. color: $morandi-text-secondary;
  462. }
  463. .mt-search {
  464. flex: 1;
  465. min-width: 0;
  466. }
  467. .mt-row--compat {
  468. align-items: stretch;
  469. }
  470. .mt-compat-remove {
  471. flex-shrink: 0;
  472. display: flex;
  473. flex-direction: row;
  474. align-items: center;
  475. justify-content: center;
  476. gap: 6rpx;
  477. padding: 0 8rpx;
  478. min-height: 64rpx;
  479. box-sizing: border-box;
  480. }
  481. .mt-compat-remove__txt {
  482. font-size: 22rpx;
  483. color: #9ca3af;
  484. line-height: 1.2;
  485. white-space: nowrap;
  486. }
  487. .mt-compat-add {
  488. display: flex;
  489. flex-direction: row;
  490. justify-content: flex-start;
  491. padding-top: 4rpx;
  492. }
  493. .mt-date-field {
  494. flex: 1;
  495. min-width: 0;
  496. display: flex;
  497. flex-direction: row;
  498. align-items: center;
  499. justify-content: space-between;
  500. gap: 12rpx;
  501. box-sizing: border-box;
  502. padding: 10rpx 18rpx;
  503. min-height: 72rpx;
  504. border: 1rpx solid #e8e8e8;
  505. border-radius: 8rpx;
  506. background: #ffffff;
  507. }
  508. .mt-date-field__txt {
  509. flex: 1;
  510. min-width: 0;
  511. font-size: 28rpx;
  512. line-height: 1.45;
  513. color: #303133;
  514. word-break: break-all;
  515. }
  516. .mt-date-field__txt--placeholder {
  517. color: #c0c4cc;
  518. }
  519. .mt-row-btn {
  520. display: flex;
  521. flex-direction: row;
  522. justify-content: center;
  523. padding-top: 8rpx;
  524. }
  525. .mt-row-btn :deep(.u-button) {
  526. min-width: 280rpx;
  527. }
  528. .mt-gap-lg {
  529. height: 32rpx;
  530. }
  531. .mt-line {
  532. color: $morandi-text;
  533. line-height: 1.55;
  534. word-break: break-word;
  535. overflow-wrap: anywhere;
  536. }
  537. .mt-line--block {
  538. display: block;
  539. }
  540. .mt-footer-spacer {
  541. height: 48rpx;
  542. }
  543. .mt-page.lang-bo {
  544. .mt-label {
  545. width: 200rpx;
  546. font-size: 24rpx;
  547. line-height: 1.75;
  548. letter-spacing: 2rpx;
  549. font-family: 'Noto Sans Tibetan', 'PingFang SC', 'Microsoft YaHei', sans-serif;
  550. }
  551. .mt-line {
  552. font-size: 26rpx;
  553. line-height: 1.75;
  554. letter-spacing: 2rpx;
  555. font-family: 'Noto Sans Tibetan', 'PingFang SC', 'Microsoft YaHei', sans-serif;
  556. }
  557. .mt-date-field__txt {
  558. font-size: 26rpx;
  559. line-height: 1.75;
  560. letter-spacing: 2rpx;
  561. font-family: 'Noto Sans Tibetan', 'PingFang SC', 'Microsoft YaHei', sans-serif;
  562. }
  563. }
  564. </style>