cwg-file-picker.vue 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842
  1. <template>
  2. <view class="common-file-uploader" :class="customClass">
  3. <!-- 编辑状态:显示可上传的文件选择器 -->
  4. <view v-if="editable" class="upload-wrapper">
  5. <uni-file-picker :limit="multiple ? limit : 1" :title="title" :file-mediatype="fileMediatype" :mode="mode"
  6. :auto-upload="false" :value="fileList" :disabled="disabled" :readonly="readonly"
  7. :image-styles="imageStyles" :list-styles="listStyles" :del-icon="showPreviewDelete"
  8. :disablePreview="disablePreview" :canChoose="canChoose" @select="handleSelect" @delete="handleDelete">
  9. <!-- 自定义上传按钮(单张图片且已有图片时显示替换) -->
  10. <template v-if="$slots.default || showCustomButton">
  11. <slot name="default">
  12. <view class="custom-upload-btn" :class="{ 'replace-btn': !multiple && fileList.length > 0 }"
  13. :style="customButtonStyle">
  14. <template v-if="!multiple && fileList.length > 0">
  15. <text class="replace-icon">↻</text>
  16. <text class="tip">{{ replaceText || t('Common.Replace') }}</text>
  17. </template>
  18. <template v-else>
  19. <text class="plus">+</text>
  20. <!-- <text class="tip">{{ uploadText || t('common.upload') }}</text>-->
  21. </template>
  22. </view>
  23. </slot>
  24. </template>
  25. </uni-file-picker>
  26. <!-- 上传进度 -->
  27. <view v-if="showProgress && uploadProgress > 0 && uploadProgress < 100" class="upload-progress"
  28. :style="progressStyle">
  29. <view class="progress-bar" :style="{ width: uploadProgress + '%' }"></view>
  30. <text class="progress-text">{{ uploadProgress }}%</text>
  31. </view>
  32. <!-- 上传错误提示 -->
  33. <view v-if="uploadError && showError" class="upload-error">
  34. <text class="error-text">{{ uploadError }}</text>
  35. </view>
  36. </view>
  37. <!-- 非编辑状态:显示图片预览 -->
  38. <view v-else class="image-preview" :class="previewClass">
  39. <!-- 单张图片预览 -->
  40. <template v-if="!multiple">
  41. <view class="single-preview">
  42. <image v-if="getImageUrl(modelValue)" :src="getImageUrl(modelValue)" :mode="imageMode"
  43. class="preview-image" :style="previewImageStyle" @tap="handlePreview(getImageUrl(modelValue))"
  44. @error="handleImageError" :lazy-load="true" />
  45. <view v-else class="no-image" :style="noImageStyle">
  46. <text :style="noImageTextStyle">{{ noImageText || t('Common.NoImage') }}</text>
  47. </view>
  48. </view>
  49. </template>
  50. <!-- 多张图片预览 -->
  51. <template v-else>
  52. <view v-if="getFileList(modelValue).length" class="image-list" :style="listContainerStyle">
  53. <view v-for="(file, idx) in getFileList(modelValue)" :key="idx" class="image-item"
  54. :style="imageItemStyle">
  55. <image :src="getFileUrl(file)" :mode="imageMode" class="preview-image"
  56. :style="previewImageStyle" @tap="handlePreview(getFilePath(file))" @error="handleImageError"
  57. :lazy-load="true" />
  58. <!-- 预览时的删除按钮(如果有权限) -->
  59. <view v-if="showPreviewDelete && canDelete" class="preview-delete-btn" :style="deleteBtnStyle"
  60. @tap.stop="handlePreviewDelete(file, idx)">
  61. <text class="delete-icon">×</text>
  62. </view>
  63. </view>
  64. </view>
  65. <view v-else class="no-image" :style="noImageStyle">
  66. <text :style="noImageTextStyle">{{ noImageText || t('Common.NoImage') }}</text>
  67. </view>
  68. </template>
  69. </view>
  70. </view>
  71. </template>
  72. <script setup>
  73. import { computed, ref, watch } from 'vue'
  74. import { useI18n } from 'vue-i18n'
  75. import config from '@/config';
  76. import { userToken } from '@/composables/config'
  77. const { t } = useI18n()
  78. const props = defineProps({
  79. // 基础配置
  80. modelValue: {
  81. type: [String, Array, Object],
  82. default: null
  83. },
  84. editable: {
  85. type: Boolean,
  86. default: false
  87. },
  88. multiple: {
  89. type: Boolean,
  90. default: false
  91. },
  92. limit: {
  93. type: Number,
  94. default: 1
  95. },
  96. // 上传配置
  97. title: {
  98. type: String,
  99. default: ''
  100. },
  101. fileMediatype: {
  102. type: String,
  103. default: 'image'
  104. },
  105. mode: {
  106. type: String,
  107. default: 'grid'
  108. },
  109. disabled: {
  110. type: Boolean,
  111. default: false
  112. },
  113. disablePreview: {
  114. type: Boolean,
  115. default: false
  116. },
  117. canChoose: {
  118. type: Boolean,
  119. default: false
  120. },
  121. readonly: {
  122. type: Boolean,
  123. default: false
  124. },
  125. // 上传API配置
  126. uploadUrl: {
  127. type: String,
  128. required: true,
  129. default: '/custom/bank/upload'
  130. },
  131. uploadHeaders: {
  132. type: Object,
  133. default: () => ({})
  134. },
  135. uploadName: {
  136. type: String,
  137. default: 'file'
  138. },
  139. uploadData: {
  140. type: Object,
  141. default: () => ({})
  142. },
  143. // 图片服务配置
  144. baseUrl: {
  145. type: String,
  146. default: config.Host80
  147. },
  148. imagePathPrefix: {
  149. type: String,
  150. default: ''
  151. },
  152. // 样式配置
  153. customClass: {
  154. type: String,
  155. default: ''
  156. },
  157. previewClass: {
  158. type: String,
  159. default: ''
  160. },
  161. // 尺寸配置
  162. imageWidth: {
  163. type: [String, Number],
  164. default: 160
  165. },
  166. imageHeight: {
  167. type: [String, Number],
  168. default: 160
  169. },
  170. imageGap: {
  171. type: [String, Number],
  172. default: 16
  173. },
  174. imageBorderRadius: {
  175. type: [String, Number],
  176. default: 8
  177. },
  178. // 图片配置
  179. imageMode: {
  180. type: String,
  181. default: 'aspectFill'
  182. },
  183. // 上传按钮配置
  184. showCustomButton: {
  185. type: Boolean,
  186. default: true
  187. },
  188. uploadText: {
  189. type: String,
  190. default: ''
  191. },
  192. replaceText: {
  193. type: String,
  194. default: ''
  195. },
  196. // 暂无图片配置
  197. noImageText: {
  198. type: String,
  199. default: ''
  200. },
  201. // 预览配置
  202. showPreviewDelete: {
  203. type: Boolean,
  204. default: false
  205. },
  206. canDelete: {
  207. type: Boolean,
  208. default: true
  209. },
  210. // 进度显示
  211. showProgress: {
  212. type: Boolean,
  213. default: true
  214. },
  215. // 是否自动上传
  216. autoUpload: {
  217. type: Boolean,
  218. default: true
  219. },
  220. // 响应数据处理函数
  221. responseHandler: {
  222. type: Function,
  223. default: (res) => {
  224. try {
  225. const data = typeof res === 'string' ? JSON.parse(res) : res
  226. return {
  227. success: data.code === 200,
  228. path: data.data?.path || data.data,
  229. message: data.msg || '上传成功',
  230. data: data
  231. }
  232. } catch (e) {
  233. return {
  234. success: false,
  235. path: null,
  236. message: '解析响应失败',
  237. data: null
  238. }
  239. }
  240. }
  241. },
  242. // uni-file-picker 的图片样式
  243. imageStyles: {
  244. type: Object,
  245. default: () => ({})
  246. },
  247. // uni-file-picker 的列表样式
  248. listStyles: {
  249. type: Object,
  250. default: () => ({})
  251. },
  252. // 自定义样式对象
  253. customStyles: {
  254. type: Object,
  255. default: () => ({})
  256. },
  257. // 展示错误信息
  258. showError: {
  259. type: Object,
  260. default: true
  261. }
  262. })
  263. const emit = defineEmits([
  264. 'update:modelValue',
  265. 'select',
  266. 'delete',
  267. 'progress',
  268. 'success',
  269. 'fail',
  270. 'preview',
  271. 'image-error',
  272. 'preview-delete',
  273. 'upload-start',
  274. 'upload-complete'
  275. ])
  276. // 上传进度
  277. const uploadProgress = ref(0)
  278. const uploadError = ref('')
  279. const isUploading = ref(false)
  280. // 文件列表(用于uni-file-picker回显)
  281. const fileList = computed(() => {
  282. return getFileValue(props.modelValue)
  283. })
  284. // 计算样式
  285. const imageWidthPx = computed(() => {
  286. return typeof props.imageWidth === 'number' ? px2rpx(props.imageWidth) : props.imageWidth
  287. })
  288. const imageHeightPx = computed(() => {
  289. return typeof props.imageHeight === 'number' ? px2rpx(props.imageHeight) : props.imageHeight
  290. })
  291. const imageGapPx = computed(() => {
  292. return typeof props.imageGap === 'number' ? px2rpx(props.imageGap) : props.imageGap
  293. })
  294. const imageBorderRadiusPx = computed(() => {
  295. return typeof props.imageBorderRadius === 'number' ? px2rpx(props.imageBorderRadius) : props.imageBorderRadius
  296. })
  297. // 自定义按钮样式
  298. const customButtonStyle = computed(() => ({
  299. width: imageWidthPx.value,
  300. height: imageHeightPx.value,
  301. ...props.customStyles.customButton
  302. }))
  303. // 预览图片样式
  304. const previewImageStyle = computed(() => ({
  305. width: imageWidthPx.value,
  306. height: imageHeightPx.value,
  307. borderRadius: imageBorderRadiusPx.value,
  308. ...props.customStyles.previewImage
  309. }))
  310. // 图片项样式
  311. const imageItemStyle = computed(() => ({
  312. width: imageWidthPx.value,
  313. height: imageHeightPx.value,
  314. borderRadius: imageBorderRadiusPx.value,
  315. ...props.customStyles.imageItem
  316. }))
  317. // 列表容器样式
  318. const listContainerStyle = computed(() => ({
  319. gap: imageGapPx.value,
  320. ...props.customStyles.listContainer
  321. }))
  322. // 暂无图片样式
  323. const noImageStyle = computed(() => ({
  324. width: imageWidthPx.value,
  325. height: imageHeightPx.value,
  326. borderRadius: imageBorderRadiusPx.value,
  327. ...props.customStyles.noImage
  328. }))
  329. const noImageTextStyle = computed(() => props.customStyles.noImageText || {})
  330. const progressStyle = computed(() => props.customStyles.progress || {})
  331. const deleteBtnStyle = computed(() => props.customStyles.deleteBtn || {})
  332. // 获取文件值,用于回显
  333. const getFileValue = (value) => {
  334. if (!value) return props.multiple ? [] : []
  335. if (props.multiple) {
  336. if (Array.isArray(value)) {
  337. return value.map(item => ({
  338. url: getFullUrl(item.path || item),
  339. path: item.path || item,
  340. name: item.name || '图片',
  341. uuid: item.id || item.path || Date.now() + Math.random()
  342. }))
  343. }
  344. return []
  345. } else {
  346. console.log(value, [{
  347. url: getFullUrl(value),
  348. path: value,
  349. name: '图片',
  350. uuid: value
  351. }], 121212121);
  352. if (typeof value === 'string') {
  353. return value ? [{
  354. url: getFullUrl(value),
  355. path: value,
  356. name: '图片',
  357. uuid: value
  358. }] : []
  359. }
  360. if (value && value.path) {
  361. return [{
  362. url: getFullUrl(value.path),
  363. path: value.path,
  364. name: value.name || '图片',
  365. uuid: value.id || value.path
  366. }]
  367. }
  368. return []
  369. }
  370. }
  371. // 获取文件列表
  372. const getFileList = (value) => {
  373. if (!value) return []
  374. if (props.multiple) {
  375. return Array.isArray(value) ? value : []
  376. } else {
  377. if (typeof value === 'string') {
  378. return value ? [{ path: value }] : []
  379. }
  380. return value ? [value] : []
  381. }
  382. }
  383. // 获取完整URL
  384. const getFullUrl = (path) => {
  385. if (!path) return ''
  386. if (path.startsWith('http')) return path
  387. return (props.baseUrl || config.Host80) + (props.imagePathPrefix || '') + path
  388. }
  389. // 获取图片URL
  390. const getImageUrl = (value) => {
  391. if (!value) return ''
  392. if (typeof value === 'string') return getFullUrl(value)
  393. if (value.url) return value.url
  394. if (value.path) return getFullUrl(value.path)
  395. return ''
  396. }
  397. // 获取文件路径
  398. const getFilePath = (file) => {
  399. if (!file) return ''
  400. if (typeof file === 'string') return file
  401. return file.path || file.url || ''
  402. }
  403. // 获取文件URL(用于预览)
  404. const getFileUrl = (file) => {
  405. if (!file) return ''
  406. if (typeof file === 'string') return getFullUrl(file)
  407. return getFullUrl(file.path || file.url || '')
  408. }
  409. // 处理选择文件
  410. const handleSelect = async (e) => {
  411. uploadError.value = ''
  412. emit('select', e)
  413. if (props.autoUpload) {
  414. await uploadFiles(e.tempFiles)
  415. }
  416. }
  417. // 上传文件
  418. const uploadFiles = async (tempFiles) => {
  419. if (!tempFiles || tempFiles.length === 0) return
  420. isUploading.value = true
  421. emit('upload-start')
  422. try {
  423. const uploadedPaths = []
  424. for (let i = 0; i < tempFiles.length; i++) {
  425. const file = tempFiles[i]
  426. uploadProgress.value = Math.round(((i) / tempFiles.length) * 100)
  427. const result = await uploadSingleFile(file)
  428. if (result.success) {
  429. uploadedPaths.push(result.path)
  430. } else {
  431. throw new Error(result.message)
  432. }
  433. uploadProgress.value = Math.round(((i + 1) / tempFiles.length) * 100)
  434. }
  435. // 更新modelValue
  436. if (props.multiple) {
  437. // 多张图片:合并新旧数据
  438. const currentValue = Array.isArray(props.modelValue) ? [...props.modelValue] : []
  439. const newValue = [...currentValue, ...uploadedPaths.map(path => ({ path }))]
  440. emit('update:modelValue', newValue)
  441. } else {
  442. // 单张图片:直接替换
  443. const newPath = uploadedPaths[0]
  444. console.log(3333, props.modelValue);
  445. emit('update:modelValue', newPath)
  446. }
  447. uploadProgress.value = 100
  448. setTimeout(() => {
  449. uploadProgress.value = 0
  450. }, 500)
  451. emit('upload-complete', { success: true, paths: uploadedPaths })
  452. } catch (error) {
  453. uploadError.value = error.message || '上传失败'
  454. uploadProgress.value = 0
  455. emit('upload-complete', { success: false, error: error.message })
  456. uni.showToast({
  457. title: error.message || '上传失败',
  458. icon: 'none'
  459. })
  460. } finally {
  461. isUploading.value = false
  462. }
  463. }
  464. // 上传单个文件
  465. const uploadSingleFile = (file) => {
  466. return new Promise((resolve, reject) => {
  467. const uploadTask = uni.uploadFile({
  468. url: config.Host80 + props.uploadUrl,
  469. filePath: file.path,
  470. name: props.uploadName,
  471. header: props.uploadHeaders,
  472. name: 'file',
  473. header: {
  474. 'Access-Token': userToken.value
  475. },
  476. formData: props.uploadData,
  477. success: (res) => {
  478. const result = props.responseHandler(res.data)
  479. if (result.success) {
  480. resolve(result)
  481. } else {
  482. reject(new Error(result.message))
  483. }
  484. },
  485. fail: (err) => {
  486. console.error('上传失败:', err)
  487. reject(new Error('网络错误,请重试'))
  488. }
  489. })
  490. // 监听上传进度
  491. uploadTask.onProgressUpdate((res) => {
  492. // 这里可以处理单个文件的上传进度
  493. console.log('单文件上传进度:', res.progress)
  494. })
  495. })
  496. }
  497. // 处理删除文件
  498. const handleDelete = (e) => {
  499. emit('delete', e)
  500. // 更新modelValue
  501. if (props.multiple) {
  502. const currentValue = Array.isArray(props.modelValue) ? [...props.modelValue] : []
  503. const newValue = currentValue.filter((_, index) => index !== e.index)
  504. emit('update:modelValue', newValue)
  505. } else {
  506. emit('update:modelValue', '')
  507. }
  508. }
  509. // 处理预览
  510. const handlePreview = (path) => {
  511. if (!path) return
  512. const urls = getFileList(props.modelValue).map(f => getFullUrl(getFilePath(f)))
  513. uni.previewImage({
  514. current: getFullUrl(path),
  515. urls: urls.length ? urls : [getFullUrl(path)]
  516. })
  517. emit('preview', path)
  518. }
  519. // 处理图片加载错误
  520. const handleImageError = (e) => {
  521. console.error('图片加载失败:', e)
  522. emit('image-error', e)
  523. }
  524. // 处理预览时的删除
  525. const handlePreviewDelete = (file, index) => {
  526. emit('preview-delete', { file, index })
  527. // 更新modelValue
  528. if (props.multiple) {
  529. const currentValue = Array.isArray(props.modelValue) ? [...props.modelValue] : []
  530. currentValue.splice(index, 1)
  531. emit('update:modelValue', currentValue)
  532. }
  533. }
  534. // 单位转换函数
  535. const px2rpx = (px) => {
  536. // 假设设计稿750px,1px = 2rpx
  537. return px * 2 + 'rpx'
  538. }
  539. // 对外暴露方法
  540. defineExpose({
  541. uploadFiles,
  542. clearError: () => uploadError.value = ''
  543. })
  544. </script>
  545. <style lang="scss" scoped>
  546. .common-file-uploader {
  547. width: 100%;
  548. .upload-wrapper {
  549. width: 100%;
  550. height: 100%;
  551. :deep(.uni-file-picker) {
  552. .file-picker__box {
  553. border: none;
  554. background: transparent;
  555. }
  556. .uni-file-picker__files {
  557. height: 100%;
  558. }
  559. .files-button {
  560. height: 100%;
  561. }
  562. .custom-upload-btn {
  563. width: 100% !important;
  564. height: 100% !important;
  565. display: flex;
  566. flex-direction: column;
  567. align-items: center;
  568. justify-content: center;
  569. &.replace-btn {
  570. background: rgba(0, 0, 0, 0.03);
  571. .replace-icon {
  572. font-size: 40px;
  573. color: var(--bs-heading-color);
  574. margin-bottom: 4px;
  575. transform: rotate(90deg);
  576. }
  577. .tip {
  578. color: var(--bs-heading-color);
  579. }
  580. &:hover {
  581. background: rgba(234, 32, 39, 0.05);
  582. .replace-icon,
  583. .tip {
  584. color: #ea2027;
  585. }
  586. }
  587. }
  588. .plus {
  589. font-size: 48px;
  590. color: #9ca3af;
  591. //line-height: 1;
  592. }
  593. .tip {
  594. font-size: 24px;
  595. color: #6b7280;
  596. }
  597. }
  598. .file-picker__box-list {
  599. display: flex;
  600. gap: v-bind(imageGapPx);
  601. flex-wrap: wrap;
  602. .file-picker__box-item {
  603. position: relative;
  604. border: 1px solid #e5e7eb;
  605. overflow: hidden;
  606. .file-picker__box-item-image {
  607. width: 100%;
  608. height: 100%;
  609. object-fit: cover;
  610. }
  611. .file-picker__box-item-close {
  612. position: absolute;
  613. top: 4px;
  614. right: 4px;
  615. width: 24px;
  616. height: 24px;
  617. background: rgba(0, 0, 0, 0.5);
  618. border-radius: 50%;
  619. display: flex;
  620. align-items: center;
  621. justify-content: center;
  622. &::before {
  623. content: '×';
  624. color: var(--bs-emphasis-color);
  625. font-size: 28px;
  626. line-height: 1;
  627. }
  628. .close-icon {
  629. display: none;
  630. }
  631. }
  632. }
  633. }
  634. .file-picker__box-add {
  635. margin: 0;
  636. border: 2rpx dashed #d1d5db;
  637. background: #f9fafb;
  638. transition: all 0.3s;
  639. cursor: pointer;
  640. &:hover {
  641. border-color: #ea2027;
  642. background: #fef2f2;
  643. }
  644. .add-icon {
  645. display: none;
  646. }
  647. }
  648. .file-picker__box-progress {
  649. border-radius: v-bind(imageBorderRadiusPx);
  650. background: rgba(0, 0, 0, 0.5);
  651. color: var(--bs-emphasis-color);
  652. }
  653. }
  654. .upload-progress {
  655. margin-top: 8px;
  656. padding: 8px;
  657. background: #f5f5f5;
  658. border-radius: 4px;
  659. position: relative;
  660. .progress-bar {
  661. height: 4px;
  662. background: #ea2027;
  663. border-radius: 2px;
  664. transition: width 0.3s;
  665. }
  666. .progress-text {
  667. position: absolute;
  668. right: 8px;
  669. top: 8px;
  670. font-size: 24px;
  671. color: var(--bs-heading-color);
  672. }
  673. }
  674. .upload-error {
  675. margin-top: 8px;
  676. padding: 8px;
  677. background: #fee2e2;
  678. border-radius: 4px;
  679. .error-text {
  680. color: #dc2626;
  681. font-size: 24px;
  682. }
  683. }
  684. }
  685. .image-preview {
  686. .image-list {
  687. display: flex;
  688. gap: v-bind(imageGapPx);
  689. flex-wrap: wrap;
  690. .image-item {
  691. position: relative;
  692. overflow: hidden;
  693. border: 1px solid #e5e7eb;
  694. .preview-image {
  695. width: 100%;
  696. height: 100%;
  697. cursor: pointer;
  698. }
  699. .preview-delete-btn {
  700. position: absolute;
  701. top: 4px;
  702. right: 4px;
  703. width: 24px;
  704. height: 24px;
  705. background: rgba(0, 0, 0, 0.5);
  706. border-radius: 50%;
  707. display: flex;
  708. align-items: center;
  709. justify-content: center;
  710. cursor: pointer;
  711. .delete-icon {
  712. color: var(--bs-emphasis-color);
  713. font-size: 28px;
  714. line-height: 1;
  715. }
  716. }
  717. }
  718. }
  719. .single-preview {
  720. .preview-image {
  721. cursor: pointer;
  722. border: 1px solid #e5e7eb;
  723. }
  724. }
  725. .no-image {
  726. display: flex;
  727. align-items: center;
  728. justify-content: center;
  729. background: #f5f5f5;
  730. border: 1px solid #e5e7eb;
  731. text {
  732. color: var(--bs-heading-color);
  733. font-size: 24px;
  734. }
  735. }
  736. }
  737. }
  738. </style>