index.vue 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955
  1. <template>
  2. <view class="page">
  3. <NavBar title="签到打卡" :showBack="true" />
  4. <view class="content">
  5. <view class="location-card">
  6. <view class="location-header">
  7. <view class="location-icon"></view>
  8. <text class="location-title">当前位置</text>
  9. <view class="refresh-btn" @click="refreshLocation">
  10. <view class="refresh-icon" :class="{ rotating: isLocating }"></view>
  11. </view>
  12. </view>
  13. <view class="location-content" v-if="currentLocation">
  14. <view class="location-row">
  15. <text class="location-label">经纬度:</text>
  16. <text class="location-value">{{ currentLocation.latitude.toFixed(6) }}, {{ currentLocation.longitude.toFixed(6) }}</text>
  17. </view>
  18. <view class="location-row">
  19. <text class="location-label">地址:</text>
  20. <text class="location-value">{{ currentLocation.address }}</text>
  21. </view>
  22. <view class="location-row">
  23. <text class="location-label">精度:</text>
  24. <text class="location-value">{{ currentLocation.accuracy }}米</text>
  25. </view>
  26. </view>
  27. <view class="location-loading" v-else>
  28. <text class="loading-text">{{ locationError || '正在获取位置...' }}</text>
  29. </view>
  30. </view>
  31. <view class="type-section">
  32. <view class="section-header">
  33. <text class="section-title">签到类型</text>
  34. </view>
  35. <view class="type-list">
  36. <view
  37. class="type-item"
  38. :class="{ active: selectedType === CheckinType.INVENTORY_TALLY }"
  39. @click="selectType(CheckinType.INVENTORY_TALLY)"
  40. >
  41. <view class="type-icon inventory">
  42. <view class="icon-inner"></view>
  43. </view>
  44. <view class="type-info">
  45. <text class="type-name">理货盘点签到</text>
  46. <text class="type-desc">仓库人员理货盘点确认</text>
  47. </view>
  48. <view class="type-check" v-if="selectedType === CheckinType.INVENTORY_TALLY">
  49. <view class="check-icon"></view>
  50. </view>
  51. </view>
  52. <view
  53. class="type-item"
  54. :class="{ active: selectedType === CheckinType.DELIVERY_REPLENISH }"
  55. @click="selectType(CheckinType.DELIVERY_REPLENISH)"
  56. >
  57. <view class="type-icon delivery">
  58. <view class="icon-inner"></view>
  59. </view>
  60. <view class="type-info">
  61. <text class="type-name">补货确认签到</text>
  62. <text class="type-desc">配送人员补货确认</text>
  63. </view>
  64. <view class="type-check" v-if="selectedType === CheckinType.DELIVERY_REPLENISH">
  65. <view class="check-icon"></view>
  66. </view>
  67. </view>
  68. </view>
  69. </view>
  70. <view class="photo-section">
  71. <view class="section-header">
  72. <text class="section-title">拍照签到</text>
  73. <text class="section-tip">至少拍摄1张照片</text>
  74. </view>
  75. <view class="photo-grid">
  76. <view
  77. class="photo-item"
  78. v-for="(photo, index) in photos"
  79. :key="photo.id"
  80. >
  81. <image
  82. class="photo-image"
  83. :src="photo.path"
  84. mode="aspectFill"
  85. @click="previewPhoto(photo.path)"
  86. />
  87. <view class="photo-delete" @click="removePhoto(index)">
  88. <view class="delete-icon"></view>
  89. </view>
  90. <view class="photo-index">{{ index + 1 }}</view>
  91. </view>
  92. <view class="photo-add" @click="addPhoto" v-if="photos.length < 5">
  93. <view class="add-icon">
  94. <view class="add-h"></view>
  95. <view class="add-v"></view>
  96. </view>
  97. <text class="add-text">拍照</text>
  98. </view>
  99. </view>
  100. </view>
  101. <view class="remark-section">
  102. <view class="section-header">
  103. <text class="section-title">备注信息</text>
  104. <text class="section-tip">选填</text>
  105. </view>
  106. <textarea
  107. class="remark-input"
  108. v-model="remark"
  109. placeholder="请输入备注信息..."
  110. :maxlength="200"
  111. placeholder-class="placeholder"
  112. />
  113. <view class="remark-count">
  114. <text>{{ remark.length }}/200</text>
  115. </view>
  116. </view>
  117. <view class="offline-tip" v-if="pendingSyncCount > 0">
  118. <view class="tip-icon"></view>
  119. <text class="tip-text">有 {{ pendingSyncCount }} 条离线签到待同步</text>
  120. <text class="tip-action" @click="syncOfflineRecords">立即同步</text>
  121. </view>
  122. <view class="submit-section">
  123. <button
  124. class="submit-btn"
  125. :class="{ disabled: !canSubmit }"
  126. :disabled="!canSubmit || isSubmitting"
  127. @click="handleSubmit"
  128. >
  129. <view class="btn-loading" v-if="isSubmitting"></view>
  130. <text v-else>{{ isSubmitting ? '提交中...' : '确认签到' }}</text>
  131. </button>
  132. </view>
  133. </view>
  134. <view class="success-modal" v-if="showSuccess">
  135. <view class="success-content">
  136. <view class="success-icon">
  137. <view class="check-mark"></view>
  138. </view>
  139. <text class="success-title">签到成功</text>
  140. <text class="success-time">{{ successTime }}</text>
  141. <button class="success-btn" @click="handleSuccessClose">确定</button>
  142. </view>
  143. </view>
  144. </view>
  145. </template>
  146. <script setup lang="ts">
  147. import { ref, computed, onMounted } from 'vue';
  148. import NavBar from '@/components/NavBar.vue';
  149. import { CheckinType } from '@/api/checkin';
  150. import type { LocationInfo, CheckinPhoto } from '@/api/checkin';
  151. import {
  152. getCurrentLocation,
  153. takePhotoWithWatermark,
  154. saveOfflineCheckin,
  155. getOfflineCheckins,
  156. removeOfflineCheckin,
  157. generateOfflineId,
  158. checkLocationPermission,
  159. checkCameraPermission,
  160. formatWatermarkTime
  161. } from '@/utils/checkin';
  162. import { getUserInfo } from '@/utils/auth';
  163. const selectedType = ref<CheckinType>(CheckinType.INVENTORY_TALLY);
  164. const currentLocation = ref<LocationInfo | null>(null);
  165. const locationError = ref('');
  166. const isLocating = ref(false);
  167. const photos = ref<CheckinPhoto[]>([]);
  168. const remark = ref('');
  169. const isSubmitting = ref(false);
  170. const showSuccess = ref(false);
  171. const successTime = ref('');
  172. const pendingSyncCount = ref(0);
  173. const canSubmit = computed(() => {
  174. return currentLocation.value && photos.value.length > 0 && !isSubmitting.value;
  175. });
  176. onMounted(() => {
  177. initLocation();
  178. updatePendingCount();
  179. });
  180. const initLocation = async () => {
  181. isLocating.value = true;
  182. locationError.value = '';
  183. try {
  184. const hasPermission = await checkLocationPermission();
  185. if (!hasPermission) {
  186. locationError.value = '请开启定位权限';
  187. return;
  188. }
  189. currentLocation.value = await getCurrentLocation();
  190. } catch (error: any) {
  191. locationError.value = error.message || '获取位置失败';
  192. } finally {
  193. isLocating.value = false;
  194. }
  195. };
  196. const refreshLocation = async () => {
  197. if (isLocating.value) return;
  198. await initLocation();
  199. };
  200. const selectType = (type: CheckinType) => {
  201. selectedType.value = type;
  202. };
  203. const addPhoto = async () => {
  204. if (!currentLocation.value) {
  205. uni.showToast({
  206. title: '请先获取位置信息',
  207. icon: 'none'
  208. });
  209. return;
  210. }
  211. const hasPermission = await checkCameraPermission();
  212. if (!hasPermission) return;
  213. try {
  214. const photo = await takePhotoWithWatermark(selectedType.value, currentLocation.value);
  215. photos.value.push(photo);
  216. } catch (error: any) {
  217. uni.showToast({
  218. title: error.message || '拍照失败',
  219. icon: 'none'
  220. });
  221. }
  222. };
  223. const removePhoto = (index: number) => {
  224. photos.value.splice(index, 1);
  225. };
  226. const previewPhoto = (path: string) => {
  227. uni.previewImage({
  228. urls: photos.value.map(p => p.path),
  229. current: path
  230. });
  231. };
  232. const updatePendingCount = () => {
  233. const offlineRecords = getOfflineCheckins();
  234. pendingSyncCount.value = offlineRecords.filter(r => r.status === 'pending').length;
  235. };
  236. const handleSubmit = async () => {
  237. if (!canSubmit.value || !currentLocation.value) return;
  238. isSubmitting.value = true;
  239. try {
  240. const userInfo = getUserInfo() || {};
  241. const offlineId = generateOfflineId();
  242. const params = {
  243. type: selectedType.value,
  244. location: currentLocation.value,
  245. photos: photos.value,
  246. remark: remark.value,
  247. offlineId
  248. };
  249. try {
  250. const record = await submitCheckin(params);
  251. showSuccess.value = true;
  252. successTime.value = formatWatermarkTime();
  253. photos.value = [];
  254. remark.value = '';
  255. updatePendingCount();
  256. } catch (error) {
  257. const offlineRecord = {
  258. id: offlineId,
  259. type: selectedType.value,
  260. typeName: selectedType.value === CheckinType.INVENTORY_TALLY ? '理货盘点签到' : '补货确认签到',
  261. userId: userInfo.id || 1,
  262. userName: userInfo.nickname || userInfo.username || '未知用户',
  263. userNo: userInfo.userNo || 'N/A',
  264. location: currentLocation.value!,
  265. photos: photos.value,
  266. remark: remark.value,
  267. status: 'pending' as const,
  268. createdAt: new Date().toISOString(),
  269. offlineId
  270. };
  271. saveOfflineCheckin(offlineRecord);
  272. uni.showToast({
  273. title: '已保存到本地,稍后同步',
  274. icon: 'none'
  275. });
  276. photos.value = [];
  277. remark.value = '';
  278. updatePendingCount();
  279. }
  280. } finally {
  281. isSubmitting.value = false;
  282. }
  283. };
  284. const syncOfflineRecords = async () => {
  285. const offlineRecords = getOfflineCheckins().filter(r => r.status === 'pending');
  286. if (offlineRecords.length === 0) {
  287. uni.showToast({
  288. title: '没有待同步的记录',
  289. icon: 'none'
  290. });
  291. return;
  292. }
  293. uni.showLoading({ title: '同步中...' });
  294. try {
  295. const result = await syncOfflineCheckins(offlineRecords);
  296. uni.hideLoading();
  297. if (result.success > 0) {
  298. uni.showToast({
  299. title: `成功同步 ${result.success} 条记录`,
  300. icon: 'success'
  301. });
  302. offlineRecords.forEach(record => {
  303. if (record.offlineId) {
  304. removeOfflineCheckin(record.offlineId);
  305. }
  306. });
  307. updatePendingCount();
  308. }
  309. } catch (error) {
  310. uni.hideLoading();
  311. uni.showToast({
  312. title: '同步失败,请稍后重试',
  313. icon: 'none'
  314. });
  315. }
  316. };
  317. const handleSuccessClose = () => {
  318. showSuccess.value = false;
  319. };
  320. </script>
  321. <style lang="scss" scoped>
  322. .page {
  323. min-height: 100vh;
  324. background: $bg-color-page;
  325. }
  326. .content {
  327. padding: 24rpx;
  328. padding-top: 0;
  329. }
  330. .location-card {
  331. background: $bg-color-card;
  332. border-radius: 20rpx;
  333. padding: 24rpx;
  334. margin-bottom: 24rpx;
  335. border: 1rpx solid $border-color;
  336. }
  337. .location-header {
  338. display: flex;
  339. align-items: center;
  340. margin-bottom: 16rpx;
  341. }
  342. .location-icon {
  343. width: 36rpx;
  344. height: 36rpx;
  345. background: $primary-color;
  346. border-radius: 50%;
  347. margin-right: 12rpx;
  348. position: relative;
  349. &::before {
  350. content: '';
  351. position: absolute;
  352. top: 50%;
  353. left: 50%;
  354. transform: translate(-50%, -50%);
  355. width: 12rpx;
  356. height: 12rpx;
  357. background: $bg-color-card;
  358. border-radius: 50%;
  359. }
  360. }
  361. .location-title {
  362. font-size: 28rpx;
  363. font-weight: 600;
  364. color: $text-color-primary;
  365. flex: 1;
  366. }
  367. .refresh-btn {
  368. width: 56rpx;
  369. height: 56rpx;
  370. display: flex;
  371. align-items: center;
  372. justify-content: center;
  373. background: $bg-color-secondary;
  374. border-radius: 50%;
  375. &:active {
  376. background: $border-color;
  377. }
  378. }
  379. .refresh-icon {
  380. width: 24rpx;
  381. height: 24rpx;
  382. border: 3rpx solid $text-color-tertiary;
  383. border-radius: 50%;
  384. border-top-color: transparent;
  385. &.rotating {
  386. animation: rotate 1s linear infinite;
  387. }
  388. @keyframes rotate {
  389. from { transform: rotate(0deg); }
  390. to { transform: rotate(360deg); }
  391. }
  392. }
  393. .location-content {
  394. padding: 16rpx;
  395. background: $bg-color-page;
  396. border-radius: 12rpx;
  397. }
  398. .location-row {
  399. display: flex;
  400. align-items: flex-start;
  401. margin-bottom: 12rpx;
  402. &:last-child {
  403. margin-bottom: 0;
  404. }
  405. }
  406. .location-label {
  407. font-size: 24rpx;
  408. color: $text-color-tertiary;
  409. width: 100rpx;
  410. flex-shrink: 0;
  411. }
  412. .location-value {
  413. font-size: 24rpx;
  414. color: $text-color-primary;
  415. flex: 1;
  416. }
  417. .location-loading {
  418. padding: 24rpx;
  419. text-align: center;
  420. }
  421. .loading-text {
  422. font-size: 26rpx;
  423. color: $text-color-tertiary;
  424. }
  425. .section-header {
  426. display: flex;
  427. justify-content: space-between;
  428. align-items: center;
  429. margin-bottom: 16rpx;
  430. }
  431. .section-title {
  432. font-size: 28rpx;
  433. font-weight: 600;
  434. color: $text-color-primary;
  435. }
  436. .section-tip {
  437. font-size: 24rpx;
  438. color: $text-color-muted;
  439. }
  440. .type-section {
  441. margin-bottom: 24rpx;
  442. }
  443. .type-list {
  444. background: $bg-color-card;
  445. border-radius: 20rpx;
  446. border: 1rpx solid $border-color;
  447. overflow: hidden;
  448. }
  449. .type-item {
  450. display: flex;
  451. align-items: center;
  452. padding: 28rpx 24rpx;
  453. border-bottom: 1rpx solid $bg-color-secondary;
  454. transition: background 0.15s;
  455. &:last-child {
  456. border-bottom: none;
  457. }
  458. &:active {
  459. background: $bg-color-page;
  460. }
  461. &.active {
  462. background: $success-color-bg;
  463. }
  464. }
  465. .type-icon {
  466. width: 72rpx;
  467. height: 72rpx;
  468. border-radius: 16rpx;
  469. display: flex;
  470. align-items: center;
  471. justify-content: center;
  472. margin-right: 20rpx;
  473. &.inventory {
  474. background: $success-color-bg;
  475. .icon-inner {
  476. width: 32rpx;
  477. height: 40rpx;
  478. border: 3rpx solid $primary-color;
  479. border-radius: 4rpx;
  480. position: relative;
  481. &::before {
  482. content: '';
  483. position: absolute;
  484. top: 8rpx;
  485. left: 6rpx;
  486. width: 14rpx;
  487. height: 3rpx;
  488. background: $primary-color;
  489. border-radius: 2rpx;
  490. }
  491. &::after {
  492. content: '';
  493. position: absolute;
  494. top: 16rpx;
  495. left: 6rpx;
  496. width: 10rpx;
  497. height: 3rpx;
  498. background: $primary-color;
  499. border-radius: 2rpx;
  500. }
  501. }
  502. }
  503. &.delivery {
  504. background: $accent-color-bg;
  505. .icon-inner {
  506. width: 36rpx;
  507. height: 28rpx;
  508. border: 3rpx solid $accent-color;
  509. border-radius: 4rpx;
  510. position: relative;
  511. &::before {
  512. content: '';
  513. position: absolute;
  514. top: 50%;
  515. left: 50%;
  516. transform: translate(-50%, -50%);
  517. width: 12rpx;
  518. height: 12rpx;
  519. background: $accent-color;
  520. border-radius: 50%;
  521. }
  522. }
  523. }
  524. }
  525. .type-info {
  526. flex: 1;
  527. }
  528. .type-name {
  529. display: block;
  530. font-size: 28rpx;
  531. font-weight: 500;
  532. color: $text-color-primary;
  533. margin-bottom: 4rpx;
  534. }
  535. .type-desc {
  536. font-size: 24rpx;
  537. color: $text-color-tertiary;
  538. }
  539. .type-check {
  540. width: 40rpx;
  541. height: 40rpx;
  542. background: $primary-color;
  543. border-radius: 50%;
  544. display: flex;
  545. align-items: center;
  546. justify-content: center;
  547. }
  548. .check-icon {
  549. width: 16rpx;
  550. height: 10rpx;
  551. border-left: 3rpx solid $bg-color-card;
  552. border-bottom: 3rpx solid $bg-color-card;
  553. transform: rotate(-45deg);
  554. margin-top: -4rpx;
  555. }
  556. .photo-section {
  557. margin-bottom: 24rpx;
  558. }
  559. .photo-grid {
  560. display: flex;
  561. flex-wrap: wrap;
  562. gap: 16rpx;
  563. }
  564. .photo-item {
  565. width: calc(33.33% - 12rpx);
  566. aspect-ratio: 1;
  567. border-radius: 12rpx;
  568. overflow: hidden;
  569. position: relative;
  570. background: $bg-color-secondary;
  571. }
  572. .photo-image {
  573. width: 100%;
  574. height: 100%;
  575. }
  576. .photo-delete {
  577. position: absolute;
  578. top: 8rpx;
  579. right: 8rpx;
  580. width: 40rpx;
  581. height: 40rpx;
  582. background: rgba(0, 0, 0, 0.5);
  583. border-radius: 50%;
  584. display: flex;
  585. align-items: center;
  586. justify-content: center;
  587. }
  588. .delete-icon {
  589. width: 16rpx;
  590. height: 16rpx;
  591. position: relative;
  592. &::before, &::after {
  593. content: '';
  594. position: absolute;
  595. top: 50%;
  596. left: 50%;
  597. width: 20rpx;
  598. height: 3rpx;
  599. background: $bg-color-card;
  600. border-radius: 2rpx;
  601. }
  602. &::before {
  603. transform: translate(-50%, -50%) rotate(45deg);
  604. }
  605. &::after {
  606. transform: translate(-50%, -50%) rotate(-45deg);
  607. }
  608. }
  609. .photo-index {
  610. position: absolute;
  611. bottom: 8rpx;
  612. left: 8rpx;
  613. width: 36rpx;
  614. height: 36rpx;
  615. background: rgba(0, 0, 0, 0.5);
  616. border-radius: 50%;
  617. display: flex;
  618. align-items: center;
  619. justify-content: center;
  620. font-size: 22rpx;
  621. color: $bg-color-card;
  622. }
  623. .photo-add {
  624. width: calc(33.33% - 12rpx);
  625. aspect-ratio: 1;
  626. border-radius: 12rpx;
  627. border: 2rpx dashed $text-color-placeholder;
  628. display: flex;
  629. flex-direction: column;
  630. align-items: center;
  631. justify-content: center;
  632. background: $bg-color-card;
  633. &:active {
  634. background: $bg-color-page;
  635. }
  636. }
  637. .add-icon {
  638. width: 48rpx;
  639. height: 48rpx;
  640. position: relative;
  641. margin-bottom: 8rpx;
  642. }
  643. .add-h, .add-v {
  644. position: absolute;
  645. top: 50%;
  646. left: 50%;
  647. transform: translate(-50%, -50%);
  648. background: $text-color-muted;
  649. border-radius: 2rpx;
  650. }
  651. .add-h {
  652. width: 32rpx;
  653. height: 4rpx;
  654. }
  655. .add-v {
  656. width: 4rpx;
  657. height: 32rpx;
  658. }
  659. .add-text {
  660. font-size: 24rpx;
  661. color: $text-color-muted;
  662. }
  663. .remark-section {
  664. margin-bottom: 24rpx;
  665. }
  666. .remark-input {
  667. width: 100%;
  668. min-height: 160rpx;
  669. background: $bg-color-card;
  670. border: 1rpx solid $border-color;
  671. border-radius: 16rpx;
  672. padding: 20rpx;
  673. font-size: 28rpx;
  674. color: $text-color-primary;
  675. box-sizing: border-box;
  676. }
  677. .placeholder {
  678. color: $text-color-muted;
  679. }
  680. .remark-count {
  681. text-align: right;
  682. margin-top: 8rpx;
  683. text {
  684. font-size: 24rpx;
  685. color: $text-color-muted;
  686. }
  687. }
  688. .offline-tip {
  689. display: flex;
  690. align-items: center;
  691. padding: 20rpx 24rpx;
  692. background: $warning-color-bg;
  693. border: 1rpx solid $primary-color-light;
  694. border-radius: 12rpx;
  695. margin-bottom: 24rpx;
  696. }
  697. .tip-icon {
  698. width: 32rpx;
  699. height: 32rpx;
  700. background: $warning-color;
  701. border-radius: 50%;
  702. margin-right: 12rpx;
  703. position: relative;
  704. &::before {
  705. content: '!';
  706. position: absolute;
  707. top: 50%;
  708. left: 50%;
  709. transform: translate(-50%, -50%);
  710. font-size: 22rpx;
  711. font-weight: 700;
  712. color: $bg-color-card;
  713. }
  714. }
  715. .tip-text {
  716. flex: 1;
  717. font-size: 26rpx;
  718. color: $primary-color-dark;
  719. }
  720. .tip-action {
  721. font-size: 26rpx;
  722. color: $warning-color;
  723. font-weight: 500;
  724. }
  725. .submit-section {
  726. padding: 24rpx 0;
  727. padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
  728. }
  729. .submit-btn {
  730. width: 100%;
  731. height: 96rpx;
  732. line-height: 96rpx;
  733. padding: 0;
  734. background: linear-gradient(135deg, $primary-color 0%, $primary-color-dark 100%);
  735. border-radius: 48rpx;
  736. display: flex;
  737. align-items: center;
  738. justify-content: center;
  739. font-size: 32rpx;
  740. font-weight: 600;
  741. color: $bg-color-card;
  742. border: none;
  743. &.disabled {
  744. background: $text-color-placeholder;
  745. color: $text-color-muted;
  746. }
  747. &:active:not(.disabled) {
  748. opacity: 0.9;
  749. }
  750. }
  751. .btn-loading {
  752. width: 36rpx;
  753. height: 36rpx;
  754. border: 3rpx solid rgba(255, 255, 255, 0.3);
  755. border-top-color: $bg-color-card;
  756. border-radius: 50%;
  757. animation: rotate 0.8s linear infinite;
  758. @keyframes rotate {
  759. from { transform: rotate(0deg); }
  760. to { transform: rotate(360deg); }
  761. }
  762. }
  763. .success-modal {
  764. position: fixed;
  765. top: 0;
  766. left: 0;
  767. right: 0;
  768. bottom: 0;
  769. background: rgba(0, 0, 0, 0.5);
  770. display: flex;
  771. align-items: center;
  772. justify-content: center;
  773. z-index: 1000;
  774. }
  775. .success-content {
  776. width: 560rpx;
  777. background: $bg-color-card;
  778. border-radius: 24rpx;
  779. padding: 48rpx;
  780. display: flex;
  781. flex-direction: column;
  782. align-items: center;
  783. }
  784. .success-icon {
  785. width: 120rpx;
  786. height: 120rpx;
  787. background: $success-color-bg;
  788. border-radius: 50%;
  789. display: flex;
  790. align-items: center;
  791. justify-content: center;
  792. margin-bottom: 24rpx;
  793. }
  794. .check-mark {
  795. width: 48rpx;
  796. height: 28rpx;
  797. border-left: 6rpx solid $primary-color;
  798. border-bottom: 6rpx solid $primary-color;
  799. transform: rotate(-45deg);
  800. margin-top: -12rpx;
  801. }
  802. .success-title {
  803. font-size: 36rpx;
  804. font-weight: 600;
  805. color: $text-color-primary;
  806. margin-bottom: 12rpx;
  807. }
  808. .success-time {
  809. font-size: 26rpx;
  810. color: $text-color-tertiary;
  811. margin-bottom: 32rpx;
  812. }
  813. .success-btn {
  814. width: 100%;
  815. height: 88rpx;
  816. line-height: 88rpx;
  817. padding: 0;
  818. background: $primary-color;
  819. border-radius: 44rpx;
  820. font-size: 30rpx;
  821. font-weight: 500;
  822. color: $bg-color-card;
  823. border: none;
  824. &:active {
  825. opacity: 0.9;
  826. }
  827. }
  828. </style>