Explorar el Código

init:H5演示版初始化 feat:一键办理学生休复学完成

wanghuahuo hace 4 días
commit
7b20111e06
Se han modificado 100 ficheros con 11538 adiciones y 0 borrados
  1. 24 0
      .gitignore
  2. 36 0
      App.vue
  3. 101 0
      auto-imports.d.ts
  4. 125 0
      business/PrivacyAgreement.vue
  5. 144 0
      business/add-student-hint.vue
  6. 143 0
      business/time-hint.vue
  7. 14 0
      business/top-nav-bar.vue
  8. 413 0
      common/common.scss
  9. 3 0
      common/iconfont.css
  10. 26 0
      common/variable.scss
  11. 90 0
      components/fs-action/fs-action.vue
  12. 36 0
      components/fs-avatar-group/fs-avatar-group.vue
  13. 211 0
      components/fs-avatar/fs-avatar.vue
  14. 69 0
      components/fs-back-top/fs-back-top.vue
  15. 96 0
      components/fs-badge/fs-badge.vue
  16. 266 0
      components/fs-button/fs-button.vue
  17. 100 0
      components/fs-captcha/fs-captcha.vue
  18. 105 0
      components/fs-card/fs-card.vue
  19. 110 0
      components/fs-cell-group/fs-cell-group.vue
  20. 234 0
      components/fs-cell/fs-cell.vue
  21. 100 0
      components/fs-checkbox-button/fs-checkbox-button.vue
  22. 56 0
      components/fs-checkbox-cell/fs-checkbox-cell.vue
  23. 133 0
      components/fs-checkbox-group/fs-checkbox-group.vue
  24. 110 0
      components/fs-checkbox/fs-checkbox.vue
  25. 51 0
      components/fs-col/fs-col.vue
  26. 103 0
      components/fs-collapse-item/fs-collapse-item.vue
  27. 94 0
      components/fs-collapse/fs-collapse.vue
  28. 231 0
      components/fs-comment/fs-comment.vue
  29. 63 0
      components/fs-container/fs-container.vue
  30. 37 0
      components/fs-date-format/fs-date-format.vue
  31. 55 0
      components/fs-divide-list/fs-divide-list.vue
  32. 86 0
      components/fs-divider/fs-divider.vue
  33. 102 0
      components/fs-dropdown-item/fs-dropdown-item.vue
  34. 44 0
      components/fs-dropdown/fs-dropdown.vue
  35. BIN
      components/fs-empty/empty.png
  36. 54 0
      components/fs-empty/fs-empty.vue
  37. 138 0
      components/fs-fab/fs-fab.vue
  38. 234 0
      components/fs-field/fs-field.vue
  39. 115 0
      components/fs-form-item/fs-form-item.vue
  40. 60 0
      components/fs-form/fs-form.vue
  41. 78 0
      components/fs-grid-item/fs-grid-item.vue
  42. 64 0
      components/fs-grid/fs-grid.vue
  43. 38 0
      components/fs-gutter/fs-gutter.vue
  44. 88 0
      components/fs-icon/fs-icon.vue
  45. 238 0
      components/fs-icon/icon.css
  46. 220 0
      components/fs-index-list/fs-index-list.vue
  47. 158 0
      components/fs-keyboard/car.vue
  48. 67 0
      components/fs-keyboard/fs-keyboard.vue
  49. 46 0
      components/fs-keyboard/number.vue
  50. 109 0
      components/fs-license-plate/fs-license-plate.vue
  51. 69 0
      components/fs-loading/fs-loading.vue
  52. 98 0
      components/fs-loadmore/fs-loadmore.vue
  53. 66 0
      components/fs-mask/fs-mask.vue
  54. 116 0
      components/fs-message/fs-message.vue
  55. 226 0
      components/fs-modal/fs-modal.vue
  56. 138 0
      components/fs-notice-bar/fs-notice-bar.vue
  57. 129 0
      components/fs-number-box/fs-number-box.vue
  58. 47 0
      components/fs-panel/fs-panel.vue
  59. 341 0
      components/fs-popover/fs-popover.vue
  60. 118 0
      components/fs-popup/fs-popup.vue
  61. 108 0
      components/fs-radio-button/fs-radio-button.vue
  62. 64 0
      components/fs-radio-cell/fs-radio-cell.vue
  63. 96 0
      components/fs-radio-group/fs-radio-group.vue
  64. 114 0
      components/fs-radio/fs-radio.vue
  65. 120 0
      components/fs-rate/fs-rate.vue
  66. 110 0
      components/fs-readmore/fs-readmore.vue
  67. 37 0
      components/fs-row/fs-row.vue
  68. 129 0
      components/fs-scroll-list/fs-scroll-list.vue
  69. 194 0
      components/fs-search/fs-search.vue
  70. 138 0
      components/fs-select/fs-select.vue
  71. 105 0
      components/fs-sidebar/fs-sidebar.vue
  72. 48 0
      components/fs-space/fs-space.vue
  73. 46 0
      components/fs-swipe-action-group/fs-swipe-action-group.vue
  74. 164 0
      components/fs-swipe-action/fs-swipe-action.vue
  75. 190 0
      components/fs-swiper/fs-swiper.vue
  76. 121 0
      components/fs-switch/fs-switch.vue
  77. 259 0
      components/fs-tab/fs-tab.vue
  78. 161 0
      components/fs-tag/fs-tag.vue
  79. 87 0
      components/fs-text/fs-text.vue
  80. 34 0
      components/fs-timeago/fs-timeago.vue
  81. 91 0
      components/fs-timeline/fs-timeline.vue
  82. 192 0
      components/fs-upload/fs-upload.vue
  83. 173 0
      components/fs-week-bar/fs-week-bar.vue
  84. 59 0
      components/fs-wx-avatar/fs-wx-avatar.vue
  85. 38 0
      hooks/useForm/index.js
  86. 25 0
      hooks/useGeocoder/index.js
  87. 40 0
      hooks/useLoadmore/index.js
  88. 22 0
      hooks/useQrcodeQuery/index.js
  89. 11 0
      hooks/useScrollTop/index.js
  90. 64 0
      hooks/useValidator/index.js
  91. 18 0
      index.html
  92. 20 0
      main.js
  93. 80 0
      manifest.json
  94. 12 0
      modules/common/detail.vue
  95. 442 0
      modules/common/student-prosthetics/approval-progress.vue
  96. 452 0
      modules/common/student-prosthetics/certificate.vue
  97. 744 0
      modules/common/student-prosthetics/index.vue
  98. 12 0
      modules/index/detail.vue
  99. 26 0
      modules/my/about/agreement.vue
  100. 26 0
      modules/my/about/policy.vue

+ 24 - 0
.gitignore

@@ -0,0 +1,24 @@
+.DS_Store
+node_modules
+/dist
+/unpackage
+/.hbuilderx
+
+# local env files
+.env.local
+.env.*.local
+
+# Log files
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+
+# Editor directories and files
+.idea
+.vscode
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 36 - 0
App.vue

@@ -0,0 +1,36 @@
+<script>
+	export default {
+		onLaunch: function() {
+			// #ifndef H5 || APP-NVUE || APP-PLUS || APP-PLUS-NVUE
+			const updateManager = uni.getUpdateManager();
+			updateManager.onUpdateReady(function(res) {
+				uni.showModal({
+					title: '更新提示',
+					content: '新版本已经准备好,是否重启应用?',
+					success(res) {
+						if (res.confirm) {
+							updateManager.applyUpdate();
+						}
+					}
+				});
+			});
+			// #endif
+		},
+		onShow: function() {
+			console.log('App Show')
+		},
+		onHide: function() {
+			console.log('App Hide')
+		}
+	}
+</script>
+
+<style lang="scss">
+	@import './common/common.scss';
+</style>
+
+<style>
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
+</style>

+ 101 - 0
auto-imports.d.ts

@@ -0,0 +1,101 @@
+/* eslint-disable */
+/* prettier-ignore */
+// @ts-nocheck
+// noinspection JSUnusedGlobalSymbols
+// Generated by unplugin-auto-import
+export {}
+declare global {
+  const EffectScope: typeof import('vue')['EffectScope']
+  const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
+  const computed: typeof import('vue')['computed']
+  const createApp: typeof import('vue')['createApp']
+  const createPinia: typeof import('pinia')['createPinia']
+  const customRef: typeof import('vue')['customRef']
+  const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
+  const defineComponent: typeof import('vue')['defineComponent']
+  const defineStore: typeof import('pinia')['defineStore']
+  const effectScope: typeof import('vue')['effectScope']
+  const getActivePinia: typeof import('pinia')['getActivePinia']
+  const getCurrentInstance: typeof import('vue')['getCurrentInstance']
+  const getCurrentScope: typeof import('vue')['getCurrentScope']
+  const h: typeof import('vue')['h']
+  const inject: typeof import('vue')['inject']
+  const isProxy: typeof import('vue')['isProxy']
+  const isReactive: typeof import('vue')['isReactive']
+  const isReadonly: typeof import('vue')['isReadonly']
+  const isRef: typeof import('vue')['isRef']
+  const mapActions: typeof import('pinia')['mapActions']
+  const mapGetters: typeof import('pinia')['mapGetters']
+  const mapState: typeof import('pinia')['mapState']
+  const mapStores: typeof import('pinia')['mapStores']
+  const mapWritableState: typeof import('pinia')['mapWritableState']
+  const markRaw: typeof import('vue')['markRaw']
+  const nextTick: typeof import('vue')['nextTick']
+  const onActivated: typeof import('vue')['onActivated']
+  const onAddToFavorites: typeof import('@dcloudio/uni-app')['onAddToFavorites']
+  const onBackPress: typeof import('@dcloudio/uni-app')['onBackPress']
+  const onBeforeMount: typeof import('vue')['onBeforeMount']
+  const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
+  const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
+  const onDeactivated: typeof import('vue')['onDeactivated']
+  const onError: typeof import('@dcloudio/uni-app')['onError']
+  const onErrorCaptured: typeof import('vue')['onErrorCaptured']
+  const onHide: typeof import('@dcloudio/uni-app')['onHide']
+  const onLaunch: typeof import('@dcloudio/uni-app')['onLaunch']
+  const onLoad: typeof import('@dcloudio/uni-app')['onLoad']
+  const onMounted: typeof import('vue')['onMounted']
+  const onNavigationBarButtonTap: typeof import('@dcloudio/uni-app')['onNavigationBarButtonTap']
+  const onNavigationBarSearchInputChanged: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputChanged']
+  const onNavigationBarSearchInputClicked: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputClicked']
+  const onNavigationBarSearchInputConfirmed: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputConfirmed']
+  const onNavigationBarSearchInputFocusChanged: typeof import('@dcloudio/uni-app')['onNavigationBarSearchInputFocusChanged']
+  const onPageNotFound: typeof import('@dcloudio/uni-app')['onPageNotFound']
+  const onPageScroll: typeof import('@dcloudio/uni-app')['onPageScroll']
+  const onPullDownRefresh: typeof import('@dcloudio/uni-app')['onPullDownRefresh']
+  const onReachBottom: typeof import('@dcloudio/uni-app')['onReachBottom']
+  const onReady: typeof import('@dcloudio/uni-app')['onReady']
+  const onRenderTracked: typeof import('vue')['onRenderTracked']
+  const onRenderTriggered: typeof import('vue')['onRenderTriggered']
+  const onResize: typeof import('@dcloudio/uni-app')['onResize']
+  const onScopeDispose: typeof import('vue')['onScopeDispose']
+  const onServerPrefetch: typeof import('vue')['onServerPrefetch']
+  const onShareAppMessage: typeof import('@dcloudio/uni-app')['onShareAppMessage']
+  const onShareTimeline: typeof import('@dcloudio/uni-app')['onShareTimeline']
+  const onShow: typeof import('@dcloudio/uni-app')['onShow']
+  const onTabItemTap: typeof import('@dcloudio/uni-app')['onTabItemTap']
+  const onThemeChange: typeof import('@dcloudio/uni-app')['onThemeChange']
+  const onUnhandledRejection: typeof import('@dcloudio/uni-app')['onUnhandledRejection']
+  const onUnload: typeof import('@dcloudio/uni-app')['onUnload']
+  const onUnmounted: typeof import('vue')['onUnmounted']
+  const onUpdated: typeof import('vue')['onUpdated']
+  const provide: typeof import('vue')['provide']
+  const reactive: typeof import('vue')['reactive']
+  const readonly: typeof import('vue')['readonly']
+  const ref: typeof import('vue')['ref']
+  const resolveComponent: typeof import('vue')['resolveComponent']
+  const setActivePinia: typeof import('pinia')['setActivePinia']
+  const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
+  const shallowReactive: typeof import('vue')['shallowReactive']
+  const shallowReadonly: typeof import('vue')['shallowReadonly']
+  const shallowRef: typeof import('vue')['shallowRef']
+  const storeToRefs: typeof import('pinia')['storeToRefs']
+  const toRaw: typeof import('vue')['toRaw']
+  const toRef: typeof import('vue')['toRef']
+  const toRefs: typeof import('vue')['toRefs']
+  const toValue: typeof import('vue')['toValue']
+  const triggerRef: typeof import('vue')['triggerRef']
+  const unref: typeof import('vue')['unref']
+  const useAttrs: typeof import('vue')['useAttrs']
+  const useCssModule: typeof import('vue')['useCssModule']
+  const useCssVars: typeof import('vue')['useCssVars']
+  const useSlots: typeof import('vue')['useSlots']
+  const watch: typeof import('vue')['watch']
+  const watchEffect: typeof import('vue')['watchEffect']
+  const watchPostEffect: typeof import('vue')['watchPostEffect']
+  const watchSyncEffect: typeof import('vue')['watchSyncEffect']
+}
+// for type re-export
+declare global {
+  // @ts-ignore
+  export type { Component, ComponentPublicInstance, ComputedRef, InjectionKey, PropType, Ref, VNode, WritableComputedRef } from 'vue'
+}

+ 125 - 0
business/PrivacyAgreement.vue

@@ -0,0 +1,125 @@
+<template>
+	<fs-modal v-model="showPrivacy" :showTitle="false" :showCancel="false" :showConfirm="false" :maskClickable="false">
+		<view class="modal-content">
+			<view class="modal-content-title bdb">用户隐私保护提示</view>
+			<view class="modal-content-section">
+				<view class="section">
+					欢迎使用我们的服务!在您开始体验我们提供的便捷与高效之前,请您务必仔细阅读我们的<text class="primary" @click="handleOpenPrivacyContract">{{
+						privacyContractName }}</text>。该指引详细阐述了我们如何收集、使用、存储和保护您的个人信息,以及您在使用服务过程中所享有的权利。
+				</view>
+
+				<view class="section">
+					我们深知个人信息对您的重要性,并承诺将严格按照相关法律法规和<text class="primary" @click="handleOpenPrivacyContract">{{
+						privacyContractName }}</text>的约定,保护您的个人信息安全。我们建议您完整阅读隐私指引,以便更好地了解我们的隐私保护措施。
+				</view>
+
+				<view class="section">
+					如果您对<text class="primary" @click="handleOpenPrivacyContract">{{
+						privacyContractName }}</text>的内容表示理解并同意,请您点击“同意”按钮,以开始使用本小程序。您的点击同意将视为对我们隐私指引的认可和接受。
+				</view>
+			</view>
+			<view class="modal-content-footer-btn bdt tw-flex tw-items-center tw-justify-around">
+				<button class="xc-button bg-danger tw-w-[46%] tw-rounded-[8rpx]"
+					@click="handleRefusePrivacyAuthorization">拒绝</button>
+				<button class="xc-button bg-success tw-w-[46%] tw-rounded-[8rpx]" id="agree-btn"
+					open-type="agreePrivacyAuthorization" @click="handleAgreePrivacyAuthorization">同意</button>
+			</view>
+		</view>
+	</fs-modal>
+</template>
+
+<script setup>
+const TNAME = '《用户隐私保护指引》'
+const emits = defineEmits(['change'])
+
+const showPrivacy = ref(false)
+const privacyContractName = ref(TNAME)
+
+onMounted(() => {
+	initGetPrivacySetting()
+})
+
+// 获取隐私设置
+const initGetPrivacySetting = () => {
+	if (!uni.getPrivacySetting) {
+		// H5端 直接跳过
+		showPrivacy.value = false
+		emits('change', false)
+		return false
+	}
+	uni.getPrivacySetting({
+		success: (res) => {
+			privacyContractName.value = res.privacyContractName || TNAME
+			if (res.needAuthorization) {
+				showPrivacy.value = true // 需要授权
+			} else {
+				handleAgreePrivacyAuthorization() // 不需要授权
+			}
+		},
+		fail: (err) => {
+			handleAgreePrivacyAuthorization()
+		},
+	})
+}
+
+// 查看隐私协议内容
+const handleOpenPrivacyContract = () => {
+	uni.openPrivacyContract({
+		success: (res) => { },
+		fail: (err) => {
+			uni.showToast({
+				title: '打开隐私协议失败',
+				icon: 'none',
+			})
+		},
+		complete: () => { }
+	});
+}
+
+// 同意隐私授权
+const handleAgreePrivacyAuthorization = () => {
+	showPrivacy.value = false
+	emits('change', false)
+}
+// 拒绝隐私授权
+const handleRefusePrivacyAuthorization = () => {
+	showPrivacy.value = false
+	emits('change', true)
+}
+
+// 导出
+defineExpose({
+	initGetPrivacySetting,
+})
+</script>
+
+<style lang="scss" scoped>
+.modal-content {
+	padding: 20rpx;
+	text-align: left;
+
+	&-title {
+		font-size: 38rpx;
+		padding-bottom: 20rpx;
+		font-weight: 700;
+		text-align: center;
+	}
+
+	&-section {
+		min-height: 200rpx;
+		max-height: 600rpx;
+		overflow-y: auto;
+		word-wrap: break-word;
+		white-space: pre-line;
+
+		.section {
+			text-indent: 2em;
+			margin: 10rpx 0;
+		}
+	}
+
+	&-footer-btn {
+		padding-top: 30rpx;
+	}
+}
+</style>

+ 144 - 0
business/add-student-hint.vue

@@ -0,0 +1,144 @@
+<template>
+	<view class="modal" v-if="visible">
+		<view class="modal-content">
+			<image :src="config.ossPathPerfixs + '/pop-up-bg.png'" mode="widthFix" class="modal-content-bg"></image>
+			<view class="modal-content-box">
+				<view class="modal-content-box-title">
+					<text class="modal-content-box-title-text">温馨提示</text>
+				</view>
+				<view class="modal-content-box-section" v-html="innerHtml" v-if="innerHtml"></view>
+				<fs-empty padding="100rpx 0" imageWidth="200rpx" v-else></fs-empty>
+				<view class="modal-content-box-footer-btn">
+					<fs-button round type="primary" @click="handleSubmit" :disabled="isDisabled">
+						<text>我已阅读并同意</text>
+						<text v-if="timerNum > 0">({{ timerNum }}s)</text>
+					</fs-button>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script setup>
+import config from '@/utils/config'
+
+const props = defineProps({
+	time: {
+		type: Number,
+		default: 5
+	}
+})
+
+const emits = defineEmits(['change'])
+
+const visible = ref(false)
+const timer = ref(null)
+const timerNum = ref(0)
+const isDisabled = computed(() => timerNum.value > 0)
+const innerHtml = ref('')
+
+onMounted(() => {
+	visible.value = true
+	timerNum.value = props.time
+	timer.value = setInterval(() => {
+		if (timerNum.value <= 0) return clearInterval(timer.value)
+		timerNum.value -= 1
+	}, 1000)
+	getWarmPromptData()
+})
+
+const getWarmPromptData = async () => {
+	// getWarmPrompt().then(res => {
+	// 	innerHtml.value = res.data || ''
+	// })
+	innerHtml.value = `
+		<p>为保障学生安全、规范信息管理并提升校园体验,现发布以下注意事项,请全体学生及家长予以重视和配合。</p>
+		<p>录入信息时,请严格按学号顺序排列。‌</p>
+		<p>姓名、身份证号必须与户口本信息完全一致。‌‌</p>
+		<p>出生日期、入学年月等日期信息,应统一使用“YYYY-MM-DD”格式的阳历日期,并与身份证保持一致。‌‌‌</p>
+	`
+}
+const handleSubmit = () => {
+	if (isDisabled.value) return uni.showToast({ title: `${timerNum.value}s后可同意`, icon: 'none' })
+	closeModal()
+}
+
+const closeModal = () => {
+	visible.value = false
+	emits('change', false)
+}
+</script>
+
+<style lang="scss" scoped>
+.modal {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-color: rgba(0, 0, 0, 0.5);
+	z-index: 999;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+
+	.modal-content {
+		padding: 70rpx;
+		border-radius: 50rpx;
+		position: relative;
+
+		.modal-content-bg {
+			position: absolute;
+			width: 100%;
+			height: auto;
+			top: 0;
+			left: 0;
+			z-index: 0;
+		}
+
+		.modal-content-box {
+			position: relative;
+			z-index: 1;
+
+			.modal-content-box-title {
+				font-size: 32rpx;
+				padding-bottom: 20rpx;
+				font-weight: 700;
+				text-align: center;
+				color: #333;
+				position: relative;
+
+				&-text {
+					position: relative;
+					z-index: 1;
+				}
+
+				&::after {
+					content: '';
+					position: absolute;
+					z-index: 0;
+					bottom: 20rpx;
+					left: 50%;
+					transform: translateX(-50%);
+					width: 160rpx;
+					height: 16rpx;
+					border-radius: 8rpx;
+					background-image: linear-gradient(90deg, #0871FF 0%, #00EEA8 100%);
+				}
+			}
+
+			.modal-content-box-section {
+				min-height: 180rpx;
+				max-height: 340rpx;
+				overflow-y: auto;
+				word-wrap: break-word;
+			}
+
+			.modal-content-box-footer-btn {
+				padding-top: 20rpx;
+				text-align: center;
+			}
+		}
+	}
+}
+</style>

+ 143 - 0
business/time-hint.vue

@@ -0,0 +1,143 @@
+<template>
+	<view class="modal" v-if="visible">
+		<view class="modal-content">
+			<image :src="config.ossPathPerfixs + '/pop-up-bg.png'" mode="aspectFill" class="modal-content-bg"></image>
+			<view class="modal-content-box">
+				<view class="modal-content-box-title">
+					<text class="modal-content-box-title-text">{{ title }}</text>
+				</view>
+				<view class="modal-content-box-section" v-html="innerHtml" v-if="innerHtml"></view>
+				<fs-empty padding="100rpx 0" imageWidth="200rpx" v-else></fs-empty>
+				<view class="modal-content-box-footer-btn">
+					<fs-button round type="primary" @click="handleSubmit" :disabled="isDisabled">
+						<text>我已知晓</text>
+						<text v-if="timerNum > 0">({{ timerNum }}s)</text>
+					</fs-button>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script setup>
+// import timeHint from '@/business/time-hint'
+// <time-hint v-if="isShowtimeHint" :text="info.importantInfo" @change="handleReserve"></time-hint>
+import config from '@/utils/config'
+
+const props = defineProps({
+	time: {
+		type: Number,
+		default: 5
+	},
+	title: {
+		type: String,
+		default: '温馨提示'
+	},
+	text: {
+		type: String,
+		default: ''
+	}
+})
+
+const emits = defineEmits(['change'])
+
+const visible = ref(false)
+const timer = ref(null)
+const timerNum = ref(0)
+const isDisabled = computed(() => timerNum.value > 0)
+const innerHtml = ref('')
+
+onMounted(() => {
+	innerHtml.value = props.text
+	visible.value = true
+	timerNum.value = props.time
+	timer.value = setInterval(() => {
+		if (timerNum.value <= 0) return clearInterval(timer.value)
+		timerNum.value -= 1
+	}, 1000)
+})
+
+const handleSubmit = () => {
+	if (isDisabled.value) return uni.showToast({ title: `${timerNum.value}s后可关闭`, icon: 'none' })
+	closeModal()
+}
+
+const closeModal = () => {
+	visible.value = false
+	emits('change', false)
+}
+</script>
+
+<style lang="scss" scoped>
+.modal {
+	position: fixed;
+	top: 0;
+	left: 0;
+	right: 0;
+	bottom: 0;
+	background-color: rgba(0, 0, 0, 0.5);
+	z-index: 999;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+
+	.modal-content {
+		width: 100vw;
+		padding: 50rpx 70rpx 70rpx 70rpx;
+		border-radius: 50rpx;
+		position: relative;
+
+		.modal-content-bg {
+			position: absolute;
+			width: 100vw;
+			height: 600rpx;
+			top: 0;
+			left: 0;
+			z-index: 0;
+		}
+
+		.modal-content-box {
+			position: relative;
+			z-index: 1;
+
+			.modal-content-box-title {
+				font-size: 36rpx;
+				padding-bottom: 20rpx;
+				font-weight: 700;
+				text-align: center;
+				color: #333;
+				position: relative;
+
+				&-text {
+					position: relative;
+					z-index: 1;
+				}
+
+				&::after {
+					content: '';
+					position: absolute;
+					z-index: 0;
+					bottom: 20rpx;
+					left: 50%;
+					transform: translateX(-50%);
+					width: 160rpx;
+					height: 16rpx;
+					border-radius: 8rpx;
+					background-image: linear-gradient(90deg, #0871FF 0%, #00EEA8 100%);
+				}
+			}
+
+			.modal-content-box-section {
+				height: 340rpx;
+				overflow-y: auto;
+				word-wrap: break-word;
+			}
+
+			.modal-content-box-footer-btn {
+				padding-top: 20rpx;
+				text-align: center;
+			}
+		}
+	}
+}
+</style>

+ 14 - 0
business/top-nav-bar.vue

@@ -0,0 +1,14 @@
+<template>
+    <view>
+        <van-nav-bar :title="title" left-text="" fixed left-arrow @click-left="onClickLeft" />
+    </view>
+</template>
+<script setup>
+const title = computed(() => {
+    return document && document.title || '修复学申请'
+})
+const onClickLeft = () => {
+    uni.navigateBack();
+};
+</script>
+<style scoped></style>

+ 413 - 0
common/common.scss

@@ -0,0 +1,413 @@
+@import "./variable.scss";
+
+page {
+  color: var(--content);
+  background-color: var(--bg-color);
+  -webkit-font-smoothing: antialiased;
+  font-size: var(--content-size);
+  line-height: 1.5;
+  box-sizing: border-box;
+  min-height: 100%;
+}
+view {
+  box-sizing: border-box;
+}
+::-webkit-scrollbar {
+  display: none;
+}
+
+.primary {
+  color: var(--primary);
+}
+.success {
+  color: var(--success);
+}
+.info {
+  color: var(--info);
+}
+.warning {
+  color: var(--warning);
+}
+.danger {
+  color: var(--danger);
+}
+.gray {
+  color: var(--gray);
+}
+
+.bg-default {
+  background-color: var(--border-color);
+}
+.bg-primary {
+  background-color: var(--primary);
+}
+.bg-success {
+  background-color: var(--success);
+}
+.bg-info {
+  background-color: var(--info);
+}
+.bg-warning {
+  background-color: var(--warning);
+}
+.bg-danger {
+  background-color: var(--danger);
+}
+.bg-gray {
+  background-color: var(--gray);
+}
+.bg-white {
+  background-color: #fff;
+}
+
+.layout-box {
+  padding: var(--gutter);
+  background-color: #fff;
+}
+
+.radius {
+  border-radius: 8rpx;
+}
+.radius-lg {
+  border-radius: 16rpx;
+}
+.bdt {
+  border-top: 1px solid var(--border-color);
+}
+.bdb {
+  border-bottom: 1px solid var(--border-color);
+}
+.bdt-dashed {
+  border-top: 1px dashed var(--border-color);
+}
+.bdb-dashed {
+  border-bottom: 1px dashed var(--border-color);
+}
+
+.xc-border {
+  border: 1px solid var(--border-color);
+}
+
+.gutter-v {
+  margin-bottom: var(--gutter-v);
+}
+
+.text-center {
+  text-align: center;
+}
+.text-left {
+  text-align: left;
+}
+.text-right {
+  text-align: right;
+}
+.text-justify {
+  text-align-last: justify;
+}
+.underline {
+  text-decoration: underline;
+}
+.line-through {
+  text-decoration: line-through;
+}
+
+.flex {
+  display: flex;
+}
+.flex-grow {
+  flex-grow: 1;
+}
+.justify-center {
+  justify-content: center;
+}
+.justify-between {
+  justify-content: space-between;
+}
+.justify-end {
+  justify-content: flex-end;
+}
+.align-end {
+  align-items: flex-end;
+}
+.align-center {
+  align-items: center;
+}
+.direction-row {
+  flex-direction: row;
+}
+.direction-column {
+  flex-direction: column;
+}
+
+.shadow {
+  box-shadow: 0 0 10px 2px rgba(65, 65, 70, 0.2);
+}
+
+.card-shadow {
+  box-shadow: 0 0 20rpx 8rpx rgba(65, 65, 70, 0.07);
+  border-radius: 12rpx;
+  overflow: hidden;
+}
+
+.title {
+  font-size: var(--title-size);
+  color: var(--title);
+}
+.content {
+  font-size: var(--content-size);
+  color: var(--content);
+}
+.sub {
+  font-size: var(--sub-size);
+  color: var(--sub);
+}
+.bold {
+  font-weight: bold;
+}
+
+.line0 {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+.line1 {
+  max-height: 42px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 1;
+  -webkit-box-orient: vertical;
+}
+.line2 {
+  max-height: 42px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 2;
+  -webkit-box-orient: vertical;
+}
+.line3 {
+  max-height: 62px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  display: -webkit-box;
+  -webkit-line-clamp: 3;
+  -webkit-box-orient: vertical;
+}
+
+.container {
+  display: flex;
+  flex-direction: column;
+  height: calc(100vh - var(--window-top));
+}
+.container-box {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+}
+.main {
+  flex: 1;
+  overflow-x: hidden;
+  overflow-y: auto;
+  -webkit-overflow-scrolling: touch;
+}
+
+.title-hd {
+  padding: 20rpx;
+  position: relative;
+}
+.title-hd::before {
+  position: absolute;
+  content: "";
+  width: 3px;
+  height: 13px;
+  background-color: var(--primary);
+  left: 0;
+  top: 50%;
+  transform: translateY(-50%);
+}
+
+.inline-block {
+  display: inline-block;
+}
+.block {
+  display: block;
+}
+.vm {
+  vertical-align: middle;
+}
+.fs12 {
+  font-size: 12px;
+}
+.pr {
+  position: relative;
+}
+.pa {
+  position: absolute;
+}
+.pf {
+  position: fixed;
+}
+.sticky {
+  position: sticky;
+  top: 0;
+  z-index: 1;
+}
+
+.fs-list {
+  padding: var(--gutter);
+
+  &-item {
+    background-color: #fff;
+    padding: var(--gutter);
+    border-radius: 8rpx;
+    position: relative;
+
+    &.border {
+      border-left: 6rpx solid var(--primary);
+    }
+
+    & + & {
+      margin-top: var(--gutter);
+    }
+
+    &-sub {
+      display: flex;
+      & + & {
+        margin-top: 10rpx;
+      }
+    }
+
+    &-left {
+      color: #78869c;
+      width: 55px;
+      text-align-last: justify;
+      position: relative;
+      font-size: var(--content-size);
+      margin-right: 20rpx;
+      white-space: nowrap;
+      flex-shrink: 0;
+      &::after {
+        position: absolute;
+        content: ":";
+        color: #78869c;
+      }
+    }
+  }
+}
+
+.fs-cell-right {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  justify-content: space-between;
+}
+
+.w-full {
+  width: 100%;
+}
+.h-full {
+  height: 100%;
+}
+.wrapper {
+  min-height: 100vh;
+}
+.footer-btn {
+  padding: 30rpx 0;
+}
+
+.p-30 {
+  padding: 30rpx;
+}
+.p-x-30 {
+  padding-left: 30rpx;
+  padding-right: 30rpx;
+}
+.p-y-30 {
+  padding-top: 30rpx;
+  padding-bottom: 30rpx;
+}
+.m-t-30 {
+  margin-top: 30rpx;
+}
+
+.m-10 {
+  margin: 10rpx;
+}
+.empty {
+  .empty_text {
+    font-size: 30rpx;
+    color: #666;
+    text-align: center;
+  }
+}
+
+.title-line {
+  position: relative;
+  text,
+  .text {
+    position: relative;
+    z-index: 1;
+    font-size: 32rpx;
+    color: #333;
+    font-weight: 700;
+  }
+
+  .num {
+    font-size: 32rpx;
+    color: #4479ff;
+    margin-left: 2rpx;
+  }
+
+  &::after {
+    content: "";
+    position: absolute;
+    z-index: 0;
+    left: 0;
+    bottom: 0;
+    width: 150rpx;
+    height: 14rpx;
+    background: linear-gradient(-76deg, #fff, #00eea8, #0871ff);
+    opacity: 0.5;
+  }
+}
+
+.color-font {
+  background: linear-gradient(
+    -76deg,
+    #b193ff 0%,
+    #1cb2ff 53.0029296875%,
+    #0871ff 100%
+  );
+  -webkit-background-clip: text;
+  -webkit-text-fill-color: transparent;
+}
+
+.rich-img {
+  max-width: 100% !important;
+  object-fit: cover;
+}
+
+/* #ifdef H5 */
+uni-page-head {
+  display: none;
+}
+.fs-mask {
+  top: 0 !important;
+}
+.fs-popup {
+  .bottom {
+    bottom: 0 !important;
+  }
+}
+
+/* #endif */
+
+.page-top-bg-class {
+  background-image: url("@/static/images/page-top-bg.png");
+  background-position: top center;
+  background-repeat: no-repeat;
+  background-size: 100% 290rpx;
+  box-sizing: border-box;
+  padding: 30rpx;
+}

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 3 - 0
common/iconfont.css


+ 26 - 0
common/variable.scss

@@ -0,0 +1,26 @@
+// css 变量
+page {
+	--primary: 				#0063F5;
+	--success: 				#19be6b;
+	--warning: 				#FF9F15;
+	--danger: 				#ff6059;
+	--info: 					#2db7f5;
+	--gray: 					#E8EAF2;
+	--border-color: 	#E8EAF2;
+	--bg-color: 			#fff;
+	--title: 					#222222;
+	--content: 				#666666;
+	--sub: 						#999999;
+	--disabled: 			#bcbec3;
+	--divider: 				#E8EAF2;
+
+	--gutter: 				20rpx;
+	--tighten-gutter: 20rpx;
+	--gutter-v: 			20rpx;
+
+	--title-size: 		16px;
+	--content-size: 	15px;
+	--sub-size: 			14px;
+
+	--radius: 				12rpx;
+}

+ 90 - 0
components/fs-action/fs-action.vue

@@ -0,0 +1,90 @@
+<template>
+	<fs-popup direction="bottom" height="auto" v-model="visible" :showMask="showMask" :maskClickable="maskClickable">
+		<view class="fs-action">
+			<view class="fs-action-item" v-for="(item, index) in list" :key="index" @click="handleAction(item)">
+				{{ item.name }}
+			</view>
+			<view class="fs-action-extra"><slot></slot></view>
+			<view class="fs-action-cancel" v-if="showCancel" @click="cancel">{{ cancelText }}</view>
+		</view>
+	</fs-popup>
+</template>
+
+<script>
+/**
+ * 动作组件
+ * @description 动作组件
+ * @property {Array} list action列表
+ * @property {Boolean} showMask 是否展示遮罩
+ * @property {Boolean} maskClickable 遮罩是否可点击
+ * @property {Boolean} showCancel 是否展示取消action
+ * @property {String} cancelText “取消”文字
+ * @event {Function} change change事件
+ * @example <fs-action :list="list" v-model="show" @change="change"></fs-action>
+ */
+export default {
+	name: 'fs-action'
+}
+</script>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+	list: Array,
+	modelValue: Boolean,
+	showMask: {
+		type: Boolean,
+		default: true
+	},
+	maskClickable: {
+		type: Boolean,
+		default: true
+	},
+	cancelText: {
+		type: String,
+		default: '取消'
+	},
+	showCancel: {
+		type: Boolean,
+		default: true
+	}
+})
+const emits = defineEmits(['update:modelValue', 'change'])
+
+const visible = computed({
+	get: () => props.modelValue,
+	set: value => emits('update:modelValue', value)
+})
+
+const cancel = () => {
+	emits('update:modelValue', false)
+}
+const handleAction = item => {
+	emits('change', item)
+	cancel()
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-action {
+	background-color: #f8f8f8;
+
+	&-item {
+		padding: 20rpx;
+		text-align: center;
+		background-color: #fff;
+
+		& + & {
+			border-top: 1px solid var(--border-color);
+		}
+	}
+
+	&-cancel {
+		padding: 20rpx;
+		text-align: center;
+		background-color: #fff;
+		margin-top: 10rpx;
+	}
+}
+</style>

+ 36 - 0
components/fs-avatar-group/fs-avatar-group.vue

@@ -0,0 +1,36 @@
+<template>
+	<view class="fs-avatar-group"><slot></slot></view>
+</template>
+
+<script>
+/**
+ * 头像组组件
+ * @description 头像组组件
+ * @property {Number} margin 头像之间间隔
+ * @property {Boolean} border 是否展示边框
+ */
+export default {
+	name: 'fs-avatar-group'
+}
+</script>
+
+<script setup>
+import { provide, computed } from 'vue'
+
+const props = defineProps({
+	margin: {
+		type: Number,
+		default: '-30'
+	},
+	border: Boolean
+})
+
+const marginLeft = computed(() => -props.margin + 'rpx')
+provide('avatarGroup', props)
+</script>
+
+<style lang="scss" scoped>
+.fs-avatar-group {
+	margin-left: v-bind(marginLeft);
+}
+</style>

+ 211 - 0
components/fs-avatar/fs-avatar.vue

@@ -0,0 +1,211 @@
+<template>
+	<view
+		class="fs-avatar"
+		:class="[
+			shape,
+			{
+				radius,
+				fixed
+			}
+		]"
+		:style="{
+			width: width || size,
+			height: height || size,
+			right: fixed ? right : 0,
+			bottom: fixed ? bottom : 0,
+			border: borderStyle,
+			'margin-left': avatarGroup.margin + 'rpx'
+		}"
+		@click="handleClick"
+	>
+		<image
+			class="fs-avatar-img"
+			:src="errImg"
+			v-if="errImg"
+			:lazy-load="lazyLoad"
+			:mode="imageMode"
+			@click="handlePreview"
+		/>
+		<image
+			class="fs-avatar-img"
+			:src="src"
+			v-if="src"
+			:lazy-load="lazyLoad"
+			:mode="imageMode"
+			:show-menu-by-longpress="showMenu"
+			@click="handlePreview"
+			@error="handleError"
+		/>
+		<view v-else class="fs-avatar-slot" :class="['bg-' + bgColorType]" :style="{ backgroundColor: bgColor }">
+			<slot></slot>
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 头像组件
+ * @description 头像组件
+ * @property {String} src 头像地址
+ * @property {String} shape = [circle | square] 头像类型
+ * @property {String} size 头像大小
+ * @property {String} width 头像宽
+ * @property {String} height 头像高
+ * @property {String} bgColor 背景颜色
+ * @property {String} bgColorType = [primary | danger | warning | info | success] 背景颜色类型
+ * @property {Boolean} border 是否展示边框
+ * @property {String} borderWidth 边框宽
+ * @property {String} borderColor 边框颜色
+ * @property {Boolean} lazyLoad 是否懒加载
+ * @property {String} imageMode 图片模式
+ * @property {Boolean} preview 是否预览图片
+ * @property {Boolean} radius 是否圆角
+ * @property {String} link 跳转地址
+ * @property {String} linkType 跳转类型
+ * @property {Boolean} fixed 是否悬浮
+ * @property {String} right 悬浮右边距(仅在fixed为true时有效)
+ * @property {String} bottom 悬浮下边距(仅在fixed为true时有效)
+ * @property {Boolean} showMenu 长按图片显示菜单
+ * @property {String} defaultErrorImg 图片加载失败显示的默认图片
+ * @example <fs-avatar size="100rpx" src="***"></fs-avatar>
+ */
+export default {
+	name: 'fs-avatar'
+}
+</script>
+
+<script setup>
+import { computed, inject, ref } from 'vue'
+import config from '@/utils/config'
+
+const props = defineProps({
+	src: String,
+	shape: {
+		type: String,
+		default: 'circle', // square, circle
+		validator(value) {
+			return ['circle', 'square'].includes(value)
+		}
+	},
+	size: {
+		type: String,
+		default: '80rpx'
+	},
+	width: String,
+	height: String,
+	bgColor: String,
+	bgColorType: {
+		type: String,
+		default: 'primary',
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger'].includes(value)
+		}
+	},
+	border: Boolean,
+	borderWidth: {
+		type: String,
+		default: '4rpx'
+	},
+	borderColor: {
+		type: String,
+		default: '#fff'
+	},
+	lazyLoad: Boolean,
+	imageMode: {
+		type: String,
+		default: 'scaleToFill'
+	},
+	preview: Boolean,
+	radius: Boolean,
+	link: String,
+	linkType: {
+		type: String,
+		default: 'navigateTo'
+	},
+	fixed: Boolean,
+	right: {
+		type: String,
+		default: '40rpx'
+	},
+	bottom: {
+		type: String,
+		default: '60rpx'
+	},
+	showMenu: Boolean,
+	defaultErrorImg: {
+		type: String,
+		default: config.defaultErrorImg
+	}
+})
+
+const emits = defineEmits(['click'])
+
+const avatarGroup = inject('avatarGroup', {})
+
+const borderStyle = computed(() => {
+	return props.border || avatarGroup.border ? `${props.borderWidth} solid ${props.borderColor}` : 'none'
+})
+
+const handleClick = () => {
+	if (props.link) {
+		uni[props.linkType]({
+			url: props.link
+		})
+	}
+	emits('click')
+}
+
+const handlePreview = () => {
+	if (props.preview) {
+		uni.previewImage({
+			urls: [props.src || errImg.value]
+		})
+	}
+}
+
+const errImg = ref('')
+const handleError = e => {
+	errImg.value = props.defaultErrorImg
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-avatar {
+	display: inline-block;
+	white-space: nowrap;
+	position: relative;
+	overflow: hidden;
+	vertical-align: middle;
+	text-align: center;
+
+	&.radius {
+		border-radius: var(--radius);
+	}
+	&.circle,
+	&.circle &-img {
+		border-radius: 50%;
+	}
+	&.fixed {
+		position: fixed;
+		z-index: 50;
+		margin-bottom: var(--window-bottom);
+	}
+
+	&-img {
+		width: 100%;
+		height: 100%;
+		object-fit: cover;
+	}
+
+	&-slot {
+		width: 100%;
+		height: 100%;
+		display: flex;
+		justify-content: center;
+		align-items: center;
+		color: #fff;
+		white-space: normal;
+		line-height: 1;
+	}
+}
+</style>

+ 69 - 0
components/fs-back-top/fs-back-top.vue

@@ -0,0 +1,69 @@
+<template>
+	<view class="fs-back-top" :style="{ right, bottom }" @click="handleClick" v-if="visible">
+		<fs-icon type="icon-arrow-up" size="50rpx" color="#fff"></fs-icon>
+	</view>
+</template>
+
+<script>
+/**
+ * 回到顶部组件
+ * @description 回到顶部组件
+ * @property {String, Number} scrollTop useScrollTop
+ * @property {String} top 距离顶部多大时显示返回顶部
+ * @property {String} right “返回顶部”距离页面右边距
+ * @property {String} bottom “返回顶部”距离页面下边距
+ */
+export default {
+	name: 'fs-back-top'
+}
+</script>
+
+<script setup>
+import { computed, watch } from 'vue'
+
+const props = defineProps({
+	scrollTop: {
+		type: [String, Number],
+		required: true
+	},
+	top: {
+		type: String,
+		default: '150px'
+	},
+	right: {
+		type: String,
+		default: '40rpx'
+	},
+	bottom: {
+		type: String,
+		default: '40rpx'
+	}
+})
+
+const visible = computed(() => {
+	return parseFloat(props.scrollTop) >= parseFloat(props.top)
+})
+
+const handleClick = () => {
+	uni.pageScrollTo({
+		scrollTop: 0,
+		duration: 100
+	})
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-back-top {
+	position: fixed;
+	width: 90rpx;
+	height: 90rpx;
+	border-radius: 50%;
+	background-color: #606266;
+	opacity: 0.5;
+	z-index: 100;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	margin-bottom: var(--window-bottom);
+}
+</style>

+ 96 - 0
components/fs-badge/fs-badge.vue

@@ -0,0 +1,96 @@
+<template>
+	<view class="fs-badge">
+		<slot></slot>
+		<view
+			v-if="dot"
+			class="fs-badge-dot"
+			:class="['bg-' + bgColorType, { absolute: slots.default }]"
+			:style="{
+				backgroundColor: bgColor
+			}"
+		></view>
+		<view
+			v-else
+			class="fs-badge-count"
+			:class="['bg-' + bgColorType, { absolute: slots.default }]"
+			:style="{
+				backgroundColor: bgColor
+			}"
+		>
+			<slot name="count">{{ value }}</slot>
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 徽章组件
+ * @description 徽章组件
+ * @property {Number} count 数字
+ * @property {Boolean} dot 是否显示圆点
+ * @property {String} bgColor 背景颜色
+ * @property {String} bgColorType = [primary | danger | warning | info | success] 背景颜色类型
+ */
+export default {
+	name: 'fs-badge'
+}
+</script>
+
+<script setup>
+import { computed, useSlots } from 'vue'
+
+const props = defineProps({
+	dot: Boolean,
+	count: Number,
+	bgColor: String,
+	bgColorType: {
+		type: String,
+		default: 'danger',
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger'].includes(value)
+		}
+	}
+})
+const value = computed(() => (props.count > 99 ? '99+' : props.count))
+
+const slots = useSlots()
+</script>
+
+<style lang="scss" scoped>
+.fs-badge {
+	position: relative;
+	display: inline-block;
+	vertical-align: middle;
+
+	&-dot {
+		width: 16rpx;
+		height: 16rpx;
+		border-radius: 50%;
+		top: -8rpx;
+		right: -8rpx;
+		z-index: 10;
+	}
+	&-count {
+		color: #fff;
+		font-size: 12px;
+		font-weight: 500;
+		padding: 0 6px;
+		line-height: 36rpx;
+		min-width: 36rpx;
+		border-radius: 18rpx;
+		text-align: center;
+		box-sizing: border-box;
+		white-space: nowrap;
+		z-index: 10;
+
+		&.absolute {
+			top: 0;
+			right: 0;
+			transform: translate(50%, -50%) scale(0.8);
+		}
+	}
+	.absolute {
+		position: absolute;
+	}
+}
+</style>

+ 266 - 0
components/fs-button/fs-button.vue

@@ -0,0 +1,266 @@
+<template>
+	<button
+		hover-class="fs-hover"
+		class="fs-button"
+		:class="[
+			type,
+			size,
+			plain ? 'plain' : '',
+			radius ? 'radius' : '',
+			round ? 'round' : '',
+			disabled ? 'disabled' : '',
+			block ? 'block' : '',
+			full ? 'block full' : ''
+		]"
+		:style="[{ width: width }, customStyle]"
+		:open-type="openType"
+		:form-type="formType"
+		@getuserinfo="getuserinfo"
+		@contact="contact"
+		@getphonenumber="getphonenumber"
+		@opensetting="opensetting"
+		@error="error"
+		@click="handleClick"
+	>
+		<view class="fs-loader" v-if="loading"></view>
+		<template v-else>
+			<slot></slot>
+		</template>
+	</button>
+</template>
+
+<script>
+/**
+ * 按钮组件
+ * @description 按钮组件
+ * @property {String} openType 参考小程序
+ * @property {String} formType 参考小程序
+ * @property {String} size = [mini | small | medium] 按钮大小
+ * @property {Boolean} plain 是否镂空
+ * @property {Boolean} radius 是否圆角
+ * @property {Boolean} round 是否半圆
+ * @property {Boolean} loading 加载效果
+ * @property {Boolean} disabled disabled
+ * @property {Boolean} full 通屏按钮
+ * @property {Boolean} block 块状按钮
+ * @property {String} link 跳转地址
+ * @property {String} linkType 跳转类型
+ * @property {String} width 按钮宽度
+ * @property {Object} customStyle 按钮自定义样式
+ * @property {String} type = [primary | danger | warning | info | success | default] 按钮颜色类型
+ */
+export default {
+	name: 'fs-button'
+}
+</script>
+
+<script setup>
+import { computed, useAttrs } from 'vue'
+
+const props = defineProps({
+	openType: String,
+	formType: String,
+	size: {
+		type: String,
+		validator(value) {
+			return ['mini', 'small', 'medium'].includes(value)
+		}
+	},
+	type: {
+		type: String,
+		default: 'primary',
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger', 'default'].includes(value)
+		}
+	},
+	plain: Boolean,
+	radius: Boolean,
+	round: Boolean,
+	disabled: Boolean,
+	full: Boolean,
+	block: Boolean,
+	link: String,
+	linkType: {
+		type: String,
+		default: 'navigateTo'
+	},
+	width: String,
+	customStyle: {
+		type: Object,
+		default() {
+			return {}
+		}
+	},
+	loading: Boolean
+})
+
+const emits = defineEmits(['click', 'getuserinfo', 'contact', 'getphonenumber', 'opensetting', 'error'])
+
+const handleClick = e => {
+	if (props.link && !props.disabled) {
+		uni[props.linkType]({
+			url: props.link
+		})
+	}
+	!props.disabled && emits('click')
+}
+const getuserinfo = event => {
+	emits('getuserinfo', event.detail)
+}
+const contact = event => {
+	emits('contact', event.detail)
+}
+const getphonenumber = event => {
+	emits('getphonenumber', event.detail)
+}
+const opensetting = event => {
+	emits('opensetting', event.detail)
+}
+const error = event => {
+	emits('error', event.detail)
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-button {
+	font-weight: normal;
+	font-size: 14px;
+	color: #fff;
+	padding: 0 40rpx;
+	line-height: 1;
+	text-align: center;
+	border-radius: 0;
+	display: inline-block;
+	vertical-align: bottom;
+	background-color: #cfcfcf;
+	margin: 0;
+	border: 0;
+	height: 70rpx;
+	line-height: 70rpx;
+
+	&.primary,
+	&.primary.selected,
+	&.selected {
+		background-color: var(--primary);
+	}
+	&.success {
+		background-color: var(--success);
+	}
+	&.info {
+		background-color: var(--info);
+	}
+	&.warning {
+		background-color: var(--warning);
+	}
+	&.danger {
+		background-color: var(--danger);
+	}
+
+	&.plain,
+	&.plain.selected {
+		background-color: transparent;
+		box-shadow: inset 0 0 0 1px currentColor;
+		color: #999999;
+	}
+
+	&.medium {
+		// width: auto !important;
+		height: 60rpx;
+		line-height: 60rpx;
+		font-size: 13px;
+		padding: 0 30rpx;
+	}
+	&.small {
+		width: auto !important;
+		height: 50rpx;
+		line-height: 50rpx;
+		font-size: 12px;
+		padding: 0 20rpx;
+	}
+	&.mini {
+		width: auto !important;
+		height: 40rpx;
+		line-height: 40rpx;
+		font-size: 12px;
+		padding: 0 20rpx;
+	}
+	&.block {
+		display: block;
+		height: 100rpx;
+		line-height: 100rpx;
+		margin-left: var(--gutter) !important;
+		margin-right: var(--gutter) !important;
+		font-size: 18px;
+		width: auto;
+
+		&.radius {
+			border-radius: 16rpx;
+		}
+	}
+	&.full {
+		margin-left: 0 !important;
+		margin-right: 0 !important;
+	}
+
+	&::after {
+		border: none;
+	}
+
+	&.radius {
+		border-radius: 8rpx;
+	}
+	&.round {
+		border-radius: 30px;
+	}
+
+	&.plain.primary,
+	&.plain.selected {
+		color: var(--primary);
+	}
+
+	&.plain.success {
+		color: var(--success);
+	}
+
+	&.plain.warning {
+		color: var(--warning);
+	}
+
+	&.plain.danger {
+		color: var(--danger);
+	}
+
+	&.plain.info {
+		color: var(--info);
+	}
+
+	&.disabled {
+		opacity: 0.5;
+	}
+}
+
+.fs-hover {
+	opacity: 0.5;
+}
+
+.fs-loader {
+	display: inline-block;
+	width: 40rpx;
+	height: 40rpx;
+	color: inherit;
+	vertical-align: middle;
+	border: 4rpx solid currentcolor;
+	border-bottom-color: transparent;
+	border-radius: 50%;
+	animation: 1s loader linear infinite;
+	position: relative;
+}
+@keyframes loader {
+	0% {
+		transform: rotate(0deg);
+	}
+	100% {
+		transform: rotate(360deg);
+	}
+}
+</style>

+ 100 - 0
components/fs-captcha/fs-captcha.vue

@@ -0,0 +1,100 @@
+<template>
+	<fs-button
+		v-if="type === 'button'"
+		size="medium"
+		round
+		:plain="plain"
+		:block="block"
+		@click="getCaptcha"
+		:style="customStyle"
+		:disabled="state.sending"
+	>
+		{{ state.timerText }}
+	</fs-button>
+	<view v-else class="primary" :style="customStyle" @click="getCaptcha">{{ state.timerText }}</view>
+</template>
+
+<script>
+/**
+ * 验证码组件
+ * @description 验证码组件
+ * @property {String} mobile 手机号
+ * @property {Number} seconds 倒计时(单位s)
+ * @property {String} type = [button | text] 类型
+ * @property {Boolean} plain 是否镂空
+ * @property {Boolean} block 块状
+ * @property {Object} customStyle 自定义样式
+ * @event {Function} start 倒计时开始事件
+ * @event {Function} end 倒计时结束事件
+ */
+export default {
+	name: 'fs-captcha'
+}
+</script>
+
+<script setup>
+import { reactive } from 'vue'
+
+const props = defineProps({
+	mobile: String,
+	seconds: {
+		type: Number,
+		default: 60
+	},
+	type: {
+		type: String,
+		default: 'button',
+		validator(value) {
+			return ['button', 'text'].includes(value)
+		}
+	},
+	block: Boolean,
+	plain: {
+		type: Boolean,
+		default: true
+	},
+	customStyle: {
+		type: Object,
+		default: () => {}
+	}
+})
+const emits = defineEmits(['start', 'end'])
+
+const state = reactive({
+	timerText: '获取验证码',
+	sending: false,
+	timerId: null
+})
+
+const getCaptcha = () => {
+	if (!state.sending) {
+		if (!/^1\d{10}$/.test(props.mobile)) {
+			return uni.showToast({
+				icon: 'none',
+				title: '请输入正确的手机号'
+			})
+		}
+		state.sending = true
+
+		let timer = props.seconds
+		state.timerText = `${timer}s`
+
+		state.timerId = setInterval(() => {
+			if (--timer > 0) {
+				state.timerText = `${timer}s`
+			} else {
+				endSendCaptcha()
+			}
+		}, 1000)
+		emits('start')
+	}
+}
+const endSendCaptcha = () => {
+	state.sending = false
+	state.timerText = '获取验证码'
+	clearInterval(state.timerId)
+	emits('end')
+}
+</script>
+
+<style></style>

+ 105 - 0
components/fs-card/fs-card.vue

@@ -0,0 +1,105 @@
+<template>
+	<view class="fs-card" :class="{ 'fs-card-full': full, 'fs-card-gutter': gutter }" @click="handleClick">
+		<view class="fs-card-box" :class="{ 'fs-card-radius': radius, shadow }">
+			<view class="fs-card-title" v-if="slots.title || title" :style="titleStyle">
+				<slot name="title">{{ title }}</slot>
+			</view>
+			<view :class="{ 'fs-card-content': contentPadding }"><slot></slot></view>
+			<view class="fs-card-ft" v-if="slots.footer"><slot name="footer"></slot></view>
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 卡片组件
+ * @description 卡片组件
+ * @property {String} title 标题
+ * @property {Object} titleStyle 标题样式
+ * @property {Boolean} full 是否通屏
+ * @property {Boolean} gutter 是否有下边距
+ * @property {Boolean} radius 是否圆角
+ * @property {Boolean} shadow 是否带阴影
+ * @property {String} link 跳转地址
+ * @property {String} linkType 跳转类型
+ */
+export default {
+	name: 'fs-card'
+}
+</script>
+
+<script setup>
+import { useSlots } from 'vue'
+const props = defineProps({
+	title: String,
+	titleStyle: {
+		type: Object,
+		default() {
+			return {}
+		}
+	},
+	full: Boolean,
+	gutter: Boolean,
+	radius: {
+		type: Boolean,
+		default: true
+	},
+	shadow: {
+		type: Boolean,
+		default: false
+	},
+	contentPadding: Boolean,
+	link: String,
+	linkType: {
+		type: String,
+		default: 'navigateTo'
+	}
+})
+
+const slots = useSlots()
+const emits = defineEmits(['click'])
+
+const handleClick = () => {
+	if (props.link) {
+		uni[props.linkType]({
+			url: props.link
+		})
+	}
+	emits('click')
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-card {
+	margin-left: var(--gutter);
+	margin-right: var(--gutter);
+
+	&-box {
+		background-color: #fff;
+		overflow: hidden;
+	}
+	&-radius {
+		border-radius: var(--radius);
+	}
+
+	&-title {
+		padding: 20rpx var(--gutter);
+		border-bottom: 2rpx solid var(--border-color);
+	}
+	&-ft {
+		padding: 20rpx var(--gutter);
+		border-top: 2rpx solid var(--border-color);
+	}
+	&-content {
+		padding: 20rpx var(--gutter);
+	}
+
+	&-full {
+		margin-left: 0;
+		margin-right: 0;
+	}
+	&-gutter {
+		margin-bottom: var(--gutter-v);
+	}
+}
+</style>

+ 110 - 0
components/fs-cell-group/fs-cell-group.vue

@@ -0,0 +1,110 @@
+<template>
+	<view
+		class="fs-cell-group"
+		:class="{ full, radius, 'fs-cell-group-gutter': gutter }"
+		:style="{ backgroundColor: bgColor || '#fff' }"
+	>
+		<slot />
+	</view>
+</template>
+
+<script>
+/**
+ * 单元格组组件
+ * @description 单元格组组件
+ * @property {String} titleWidth 标题宽度
+ * @property {Boolean} arrow 是否显示箭头
+ * @property {String} arrowColor 箭头颜色
+ * @property {String} arrowColorType = [primary | danger | warning | info | success] 箭头颜色类型
+ * @property {Boolean} full 是否通屏
+ * @property {Boolean} border 是否显示边框
+ * @property {Boolean} tighten 是否紧凑
+ * @property {Boolean} gutter 是否显示间距
+ * @property {Boolean} radius 是否带圆角
+ * @property {Boolean} reverse 是否翻转
+ * @property {String} bgColor 背景颜色
+ * @property {String} align = [top | center | bottom | stretch] 垂直对齐方式
+ * @property {String} justify = [left | center | right] 水平对齐方式
+ */
+export default {
+	name: 'fs-cell-group'
+}
+</script>
+
+<script setup>
+import { provide, toRefs } from 'vue'
+
+const props = defineProps({
+	titleWidth: String,
+	arrow: Boolean,
+	arrowColor: {
+		type: String,
+		default: ''
+	},
+	arrowColorType: {
+		type: String,
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger'].includes(value)
+		}
+	},
+	border: Boolean,
+	tighten: Boolean,
+	gutter: Boolean,
+	radius: {
+		type: Boolean,
+		default: true
+	},
+	reverse: Boolean,
+	align: {
+		type: String,
+		default: 'center',
+		validator(value) {
+			return ['top', 'center', 'bottom', 'stretch'].includes(value)
+		}
+	},
+	justify: {
+		type: String,
+		default: 'left',
+		validator(value) {
+			return ['left', 'center', 'right'].includes(value)
+		}
+	},
+	bgColor: {
+		type: String
+	},
+	full: Boolean
+})
+
+provide('cellGroup', props)
+</script>
+
+<style lang="scss" scoped>
+.fs-cell-group {
+	margin: 0 var(--gutter);
+	overflow: hidden;
+	position: relative;
+
+	&::after {
+		position: absolute;
+		bottom: 0;
+		left: 0;
+		height: 2rpx;
+		width: 100%;
+		background-color: #fff;
+		z-index: 10;
+		content: '';
+	}
+
+	&.full {
+		margin: 0;
+	}
+
+	&.radius {
+		border-radius: var(--radius);
+	}
+
+	&-gutter {
+		margin-bottom: var(--gutter-v);
+	}
+}
+</style>

+ 234 - 0
components/fs-cell/fs-cell.vue

@@ -0,0 +1,234 @@
+<template>
+	<view class="fs-cell" :class="[cls, { shadow }]" :style="{ backgroundColor: bgColor }" @click="handleClick">
+		<view class="fs-cell-flex" :class="['fs-cell-align-' + align, justify, { reverse }]">
+			<view class="fs-cell-title" :class="{ 'fs-cell-required': required }" :style="titleStyle">
+				<template v-if="title">
+					{{ title }}
+				</template>
+				<slot v-else name="title"></slot>
+			</view>
+			<view class="fs-cell-value">
+				<template v-if="value">
+					{{ value }}
+				</template>
+				<slot v-else name="value"></slot>
+			</view>
+			<view class="fs-cell-extra">
+				<template v-if="extra">
+					{{ extra }}
+				</template>
+				<slot v-else name="extra"></slot>
+			</view>
+		</view>
+		<view class="fs-cell-label">
+			<template v-if="label">
+				{{ label }}
+			</template>
+			<slot v-else name="label"></slot>
+		</view>
+		<view class="arrow-icon">
+			<fs-icon type="icon-d-down" rotate="-90" size="28rpx" :color="arrowColor" :colorType="arrowColorType"></fs-icon>
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 单元格组件
+ * @description 单元格组件
+ * @property {String} title 标题
+ * @property {String} titleWidth 标题宽度
+ * @property {String} value 值
+ * @property {String} label 描述信息
+ * @property {String} extra 额外信息
+ * @property {Boolean} arrow 是否显示箭头
+ * @property {String} arrowColor 箭头颜色
+ * @property {String} arrowColorType = [primary | danger | warning | info | success] 箭头颜色类型
+ * @property {Boolean} border 是否显示边框
+ * @property {Boolean} shadow 是否显示阴影
+ * @property {Boolean} tighten 是否紧凑
+ * @property {Boolean} gutter 是否显示间距
+ * @property {Boolean} radius 是否带圆角
+ * @property {Boolean} reverse 是否翻转
+ * @property {Boolean} required 是否必填配合表单使用
+ * @property {String} bgColor 背景颜色
+ * @property {String} align = [top | center | bottom | stretch] 垂直对齐方式
+ * @property {String} justify = [left | center | right] 水平对齐方式
+ * @property {String} link 跳转地址
+ * @property {String} linkType 跳转类型
+ */
+export default {
+	name: 'fs-cell'
+}
+</script>
+
+<script setup>
+import { computed, toRefs, inject } from 'vue'
+
+const props = defineProps({
+	title: String,
+	titleWidth: String,
+	value: String,
+	extra: String,
+	label: String,
+	arrow: Boolean,
+	arrowColor: {
+		type: String,
+		default: '#E8EAF2'
+	},
+	arrowColorType: {
+		type: String,
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger'].includes(value)
+		}
+	},
+	border: Boolean,
+	tighten: Boolean,
+	gutter: Boolean,
+	radius: Boolean,
+	reverse: Boolean,
+	required: Boolean,
+	align: {
+		type: String,
+		default: 'center',
+		validator(value) {
+			return ['top', 'center', 'bottom', 'stretch'].includes(value)
+		}
+	},
+	justify: {
+		type: String,
+		validator(value) {
+			return ['left', 'center', 'right'].includes(value)
+		}
+	},
+	bgColor: {
+		type: String
+	},
+	shadow: Boolean,
+	link: String,
+	linkType: {
+		type: String,
+		default: 'navigateTo'
+	}
+})
+
+const emits = defineEmits(['click'])
+const cellGroup = inject('cellGroup', {})
+
+const justify = props.justify || cellGroup.justify || 'left'
+const bgColor = props.bgColor || cellGroup.bgColor || '#fff'
+const arrowColor = props.arrowColor || cellGroup.arrowColor
+const arrowColorType = props.arrowColorType || cellGroup.arrowColorType
+const cls = computed(() => {
+	const classNames = []
+
+	;(props.arrow || cellGroup.arrow) && classNames.push('arrow')
+	;(props.border || cellGroup.border) && classNames.push('border')
+	;(props.tighten || cellGroup.tighten) && classNames.push('tighten')
+	props.gutter && classNames.push('gutter')
+	props.radius && classNames.push('radius')
+
+	return classNames.join(' ')
+})
+const titleStyle = computed(() => {
+	const width = props.titleWidth || cellGroup.titleWidth
+	return width ? `width: ${width}` : ''
+})
+
+const handleClick = () => {
+	if (props.link) {
+		uni[props.linkType]({
+			url: props.link
+		})
+	}
+	emits('click')
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-cell {
+	padding: 30rpx var(--gutter);
+	position: relative;
+	font-size: var(--content-size);
+	background-color: #fff;
+	line-height: 1.4;
+	width: 100%;
+	box-sizing: border-box;
+
+	&-flex {
+		display: flex;
+		justify-content: space-between;
+	}
+	&-value {
+		flex: 1;
+		padding-left: 20rpx;
+		text-align: right;
+	}
+	&-label {
+		font-size: var(--sub-size);
+		color: var(--sub);
+	}
+
+	&-align-top {
+		align-items: flex-start;
+	}
+	&-align-center {
+		align-items: center;
+	}
+	&-align-bottom {
+		align-items: flex-end;
+	}
+
+	&.arrow {
+		padding-right: 50rpx;
+
+		.arrow-icon {
+			display: block;
+		}
+	}
+
+	&-required {
+		position: relative;
+		padding-left: 7px;
+
+		&::before {
+			position: absolute;
+			content: '*';
+			color: red;
+			left: 0;
+		}
+	}
+
+	&.gutter {
+		margin-bottom: var(--gutter-v);
+	}
+	&.radius {
+		border-radius: var(--radius);
+	}
+	&.reverse {
+		flex-direction: row-reverse;
+	}
+	&.tighten {
+		padding: var(--tighten-gutter);
+	}
+	&.border {
+		border-bottom: 2rpx solid var(--border-color);
+	}
+}
+.arrow-icon {
+	display: none;
+	position: absolute;
+	right: 10rpx;
+	top: 50%;
+	transform: translateY(-50%);
+}
+.left .fs-cell-value {
+	text-align: left;
+}
+.center .fs-cell-value {
+	text-align: center;
+}
+.right .fs-cell-value {
+	text-align: right;
+}
+</style>

+ 100 - 0
components/fs-checkbox-button/fs-checkbox-button.vue

@@ -0,0 +1,100 @@
+<template>
+	<view
+		class="fs-checkbox-button"
+		:class="[
+			selected ? checkedColorType : 'fs-checkbox-button-default',
+			{ 'fs-checkbox-button-radius': radius, 'fs-checkbox-button-round': round },
+			buttonSize
+		]"
+		:style="{ color: checkedColor }"
+		@click="handleToggle"
+	>
+		{{ label }}
+		<slot />
+	</view>
+</template>
+
+<script>
+/**
+ * 多选框组件
+ * @description 多选框组件
+ * @property {String} label 文本
+ * @property {null} value 标识符(必须传)
+ * @property {String} size = [mini | small | medium] 按钮大小
+ * @property {String} checkedColor 选中颜色
+ * @property {String} checkedColorType = [primary | danger | warning | info | success] 选中颜色类型
+ */
+export default {
+	name: 'fs-checkbox-button'
+}
+</script>
+
+<script setup>
+import { inject, watch, toRefs, ref } from 'vue'
+
+const props = defineProps({
+	label: String,
+	value: {
+		type: null,
+		required: true
+	},
+	checkedColor: String,
+	checkedColorType: String,
+	size: {
+		type: String,
+		validator(value) {
+			return ['mini', 'small', 'medium'].includes(value)
+		}
+	}
+})
+
+const checkboxGroup = inject('checkboxGroup')
+const { inline, radius, round } = checkboxGroup
+const checkedColorType = props.checkedColorType || checkboxGroup.checkedColorType
+const checkedColor = props.checkedColor || checkboxGroup.checkedColor
+const buttonSize = props.size || checkboxGroup.size
+
+let selected = ref(false)
+checkboxGroup.updateChildren({
+	selected,
+	value: props.value
+})
+
+const handleToggle = () => {
+	checkboxGroup.updateValue(props.value)
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-checkbox-button {
+	padding: 10rpx 30rpx;
+	white-space: nowrap;
+	border: 2rpx solid currentColor;
+	margin-right: 20rpx;
+	margin-bottom: 20rpx;
+
+	&-default {
+		color: #999999;
+	}
+
+	&-radius {
+		border-radius: var(--radius);
+	}
+	&-round {
+		border-radius: 60rpx;
+	}
+
+	&.medium {
+		padding: 8rpx 25rpx;
+		font-size: 13px;
+	}
+	&.small {
+		padding: 6rpx 20rpx;
+		font-size: 12px;
+	}
+	&.mini {
+		padding: 2rpx 15rpx;
+		font-size: 11px;
+	}
+}
+</style>

+ 56 - 0
components/fs-checkbox-cell/fs-checkbox-cell.vue

@@ -0,0 +1,56 @@
+<template>
+	<fs-cell border justify="right" :title="label" @click="handleToggle">
+		<template #title>
+			<slot></slot>
+		</template>
+		<template #value>
+			<fs-icon type="icon-right" :color="checkedColor" :colorType="checkedColorType" v-if="selected"></fs-icon>
+		</template>
+	</fs-cell>
+</template>
+
+<script>
+/**
+ * 多选框组件
+ * @description 多选框组件
+ * @property {String} label 文本
+ * @property {null} value 标识符(必须传)
+ * @property {String} checkedColor 选中颜色
+ * @property {String} checkedColorType = [primary | danger | warning | info | success] 选中颜色类型
+ */
+export default {
+	name: 'fs-checkbox-cell'
+}
+</script>
+
+<script setup>
+import { ref, watch, inject, toRefs } from 'vue'
+
+const props = defineProps({
+	label: String,
+	value: {
+		type: null,
+		required: true
+	},
+	checkedColor: String,
+	checkedColorType: String
+})
+
+const checkboxGroup = inject('checkboxGroup')
+const { reverse, inline, justify } = checkboxGroup
+
+const checkedColorType = props.checkedColorType || checkboxGroup.checkedColorType
+const checkedColor = props.checkedColor || checkboxGroup.checkedColor
+
+let selected = ref(false)
+checkboxGroup.updateChildren({
+	selected,
+	value: props.value
+})
+
+const handleToggle = () => {
+	checkboxGroup.updateValue(props.value)
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 133 - 0
components/fs-checkbox-group/fs-checkbox-group.vue

@@ -0,0 +1,133 @@
+<template>
+	<view class="fs-checkbox-group" :class="{ inline }"><slot></slot></view>
+</template>
+
+<script>
+/**
+ * 多选框组组件
+ * @description 多选框组组件
+ * @property {Number} max 最多选中几个
+ * @property {String} justify 图标对齐方式
+ * @property {Boolean} reverse 是否反转
+ * @property {Boolean} inline 单行显示
+ * @property {Boolean} radius 是否圆角(仅对按钮样式有效)
+ * @property {Boolean} round 是否半圆(仅对按钮样式有效)
+ * @property {String} checkedColor 选中颜色
+ * @property {String} checkedColorType = [primary | danger | warning | info | success] 选中颜色类型
+ * @property {String} size = [mini | small | medium] 按钮大小(仅对按钮样式有效)
+ * @event {Function} change change事件
+ */
+export default {
+	name: 'fs-checkbox-group'
+}
+</script>
+
+<script setup>
+import { provide, reactive, watch, toRefs } from 'vue'
+
+const props = defineProps({
+	max: {
+		type: Number,
+		default: -1
+	},
+	justify: String,
+	reverse: Boolean,
+	inline: Boolean,
+	checkedColor: String,
+	checkedColorType: {
+		type: String,
+		default: 'primary',
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger'].includes(value)
+		}
+	},
+	radius: Boolean,
+	round: Boolean,
+	size: {
+		type: String,
+		validator(value) {
+			return ['mini', 'small', 'medium'].includes(value)
+		}
+	},
+	modelValue: {
+		type: Array,
+		default() {
+			return []
+		}
+	}
+})
+const emits = defineEmits(['update:modelValue', 'change'])
+
+const state = reactive({
+	selectedValue: props.modelValue,
+	children: []
+})
+watch(
+	() => props.modelValue,
+	val => {
+		state.selectedValue = val
+	}
+)
+
+const checkStrategy = value => {
+	state.children.forEach(item => {
+		if (typeof item.value === 'object') {
+			item.selected = state.selectedValue.filter(selected => selected.id === item.value.id).length > 0
+		} else {
+			item.selected = state.selectedValue.indexOf(item.value) > -1
+		}
+	})
+}
+const updateChildren = child => {
+	state.children.push(child)
+	checkStrategy()
+}
+const updateValue = value => {
+	let index = -1
+
+	if (typeof value === 'object') {
+		state.selectedValue.forEach((item, key) => {
+			if (item.id === value.id) {
+				index = key
+			}
+		})
+	} else {
+		index = state.selectedValue.indexOf(value)
+	}
+
+	if (state.selectedValue.length < props.max || props.max === -1) {
+		if (index === -1) {
+			state.selectedValue.push(value)
+		} else {
+			state.selectedValue.splice(index, 1)
+		}
+	} else {
+		index > -1 && state.selectedValue.splice(index, 1)
+	}
+}
+
+watch(
+	() => state.selectedValue,
+	val => {
+		checkStrategy()
+		emits('update:modelValue', val)
+		emits('change', val)
+	},
+	{ deep: true }
+)
+
+provide('checkboxGroup', {
+	...toRefs(props),
+	updateChildren,
+	updateValue
+})
+</script>
+
+<style lang="scss" scoped>
+.fs-checkbox-group {
+	&.inline {
+		display: flex;
+		flex-wrap: wrap;
+	}
+}
+</style>

+ 110 - 0
components/fs-checkbox/fs-checkbox.vue

@@ -0,0 +1,110 @@
+<template>
+	<view
+		class="fs-checkbox"
+		:class="[justify, { 'fs-checkbox-reverse': reverse, 'fs-checkbox-inline': inline }]"
+		@click="handleToggle"
+	>
+		<fs-icon
+			v-if="icon"
+			source="out"
+			:type="selected ? selectIcon || icon : icon"
+			:colorType="selected ? checkedColorType : 'gray'"
+			:size="iconSize"
+			:color="checkedColor"
+		></fs-icon>
+		<fs-icon
+			v-else
+			:type="selected ? 'icon-squarecheck' : 'icon-square'"
+			:colorType="selected ? checkedColorType : 'gray'"
+			:size="iconSize"
+			:color="checkedColor"
+		></fs-icon>
+		<view class="fs-checkbox-lable">
+			{{ label }}
+			<slot />
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 多选框组件
+ * @description 多选框组件
+ * @property {String} label 文本
+ * @property {String} icon 自定义图标
+ * @property {String} iconSize 图标大小
+ * @property {String} selectIcon 自定义选中图标
+ * @property {null} value 标识符(必须传)
+ * @property {String} checkedColor 选中颜色
+ * @property {String} checkedColorType = [primary | danger | warning | info | success] 选中颜色类型
+ */
+export default {
+	name: 'fs-checkbox'
+}
+</script>
+
+<script setup>
+import { inject, watch, toRefs, ref, computed } from 'vue'
+
+const props = defineProps({
+	label: String,
+	icon: String,
+	selectIcon: String,
+	iconSize: {
+		type: String,
+		default: '40rpx'
+	},
+	value: {
+		type: null,
+		required: true
+	},
+	checkedColor: String,
+	checkedColorType: {
+		type: String,
+		default: 'primary',
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger'].includes(value)
+		}
+	}
+})
+
+const checkboxGroup = inject('checkboxGroup')
+const { reverse, inline, justify } = checkboxGroup
+
+let selected = ref(false)
+checkboxGroup.updateChildren({
+	selected,
+	value: props.value
+})
+
+const handleToggle = () => {
+	checkboxGroup.updateValue(props.value)
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-checkbox {
+	display: flex;
+	align-items: center;
+	justify-content: flex-start;
+	margin-bottom: 14rpx;
+
+	&-lable {
+		margin-left: 6rpx;
+		margin-right: 20rpx;
+	}
+
+	&-reverse {
+		flex-direction: row-reverse;
+		justify-content: flex-end;
+	}
+	&-reverse &-lable {
+		margin-left: 0;
+		margin-right: 6rpx;
+	}
+
+	&.right {
+		justify-content: space-between;
+	}
+}
+</style>

+ 51 - 0
components/fs-col/fs-col.vue

@@ -0,0 +1,51 @@
+<template>
+	<view class="fs-col" :class="['fs-col-' + span, { gutter }]" :style="styleStr"><slot></slot></view>
+</template>
+
+<script>
+/**
+ * 多选框组件
+ * @description 多选框组件
+ * @property {Number, String} span 列
+ * @property {Boolean} gutter 是否有下边距
+ */
+export default {
+	name: 'fs-col'
+}
+</script>
+
+<script setup>
+import { computed, toRefs, inject } from 'vue'
+
+const props = defineProps({
+	span: {
+		type: [Number, String],
+		default: 12
+	},
+	gutter: Boolean
+})
+
+const rowGap = inject('rowGap')
+
+const styleStr = computed(() => {
+	const padding = parseInt(rowGap) / 2
+	return padding ? `padding-left: ${padding}rpx;padding-right: ${padding}rpx;` : ''
+})
+</script>
+
+<style lang="scss" scoped>
+@use "sass:math";
+
+.fs-col {
+	float: left;
+
+	&.gutter {
+		margin-bottom: var(--gutter-v);
+	}
+}
+@for $i from 1 through 12 {
+	.fs-col-#{$i} {
+		width: math.div(100%, 12) * $i;
+	}
+}
+</style>

+ 103 - 0
components/fs-collapse-item/fs-collapse-item.vue

@@ -0,0 +1,103 @@
+<template>
+	<view class="fs-collapse-item" :class="{ 'fs-collapse-item-border': border }">
+		<view class="fs-title-box" :class="[{ open }, position]" @click="handleClick">
+			<view class="fs-item-hd" :class="[highlight]"><slot name="title"></slot></view>
+			<view class="fs-arrow-box" :class="[{ open }, highlight]">
+				<slot name="arrow"><view class="fs-arrow"></view></slot>
+			</view>
+		</view>
+		<view class="content" v-if="open"><slot name="content"></slot></view>
+	</view>
+</template>
+
+<script>
+/**
+ * 折叠面板组件
+ * @description 折叠面板组件
+ * @property {String, Number} name name
+ * @property {Boolean} disabled disabled
+ */
+export default {
+	name: 'fs-collapse-item'
+}
+</script>
+
+<script setup>
+import { computed, inject, watch, ref } from 'vue'
+
+const props = defineProps({
+	name: [String, Number],
+	disabled: Boolean
+})
+const collapse = inject('collapse')
+
+const border = collapse.border
+const open = ref(collapse.allOpen || collapse.active === props.name)
+const position = collapse.position
+const highlight = computed(() => open.value && !props.disabled && collapse.activeType)
+
+const setActive = active => {
+	open.value = active && !props.disabled
+}
+
+collapse.children.push({
+	name: props.name,
+	open,
+	setActive
+})
+
+const handleClick = () => {
+	!props.disabled && collapse.emitEvent(props.name)
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-collapse-item {
+	&-border {
+		border-bottom: 1px solid var(--border-color);
+	}
+	.open {
+		.fs-arrow {
+			transform: rotate(135deg);
+		}
+	}
+}
+
+.fs-title-box {
+	display: flex;
+	padding: 20rpx var(--gutter);
+	justify-content: space-between;
+	align-items: center;
+
+	.fs-item-hd {
+		min-width: 0;
+		flex: 1;
+	}
+	&.left {
+		flex-direction: row-reverse;
+		.fs-item-hd {
+			padding-left: 10rpx;
+		}
+	}
+	&.right {
+		.fs-item-hd {
+			padding-right: 10rpx;
+		}
+	}
+}
+
+.fs-arrow-box {
+	line-height: 1;
+}
+
+.fs-arrow {
+	border-top: 2rpx solid currentColor;
+	border-right: 2rpx solid currentColor;
+	transform: rotate(45deg);
+	width: 16rpx;
+	height: 16rpx;
+	color: inherit;
+	transition: all 0.1s;
+	flex-shrink: 0;
+}
+</style>

+ 94 - 0
components/fs-collapse/fs-collapse.vue

@@ -0,0 +1,94 @@
+<template>
+	<view class="fs-collapse"><slot></slot></view>
+</template>
+
+<script>
+/**
+ * 折叠面板组件
+ * @description 折叠面板组件
+ * @property {Boolean} accordion 手风琴效果
+ * @property {Number, String} active 激活项name
+ * @property {String} position 箭头位置
+ * @property {Number, String} rotate 旋转
+ * @property {String} activeColor 高亮颜色
+ * @property {String} activeType 高亮颜色类型
+ * @property {Boolean} allOpen 是否全部展开
+ * @property {Boolean} border 是否显示边框
+ * @event {Function} change change事件
+ */
+export default {
+	name: 'fs-collapse'
+}
+</script>
+
+<script setup>
+import { computed, provide, watch, reactive, onMounted, getCurrentInstance } from 'vue'
+
+const props = defineProps({
+	accordion: Boolean,
+	active: {
+		type: [Number, String],
+		default: '0'
+	},
+	position: {
+		type: String,
+		default: 'right',
+		validator(value) {
+			return ['left', 'right'].includes(value)
+		}
+	},
+	rotate: {
+		type: [Number, String],
+		default: 90
+	},
+	activeType: String,
+	activeColor: String,
+	allOpen: Boolean,
+	border: {
+		type: Boolean,
+		default: true
+	}
+})
+const emits = defineEmits(['change'])
+const emitEvent = name => {
+	setActive(name)
+	emits('change', name)
+}
+
+const children = reactive([])
+const setActive = name => {
+	children.forEach(item => {
+		if (props.accordion) {
+			item.setActive(name === item.name ? !item.open : false)
+		} else {
+			if (name === item.name) {
+				item.setActive(!item.open)
+			}
+		}
+	})
+}
+provide(
+	'collapse',
+	reactive({
+		...props,
+		children,
+		emitEvent
+	})
+)
+watch(
+	() => props.active,
+	value => {
+		setActive(value)
+	}
+)
+
+defineExpose({
+	children
+})
+</script>
+
+<style lang="scss" scoped>
+.fs-collapse {
+	// background-color: #fff;
+}
+</style>

+ 231 - 0
components/fs-comment/fs-comment.vue

@@ -0,0 +1,231 @@
+<template>
+	<view>
+		<fs-cell-group full border :radius="false">
+			<fs-cell align="top" v-for="(item, index) in list" :key="item.id">
+				<template #title>
+					<fs-avatar :src="item[keyMap.avatar]"></fs-avatar>
+				</template>
+				<template #value>
+					<view class="fs-comment-hd">
+						<view class="fs-comment-hd-item fs-comment-hd-title">{{ item[keyMap.name] }}</view>
+						<view class="fs-comment-hd-item">{{ item[keyMap.time] }}</view>
+					</view>
+					<view class="fs-comment-content">{{ item[keyMap.content] }}</view>
+					<view class="fs-comment-reply">
+						<view class="fs-comment-reply-item" @click="handleLike(item)" v-if="showLike">
+							<fs-icon type="icon-like" :color="item[keyMap.isLike] ? 'red' : ''"></fs-icon>
+							<view v-if="!item[keyMap.isLike]">点赞</view>
+						</view>
+						<view class="fs-comment-reply-item" @click="initInput(item)">
+							<fs-icon type="icon-comment"></fs-icon>
+							回复
+						</view>
+					</view>
+					<view class="fs-comment-reply-box">
+						<fs-cell-group border bgColor="transparent" full>
+							<fs-cell align="top" v-for="reply in item[keyMap.replyList]" :key="reply.id">
+								<template #title>
+									<fs-avatar :src="reply[keyMap.avatar]"></fs-avatar>
+								</template>
+								<template #value>
+									<view class="fs-comment-hd fs-comment-hd-title">
+										<view class="fs-comment-hd-item">{{ reply[keyMap.name] }}</view>
+										<view class="fs-comment-hd-item">{{ reply[keyMap.time] }}</view>
+									</view>
+									<view class="fs-comment-content">{{ reply[keyMap.content] }}</view>
+									<view class="fs-comment-reply">
+										<view class="fs-comment-reply-item" @click="handleLike(reply)" v-if="showLike">
+											<fs-icon type="icon-like" :color="reply[keyMap.isLike] ? 'red' : ''"></fs-icon>
+											<view v-if="!reply[keyMap.isLike]">点赞</view>
+										</view>
+										<view class="fs-comment-reply-item" @click="initInput(reply)">
+											<fs-icon type="icon-comment"></fs-icon>
+											回复
+										</view>
+									</view>
+								</template>
+							</fs-cell>
+						</fs-cell-group>
+					</view>
+				</template>
+			</fs-cell>
+		</fs-cell-group>
+
+		<view class="fs-comment-input-box" v-if="showInput">
+			<input
+				type="text"
+				class="fs-comment-input"
+				placeholder="回复..."
+				v-model="replyValue"
+				:focus="focus"
+				@confirm="handleConfirm"
+			/>
+			<view class="fs-comment-button" @click="handleConfirm">发送</view>
+			<!-- :style="{backgroundColor: replyValue ? '#08bf65' : '#f7f7f7',color: replyValue ? '#fff' : '#cecece'}" -->
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 评论组件
+ * @description 评论组件
+ * @property {Array} list 手风琴效果
+ * @property {Boolean} showLike 激活项name
+ * @property {Object} keyMap 箭头位置
+ * @event {Function} like 点赞事件
+ * @event {Function} reply 发送事件
+ */
+export default {
+	name: 'fs-comment'
+}
+</script>
+
+<script setup>
+import { ref, nextTick } from 'vue'
+
+const props = defineProps({
+	list: {
+		type: Array,
+		default() {
+			return []
+		}
+	},
+	showLike: Boolean,
+	keyMap: {
+		type: Object,
+		default() {
+			return {
+				avatar: 'avatar',
+				name: 'name',
+				time: 'time',
+				content: 'content',
+				replyList: 'children',
+				isLike: 'isLike'
+			}
+		}
+	}
+})
+const emits = defineEmits(['like', 'reply'])
+
+let focus = ref(false)
+let showInput = ref(false)
+let reply = ref(null)
+let replyValue = ref('')
+const initInput = item => {
+	showInput.value = true
+	focus.value = true
+	reply.value = item
+	replyValue.value = ''
+}
+const handleBlur = () => {
+	nextTick(() => {
+		showInput.value = false
+		focus.value = false
+	})
+}
+const handleConfirm = () => {
+	if (replyValue.value) {
+		emits('reply', reply.value)
+	}
+	showInput.value = false
+	focus.value = false
+}
+
+const handleLike = item => {
+	item[props.keyMap.isLike] = !item[props.keyMap.isLike]
+	emits('like', item)
+}
+</script>
+
+<style lang="scss">
+.fs-comment {
+	font-size: 14px;
+
+	&-hd {
+		display: flex;
+
+		&-title {
+			color: #222;
+		}
+
+		&-item {
+			padding-right: 30rpx;
+			position: relative;
+			margin-bottom: 10rpx;
+
+			& + & {
+				padding-left: 30rpx;
+
+				&::before {
+					position: absolute;
+					left: 0;
+					top: 50%;
+					transform: translateY(-50%);
+					width: 2rpx;
+					height: 24rpx;
+					background-color: #d9d9d9;
+					content: '';
+				}
+			}
+		}
+	}
+
+	&-content {
+		font-size: 14px;
+	}
+
+	&-reply-box {
+		background-color: #f7f8fa;
+		margin-top: 10rpx;
+		border-radius: var(--radius);
+		overflow: hidden;
+	}
+
+	&-reply {
+		display: flex;
+		font-size: 14px;
+		margin-top: 10rpx;
+		align-items: center;
+
+		&-item {
+			display: flex;
+			align-items: center;
+			margin-right: 30rpx;
+		}
+	}
+
+	&-input-box {
+		height: 110rpx;
+		position: fixed;
+		display: flex;
+		left: 0;
+		right: 0;
+		bottom: 0;
+		padding: 20rpx;
+		border-top: 1rpx solid #eaeaea;
+		background-color: #fff;
+		align-items: center;
+		z-index: 10;
+	}
+	&-input {
+		height: 100%;
+		margin-right: 20rpx;
+		flex: 1;
+		background-color: #fff;
+		border-radius: 8rpx;
+		padding-left: 20rpx;
+	}
+
+	&-button {
+		height: 100%;
+		padding: 10rpx 20rpx;
+		border-radius: 8rpx;
+		box-sizing: border-box;
+		display: flex;
+		align-items: center;
+		background-color: #08bf65;
+		color: #fff;
+	}
+}
+</style>

+ 63 - 0
components/fs-container/fs-container.vue

@@ -0,0 +1,63 @@
+<template>
+	<view class="fs-container">
+		<view class="fs-container-header"><slot name="header"></slot></view>
+		<view class="fs-container-main" :style="{ paddingTop: top + 'px', paddingBottom: bottom + 'px' }">
+			<slot></slot>
+		</view>
+		<view class="fs-container-footer"><slot name="footer"></slot></view>
+	</view>
+</template>
+
+<script>
+/**
+ * 容器组件
+ * @description 容器组件
+ */
+export default {
+	name: 'fs-container',
+	data() {
+		return {
+			top: 0,
+			bottom: 0
+		}
+	},
+	mounted() {
+		uni
+			.createSelectorQuery()
+			.in(this)
+			.select('.fs-container-header')
+			.boundingClientRect(data => {
+				this.top = data.height
+			})
+			.exec()
+		uni
+			.createSelectorQuery()
+			.in(this)
+			.select('.fs-container-footer')
+			.boundingClientRect(data => {
+				this.bottom = data.height
+			})
+			.exec()
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-container {
+	&-header {
+		position: fixed;
+		top: var(--window-top);
+		left: 0;
+		right: 0;
+		z-index: 100;
+	}
+
+	&-footer {
+		position: fixed;
+		left: 0;
+		right: 0;
+		bottom: var(--window-bottom);
+		z-index: 100;
+	}
+}
+</style>

+ 37 - 0
components/fs-date-format/fs-date-format.vue

@@ -0,0 +1,37 @@
+<template>
+	<view>{{ dateFormat }}</view>
+</template>
+
+<script>
+/**
+ * 日期格式化组件
+ * @description 日期格式化组件
+ * @property {String, Object} date 日期
+ * @property {String} format 格式化
+ */
+export default {
+	name: 'fs-date-format'
+}
+</script>
+
+<script setup>
+import { computed } from 'vue'
+import dayjs from 'dayjs'
+
+const props = defineProps({
+	date: [String, Object],
+	format: {
+		type: String,
+		default: 'YYYY-MM-DD'
+	}
+})
+const emits = defineEmits(['click'])
+
+const dateFormat = computed(() => dayjs(props.date).format(props.format))
+
+const handleClick = () => {
+	emits('click')
+}
+</script>
+
+<style></style>

+ 55 - 0
components/fs-divide-list/fs-divide-list.vue

@@ -0,0 +1,55 @@
+<template>
+	<view class="fs-divide-list" :class="{ 'gutter-v': gutter }">
+		<fs-grid :columnNum="list.length" :padding="false">
+			<view v-for="(item, index) in list" :key="index" class="fs-divide-list-item">
+				<fs-grid-item><slot :item="item"></slot></fs-grid-item>
+			</view>
+		</fs-grid>
+	</view>
+</template>
+
+<script>
+/**
+ * 分割列表组件
+ * @description 分割列表组件
+ * @property {Array} list 分割列表
+ * @property {Boolean} gutter 下边距
+ */
+export default {
+	name: 'fs-divide-list'
+}
+</script>
+
+<script setup>
+const props = defineProps({
+	list: {
+		type: Array,
+		default: () => []
+	},
+	gutter: Boolean
+})
+</script>
+
+<style lang="scss" scoped>
+.fs-divide-list {
+	background-color: #fff;
+	border-radius: var(--radius);
+
+	&-item {
+		position: relative;
+		padding: 20rpx;
+		& + & {
+			&::before {
+				position: absolute;
+				left: 0;
+				top: 50%;
+				transform: translateY(-50%);
+				width: 2rpx;
+				height: 43rpx;
+				background-color: #d9d9d9;
+				content: '';
+			}
+		}
+	}
+}
+</style>

+ 86 - 0
components/fs-divider/fs-divider.vue

@@ -0,0 +1,86 @@
+<template>
+	<view class="fs-divider">
+		<view class="fs-divider-line" :class="['bg-' + (lineColorType || colorType)]" :style="lineStyle"></view>
+		<view class="fs-divider-content" :class="textColorType || colorType" :style="{ color: textColor || color }">
+			<slot></slot>
+		</view>
+		<view class="fs-divider-line" :class="['bg-' + (lineColorType || colorType)]" :style="lineStyle"></view>
+	</view>
+</template>
+
+<script>
+/**
+ * 分割线组件
+ * @description 分割线组件
+ * @property {String} lineWidth 分割线宽度
+ * @property {String} lineHeight 分割线高度
+ * @property {String} lineColor 分割线颜色(优先级高于color)
+ * @property {String} lineColorType = [primary | danger | warning | info | success] 分割线颜色类型(优先级高于colorType)
+ * @property {String} textColor 文字颜色(优先级高于color)
+ * @property {String} textColorType = [primary | danger | warning | info | success] 文字颜色类型(优先级高于color)
+ * @property {String} color 分割线、文字颜色
+ * @property {String} colorType = [primary | danger | warning | info | success] 分割线、文字颜色类型
+ */
+export default {
+	name: 'fs-divider'
+}
+</script>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+	lineWidth: {
+		type: String,
+		default: '150rpx'
+	},
+	lineHeight: {
+		type: String,
+		default: '2rpx'
+	},
+	lineColor: String,
+	lineColorType: {
+		type: String,
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger', 'default'].includes(value)
+		}
+	},
+	textColor: String,
+	textColorType: {
+		type: String,
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger', 'default'].includes(value)
+		}
+	},
+	color: String,
+	colorType: {
+		type: String,
+		default: 'default',
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger', 'default'].includes(value)
+		}
+	}
+})
+
+const lineStyle = computed(() => {
+	return `
+		width:${props.lineWidth};
+		height:${props.lineHeight};
+		backgroundColor:${props.lineColor || props.color};
+		`
+})
+</script>
+
+<style lang="scss" scoped>
+.fs-divider {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	padding: 20rpx;
+
+	&-content {
+		padding: 0 20rpx;
+		white-space: nowrap;
+	}
+}
+</style>

+ 102 - 0
components/fs-dropdown-item/fs-dropdown-item.vue

@@ -0,0 +1,102 @@
+<template>
+	<view class="fs-dropdown-item">
+		<view class="fs-dropdown-item-title" @click="handleToggle">
+			<view class="fs-dropdown-item-text">{{ title }}</view>
+			<fs-icon class="fs-dropdown-item-icon" type="icon-sort-down" size="26rpx" :class="{ visible }"></fs-icon>
+		</view>
+
+		<view class="fs-dropdown-item-content" :class="{ visible }"><slot></slot></view>
+	</view>
+	<fs-mask v-model="visible" :z-index="99"></fs-mask>
+</template>
+
+<script>
+/**
+ * 下拉组件
+ * @description 下拉组件
+ * @property {String} title 标题
+ */
+export default {
+	name: 'fs-dropdown-item'
+}
+</script>
+
+<script setup>
+import { ref, inject } from 'vue'
+
+const props = defineProps({
+	title: String
+})
+
+const visible = ref(false)
+let flag = false
+
+const updateState = () => {
+	if (flag) {
+		flag = false
+		visible.value = !visible.value
+	} else {
+		visible.value = false
+	}
+}
+
+const dropdownGroup = inject('dropdownGroup')
+dropdownGroup.updateChildren({
+	updateState
+})
+
+const handleToggle = () => {
+	flag = true
+	dropdownGroup.toggle()
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-dropdown-item {
+	flex: 1;
+	width: 100%;
+	height: 80rpx;
+	background-color: #fff;
+
+	&-title {
+		display: flex;
+		justify-content: center;
+		align-items: center;
+		position: relative;
+		z-index: 100;
+		padding: 0 20rpx;
+		width: 100%;
+		height: 100%;
+		background-color: #fff;
+
+		.visible {
+			transform: rotate(180deg);
+			transform-origin: center center;
+		}
+	}
+	&-text {
+		margin-right: 10rpx;
+	}
+	&-icon {
+		transition: all 0.2s;
+	}
+
+	&-content {
+		position: absolute;
+		left: 0;
+		right: 0;
+		top: 80rpx;
+		transform: scaleY(0);
+		transform-origin: left top;
+		transition: all 0.1s;
+		z-index: 100;
+		background-color: #fff;
+		opacity: 0;
+
+		&.visible {
+			transform: scaleY(1);
+			opacity: 1;
+		}
+	}
+}
+</style>

+ 44 - 0
components/fs-dropdown/fs-dropdown.vue

@@ -0,0 +1,44 @@
+<template>
+	<view class="fs-dropdown">
+		<slot></slot>
+	</view>
+</template>
+
+<script setup>
+import { provide, reactive } from 'vue'
+
+const state = reactive({
+	children: []
+})
+
+const updateChildren = child => {
+	state.children.push(child)
+}
+const toggle = () => {
+	state.children.forEach(child => {
+		child.updateState()
+	})
+}
+const close = () => {
+	toggle()
+}
+
+provide('dropdownGroup', {
+	updateChildren,
+	toggle
+})
+
+defineExpose({
+	close
+})
+</script>
+
+<style lang="scss" scoped>
+.fs-dropdown{
+	display: flex;
+	align-items: center;
+	background-color: #fff;
+	position: relative;
+	z-index: 100;
+}
+</style>

BIN
components/fs-empty/empty.png


+ 54 - 0
components/fs-empty/fs-empty.vue

@@ -0,0 +1,54 @@
+<template>
+	<view class="fs-empty-box" :style="{ padding: padding }">
+		<image :src="src" mode="widthFix" :style="{ width: imageWidth }"></image>
+		<view class="content">{{ text }}</view>
+		<slot></slot>
+	</view>
+</template>
+
+<script>
+/**
+ * 无数据组件
+ * @description 无数据组件
+ * @property {String} src 图片地址
+ * @property {String} text 文字
+ * @property {String} padding 内边距
+ * @property {String} imageWidth 图片宽度
+ */
+export default {
+	name: 'fs-empty'
+}
+</script>
+
+<script setup>
+import empty from './empty.png'
+
+const props = defineProps({
+	src: {
+		type: String,
+		default: empty
+	},
+	text: {
+		type: String,
+		default: '暂无数据'
+	},
+	padding: {
+		type: String,
+		default: '200rpx 30rpx'
+	},
+	imageWidth: {
+		type: String,
+		default: '400rpx'
+	}
+})
+</script>
+
+<style lang="scss" scoped>
+.fs-empty-box {
+	text-align: center;
+
+	.content {
+		margin-top: 20rpx;
+	}
+}
+</style>

+ 138 - 0
components/fs-fab/fs-fab.vue

@@ -0,0 +1,138 @@
+<template>
+	<view class="fs-fab">
+		<view class="fs-fab-btn" :style="{ right, bottom }">
+			<view class="fs-fab-option" :class="{ 'fs-fab-scale': visible }">
+				<slot name="option">
+					<fs-avatar
+						class="fs-fab-option-gutter"
+						v-for="(item, index) in options"
+						:key="index"
+						:size="size"
+						:bgColor="item.bgColor"
+						:bgColorType="item.bgColorType"
+						@click="handleOption(item)"
+					>
+						{{ item.name }}
+					</fs-avatar>
+				</slot>
+			</view>
+
+			<fs-avatar :size="size" bgColorType="danger" @click="handleToggle" v-if="visible">
+				<fs-icon type="icon-close" :size="iconSize"></fs-icon>
+			</fs-avatar>
+			<fs-avatar :size="size" :bgColor="bgColor" @click="handleToggle" v-else>
+				<slot name="icon">
+					<fs-icon
+						type="icon-plus"
+						:size="iconSize"
+						class="fs-fab-plus"
+						:class="{ 'fs-fab-visible': visible }"
+					></fs-icon>
+				</slot>
+			</fs-avatar>
+		</view>
+
+		<fs-mask v-if="showMask" v-model="visible"></fs-mask>
+	</view>
+</template>
+
+<script>
+/**
+ * 悬浮按钮组件
+ * @description 悬浮按钮组件
+ * @property {String} right 按钮右边距
+ * @property {String} bottom 按钮下边距
+ * @property {Array} options 选项
+ * @property {String} size 按钮大小
+ * @property {String} iconSize 图标大小
+ * @property {String} bgColor 背景色
+ * @property {Boolean} showMask 是否显示遮罩
+ */
+export default {
+	name: 'fs-fab'
+}
+</script>
+
+<script setup>
+import { ref } from 'vue'
+
+const props = defineProps({
+	right: {
+		type: String,
+		default: '40rpx'
+	},
+	bottom: {
+		type: String,
+		default: '40rpx'
+	},
+	options: {
+		type: Array,
+		default: () => []
+	},
+	size: {
+		type: String,
+		default: '120rpx'
+	},
+	iconSize: {
+		type: String,
+		default: '50rpx'
+	},
+	bgColor: String,
+	showMask: Boolean
+})
+
+const emits = defineEmits(['clickOption'])
+
+const visible = ref(false)
+
+const handleToggle = () => {
+	visible.value = !visible.value
+}
+const handleOption = item => {
+	close()
+	emits('clickOption', item)
+}
+const close = () => {
+	visible.value = false
+}
+
+defineExpose({
+	close
+})
+</script>
+
+<style lang="scss" scoped>
+.fs-fab {
+	&-btn {
+		position: fixed;
+		margin-bottom: var(--window-bottom);
+		z-index: 900;
+	}
+
+	&-plus {
+		transition: all 0.2s;
+	}
+	&-visible {
+		transform: rotate(315deg);
+	}
+
+	&-option {
+		display: flex;
+		flex-direction: column;
+		margin-bottom: 30rpx;
+		transition: all 0.2s;
+		transform: translateY(-50%);
+		opacity: 0;
+		z-index: -1;
+
+		&-gutter {
+			margin-top: 20rpx;
+		}
+	}
+	&-scale {
+		transform: translateY(0);
+		opacity: 1;
+		z-index: 900;
+	}
+}
+</style>

+ 234 - 0
components/fs-field/fs-field.vue

@@ -0,0 +1,234 @@
+<template>
+	<view
+		class="fs-field"
+		:class="{
+			'fs-field-tighten': tighten
+		}"
+		:style="{ 'background-color': bgColor, borderRadius: round ? '80rpx' : '' }"
+	>
+		<view
+			v-if="type === 'textarea'"
+			class="fs-field-textarea"
+			:class="{ 'fs-field-padding': !formItemPosition }"
+			:style="{ height: height }"
+		>
+			<slot name="before"></slot>
+			<textarea
+				class="fs-textarea"
+				placeholder-class="fs-ph-class"
+				:class="{ clearable, 'fs-field-border': border }"
+				:name="name"
+				:placeholder="placeholder"
+				:maxlength="maxlength"
+				:disabled="disabled"
+				:value="modelValue"
+				:auto-height="autoHeight"
+				:fixed="fixed"
+				@input="handleInput"
+				@focus="handleFocus"
+				@blur="handleBlur"
+				@confirm="handleConfirm"
+			/>
+			<fs-icon
+				class="fs-field-icon fs-field-icon-close"
+				type="icon-guanbi2fill"
+				size="20px"
+				@touchstart="handleClear"
+				v-if="clearable"
+			></fs-icon>
+			<slot name="after"></slot>
+		</view>
+
+		<view class="fs-field-input" :class="{ 'fs-field-padding': !formItemPosition }" v-else>
+			<view v-if="slots.before" style="padding-right: 10rpx;"><slot name="before"></slot></view>
+			<input
+				class="fs-input"
+				placeholder-class="fs-ph-class"
+				:class="{ clearable, 'fs-field-border': border }"
+				:value="modelValue"
+				:type="type"
+				:password="type === 'password'"
+				:placeholder="placeholder"
+				:name="name"
+				:maxlength="maxlength"
+				:disabled="disabled"
+				@input="handleInput"
+				@focus="handleFocus"
+				@blur="handleBlur"
+				@confirm="handleConfirm"
+			/>
+			<fs-icon
+				class="fs-field-icon fs-field-icon-close"
+				type="icon-close-circle"
+				size="20px"
+				@touchstart="handleClear"
+				v-if="clearable && modelValue"
+			></fs-icon>
+			<view v-if="slots.after" style="padding-left: 10rpx;"><slot name="after"></slot></view>
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 输入框组件
+ * @description 输入框组件
+ * @property {String} placeholder 占位符
+ * @property {String} type 输入框类型
+ * @property {Number, String} maxlength 输入的最大字符数
+ * @property {Boolean} disabled 是否禁用
+ * @property {Boolean} border 是否显示边框
+ * @property {Boolean} clearable 是否启用清除图标,点击清除图标后会清空输入框
+ * @property {Boolean} autoHeight 仅对type=textarea有效
+ * @property {Boolean} tighten 是否紧凑
+ * @property {Boolean} fixed 如果 textarea 是在一个 position:fixed 的区域,需要显示指定属性 fixed 为 true
+ * @property {Boolean} round 是否圆角
+ * @property {String} height 输入框高度
+ * @property {String} bgColor 背景颜色
+ * @event {Function} focus 输入框聚焦事件
+ * @event {Function} blur 输入框失去焦点事件
+ * @event {Function} confirm 点击完成事件
+ */
+export default {
+	name: 'fs-field'
+}
+</script>
+
+<script setup>
+import { inject, computed, useSlots } from 'vue'
+
+const props = defineProps({
+	modelValue: String,
+	placeholder: String,
+	name: String,
+	type: {
+		type: String,
+		default: 'text'
+	},
+	maxlength: {
+		type: [Number, String],
+		default: 140
+	},
+	disabled: Boolean,
+	border: Boolean,
+	clearable: Boolean,
+	autoHeight: Boolean,
+	tighten: Boolean,
+	fixed: Boolean,
+	round: Boolean,
+	height: {
+		type: String,
+		default: '70rpx'
+	},
+	bgColor: {
+		type: String,
+		default: '#fff'
+	}
+})
+const emits = defineEmits(['update:modelValue', 'focus', 'blur', 'confirm'])
+const slots = useSlots()
+
+const formItemPosition = inject('form-item-position', '')
+const position = computed(() => props.labelPosition || formItemPosition)
+
+const handleInput = e => {
+	emits('update:modelValue', e.detail.value)
+}
+const handleFocus = () => {
+	emits('focus')
+}
+const handleBlur = e => {
+	emits('blur', e.detail.value)
+}
+const handleConfirm = () => {
+	emits('confirm')
+}
+const handleClear = () => {
+	emits('update:modelValue', '')
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-field {
+	box-sizing: border-box;
+	font-size: var(--content-size);
+	overflow: hidden;
+
+	&-input {
+		display: flex;
+		height: 70rpx;
+		align-items: center;
+		position: relative;
+		background-color: inherit;
+	}
+	&-textarea {
+		display: flex;
+		width: 100%;
+		align-items: center;
+		position: relative;
+	}
+	&-padding {
+		padding: 20rpx 30rpx;
+		height: 90rpx;
+	}
+
+	&-border {
+		border: 2rpx solid var(--border-color) !important;
+		border-radius: 4rpx !important;
+		&.fs-input,
+		&.fs-textarea {
+			padding: 20rpx;
+		}
+	}
+
+	&-icon {
+		position: absolute;
+		top: 50%;
+		transform: translateY(-50%);
+		color: var(--sub);
+		z-index: 10;
+	}
+	&-icon-close {
+		right: var(--gutter);
+	}
+
+	&-opacity {
+		background-color: rgba(255, 255, 255, 0.5);
+		color: #fff;
+
+		.fs-ph-class {
+			color: #fff;
+		}
+	}
+
+	&-tighten {
+		padding: 0 var(--tighten-gutter);
+	}
+	&-round {
+		border-radius: 35px;
+	}
+}
+
+.fs-textarea,
+.fs-input {
+	width: 100%;
+	height: 100%;
+	flex: 1;
+	box-sizing: border-box !important;
+	border-radius: 6rpx;
+	border: none;
+	outline: none;
+}
+.fs-input {
+	background: transparent;
+
+	&.fs-clearable {
+		padding-right: 70rpx;
+	}
+}
+
+.fs-ph-class {
+	color: #c0c5ce;
+	font-size: var(--content-size);
+}
+</style>

+ 115 - 0
components/fs-form-item/fs-form-item.vue

@@ -0,0 +1,115 @@
+<template>
+	<view class="fs-form-item" :class="['fs-form-item-' + position, { 'fs-form-item-border': border }]">
+		<view class="fs-form-item-label-before" v-if="slots.before"><slot name="before"></slot></view>
+		<view
+			class="fs-form-item-label"
+			:class="['text-' + align, { 'fs-form-item-required': required }]"
+			:style="{ width: width }"
+			v-if="label"
+		>
+			{{ label }}
+		</view>
+		<view class="fs-form-item-right"><slot></slot></view>
+	</view>
+</template>
+
+<script>
+/**
+ * 表单项组件
+ * @description 表单项组件
+ * @property {String} label label
+ * @property {String} labelWidth label宽度
+ * @property {String} labelPosition = [left | top] label位置
+ * @property {String} labelAlign = [left | center | right | justify] label对齐方式
+ * @property {Boolean} required  是否必填,值为true时会有红色星号
+ * @property {Boolean} border 是否显示边框
+ */
+export default {
+	name: 'fs-form-item'
+}
+</script>
+
+<script setup>
+import { inject, provide, computed, useSlots } from 'vue'
+
+const props = defineProps({
+	label: String,
+	labelWidth: String,
+	labelPosition: {
+		type: String,
+		validator(value) {
+			return ['left', 'top'].includes(value)
+		}
+	},
+	labelAlign: {
+		type: String,
+		validator(value) {
+			return ['left', 'center', 'right', 'justify'].includes(value)
+		}
+	},
+	required: Boolean,
+	border: {
+		type: Boolean,
+		default: true
+	}
+})
+const slots = useSlots()
+
+const form = inject('form', {})
+const position = props.labelPosition || form.labelPosition || 'left'
+const width = props.labelWidth || form.labelWidth || '120rpx'
+const align = props.labelAlign || form.labelAlign || 'left'
+
+provide('form-item-position', position)
+</script>
+
+<style lang="scss" scoped>
+.fs-form-item {
+	display: flex;
+	background-color: #fff;
+	padding: 20rpx var(--gutter);
+	min-height: 110rpx;
+
+	&-border {
+		border-bottom: 2rpx solid var(--border-color);
+	}
+
+	&-left {
+		align-items: center;
+		// .label{
+		// 	text-align-last: justify;
+		// }
+	}
+	&-top {
+		flex-direction: column;
+		.form-item-label {
+			margin-bottom: 10rpx;
+			width: auto !important;
+		}
+	}
+
+	&-label {
+		font-size: var(--content-size);
+		margin-right: 20rpx;
+
+		&-before {
+			margin-right: 20rpx;
+		}
+	}
+	&-required {
+		position: relative;
+		padding-left: 14rpx;
+
+		&::before {
+			position: absolute;
+			content: '*';
+			color: red;
+			left: 0;
+		}
+	}
+
+	&-right {
+		flex: 1;
+	}
+}
+</style>

+ 60 - 0
components/fs-form/fs-form.vue

@@ -0,0 +1,60 @@
+<template>
+	<view><slot></slot></view>
+</template>
+
+<script>
+/**
+ * 表单组件
+ * @description 表单组件
+ * @property {String} labelWidth label宽度
+ * @property {String} labelPosition = [left | top] label位置
+ * @property {String} labelAlign = [left | center | right | justify] label对齐方式
+ * @property {String} errorType = [toast | message] 错误提示类型
+ * @property {Object} model 数据对象,表单校验时用
+ */
+export default {
+	name: 'fs-form'
+}
+</script>
+
+<script setup>
+import { provide } from 'vue'
+
+const props = defineProps({
+	labelWidth: {
+		type: String,
+		default: '120rpx'
+	},
+	labelPosition: {
+		type: String,
+		default: 'left',
+		validator(value) {
+			return ['left', 'top'].includes(value)
+		}
+	},
+	labelAlign: {
+		type: String,
+		default: 'left',
+		validator(value) {
+			return ['left', 'center', 'right', 'justify'].includes(value)
+		}
+	},
+	errorType: {
+		type: String,
+		default: 'toast',
+		validator(value) {
+			return ['toast', 'message'].includes(value)
+		}
+	},
+	model: Object
+})
+
+provide('form', props)
+
+defineExpose({
+	model: props.model,
+	errorType: props.errorType
+})
+</script>
+
+<style lang="scss"></style>

+ 78 - 0
components/fs-grid-item/fs-grid-item.vue

@@ -0,0 +1,78 @@
+<template>
+	<view
+		class="fs-grid-item"
+		:class="{ 'fs-grid-item-border': border, 'fs-grid-item-radius': radius }"
+		:style="{ padding: padding ? '20rpx 0' : 0, backgroundColor: bgColor }"
+		@click="handleClick"
+	>
+		<slot></slot>
+	</view>
+</template>
+
+<script>
+/**
+ * 宫格项组件
+ * @description 宫格项组件
+ * @property {String} link 跳转地址
+ * @property {String} linkType 跳转类型
+ */
+export default {
+	name: 'fs-grid-item'
+}
+</script>
+
+<script setup>
+import { inject } from 'vue'
+
+const props = defineProps({
+	link: String,
+	linkType: {
+		type: String,
+		default: 'navigateTo'
+	}
+})
+
+const emits = defineEmits(['click'])
+
+const gird = inject('fsGrid', {})
+
+const { border, padding, bgColor, radius } = gird
+
+const handleClick = () => {
+	if (props.link) {
+		uni[props.linkType]({
+			url: props.link
+		})
+	}
+	emits('click')
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-grid-item {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	flex-direction: column;
+	text-align: center;
+	position: relative;
+	height: 100%;
+	background-color: #fff;
+
+	&-border::before {
+		position: absolute;
+		content: '';
+		top: 0;
+		right: 0;
+		bottom: 0;
+		left: 0;
+		border: 1px solid var(--border-color);
+		margin-left: -1px;
+		margin-top: -1px;
+	}
+
+	&-radius {
+		border-radius: var(--radius);
+	}
+}
+</style>

+ 64 - 0
components/fs-grid/fs-grid.vue

@@ -0,0 +1,64 @@
+<template>
+	<view class="fs-grid" :class="{ 'fs-grid-border': border }" :style="styleStr"><slot></slot></view>
+</template>
+
+<script>
+/**
+ * 宫格组件
+ * @description 宫格组件
+ * @property {Number, String} gutter 间距
+ * @property {Number} columnNum 列数量
+ * @property {Boolean} padding 网格项内边距
+ * @property {String} bgColor 背景色
+ * @property {Boolean} border 是否显示边框
+ * @property {Boolean} radius 是否圆角
+ */
+export default {
+	name: 'fs-grid'
+}
+</script>
+
+<script setup>
+import { computed, provide } from 'vue'
+
+const props = defineProps({
+	gutter: {
+		type: [Number, String],
+		default: 0
+	},
+	border: Boolean,
+	columnNum: {
+		type: Number,
+		default: 3
+	},
+	padding: {
+		type: Boolean,
+		default: true
+	},
+	bgColor: {
+		type: String,
+		default: '#fff'
+	},
+	radius: Boolean
+})
+
+const styleStr = computed(() => {
+	return `grid-template-columns: repeat(${props.columnNum},1fr);gap:${props.gutter};`
+})
+
+provide('fsGrid', props)
+provide('gridBorder', props.border)
+provide('gridPadding', props.padding)
+provide('gridBgColor', props.bgColor)
+</script>
+
+<style lang="scss" scoped>
+.fs-grid {
+	display: grid;
+
+	&-border {
+		border: 1px solid var(--border-color);
+		border-bottom: none;
+	}
+}
+</style>

+ 38 - 0
components/fs-gutter/fs-gutter.vue

@@ -0,0 +1,38 @@
+<template>
+	<view class="fs-gutter" :style="styleStr"></view>
+</template>
+
+<script>
+/**
+ * 垂直间隔组件
+ * @description 垂直间隔组件
+ * @property {String} height 间距高度
+ * @property {String} bgColor 背景色
+ */
+export default {
+	name: 'fs-gutter'
+}
+</script>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+	height: {
+		type: String,
+		default: '20rpx'
+	},
+	bgColor: String
+})
+
+const styleStr = computed(() => {
+	return `height: ${props.height};background-color:${props.bgColor}`
+})
+</script>
+
+<style lang="scss" scoped>
+.fs-gutter {
+	width: 100%;
+	background-color: var(--bg-color);
+}
+</style>

+ 88 - 0
components/fs-icon/fs-icon.vue

@@ -0,0 +1,88 @@
+<template>
+	<view
+		:class="[type, colorType, source === 'inner' ? 'fsfont' : 'iconfont']"
+		:style="style"
+		@click="handleClick"
+	></view>
+</template>
+
+<script>
+/**
+ * 图标组件
+ * @description 图标组件
+ * @property {String} type 图标类型
+ * @property {String} size 图标大小
+ * @property {String} source = [inner | outer] 来源
+ * @property {String} rotate 旋转
+ * @property {String} color 图标颜色
+ * @property {String} colorType = [primary | danger | warning | info | success | gray] 图标颜色类型
+ * @property {String} link 跳转地址
+ * @property {String} linkType 跳转类型
+ */
+export default {
+	name: 'fs-icon'
+}
+</script>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+	type: String,
+	size: {
+		type: String,
+		default: '36rpx'
+	},
+	color: String,
+	colorType: {
+		type: String,
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger', 'gray'].includes(value)
+		}
+	},
+	source: {
+		type: String,
+		default: 'inner'
+	},
+	link: String,
+	linkType: {
+		type: String,
+		default: 'navigateTo'
+	},
+	rotate: String
+})
+
+const emits = defineEmits(['click'])
+
+const style = computed(() => {
+	let style = `font-size: ${props.size};`
+
+	if (props.color) {
+		style += `color: ${props.color};`
+	}
+	if (props.rotate) {
+		style += `transform: rotate(${props.rotate}deg);`
+	}
+	return style
+})
+
+const handleClick = () => {
+	if (props.link) {
+		uni[props.linkType]({
+			url: props.link
+		})
+	}
+	emits('click')
+}
+</script>
+
+<style lang="scss" scoped>
+@import '../../common/iconfont.css';
+@import './icon.css';
+
+.fsfont,
+.iconfont {
+	display: inline-block;
+	vertical-align: middle;
+}
+</style>

+ 238 - 0
components/fs-icon/icon.css

@@ -0,0 +1,238 @@
+@font-face {
+  font-family: "fsfont"; /* Project id 2762084 */
+  src: url('//at.alicdn.com/t/font_2762084_bf9che3kfth.woff2?t=1651119718610') format('woff2'),
+       url('//at.alicdn.com/t/font_2762084_bf9che3kfth.woff?t=1651119718610') format('woff'),
+       url('//at.alicdn.com/t/font_2762084_bf9che3kfth.ttf?t=1651119718610') format('truetype');
+}
+
+.fsfont {
+  font-family: "fsfont" !important;
+  font-size: 16px;
+  font-style: normal;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+.icon-about:before {
+  content: "\e62e";
+}
+
+.icon-copy:before {
+  content: "\e706";
+}
+
+.icon-notice:before {
+  content: "\e7eb";
+}
+
+.icon-close-circle:before {
+  content: "\e6d7";
+}
+
+.icon-setting:before {
+  content: "\e78e";
+}
+
+.icon-auth:before {
+  content: "\e636";
+}
+
+.icon-minus:before {
+  content: "\e7fd";
+}
+
+.icon-minus-circle-fill:before {
+  content: "\e844";
+}
+
+.icon-minus-circle:before {
+  content: "\e677";
+}
+
+.icon-star-fill:before {
+  content: "\e660";
+}
+
+.icon-like:before {
+  content: "\e7c8";
+}
+
+.icon-comment:before {
+  content: "\e668";
+}
+
+.icon-car:before {
+  content: "\e7d7";
+}
+
+.icon-backspace:before {
+  content: "\e7a9";
+}
+
+.icon-sound:before {
+  content: "\e60d";
+}
+
+.icon-password:before {
+  content: "\e82b";
+}
+
+.icon-user-add:before {
+  content: "\e912";
+}
+
+.icon-user-minus:before {
+  content: "\e914";
+}
+
+.icon-user:before {
+  content: "\e916";
+}
+
+.icon-tongxunlu:before {
+  content: "\e634";
+}
+
+.icon-org:before {
+  content: "\e62a";
+}
+
+.icon-sort-down:before {
+  content: "\e843";
+}
+
+.icon-arrow-up:before {
+  content: "\e60c";
+}
+
+.icon-close:before {
+  content: "\e637";
+}
+
+.icon-square:before {
+  content: "\e6d5";
+}
+
+.icon-squarecheck:before {
+  content: "\e6d6";
+}
+
+.icon-loading:before {
+  content: "\e601";
+}
+
+.icon-plus:before {
+  content: "\e6b3";
+}
+
+.icon-mobile:before {
+  content: "\e611";
+}
+
+.icon-tag:before {
+  content: "\ecf0";
+}
+
+.icon-off:before {
+  content: "\e621";
+}
+
+.icon-right:before {
+  content: "\e64f";
+}
+
+.icon-location:before {
+  content: "\e620";
+}
+
+.icon-plus-circle-fill:before {
+  content: "\e7ba";
+}
+
+.icon-more:before {
+  content: "\e608";
+}
+
+.icon-search:before {
+  content: "\e661";
+}
+
+.icon-del:before {
+  content: "\e6ac";
+}
+
+.icon-d-down:before {
+  content: "\e641";
+}
+
+.icon-plus-circle:before {
+  content: "\e60f";
+}
+
+.icon-feedback:before {
+  content: "\e6f4";
+}
+
+.icon-image:before {
+  content: "\e604";
+}
+
+.icon-photo:before {
+  content: "\e710";
+}
+
+.icon-uncheck:before {
+  content: "\e61f";
+}
+
+.icon-qrcode:before {
+  content: "\e739";
+}
+
+.icon-edit:before {
+  content: "\e609";
+}
+
+.icon-star:before {
+  content: "\e615";
+}
+
+.icon-close-circle-fill:before {
+  content: "\e670";
+}
+
+.icon-version:before {
+  content: "\e60b";
+}
+
+.icon-checked:before {
+  content: "\e61e";
+}
+
+.icon-notice-fill:before {
+  content: "\e659";
+}
+
+.icon-call:before {
+  content: "\e613";
+}
+
+.icon-success:before {
+  content: "\e6bd";
+}
+
+.icon-time:before {
+  content: "\e622";
+}
+
+.icon-sex-male:before {
+  content: "\e892";
+}
+
+.icon-sex-female:before {
+  content: "\e893";
+}
+
+.icon-arrow-right:before {
+  content: "\e612";
+}

+ 220 - 0
components/fs-index-list/fs-index-list.vue

@@ -0,0 +1,220 @@
+<template>
+	<view>
+		<view class="fs-sidebar" @touchmove="handleMove" @touchend="handleEnd" v-if="list.length">
+			<view
+				class="fs-sidebar-item"
+				:class="{ primary: state.activeId === item }"
+				v-for="(item, index) in letters"
+				:key="index"
+				@click="handleClick(item)"
+			>
+				{{ item }}
+			</view>
+		</view>
+
+		<view class="fs-contact" :style="{ 'margin-top': showSearch ? '110rpx' : '' }">
+			<scroll-view scroll-y :scroll-into-view="state.intoView" @scroll="scroll" class="fs-contact-list">
+				<view v-for="item in list" :key="item.name" :id="item.name" class="fs-contact-list-item">
+					<view class="fs-panel-title" :class="{ 'fs-sticky': sticky }">{{ item[titleKey] }}</view>
+					<fs-cell
+						v-for="subitem in item[childrenKey]"
+						:key="subitem.name"
+						border
+						justify="left"
+						align="center"
+						:link="link + '?id=' + subitem.id"
+						@click="handleRoute(subitem)"
+					>
+						<template #title>
+							<fs-avatar :src="subitem[avatarKey]">{{ subitem.alais }}</fs-avatar>
+						</template>
+						<template #value>
+							<view class="fs-contact-hd">{{ subitem[hdKey] }}</view>
+							<view class="fs-contact-bd">{{ subitem[bdKey] }}</view>
+						</template>
+					</fs-cell>
+				</view>
+			</scroll-view>
+		</view>
+		<view class="fs-layer" v-if="state.showLayer">{{ state.activeId }}</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 索引列表组件
+ * @description 索引列表组件
+ * @property {Array} list 列表
+ * @property {String} childrenKey children Key
+ * @property {String} titleKey title Key
+ * @property {String} avatarKey avatar Key
+ * @property {String} hdKey hd Key
+ * @property {String} bdKey bd Key
+ * @property {String} link 跳转地址
+ * @property {Boolean} sticky 是否固定
+ * @property {Boolean} showSearch 是否显示搜索
+ */
+export default {
+	name: 'fs-index-list'
+}
+</script>
+
+<script setup>
+import { reactive } from 'vue'
+
+const letters = []
+for (var i = 0; i < 26; i++) {
+	letters.push(String.fromCharCode(65 + i))
+}
+
+const props = defineProps({
+	list: Array,
+	childrenKey: {
+		type: String,
+		default: 'list'
+	},
+	titleKey: {
+		type: String,
+		default: 'name'
+	},
+	avatarKey: {
+		type: String,
+		default: 'src'
+	},
+	hdKey: {
+		type: String,
+		default: 'name'
+	},
+	bdKey: {
+		type: String,
+		default: 'phone'
+	},
+	link: String,
+	sticky: {
+		type: Boolean,
+		default: true
+	},
+	showSearch: Boolean
+})
+
+const windowHeight = uni.getSystemInfoSync().windowHeight
+const offsetHeight = 50
+const navHeight = windowHeight - offsetHeight * 2
+const letterPos = []
+const letterHeight = navHeight / letters.length
+
+letters.forEach((item, index) => {
+	letterPos.push(offsetHeight + letterHeight * index)
+})
+
+const state = reactive({
+	intoView: '',
+	activeId: '',
+	showLayer: false
+})
+const handleClick = id => {
+	state.intoView = id
+	state.activeId = id
+}
+const handleMove = e => {
+	const y = e.touches[0].clientY
+
+	for (let i = 0, len = letterPos.length; i < len; i++) {
+		if (y >= letterPos[i] && y <= letterPos[i] + letterHeight) {
+			state.intoView = letters[i]
+			state.activeId = letters[i]
+			state.showLayer = true
+			break
+		}
+	}
+}
+const handleEnd = e => {
+	setTimeout(() => {
+		state.showLayer = false
+	}, 200)
+}
+const scroll = e => {
+	uni
+		.createSelectorQuery()
+		.selectAll('.list-item')
+		.boundingClientRect(rects => {
+			for (let i = 0; i < rects.length; i++) {
+				let rect = rects[i]
+				if (rect.top === 0 || rect.bottom > 0) {
+					state.activeId = rect.id
+					break
+				}
+			}
+		})
+		.exec()
+}
+
+const handleRoute = item => {
+	getApp().globalData.addrbookDetail = item
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-sidebar {
+	display: flex;
+	flex-direction: column;
+	justify-content: space-between;
+	position: fixed;
+	top: 50%;
+	right: 0;
+	align-items: center;
+	z-index: 900;
+	transform: translateY(-50%);
+
+	&-item {
+		padding: 6rpx 20rpx;
+		flex-shrink: 1;
+		font-size: 24rpx;
+	}
+}
+
+.fs-sticky {
+	position: sticky;
+	top: 0;
+	z-index: 100;
+}
+
+.fs-contact {
+	position: fixed;
+	top: var(--window-top);
+	left: var(--gutter);
+	right: 60rpx;
+	bottom: 0;
+
+	&-list {
+		height: 100%;
+	}
+
+	&-hd {
+		font-size: 16px;
+	}
+
+	.fs-panel-title {
+		padding: var(--gutter);
+		color: var(--title);
+		text-align: left;
+		background-color: var(--bg-color);
+	}
+}
+
+.fs-layer {
+	width: 150rpx;
+	height: 150rpx;
+	background: rgba(0, 0, 0, 0.5);
+	border-radius: 50%;
+	line-height: 150rpx;
+	color: #ffffff;
+	text-align: center;
+	position: fixed;
+	top: 50%;
+	left: 50%;
+	transform: translate(-50%, -50%);
+	font-weight: bolder;
+	font-size: 28px;
+}
+</style>

+ 158 - 0
components/fs-keyboard/car.vue

@@ -0,0 +1,158 @@
+<template>
+	<view class="fs-car" :class="modelValue ? 'show' : ''">
+		<view class="fs-car-header">
+			<view class="fs-car-header-btn" @click="handleClose">
+				关闭
+			</view>
+		</view>
+		<view class="fs-car-inner">
+			<view class="swiper-item">
+				<view class="swiper-item-inner" v-for="(item, index) in keyboardList" :key="index">
+					<view v-if="item !== '#' && item !== 'del'" @click="handleAction(item)" class="swiper-item-word">{{item}}</view>
+					<view v-if="item == 'del'" class="swiper-item-del" @click="handleAction(item)">
+						<fs-icon type="icon-backspace" size="50rpx"></fs-icon>
+					</view>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script setup>
+import { ref, computed, watch } from 'vue'
+
+const props = defineProps({
+	modelValue: Boolean,
+	index: {
+		type: Number,
+		default: 0
+	}
+})
+
+const emits = defineEmits(['update:modelValue', 'update:index', 'change', 'close'])
+
+const keyboard1 = ['晋','京','津','冀','蒙','辽','吉','黑','沪','苏','浙','皖','闽','赣','鲁','豫','鄂','湘','粤','桂','琼','渝','川','贵','云','藏','陕','甘','青','宁','新','台','港','澳','#','#','#','#','#','#','del'
+]
+const	keyboard2 = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S','T', 'U', 'V', 'W', 'X', 'Y', 'Z','#','#','#','#','#','#','#','#','#','#','#','#','#','#','#','del'
+]
+const keyboard3 = ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z',1,2,3,4,5,6,7,8,9,0,'使','领','学','#','#','del'
+]
+
+let keyboardList = computed(() => {
+	if (props.index === 0) {
+		return keyboard1
+	} else if (props.index === 1) {
+		return keyboard2
+	} else {
+		return keyboard3
+	}
+})
+
+const handleAction = item => {
+	emits('change', item)
+}
+
+const handleClose = () => {
+	emits('close')
+}
+</script>
+
+<style lang="scss" scoped>
+$height: 750rpx;
+
+.fs-car {
+	position: fixed;
+	bottom: 0;
+	left: 0;
+	right: 0;
+	z-index: 999;
+	background-color: #d7d8dc;
+	height: $height;
+	transform: translateY(100%);
+	transition: all .3s;
+
+	&-header {
+		height: 80rpx;
+		display: flex;
+		justify-content: flex-end;
+		align-items: center;
+		padding-top: 25rpx;
+		padding-right: 25rpx;
+		box-sizing: border-box;
+
+		&-btn {
+			background: #fff;
+			height: 65rpx;
+			line-height: 65rpx;
+			padding: 0 15rpx;
+			border-radius: 8rpx;
+		}
+	}
+
+	&-inner {
+		height: calc($height - 80rpx);
+		width: 100%;
+
+		.swiper-item {
+			height: calc($height - 80rpx);
+			width: 100%;
+			display: flex;
+			justify-content: space-between;
+			flex-wrap: wrap;
+			padding: 25rpx;
+			box-sizing: border-box;
+
+			.swiper-item-inner {
+				width: calc(100% / 6);
+				display: flex;
+				justify-content: center;
+				align-items: center;
+				height: 75rpx;
+				margin-bottom: calc(((100% / 6) - 40rpx) / 5);
+
+				.swiper-item-word {
+					width: 100rpx;
+					height: 75rpx;
+					border-radius: 8rpx;
+					background-color: #f0f1f5;
+					font-size: 33rpx;
+					line-height: 75rpx;
+					text-align: center;
+					color: #222;
+					font-weight: bold;
+					transition: all 0.2s;
+					box-shadow: 1px 0 25px #dfdfdf, 0 1px 0 #cfcfcf, 0 1px 0 #bfbfbf, 0 1px 1px #6f6f6f;
+
+					&:active {
+						opacity: 0.8;
+						background-color: #ffffff;
+						transform: translateY(-5rpx);
+					}
+				}
+
+				.swiper-item-del {
+					width: 100rpx;
+					height: 75rpx;
+					border-radius: 8rpx;
+					background-color: #f0f1f5;
+					transition: all 0.2s;
+					box-shadow: 1px 0 25px #dfdfdf, 0 1px 0 #cfcfcf, 0 1px 0 #bfbfbf, 0 1px 1px #6f6f6f;
+					display: flex;
+					justify-content: center;
+					align-items: center;
+
+					&:active {
+						opacity: 0.8;
+						background-color: #ffffff;
+						transform: translateY(-5rpx);
+					}
+				}
+			}
+		}
+	}
+}
+
+.show {
+	transform: translateY(0);
+}
+</style>

+ 67 - 0
components/fs-keyboard/fs-keyboard.vue

@@ -0,0 +1,67 @@
+<template>
+	<view>
+		<keyboardCar
+			v-if="mode === 'car'"
+			:index="index"
+			v-model="visible"
+			@change="handleChange"
+			@close="handleClose"
+		></keyboardCar>
+
+		<keyboardNum
+			v-if="mode === 'number'"
+			:index="index"
+			v-model="visible"
+			@change="handleChange"
+			@close="handleClose"
+		/>
+	</view>
+</template>
+
+<script>
+/**
+ * 键盘组件
+ * @description 键盘组件
+ * @property {String} mode 键盘模式
+ * @property {Number} index 激活项index
+ * @event {Function} change 键盘项变化事件
+ * @event {Function} close 键盘关闭事件
+ */
+export default {
+	name: 'fs-keyboard'
+}
+</script>
+
+<script setup>
+import keyboardCar from './car.vue'
+import keyboardNum from './number.vue'
+
+const props = defineProps({
+	modelValue: Boolean,
+	mode: {
+		type: String,
+		default: 'car'
+	},
+	index: {
+		type: Number,
+		default: 0
+	}
+})
+const emits = defineEmits(['update:modelValue', 'change', 'close'])
+
+const visible = computed({
+	get: () => props.modelValue,
+	set: value => emits('update:modelValue', value)
+})
+
+const handleChange = item => {
+	emits('change', item)
+}
+
+const handleClose = () => {
+	emits('update:modelValue', false)
+	emits('close')
+}
+</script>
+
+<style lang="scss"></style>

+ 46 - 0
components/fs-keyboard/number.vue

@@ -0,0 +1,46 @@
+<template>
+	<view class="fs-keyboard-number" v-if="modelValue">
+		<fs-grid border>
+			<fs-grid-item v-for="item in 9" :key="item" class="fs-keyboard-number-item" @click="handleClick(item)">
+				{{item}}
+			</fs-grid-item>
+			<fs-grid-item class="fs-keyboard-number-item fs-keyboard-number-bg"></fs-grid-item>
+			<fs-grid-item class="fs-keyboard-number-item" @click="handleClick(0)">0</fs-grid-item>
+			<fs-grid-item class="fs-keyboard-number-item fs-keyboard-number-bg" @click="handleClick('del')">
+				<fs-icon type="icon-backspace" size="60rpx"></fs-icon>
+			</fs-grid-item>
+		</fs-grid>
+	</view>
+</template>
+
+<script setup>
+const props = defineProps({
+	modelValue: Boolean,
+	index: {
+		type: Number,
+		default: 0
+	}
+})
+const emits = defineEmits(['update:modelValue', 'update:index', 'change', 'close'])
+
+const handleClick = item => {
+	emits('change', item)
+}
+</script>
+
+<style lang="scss">
+.fs-keyboard-number{
+	position: fixed;
+	bottom: 0;
+	left: 0;
+	right: 0;
+	z-index: 999;
+	
+	&-item{
+		font-size: 24px;
+	}
+	&-bg{
+		background-color: #e7e6eb;
+	}
+}
+</style>

+ 109 - 0
components/fs-license-plate/fs-license-plate.vue

@@ -0,0 +1,109 @@
+<template>
+	<view>
+		<view class="title text-center gutter-v" style="padding: 20rpx;">请输入车牌号</view>
+		<view class="fs-plate">
+			<view
+				class="fs-plate-item"
+				:class="{ 'fs-plate-item-active': curIndex === index, 'fs-plate-new': index === 6 && !item }"
+				v-for="(item, index) in carNo"
+				:key="index"
+				@click="handleInput(index)"
+			>
+				{{ item || '' }}
+			</view>
+		</view>
+		<fs-keyboard mode="car" v-model="visible" :index="curIndex" @change="handleChange"></fs-keyboard>
+	</view>
+</template>
+
+<script>
+/**
+ * 车牌组件
+ * @description 车牌组件
+ * @event {Function} change chagne事件
+ */
+export default {
+	name: 'fs-license-plate'
+}
+</script>
+
+<script setup>
+import { ref } from 'vue'
+
+const props = defineProps({
+	modelValue: String
+})
+const emits = defineEmits(['update:modelValue', 'change'])
+
+const carNo = ref(new Array(7))
+if (props.modelValue) {
+	for (let i = 0; i < props.modelValue.length; i++) {
+		carNo.value[i] = props.modelValue[i]
+	}
+}
+let curIndex = ref(-1)
+const handleChange = item => {
+	const length = carNo.value.filter(item => item).length
+
+	if (item === 'del') {
+		if (length >= 1) {
+			curIndex.value--
+			carNo.value[curIndex.value] = ''
+		}
+	} else {
+		if (length <= 6) {
+			carNo.value[curIndex.value] = item
+			curIndex.value++
+		}
+	}
+
+	emits('update:modelValue', carNo.value.join(''))
+	emits('change', carNo.value.join(''))
+}
+
+let visible = ref(false)
+const handleInput = index => {
+	curIndex.value = index
+	visible.value = true
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-plate {
+	display: flex;
+	justify-content: center;
+	align-items: center;
+
+	&-item {
+		width: 80rpx;
+		height: 90rpx;
+		line-height: 90rpx;
+		border-radius: 8rpx;
+		border: 1px solid #cbcbcb;
+		text-align: center;
+
+		& + & {
+			margin-left: 20rpx;
+		}
+
+		&-active {
+			border-color: var(--primary);
+		}
+	}
+	&-new {
+		position: relative;
+		&::after {
+			content: '新能源';
+			color: #999999;
+			width: 100%;
+			font-size: 10px;
+			text-align: center;
+			position: absolute;
+			left: 0;
+			right: 0;
+			top: 50%;
+			transform: translateY(-50%);
+		}
+	}
+}
+</style>

+ 69 - 0
components/fs-loading/fs-loading.vue

@@ -0,0 +1,69 @@
+<template>
+	<view class="fs-loading" :style="{ backgroundColor: bgColor }">
+		<view>
+			<view class="loader"></view>
+			<slot></slot>
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 加载组件
+ * @description 加载组件
+ * @property {String} bgColor 背景色
+ */
+export default {
+	name: 'fs-loading'
+}
+</script>
+
+<script setup>
+const props = defineProps({
+	bgColor: {
+		type: String,
+		default: 'rgba(0,0,0,.5)'
+	}
+})
+</script>
+
+<style lang="scss" scoped>
+.fs-loading {
+	position: absolute;
+	top: 0;
+	left: 0;
+	text-align: center;
+	width: 100%;
+	height: 100%;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	color: #fff;
+	z-index: 20;
+}
+.loader {
+	margin: 0 auto 20rpx;
+	font-size: 12rpx;
+	position: relative;
+	border-top: 1em solid rgba(255, 255, 255, 0.2);
+	border-right: 1em solid rgba(255, 255, 255, 0.2);
+	border-bottom: 1em solid rgba(255, 255, 255, 0.2);
+	border-left: 1em solid #fff;
+	animation: load8 1s infinite linear;
+}
+.loader,
+.loader:after {
+	border-radius: 50%;
+	width: 10em;
+	height: 10em;
+}
+
+@keyframes load8 {
+	0% {
+		transform: rotate(0deg);
+	}
+	100% {
+		transform: rotate(360deg);
+	}
+}
+</style>

+ 98 - 0
components/fs-loadmore/fs-loadmore.vue

@@ -0,0 +1,98 @@
+<template>
+	<view>
+		<slot></slot>
+		<fs-empty v-if="!state.loading && !state.dataList.length"></fs-empty>
+		<template v-else>
+			<fs-divider v-if="!state.hasMore">{{ nomore }}</fs-divider>
+		</template>
+	</view>
+</template>
+
+<script>
+/**
+ * 加载更多组件
+ * @description 加载更多组件
+ * @property {Function} fetchList 获取数据的方法
+ * @property {Number} pageSize 分页大小
+ * @property {Boolean} pullDownRefresh 是否开启下拉刷新
+ * @property {Boolean} autoCall 是否自动调用查询方法
+ * @property {String} nomore 没有更多文字
+ */
+export default {
+	name: 'fs-loadmore'
+}
+</script>
+
+<script setup>
+import { reactive, toRef } from 'vue'
+
+const props = defineProps({
+	modelValue: {
+		type: Array,
+		default() {
+			return []
+		}
+	},
+	fetchList: Function,
+	pageSize: {
+		type: Number,
+		default: 20
+	},
+	pullDownRefresh: Boolean,
+	autoCall: {
+		type: Boolean,
+		default: true
+	},
+	nomore: {
+		type: String,
+		default: '没有更多了~'
+	}
+})
+const emits = defineEmits(['update:modelValue'])
+
+const state = reactive({
+	loading: true,
+	dataList: props.modelValue,
+	pageNo: 1,
+	pageSize: props.pageSize,
+	hasMore: true
+})
+
+const query = loadmore => {
+	state.loading = true
+	if (loadmore) {
+		state.pageNo++
+	} else {
+		state.pageNo = 1
+	}
+	return props
+		.fetchList({
+			pageNo: state.pageNo,
+			pageSize: props.pageSize
+		})
+		.then(res => {
+			state.hasMore = res.length >= state.pageSize
+			state.dataList = loadmore ? [...state.dataList, ...res] : res
+			emits('update:modelValue', state.dataList)
+		})
+		.finally(() => {
+			state.loading = false
+		})
+}
+props.autoCall && query()
+
+const refresh = () => {
+	state.dataList = []
+	emits('update:modelValue', state.dataList)
+	return query()
+}
+
+defineExpose({
+	query,
+	refresh,
+	hasMore: toRef(state, 'hasMore'),
+	pullDownRefresh: props.pullDownRefresh
+})
+</script>
+
+<style></style>

+ 66 - 0
components/fs-mask/fs-mask.vue

@@ -0,0 +1,66 @@
+<template>
+	<view
+		class="fs-mask"
+		:class="{ 'fs-mask-blur': blurable }"
+		:style="{ zIndex: zIndex }"
+		v-if="modelValue"
+		@click="handleMask"
+	></view>
+</template>
+
+<script>
+/**
+ * 遮罩组件
+ * @description 遮罩组件
+ * @property {Boolean} blurable 毛玻璃效果
+ * @property {Number} zIndex 层级
+ * @property {Boolean} maskClickable 点击遮罩是否可关闭
+ * @property {Boolean} bgColor 背景色
+ * @event {Function} close 关闭事件
+ */
+export default {
+	name: 'fs-mask'
+}
+</script>
+
+<script setup>
+const props = defineProps({
+	modelValue: Boolean,
+	blurable: Boolean,
+	zIndex: {
+		type: Number,
+		default: 899
+	},
+	maskClickable: {
+		type: Boolean,
+		default: true
+	},
+	bgColor: {
+		type: String,
+		default: 'rgba(0, 0, 0, 0.5)'
+	}
+})
+const emits = defineEmits(['update:modelValue', 'close'])
+
+const handleMask = () => {
+	if (props.maskClickable) {
+		emits('update:modelValue', false)
+		emits('close')
+	}
+}
+</script>
+
+<style lang="scss">
+.fs-mask {
+	position: fixed;
+	top: var(--window-top);
+	right: 0;
+	bottom: var(--window-bottom);
+	left: 0;
+	background-color: v-bind(bgColor);
+
+	&-blur {
+		backdrop-filter: blur(12rpx);
+	}
+}
+</style>

+ 116 - 0
components/fs-message/fs-message.vue

@@ -0,0 +1,116 @@
+<template>
+	<view class="fs-message" :class="[{ show: state.options.show }, 'bg-' + state.options.type]">
+		{{ state.options.message }}
+	</view>
+</template>
+
+<script>
+/**
+ * 消息通知组件
+ * @description 消息通知组件
+ */
+export default {
+	name: 'fs-message'
+}
+</script>
+
+<script setup>
+import { reactive } from 'vue'
+
+const defaultOptions = {
+	type: 'primary',
+	duration: 3000
+}
+
+const state = reactive({
+	options: {},
+	timer: null
+})
+const formatOptions = options => {
+	if (typeof options === 'string') {
+		return {
+			message: options
+		}
+	}
+	if (options.type === 'error') {
+		options.type = 'danger'
+	}
+	return options
+}
+const show = options => {
+	state.options = {
+		...defaultOptions,
+		...formatOptions(options),
+		show: true
+	}
+
+	if (state.timer) {
+		clearTimeout(state.timer)
+	}
+
+	if (state.options.duration > 0) {
+		state.timer = setTimeout(() => {
+			handleHide()
+			state.timer = null
+		}, state.options.duration)
+	}
+}
+const success = options => {
+	show({
+		...formatOptions(options),
+		type: 'success'
+	})
+}
+const error = options => {
+	show({
+		...formatOptions(options),
+		type: 'danger'
+	})
+}
+const warning = options => {
+	show({
+		...formatOptions(options),
+		type: 'warning'
+	})
+}
+const info = options => {
+	show({
+		...formatOptions(options),
+		type: 'info'
+	})
+}
+
+const handleHide = () => {
+	state.options = {
+		...state.options,
+		show: false
+	}
+}
+
+defineExpose({
+	show,
+	success,
+	error,
+	warning,
+	info,
+	handleHide
+})
+</script>
+
+<style lang="scss" scoped>
+.fs-message {
+	position: fixed;
+	top: var(--window-top);
+	left: 0;
+	right: 0;
+	padding: 20rpx;
+	color: #fff;
+	transition: all 0.1s;
+	transform: translateY(-100%);
+	text-align: center;
+	z-index: 900;
+}
+.show {
+	transform: translateY(0);
+}
+</style>

+ 226 - 0
components/fs-modal/fs-modal.vue

@@ -0,0 +1,226 @@
+<template>
+	<view class="fs-modal">
+		<view class="fs-modal-box" :class="{ show: modelValue }" :style="{ width }">
+			<view class="fs-modal-title title" v-if="showTitle">{{ title }}</view>
+			<view class="fs-modal-content">
+				<slot>{{ content }}</slot>
+			</view>
+			<view class="fs-modal-ft">
+				<view class="fs-modal-ft-btn fs-modal-ft-cancel" v-if="showCancel" @click="handleCancel">{{ cancelText }}</view>
+				<view
+					v-if="showConfirm"
+					class="fs-modal-ft-btn fs-modal-ft-confirm"
+					:class="[confirmTextColorType]"
+					:style="{ color: confirmTextColor }"
+					@click="handleConfirm"
+				>
+					<view class="fs-loader" v-if="loading"></view>
+					<template v-else>
+						{{ confirmText }}
+					</template>
+				</view>
+			</view>
+			<view class="fs-modal-close" v-if="showClose" @click="handleClose">
+				<fs-icon type="icon-close-circle" color="#fff" size="50rpx"></fs-icon>
+			</view>
+		</view>
+		<fs-mask
+			:modelValue="modelValue"
+			@close="handleClose"
+			:maskClickable="maskClickable"
+			:blurable="blurable"
+		></fs-mask>
+	</view>
+</template>
+
+<script>
+/**
+ * 模态框组件
+ * @description 模态框组件
+ * @property {String} width 模态框宽度
+ * @property {String} title 标题
+ * @property {Boolean} showTitle 是否显示标题
+ * @property {Boolean} maskClickable 点击遮罩是否可关闭
+ * @property {String} content 内容
+ * @property {Boolean} blurable 毛玻璃效果
+ * @property {Boolean} showClose 是否显示关闭
+ * @property {Function} beforeClose 异步关闭
+ * @property {String} cancelText 取消按钮文字
+ * @property {Boolean} showCancel 是否显示取消按钮
+ * @property {String} confirmText 确定按钮文字
+ * @property {Boolean} showConfirm 是否显示确定按钮
+ * @property {String} confirmTextColor 确定按钮文字颜色
+ * @property {Boolean} confirmTextColorType = [primary | danger | warning | info | success] 确定按钮文字颜色类型
+ * @event {Function} confirm 确定事件
+ * @event {Function} cancel 取消事件
+ */
+export default {
+	name: 'fs-model'
+}
+</script>
+
+<script setup>
+import { ref } from 'vue'
+
+const props = defineProps({
+	modelValue: Boolean,
+	width: {
+		type: String,
+		default: '80vw'
+	},
+	maskClickable: {
+		type: Boolean,
+		default: true
+	},
+	blurable: Boolean,
+	title: {
+		type: String,
+		default: '提示'
+	},
+	showTitle: {
+		type: Boolean,
+		default: true
+	},
+	content: String,
+	showCancel: {
+		type: Boolean,
+		default: true
+	},
+	cancelText: {
+		type: String,
+		default: '取消'
+	},
+	showConfirm: {
+		type: Boolean,
+		default: true
+	},
+	confirmText: {
+		type: String,
+		default: '确定'
+	},
+	confirmTextColor: {
+		type: String
+	},
+	confirmTextColorType: {
+		type: String,
+		default: 'primary',
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger'].includes(value)
+		}
+	},
+	showClose: {
+		type: Boolean,
+		default: false
+	},
+	beforeClose: Function
+})
+
+const emits = defineEmits(['update:modelValue', 'confirm', 'cancel'])
+const loading = ref(false)
+
+const handleClose = () => {
+	emits('update:modelValue', false)
+}
+const handleMask = () => {
+	if (props.maskClickable) {
+		handleClose()
+	}
+}
+const handleCancel = () => {
+	handleClose()
+	emits('cancel')
+}
+const handleConfirm = () => {
+	if (props.beforeClose) {
+		loading.value = true
+		props.beforeClose('confirm', (flag = true) => {
+			loading.value = false
+			flag && handleClose()
+		})
+	} else {
+		handleClose()
+		emits('confirm', false)
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-modal {
+	&-box {
+		position: fixed;
+		background-color: #fff;
+		z-index: 900;
+		top: 50%;
+		left: 50%;
+		transform: translate(-50%, -50%) scale(0);
+		border-radius: var(--radius);
+
+		&.show {
+			transform: translate(-50%, -50%) scale(1);
+		}
+	}
+
+	&-title {
+		padding: 20rpx;
+		text-align: center;
+	}
+	&-content {
+		padding: 20rpx 20rpx 40rpx;
+		text-align: center;
+	}
+	&-ft {
+		display: flex;
+
+		&-btn {
+			flex: 1;
+			height: 80rpx;
+			line-height: 80rpx;
+			text-align: center;
+			border-top: 2rpx solid var(--border-color);
+			cursor: pointer;
+
+			& + & {
+				border-left: 2rpx solid var(--border-color);
+			}
+		}
+	}
+
+	&-close {
+		position: absolute;
+		top: -110rpx;
+		left: 50%;
+		transform: translateX(-50%);
+
+		&::before {
+			position: absolute;
+			bottom: 0;
+			left: 50%;
+			transform: translate(-50%, 100%);
+			height: 60rpx;
+			width: 2rpx;
+			background-color: #fff;
+			content: '';
+		}
+	}
+}
+.fs-loader {
+	display: inline-block;
+	width: 40rpx;
+	height: 40rpx;
+	color: inherit;
+	vertical-align: middle;
+	border: 4rpx solid currentcolor;
+	border-bottom-color: transparent;
+	border-radius: 50%;
+	animation: 1s loader linear infinite;
+	position: relative;
+}
+@keyframes loader {
+	0% {
+		transform: rotate(0deg);
+	}
+	100% {
+		transform: rotate(360deg);
+	}
+}
+</style>

+ 138 - 0
components/fs-notice-bar/fs-notice-bar.vue

@@ -0,0 +1,138 @@
+<template>
+	<view class="fs-notice-bar" :animation="animationData" :style="{ backgroundColor: bgColor, color }" v-if="visible">
+		<fs-icon v-if="showIcon" class="fs-notice-bar-notice" type="icon-sound" :color="color"></fs-icon>
+		<swiper class="fs-notice-bar-swiper" autoplay circular :vertical="vertical" :interval="interval" :duration="1000">
+			<swiper-item v-for="(item, index) in list" :key="index">
+				<view class="fs-notice-bar-item line1" @click="handleRoute(item)">{{ item[titleKey] }}</view>
+			</swiper-item>
+		</swiper>
+		<fs-icon
+			v-if="showClose"
+			class="fs-notice-bar-close"
+			:color="color"
+			type="icon-close-circle"
+			@click="handleClose"
+		></fs-icon>
+	</view>
+</template>
+
+<script>
+/**
+ * 通告栏组件
+ * @description 通告栏组件
+ * @property {Array} list 通告栏列表
+ * @property {String} titleKey 展示标题的key
+ * @property {String} urlKey 跳转路径的key
+ * @property {String} bgColor 背景色
+ * @property {String} color 文字颜色
+ * @property {Number} vertical 是否垂直滚动
+ * @property {String} interval 滚动间隔
+ * @property {Boolean} showClose 是否显示关闭按钮
+ * @property {Boolean} showIcon 是否显示通知icon
+ * @property {String} linkType  跳转类型
+ */
+export default {
+	name: 'fs-notice-bar'
+}
+</script>
+
+<script setup>
+import { ref } from 'vue'
+
+const props = defineProps({
+	list: {
+		type: Array,
+		default() {
+			return []
+		}
+	},
+	titleKey: {
+		type: String,
+		default: 'title'
+	},
+	urlKey: {
+		type: String,
+		default: 'url'
+	},
+	bgColor: {
+		type: String,
+		default: '#fffbe8'
+	},
+	color: {
+		type: String,
+		default: '#de8c17'
+	},
+	vertical: Boolean,
+	interval: {
+		type: Number,
+		default: 5000
+	},
+	showClose: {
+		type: Boolean,
+		default: true
+	},
+	showIcon: {
+		type: Boolean,
+		default: true
+	},
+	linkType: {
+		type: String,
+		default: 'navigateTo'
+	}
+})
+const emits = defineEmits(['click'])
+
+let visible = ref(true)
+let animationData = ref(null)
+const animation = uni.createAnimation({
+	duration: 200
+})
+
+const handleClose = () => {
+	animation
+		.scale(0.7, 0.7)
+		.opacity(0)
+		.step()
+	animationData.value = animation.export()
+
+	setTimeout(() => {
+		visible.value = false
+	}, 200)
+}
+
+const handleRoute = item => {
+	if (item[urlKey]) {
+		uni[props.linkType]({
+			url: item[urlKey]
+		})
+	}
+	emits('click')
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-notice-bar {
+	margin: 0 var(--gutter);
+	position: relative;
+	display: flex;
+	align-items: center;
+
+	&-notice {
+		margin-left: var(--gutter);
+	}
+
+	&-swiper {
+		height: 80rpx;
+		flex: 1;
+	}
+	&-item {
+		height: 80rpx;
+		line-height: 80rpx;
+		padding: 0 var(--gutter);
+	}
+
+	&-close {
+		margin-right: var(--gutter);
+	}
+}
+</style>

+ 129 - 0
components/fs-number-box/fs-number-box.vue

@@ -0,0 +1,129 @@
+<template>
+	<view class="fs-number-box" :class="{ 'fs-number-box-round': round }">
+		<view class="fs-number-box-item fs-number-box-left" @click="minus">
+			<fs-icon type="icon-minus" size="32rpx" :color="minusDisabled ? '#c8c9cc' : ''"></fs-icon>
+		</view>
+		<input
+			class="fs-number-box-item fs-number-box-middle"
+			type="number"
+			:value="modelValue"
+			@blur="handleChange"
+			:disabled="disableInput"
+		/>
+		<view class="fs-number-box-item fs-number-box-right" @click="add">
+			<fs-icon type="icon-plus" size="32rpx" :color="addDisabled ? '#c8c9cc' : ''"></fs-icon>
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 步进器组件
+ * @description 步进器组件
+ * @property {Number} min 最小值
+ * @property {Number} max 最大值
+ * @property {Number} step 步幅
+ * @property {Boolean} round 是否圆角
+ * @property {Boolean} disableInput 禁止输入框输入
+ */
+export default {
+	name: 'fs-number-box'
+}
+</script>
+
+<script setup>
+import { ref, watch, computed } from 'vue'
+
+const props = defineProps({
+	modelValue: {
+		type: Number,
+		default: 1
+	},
+	min: {
+		type: Number,
+		default: 1
+	},
+	max: {
+		type: Number,
+		default: Number.MAX_SAFE_INTEGER
+	},
+	step: {
+		type: Number,
+		default: 1
+	},
+	round: Boolean,
+	disableInput: Boolean
+})
+
+const emits = defineEmits(['update:modelValue', 'change'])
+
+let initValue = ref(props.modelValue)
+watch(
+	() => props.modelValue,
+	val => {
+		initValue.value = val
+	}
+)
+watch(initValue, val => {
+	emits('update:modelValue', val)
+	emits('change', val)
+})
+const add = () => {
+	initValue.value += Number(props.step)
+	if (initValue.value > props.max) {
+		initValue.value = props.max
+	}
+}
+const minus = () => {
+	initValue.value -= Number(props.step)
+	if (initValue.value < props.min) {
+		initValue.value = props.min
+	}
+}
+const handleChange = e => {
+	initValue.value = Number(e.detail.value) || props.min
+
+	if (initValue.value < props.min) {
+		initValue.value = props.min
+	} else if (initValue.value > props.max) {
+		initValue.value = props.max
+	}
+}
+
+const minusDisabled = computed(() => initValue.value === props.min)
+const addDisabled = computed(() => initValue.value === props.max)
+</script>
+
+<style lang="scss" scoped>
+.fs-number-box {
+	display: inline-flex;
+	border: 2rpx solid var(--border-color);
+	height: 60rpx;
+	background-color: #fff;
+
+	&-round {
+		border-radius: 30rpx;
+	}
+
+	&-item {
+		display: flex;
+		height: 100%;
+		justify-content: center;
+		align-items: center;
+	}
+
+	&-left,
+	&-right {
+		width: 60rpx;
+	}
+
+	&-middle {
+		box-sizing: border-box;
+		width: 80rpx;
+		border-left: 2rpx solid var(--border-color);
+		border-right: 2rpx solid var(--border-color);
+		padding: 10rpx;
+		text-align: center;
+	}
+}
+</style>

+ 47 - 0
components/fs-panel/fs-panel.vue

@@ -0,0 +1,47 @@
+<template>
+	<view class="fs-panel">
+		<view class="fs-panel-title">
+			<view v-if="title">{{ title }}</view>
+			<view v-else><slot name="title"></slot></view>
+		</view>
+		<view class="fs-panel-content" :class="{ 'fs-panel-padding': padding }" :style="{ 'background-color': bgColor }">
+			<slot name="content"></slot>
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 面板组件
+ * @description 面板组件
+ * @property {String} title 标题
+ * @property {Boolean} padding 内容区域是否带padding
+ * @property {String} bgColor 内容区域背景色
+ */
+export default {
+	name: 'fs-panel'
+}
+</script>
+
+<script setup>
+const props = defineProps({
+	title: String,
+	padding: Boolean,
+	bgColor: {
+		type: String,
+		default: '#fff'
+	}
+})
+</script>
+
+<style lang="scss">
+.fs-panel-title {
+	padding: 20rpx var(--gutter);
+	color: var(--title);
+	text-align: left;
+	background-color: var(--bg-color);
+}
+.fs-panel-padding {
+	padding: var(--gutter);
+}
+</style>

+ 341 - 0
components/fs-popover/fs-popover.vue

@@ -0,0 +1,341 @@
+<template>
+	<view class="fs-popover" :style="{ 'z-index': zIndex }">
+		<view class="fs-popover-refer" @click="handleClickRefer"><slot name="refer"></slot></view>
+		<view
+			class="fs-popover-action"
+			v-if="actionVisible"
+			:class="['fs-popover-' + placement]"
+			:style="{ 'z-index': zIndex }"
+			@click="handleClose"
+		>
+			<view class="fs-popover-arrow"></view>
+			<slot>
+				<view
+					class="fs-popover-action-item line1"
+					:class="{ 'fs-popover-active': item[valueKey] && item[valueKey] === modelValue[valueKey] }"
+					v-for="(item, index) in actions"
+					:key="index"
+					@click="handleClickAction(item)"
+				>
+					{{ item.text }}
+				</view>
+			</slot>
+		</view>
+
+		<fs-mask v-model="actionVisible" :z-index="1" bgColor="rgba(0,0,0,0)"></fs-mask>
+	</view>
+</template>
+
+<script>
+/**
+ * 气泡弹出层组件
+ * @description 气泡弹出层组件
+ * @property {String} valueKey 作为 value 唯一标识的键名
+ * @property {Array} actions 选项列表
+ * @property {String} placement = [top | top-start | top-end | bottom | bottom-start | bottom-end,left | left-start | left-end,right | right-start | right-end] 弹出位置
+ * @property {String} bgColor 背景色
+ * @property {String} textColor 文字颜色
+ * @property {String} activeColor 文字选中颜色
+ * @property {String} borderColor 边框颜色
+ * @property {String} width 气泡弹出层宽度
+ * @property {String} actionWidth 选项列表宽度
+ */
+export default {
+	name: 'fs-popover'
+}
+</script>
+
+<script setup>
+import { ref, onMounted, watch } from 'vue'
+import utils from '@/utils/utils'
+
+const props = defineProps({
+	modelValue: [Object],
+	valueKey: {
+		type: String,
+		default: 'id'
+	},
+	placement: {
+		type: String,
+		default: 'bottom',
+		validator(value) {
+			return [
+				'top',
+				'top-start',
+				'top-end',
+				'bottom',
+				'bottom-start',
+				'bottom-end',
+				'left',
+				'left-start',
+				'left-end',
+				'right',
+				'right-start',
+				'right-end'
+			].includes(value)
+		}
+	},
+	actions: Array,
+	bgColor: {
+		type: String,
+		default: '#fff'
+	},
+	textColor: {
+		type: String,
+		default: '#666'
+	},
+	activeColor: {
+		type: String,
+		default: '#165DFF'
+	},
+	borderColor: {
+		type: String,
+		default: '#E8EAF2'
+	},
+	width: {
+		type: String,
+		default: 'auto'
+	},
+	actionWidth: {
+		type: String,
+		default: 'auto'
+	}
+})
+const emits = defineEmits(['clickAction', 'visibleChange', 'update:modelValue'])
+const uuid = utils.uuid()
+
+const actionVisible = ref(false)
+const handleClickRefer = () => {
+	uni.$emit('popoverClose', { uuid: uuid })
+	actionVisible.value = !actionVisible.value
+}
+watch(actionVisible, () => {
+	emits('visibleChange')
+})
+
+const handleClickAction = item => {
+	emits('update:modelValue', item)
+	emits('clickAction', item)
+}
+const handleClose = () => {
+	actionVisible.value = false
+}
+
+const zIndex = ref(10)
+uni.$on('popoverClose', res => {
+	if (uuid !== res.uuid) {
+		zIndex.value = 10
+		handleClose()
+	} else {
+		zIndex.value = 20
+	}
+})
+
+defineExpose({
+	handleClose
+})
+</script>
+
+<style lang="scss" scoped>
+$margin: 20rpx;
+$arrowOffset: 30rpx;
+$arrowSize: 24rpx;
+
+.fs-popover {
+	position: relative;
+	display: inline-block;
+	width: v-bind(width);
+
+	&-action {
+		position: absolute;
+		min-width: 260rpx;
+		max-width: 100%;
+		width: v-bind(actionWidth);
+		background-color: v-bind(bgColor);
+		transition: opacity 0.15s, transform 0.15s;
+		border-radius: 12rpx;
+		padding: 0 30rpx;
+		color: v-bind(textColor);
+		border: 2rpx solid v-bind(borderColor);
+
+		&-item {
+			padding: 20rpx;
+			& + & {
+				border-top: 2rpx solid v-bind(borderColor);
+			}
+		}
+	}
+
+	&-active {
+		color: v-bind(activeColor);
+	}
+
+	&-arrow {
+		position: absolute;
+		width: $arrowSize;
+		height: $arrowSize;
+		transform: rotate(45deg);
+		z-index: 1000;
+		background-color: v-bind(bgColor);
+		border: 2rpx solid transparent;
+		margin-top: -$arrowSize / 2;
+		margin-left: -$arrowSize / 2;
+	}
+
+	&-top {
+		left: 50%;
+		bottom: 100%;
+		transform: translateX(-50%);
+		margin-bottom: $margin;
+		.fs-popover-arrow {
+			border-bottom-color: v-bind(borderColor);
+			border-right-color: v-bind(borderColor);
+			bottom: -$arrowSize / 2;
+			left: 50%;
+		}
+	}
+	&-top-start {
+		left: 0;
+		bottom: 100%;
+		margin-bottom: $margin;
+		.fs-popover-arrow {
+			border-bottom-color: v-bind(borderColor);
+			border-right-color: v-bind(borderColor);
+			bottom: -$arrowSize / 2;
+			left: $arrowOffset;
+			margin-left: 0;
+		}
+	}
+	&-top-end {
+		right: 0;
+		bottom: 100%;
+		margin-bottom: $margin;
+		.fs-popover-arrow {
+			border-bottom-color: v-bind(borderColor);
+			border-right-color: v-bind(borderColor);
+			bottom: -$arrowSize / 2;
+			right: $arrowOffset;
+			margin-left: 0;
+		}
+	}
+
+	&-bottom {
+		left: 50%;
+		top: 100%;
+		transform: translateX(-50%);
+		margin-top: $margin;
+
+		.fs-popover-arrow {
+			border-top-color: v-bind(borderColor);
+			border-left-color: v-bind(borderColor);
+			top: 0;
+			left: 50%;
+		}
+	}
+	&-bottom-start {
+		left: 0;
+		top: 100%;
+		margin-top: $margin;
+
+		.fs-popover-arrow {
+			border-top-color: v-bind(borderColor);
+			border-left-color: v-bind(borderColor);
+			top: 0;
+			left: $arrowOffset;
+			margin-left: 0;
+		}
+	}
+	&-bottom-end {
+		right: 0;
+		top: 100%;
+		margin-top: $margin;
+
+		.fs-popover-arrow {
+			border-top-color: v-bind(borderColor);
+			border-left-color: v-bind(borderColor);
+			top: 0;
+			right: $arrowOffset;
+			margin-left: 0;
+		}
+	}
+
+	&-left {
+		left: -$margin;
+		top: 50%;
+		transform: translate(-100%, -50%);
+		.fs-popover-arrow {
+			border-bottom-color: v-bind(borderColor);
+			border-right-color: v-bind(borderColor);
+			top: 50%;
+			right: -$arrowSize / 2;
+			transform: rotate(-45deg);
+		}
+	}
+	&-left-start {
+		left: -$margin;
+		top: 0;
+		transform: translateX(-100%);
+
+		.fs-popover-arrow {
+			border-bottom-color: v-bind(borderColor);
+			border-right-color: v-bind(borderColor);
+			top: $arrowOffset;
+			right: -$arrowSize / 2;
+			transform: rotate(-45deg);
+			margin-top: 0;
+		}
+	}
+	&-left-end {
+		left: -$margin;
+		bottom: 0;
+		transform: translateX(-100%);
+
+		.fs-popover-arrow {
+			border-bottom-color: v-bind(borderColor);
+			border-right-color: v-bind(borderColor);
+			bottom: $arrowOffset;
+			right: -$arrowSize / 2;
+			transform: rotate(-45deg);
+			margin-top: 0;
+		}
+	}
+
+	&-right {
+		left: 100%;
+		top: 50%;
+		transform: translateY(-50%);
+		margin-left: $margin;
+
+		.fs-popover-arrow {
+			border-bottom-color: v-bind(borderColor);
+			border-left-color: v-bind(borderColor);
+			top: 50%;
+			left: 0;
+		}
+	}
+	&-right-start {
+		left: 100%;
+		top: 0;
+		margin-left: $margin;
+		.fs-popover-arrow {
+			border-bottom-color: v-bind(borderColor);
+			border-left-color: v-bind(borderColor);
+			top: $arrowOffset;
+			left: 0;
+			margin-top: 0;
+		}
+	}
+	&-right-end {
+		left: 100%;
+		bottom: 0;
+		margin-left: $margin;
+		.fs-popover-arrow {
+			border-bottom-color: v-bind(borderColor);
+			border-left-color: v-bind(borderColor);
+			left: 0;
+			bottom: $arrowOffset;
+			margin-top: 0;
+		}
+	}
+}
+</style>

+ 118 - 0
components/fs-popup/fs-popup.vue

@@ -0,0 +1,118 @@
+<template>
+	<view class="fs-popup">
+		<view class="fs-popup-drawer" :class="[direction, { show: modelValue }]" :style="[style, customStyle]">
+			<slot></slot>
+		</view>
+		<fs-mask v-if="showMask" :modelValue="modelValue" @close="handleClose" :maskClickable="maskClickable"></fs-mask>
+	</view>
+</template>
+
+<script>
+/**
+ * 弹出层组件
+ * @description 弹出层组件
+ * @property {String} direction = [left | right | top | bottom] 弹出位置
+ * @property {Boolean} showMask 是否显示遮罩
+ * @property {Boolean} maskClickable 遮罩是否可点击
+ * @property {String} width 弹出层宽度(仅direction为left\right有效)
+ * @property {String} height 弹出层高度(仅direction为top\bottom有效)
+ * @property {Object} customStyle 自定义样式
+ */
+export default {
+	name: 'fs-popup'
+}
+</script>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+	modelValue: Boolean,
+	direction: {
+		type: String,
+		default: 'left',
+		validator(value) {
+			return ['left', 'right', 'top', 'bottom'].includes(value)
+		}
+	},
+	width: {
+		type: String,
+		default: '80%'
+	},
+	height: {
+		type: String,
+		default: '30%'
+	},
+	showMask: {
+		type: Boolean,
+		default: true
+	},
+	maskClickable: {
+		type: Boolean,
+		default: true
+	},
+	customStyle: {
+		type: Object,
+		default() {
+			return {}
+		}
+	}
+})
+
+const style = computed(() => {
+	let ret = ''
+	if (props.direction === 'left' || props.direction === 'right') {
+		ret = `width: ${props.width}`
+	} else {
+		ret = `height: ${props.height}`
+	}
+	return ret
+})
+
+const emits = defineEmits(['update:modelValue'])
+
+const handleClose = () => {
+	emits('update:modelValue', false)
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-popup {
+	&-drawer {
+		position: fixed;
+		background-color: #fff;
+		z-index: 900;
+		transition: all 0.3s;
+		overflow: auto;
+	}
+
+	.left {
+		top: var(--window-top);
+		bottom: var(--window-bottom);
+		left: 0;
+		transform: translateX(-100%);
+	}
+	.right {
+		top: var(--window-top);
+		bottom: var(--window-bottom);
+		right: 0;
+		transform: translateX(100%);
+	}
+	.top {
+		top: var(--window-top);
+		right: 0;
+		left: 0;
+		transform: translateY(-200%);
+	}
+	.bottom {
+		left: 0;
+		bottom: var(--window-bottom);
+		right: 0;
+		transform: translateY(100%);
+	}
+
+	.show {
+		transform: translateX(0);
+	}
+}
+</style>

+ 108 - 0
components/fs-radio-button/fs-radio-button.vue

@@ -0,0 +1,108 @@
+<template>
+	<view
+		class="fs-radio-button"
+		:class="[
+			selected ? checkedColorType : 'fs-radio-button-default',
+			{ 'fs-radio-button-radius': radius, 'fs-radio-button-round': round },
+			buttonSize
+		]"
+		:style="{ color: checkedColor }"
+		@click="handleToggle"
+	>
+		{{ label }}
+		<slot />
+	</view>
+</template>
+
+<script>
+/**
+ * 单选框组件
+ * @description 单选框组件
+ * @property {String} label 文本
+ * @property {null} value 标识符(必须传)
+ * @property {String} size = [mini | small | medium] 按钮大小
+ * @property {String} checkedColor 选中颜色
+ * @property {String} checkedColorType = [primary | danger | warning | info | success] 选中颜色类型
+ */
+export default {
+	name: 'fs-radio-button'
+}
+</script>
+
+<script setup>
+import { inject, reactive, watch, ref } from 'vue'
+
+const props = defineProps({
+	label: String,
+	value: {
+		type: null,
+		required: true
+	},
+	checkedColor: String,
+	checkedColorType: String,
+	size: {
+		type: String,
+		validator(value) {
+			return ['mini', 'small', 'medium'].includes(value)
+		}
+	},
+	checked: Boolean
+})
+
+const radioGroup = inject('radioGroup')
+const { radius, round } = radioGroup
+const checkedColorType = props.checkedColorType || radioGroup.checkedColorType
+const checkedColor = props.checkedColor || radioGroup.checkedColor
+const buttonSize = props.size || radioGroup.size
+
+let selected = ref(props.checked)
+watch(
+	() => props.checked,
+	val => {
+		selected.value = val
+	}
+)
+
+radioGroup.updateChildren({
+	selected,
+	value: props.value
+})
+
+const handleToggle = () => {
+	radioGroup.updateValue(props.value)
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-radio-button {
+	padding: 10rpx 30rpx;
+	white-space: nowrap;
+	border: 2rpx solid currentColor;
+	margin-right: 20rpx;
+	margin-bottom: 20rpx;
+
+	&-default {
+		color: #999999;
+	}
+
+	&-radius {
+		border-radius: var(--radius);
+	}
+	&-round {
+		border-radius: 60rpx;
+	}
+
+	&.medium {
+		padding: 8rpx 25rpx;
+		font-size: 13px;
+	}
+	&.small {
+		padding: 6rpx 20rpx;
+		font-size: 12px;
+	}
+	&.mini {
+		padding: 2rpx 15rpx;
+		font-size: 11px;
+	}
+}
+</style>

+ 64 - 0
components/fs-radio-cell/fs-radio-cell.vue

@@ -0,0 +1,64 @@
+<template>
+	<fs-cell border justify="right" @click="handleToggle">
+		<template #title>
+			<view :class="selected ? checkedColorType : ''" :style="{ color: selected ? checkedColor : '' }">
+				<slot>{{ label }}</slot>
+			</view>
+		</template>
+		<template #value>
+			<fs-icon type="icon-right" :colorType="checkedColorType" :color="checkedColor" v-if="selected"></fs-icon>
+		</template>
+	</fs-cell>
+</template>
+
+<script>
+/**
+ * 单选框组件
+ * @description 单选框组件
+ * @property {String} label 文本
+ * @property {null} value 标识符(必须传)
+ * @property {String} checkedColor 选中颜色
+ * @property {String} checkedColorType = [primary | danger | warning | info | success] 选中颜色类型
+ */
+export default {
+	name: 'fs-radio-cell'
+}
+</script>
+
+<script setup>
+import { ref, watch, inject } from 'vue'
+
+const props = defineProps({
+	label: String,
+	value: {
+		type: null,
+		required: true
+	},
+	checkedColor: String,
+	checkedColorType: String,
+	checked: Boolean
+})
+
+let selected = ref(props.checked)
+watch(
+	() => props.checked,
+	val => {
+		selected.value = val
+	}
+)
+
+const radioGroup = inject('radioGroup')
+const checkedColorType = props.checkedColorType || radioGroup.checkedColorType
+const checkedColor = props.checkedColor || radioGroup.checkedColor
+
+radioGroup.updateChildren({
+	selected,
+	value: props.value
+})
+
+const handleToggle = () => {
+	radioGroup.updateValue(props.value)
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 96 - 0
components/fs-radio-group/fs-radio-group.vue

@@ -0,0 +1,96 @@
+<template>
+	<view class="fs-radio-group" :class="{ inline }"><slot></slot></view>
+</template>
+
+<script>
+/**
+ * 单选框组组件
+ * @description 单选框组组件
+ * @property {String} justify 图标对齐方式
+ * @property {Boolean} reverse 是否反转
+ * @property {Boolean} inline 单行显示
+ * @property {Boolean} radius 是否圆角(仅对按钮样式有效)
+ * @property {Boolean} round 是否半圆(仅对按钮样式有效)
+ * @property {String} checkedColor 选中颜色
+ * @property {String} checkedColorType = [primary | danger | warning | info | success] 选中颜色类型
+ * @property {String} size = [mini | small | medium] 按钮大小(仅对按钮样式有效)
+ * @event {Function} change change事件
+ */
+export default {
+	name: 'fs-radio-group'
+}
+</script>
+
+<script setup>
+import { provide, reactive, watch } from 'vue'
+
+const props = defineProps({
+	justify: String,
+	reverse: Boolean,
+	inline: Boolean,
+	checkedColor: String,
+	checkedColorType: {
+		type: String,
+		default: 'primary',
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger'].includes(value)
+		}
+	},
+	radius: Boolean,
+	round: Boolean,
+	size: {
+		type: String,
+		validator(value) {
+			return ['mini', 'small', 'medium'].includes(value)
+		}
+	},
+	modelValue: String
+})
+const emits = defineEmits(['update:modelValue', 'change'])
+
+const state = reactive({
+	selectedValue: props.modelValue,
+	children: []
+})
+watch(
+	() => props.modelValue,
+	val => {
+		state.selectedValue = val
+	}
+)
+
+const radioStrategy = value => {
+	state.children.forEach(item => {
+		item.selected = item.value === state.selectedValue
+	})
+}
+const updateChildren = child => {
+	state.children.push(child)
+	radioStrategy()
+}
+const updateValue = value => (state.selectedValue = value)
+
+watch(
+	() => state.selectedValue,
+	val => {
+		radioStrategy()
+		emits('update:modelValue', val)
+		emits('change', val)
+	}
+)
+
+provide('radioGroup', {
+	...props,
+	updateChildren,
+	updateValue
+})
+</script>
+
+<style lang="scss">
+.fs-radio-group {
+	&.inline {
+		display: flex;
+		flex-wrap: wrap;
+	}
+}
+</style>

+ 114 - 0
components/fs-radio/fs-radio.vue

@@ -0,0 +1,114 @@
+<template>
+	<view
+		class="fs-radio"
+		:class="['fs-radio-' + justify, { 'fs-radio-reverse': reverse, 'fs-radio-inline': inline }]"
+		@click="handleToggle"
+	>
+		<fs-icon
+			v-if="icon"
+			source="out"
+			:type="selected ? selectIcon || icon : icon"
+			:color-type="selected ? checkedColorType : 'gray'"
+			:size="iconSize"
+			:color="checkedColor"
+		></fs-icon>
+		<fs-icon
+			v-else
+			:type="selected ? 'icon-checked' : 'icon-uncheck'"
+			:color-type="selected ? checkedColorType : 'gray'"
+			:size="iconSize"
+			:color="checkedColor"
+		></fs-icon>
+		<view class="fs-radio-lable">
+			{{ label }}
+			<slot />
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 单选框组件
+ * @description 单选框组件
+ * @property {String} label 文本
+ * @property {String} icon 自定义图标
+ * @property {String} iconSize 图标大小
+ * @property {String} selectIcon 自定义选中图标
+ * @property {null} value 标识符(必须传)
+ * @property {String} checkedColor 选中颜色
+ * @property {String} checkedColorType = [primary | danger | warning | info | success] 选中颜色类型
+ */
+export default {
+	name: 'fs-radio'
+}
+</script>
+
+<script setup>
+import { inject, reactive, watch, toRefs, ref } from 'vue'
+
+const props = defineProps({
+	label: String,
+	iconSize: {
+		type: String,
+		default: '42rpx'
+	},
+	value: {
+		type: null,
+		required: true
+	},
+	icon: String,
+	selectIcon: String,
+	checkedColor: String,
+	checkedColorType: {
+		type: String,
+		default: 'primary'
+	}
+})
+const emits = defineEmits(['change'])
+
+const radioGroup = inject('radioGroup')
+const { reverse, justify, inline } = radioGroup
+
+let selected = ref(props.checked)
+watch(
+	() => props.checked,
+	val => {
+		selected.value = val
+	}
+)
+
+radioGroup.updateChildren({
+	selected,
+	value: props.value
+})
+
+const handleToggle = () => {
+	radioGroup.updateValue(props.value)
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-radio {
+	display: flex;
+	align-items: center;
+	justify-content: flex-start;
+	padding: 10rpx 0;
+
+	&-lable {
+		margin-left: 6rpx;
+		margin-right: 20rpx;
+	}
+
+	&-reverse {
+		flex-direction: row-reverse;
+		justify-content: flex-end;
+	}
+	&-reverse &-lable {
+		margin-left: 20rpx;
+		margin-right: 6rpx;
+	}
+	&-right {
+		justify-content: space-between;
+	}
+}
+</style>

+ 120 - 0
components/fs-rate/fs-rate.vue

@@ -0,0 +1,120 @@
+<template>
+	<view class="fs-rate">
+		<view class="fs-rate-item" @click="handleClick(index)" v-for="(item, index) in count" :key="index">
+			<fs-icon
+				:type="index < modelValue ? activeIcon : inactiveIcon"
+				:color="index < modelValue ? activeColor : inactiveColor"
+				:size="iconSize"
+				:source="source"
+			></fs-icon>
+			<view v-if="allowHalf && index < modelValue && index + 1 > modelValue" class="fs-rate-half">
+				<view class="fs-rate-half-active" :style="{ width: size / 2 + 'rpx' }">
+					<fs-icon :type="activeIcon" :color="activeColor" :size="iconSize" :source="source"></fs-icon>
+				</view>
+				<view class="fs-rate-half-inactive">
+					<fs-icon :type="activeIcon" :color="inactiveColor" :size="iconSize" :source="source"></fs-icon>
+				</view>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 评分组件
+ * @description 评分组件
+ * @property {Number} count 图标数量
+ * @property {String} activeColor 图标选中的颜色
+ * @property {String} inactiveColor 图标未选中的颜色
+ * @property {String} activeIcon 选中的图标
+ * @property {String} inactiveIcon 未选中的图标
+ * @property {String} size 图标大小(单位rpx)
+ * @property {Boolean} allowHalf 是否允许半星
+ * @property {Boolean} disabled 是否禁用
+ * @property {String} source 图标来源
+ * @event {Function} change change事件
+ */
+export default {
+	name: 'fs-rate'
+}
+</script>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+	modelValue: {
+		type: Number,
+		default: 0
+	},
+	count: {
+		type: Number,
+		default: 5
+	},
+	activeColor: {
+		type: String,
+		default: '#F53F3F'
+	},
+	inactiveColor: {
+		type: String,
+		default: '#999999'
+	},
+	size: {
+		type: Number,
+		default: '40'
+	},
+	disabled: Boolean,
+	activeIcon: {
+		type: String,
+		default: 'icon-star-fill'
+	},
+	inactiveIcon: {
+		type: String,
+		default: 'icon-star'
+	},
+	source: {
+		type: String,
+		default: 'inner'
+	},
+	allowHalf: Boolean
+})
+const emits = defineEmits(['update:modelValue', 'change'])
+
+const iconSize = computed(() => props.size + 'rpx')
+
+const handleClick = index => {
+	if (!props.disabled) {
+		emits('update:modelValue', index + 1)
+		emits('change', index + 1)
+	}
+}
+</script>
+
+<style lang="scss">
+.fs-rate {
+	display: flex;
+
+	&-item {
+		position: relative;
+		& + & {
+			margin-left: 8rpx;
+		}
+	}
+
+	&-half {
+		position: absolute;
+		left: 0;
+		top: 0;
+		z-index: 1;
+		overflow: hidden;
+
+		&-active {
+			position: absolute;
+			top: 0;
+			left: 0;
+			z-index: 1;
+			overflow: hidden;
+		}
+	}
+}
+</style>

+ 110 - 0
components/fs-readmore/fs-readmore.vue

@@ -0,0 +1,110 @@
+<template>
+	<view
+		class="fs-readmore"
+		:style="{ height: isOpen ? 'auto' : `${height + 70}rpx`, paddingBottom: isOpen ? '70rpx' : 0, color }"
+	>
+		<view class="fs-readmore-content layout-box" :style="{ backgroundColor: bgColor }"><slot></slot></view>
+
+		<view class="fs-readmore-footer" :style="{ backgroundColor: bgColor }" @click="handleToggle" v-if="visible">
+			<view>{{ isOpen ? '收起' : label }}</view>
+			<fs-icon class="fs-readmore-icon" type="icon-d-down" size="26rpx" :class="{ isOpen }"></fs-icon>
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 展开更多组件
+ * @description 展开更多组件
+ * @property {Number} maxHeight 限定多高时展示更多按钮(单位rpx)
+ * @property {String} label "更多"文本
+ * @property {Boolean} open 是否展开
+ * @property {String} bgColor 背景色
+ * @property {String} color 文本颜色
+ */
+export default {
+	name: 'fs-readmore',
+	props: {
+		// 限定多高时展示更多按钮
+		maxHeight: {
+			type: Number,
+			default: 100
+		},
+		label: {
+			type: String,
+			default: '展开更多'
+		},
+		open: Boolean,
+		bgColor: {
+			type: String,
+			default: '#fff'
+		},
+		color: {
+			type: String,
+			default: '#666666'
+		}
+	},
+	data() {
+		return {
+			isOpen: false,
+			visible: false
+		}
+	},
+	mounted() {
+		this.updateHeight()
+	},
+	computed: {
+		height() {
+			return parseFloat(this.maxHeight)
+		}
+	},
+	methods: {
+		handleToggle() {
+			this.isOpen = !this.isOpen
+		},
+		updateHeight() {
+			uni
+				.createSelectorQuery()
+				.in(this)
+				.select('.fs-readmore-content')
+				.boundingClientRect(data => {
+					this.visible = data.height > this.height
+				})
+				.exec()
+		}
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-readmore {
+	position: relative;
+	overflow: hidden;
+
+	&-content {
+		overflow: hidden;
+	}
+
+	&-footer {
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		position: absolute;
+		z-index: 10;
+		width: 100%;
+		bottom: 0;
+		font-size: 13px;
+		height: 70rpx;
+		line-height: 70rpx;
+		// box-shadow: 0 -6rpx 2rpx rgba(65, 65, 70, 0.2);
+	}
+
+	&-icon {
+		transition: all 0.2s;
+		margin-left: 6rpx;
+	}
+	.isOpen {
+		transform: rotate(180deg);
+	}
+}
+</style>

+ 37 - 0
components/fs-row/fs-row.vue

@@ -0,0 +1,37 @@
+<template>
+	<view class="fs-row" :style="{ marginLeft: margin, marginRight: margin }"><slot></slot></view>
+</template>
+
+<script>
+/**
+ * 栅格布局组件
+ * @description 栅格布局组件
+ * @property {Number,String} gutter 间距(单位rpx)
+ */
+export default {
+	name: 'fs-row'
+}
+</script>
+
+<script setup>
+import { computed, toRefs, provide } from 'vue'
+
+const props = defineProps({
+	gutter: {
+		type: [Number, String],
+		default: 0
+	}
+})
+
+const margin = computed(() => `${parseInt(props.gutter) / -2}rpx`)
+
+provide('rowGap', props.gutter)
+</script>
+
+<style lang="scss" scoped>
+.fs-row::after {
+	content: '';
+	display: table;
+	clear: both;
+}
+</style>

+ 129 - 0
components/fs-scroll-list/fs-scroll-list.vue

@@ -0,0 +1,129 @@
+<template>
+	<view class="fs-scroll-list">
+		<scroll-view
+			scroll-x
+			:show-scrollbar="false"
+			:lower-threshold="0"
+			:upper-threshold="0"
+			@scrolltoupper="handleToUpper"
+			@scrolltolower="handleToLower"
+			@scroll="handleScroll"
+		>
+			<view class="fs-scroll-list-content"><slot></slot></view>
+		</scroll-view>
+
+		<view class="fs-scroll-indicator" v-if="indicator">
+			<view class="fs-scroll-indicator-line"><view class="fs-scroll-indicator-bar" :style="barStyle"></view></view>
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 滚动列表组件
+ * @description 滚动列表组件
+ * @property {Boolean} indicator 是否显示面板指示器
+ * @property {String} indicatorColor 指示器背景颜色
+ * @property {String} indicatorActiveColor 指示器滑块颜色
+ * @property {String} indicatorWidth 指示器的整体宽度(传值的时候需要带单位px)
+ * @property {String} indicatorBarWidth 滑块的宽度(传值的时候需要带单位px)
+ * @event {Function} left 滑到左边事件
+ * @event {Function} right 滑动右边事件
+ */
+export default {
+	name: 'fs-scroll-list'
+}
+</script>
+
+<script setup>
+import { onMounted, reactive, ref, computed, getCurrentInstance } from 'vue'
+
+const props = defineProps({
+	indicator: {
+		type: Boolean,
+		default: true
+	},
+	indicatorWidth: {
+		type: String,
+		default: '30px'
+	},
+	indicatorBarWidth: {
+		type: String,
+		default: '15px'
+	},
+	indicatorColor: {
+		type: String,
+		default: '#f2f2f2'
+	},
+	indicatorActiveColor: {
+		type: String,
+		default: '#3c9cff'
+	}
+})
+const emits = defineEmits(['left', 'right'])
+
+const state = reactive({
+	listWidth: 0,
+	contentWidth: 0,
+	scrollWidth: 0
+})
+onMounted(() => {
+	uni
+		.createSelectorQuery()
+		.in(getCurrentInstance().ctx)
+		.select('.fs-scroll-list')
+		.boundingClientRect(data => {
+			state.listWidth = data.width
+		})
+		.exec()
+})
+
+const indicatorLeftWidth = computed(() => {
+	return parseInt(props.indicatorWidth) - parseInt(props.indicatorBarWidth)
+})
+const listLeftWidth = computed(() => {
+	return state.contentWidth - state.listWidth
+})
+const barStyle = computed(() => {
+	const x = (state.scrollWidth * indicatorLeftWidth.value) / listLeftWidth.value + 'px'
+	return `transform: translateX(${x})`
+})
+
+const handleScroll = event => {
+	state.scrollWidth = event.detail.scrollLeft
+	state.contentWidth = event.detail.scrollWidth
+}
+
+const handleToUpper = () => emits('left')
+const handleToLower = () => emits('right')
+</script>
+
+<style lang="scss" scoped>
+.fs-scroll-list {
+	&-box {
+		display: flex;
+	}
+	&-content {
+		display: flex;
+		flex-wrap: nowrap;
+	}
+}
+.fs-scroll-indicator {
+	display: flex;
+	justify-content: center;
+
+	&-line {
+		background-color: v-bind(indicatorColor);
+		width: v-bind(indicatorWidth);
+		height: 8rpx;
+		border-radius: 50px;
+		overflow: hidden;
+	}
+	&-bar {
+		background-color: v-bind(indicatorActiveColor);
+		height: 8rpx;
+		width: v-bind(indicatorBarWidth);
+		border-radius: 50px;
+	}
+}
+</style>

+ 194 - 0
components/fs-search/fs-search.vue

@@ -0,0 +1,194 @@
+<template>
+	<view class="fs-search-box" :style="{ backgroundColor: bgColor }">
+		<view class="fs-search-box-left"><slot name="left"></slot></view>
+		<view class="fs-input-box" :class="[{ round }]" @click="handleLink" :style="{ backgroundColor: inputBgColor }">
+			<view class="sub fs-input" v-if="link">{{ placeholder }}</view>
+			<input
+				v-else
+				class="fs-input"
+				:value="modelValue"
+				:type="type"
+				:placeholder="placeholder"
+				@input="handleChange"
+				@focus="handleFocus"
+				@blur="handleBlur"
+				:focus="autoFocus"
+			/>
+			<view class="fs-icon fs-icon-search">
+				<slot name="icon"><fs-icon type="icon-search" color="#666666" size="28rpx"></fs-icon></slot>
+			</view>
+			<view class="fs-icon fs-icon-close" v-if="modelValue" @click="handleClear">
+				<fs-icon type="icon-close-circle" color="#666666"></fs-icon>
+			</view>
+		</view>
+		<view
+			v-if="showAction"
+			class="fs-cancel"
+			:class="[actionColorType]"
+			:style="{ color: actionColor }"
+			@click="handleAction"
+		>
+			{{ actionText }}
+		</view>
+		<view class="fs-search-box-right"><slot name="right"></slot></view>
+	</view>
+</template>
+
+<script>
+/**
+ * 搜索组件
+ * @description 搜索组件
+ * @property {String} placeholder 占位符
+ * @property {String} type 输入框类型
+ * @property {String} actionColor 操作文字颜色
+ * @property {String} actionColorType = [primary | danger | warning | info | success] 操作文字颜色类型
+ * @property {String} actionText 操作文字
+ * @property {String} bgColor 背景颜色
+ * @property {String} inputBgColor 输入框背景颜色
+ * @property {String} showAction 是否显示操作
+ * @property {String} round 是否圆角
+ * @property {String} autoFocus 是否圆角
+ * @property {String} link 跳转地址
+ * @property {String} linkType 跳转类型
+ * @event {Function} focus 输入框聚焦事件
+ * @event {Function} blur 输入框失去焦点事件
+ * @event {Function} change 输入框内容变化事件
+ * @event {Function} action 点击操作事件
+ */
+export default {
+	name: 'fs-search'
+}
+</script>
+
+<script setup>
+import { ref } from 'vue'
+
+const props = defineProps({
+	placeholder: {
+		type: String,
+		default: '搜索'
+	},
+	actionColor: String,
+	actionColorType: {
+		type: String,
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger'].includes(value)
+		}
+	},
+	actionText: {
+		type: String,
+		default: '取消'
+	},
+	autoFocus: Boolean,
+	showAction: Boolean,
+	round: Boolean,
+	type: {
+		type: String,
+		default: 'text'
+	},
+	bgColor: {
+		type: String,
+		default: '#fff'
+	},
+	inputBgColor: {
+		type: String,
+		default: '#f0f0f0'
+	},
+	link: String,
+	linkType: {
+		type: String,
+		default: 'navigateTo'
+	},
+	modelValue: String
+})
+
+const emits = defineEmits(['action', 'focus', 'blur', 'update:modelValue', 'change'])
+
+const handleChange = e => {
+	emits('update:modelValue', e.detail.value)
+	emits('change', e.detail.value)
+}
+const handleFocus = () => {
+	emits('focus')
+}
+const handleBlur = () => {
+	emits('blur')
+}
+const handleClear = () => {
+	emits('update:modelValue', '')
+	emits('change', '')
+}
+const handleAction = () => {
+	emits('action', props.modelValue)
+}
+const handleLink = () => {
+	if (props.link) {
+		uni[props.linkType]({
+			url: props.link
+		})
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-search-box {
+	width: 100%;
+	height: 110rpx;
+	padding: 20rpx var(--gutter);
+	display: flex;
+	background-color: #fff;
+	box-sizing: border-box;
+	align-items: center;
+}
+.fs-input-box {
+	position: relative;
+	height: 100%;
+	width: 100%;
+	flex: 1;
+	background-color: #f0f0f0;
+
+	.sub {
+		line-height: 70rpx;
+		color: var(--sub);
+	}
+
+	&.round {
+		border-radius: 20px;
+		.fs-input {
+			border-radius: inherit;
+		}
+	}
+}
+.fs-input {
+	height: 100%;
+	width: 100%;
+	padding-left: 68rpx;
+	padding-right: 70rpx;
+	border-radius: 6rpx;
+	box-sizing: border-box;
+	outline: none;
+
+	/* #ifdef H5 */
+	background-color: inherit;
+	border: none;
+	/* #endif */
+}
+
+.fs-cancel {
+	margin-left: 20rpx;
+}
+.fs-icon {
+	position: absolute;
+	top: 50%;
+	transform: translateY(-50%);
+	color: var(--sub);
+	z-index: 10;
+}
+.fs-icon-search {
+	left: 20rpx;
+	line-height: 1;
+}
+.fs-icon-close {
+	right: 20rpx;
+}
+</style>

+ 138 - 0
components/fs-select/fs-select.vue

@@ -0,0 +1,138 @@
+<template>
+	<fs-popover
+		v-model="selectedAction"
+		:placement="placement"
+		:actions="actions"
+		:width="width"
+		:actionWidth="actionWidth"
+		:valueKey="valueKey"
+		@clickAction="handleClickAction"
+		@visibleChange="handleChange"
+	>
+		<template #refer>
+			<view class="fs-select" :class="[{ 'fs-select-border': border }]">
+				<view class="fs-select-content line1">{{ selectedAction.text || placeholder }}</view>
+				<view class="fs-select-icon"><fs-icon type="icon-d-down" :rotate="state.rotate" size="30rpx"></fs-icon></view>
+			</view>
+		</template>
+		<!-- <slot></slot> -->
+	</fs-popover>
+</template>
+
+<script>
+/**
+ * 选择器组件
+ * @description 选择器组件
+ * @property {Array} actions 选项列表
+ * @property {String} valueKey 作为 value 唯一标识的键名
+ * @property {String} placement = [top | top-start | top-end | bottom | bottom-start | bottom-end,left | left-start | left-end,right | right-start | right-end] 弹出位置
+ * @property {String} placeholder 占位符
+ * @property {String} width select宽度
+ * @property {String} actionWidth 选项列表宽度
+ * @property {Boolean} border 显示边框
+ * @event {Function} change 选项变化事件
+ */
+export default {
+	name: 'fs-select'
+}
+</script>
+
+<script setup>
+import { ref, reactive, computed } from 'vue'
+
+const props = defineProps({
+	modelValue: [Object],
+	actions: Array,
+	border: Boolean,
+	placeholder: String,
+	width: {
+		type: String,
+		default: '100%'
+	},
+	actionWidth: {
+		type: String,
+		default: 'auto'
+	},
+	placement: {
+		type: String,
+		default: 'bottom',
+		validator(value) {
+			return [
+				'top',
+				'top-start',
+				'top-end',
+				'bottom',
+				'bottom-start',
+				'bottom-end',
+				'left',
+				'left-start',
+				'left-end',
+				'right',
+				'right-start',
+				'right-end'
+			].includes(value)
+		}
+	},
+	valueKey: {
+		type: String,
+		default: 'id'
+	}
+})
+const emits = defineEmits(['change', 'update:modelValue'])
+
+const selectedAction = computed({
+	get: () => props.modelValue,
+	set: value => {
+		emits('update:modelValue', value)
+	}
+})
+
+const state = reactive({
+	rotate: '0'
+})
+
+const handleClickAction = item => {
+	emits('update:modelValue', item)
+	emits('change', item)
+}
+const handleChange = () => {
+	state.rotate = state.rotate === '0' ? '180' : '0'
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-select {
+	display: flex;
+	height: 70rpx;
+	align-items: center;
+	justify-content: space-between;
+	position: relative;
+	background-color: inherit;
+	position: relative;
+	padding-right: 20rpx;
+	max-width: 100%;
+
+	&-border {
+		border: 2rpx solid var(--border-color) !important;
+		border-radius: 4rpx !important;
+		padding-left: 20rpx;
+		&.fs-select-content {
+			padding: 20rpx;
+		}
+	}
+
+	&-content {
+		flex: 1;
+		width: 400rpx;
+	}
+	&-icon {
+		padding-left: 20rpx;
+		flex-shrink: 0;
+	}
+
+	.visible {
+		transform: rotate(180deg);
+		transform-origin: center center;
+	}
+}
+</style>

+ 105 - 0
components/fs-sidebar/fs-sidebar.vue

@@ -0,0 +1,105 @@
+<template>
+	<view class="fs-side-bar">
+		<view class="fs-side-bar-left" :style="{ width: width }">
+			<view
+				class="fs-side-bar-item line1"
+				:class="{ 'fs-side-bar-active': activeId ? activeId === item[valueKey] : index === 0 }"
+				v-for="(item, index) in list"
+				:key="item[valueKey]"
+				@click="handleClick(item, index)"
+			>
+				{{ item[titleKey] }}
+			</view>
+		</view>
+		<view class="fs-side-bar-right"><slot></slot></view>
+	</view>
+</template>
+
+<script>
+/**
+ * 侧边栏组件
+ * @description 侧边栏组件
+ * @property {Array} list sidebar列表
+ * @property {String} value 默认激活项的value(通常为id)
+ * @property {String} valueKey 激活项value的key
+ * @property {String} titleKey 显示内容的key
+ * @property {String} width 侧边栏组宽度
+ * @event {Function} change 激活项变化事件
+ */
+export default {
+	name: 'fs-sidebar'
+}
+</script>
+
+<script setup>
+import { ref } from 'vue'
+
+const props = defineProps({
+	list: {
+		type: Array,
+		default() {
+			return []
+		}
+	},
+	value: null,
+	valueKey: {
+		type: String,
+		default: 'id'
+	},
+	titleKey: {
+		type: String,
+		default: 'title'
+	},
+	width: {
+		type: String,
+		default: '210rpx'
+	}
+})
+const emits = defineEmits(['change'])
+
+const activeId = ref(props.value)
+const handleClick = (item, index) => {
+	activeId.value = item[props.valueKey]
+	emits('change', {
+		item,
+		index
+	})
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-side-bar {
+	display: flex;
+	height: 100%;
+
+	&-left {
+		flex-shrink: 0;
+		background-color: #fafafa;
+		overflow: auto;
+	}
+
+	&-item {
+		padding: 30rpx var(--gutter);
+		position: relative;
+	}
+	&-active {
+		background-color: #fff;
+		color: var(--primary);
+		&::before {
+			position: absolute;
+			content: '';
+			left: 0;
+			top: 0;
+			height: 100%;
+			width: 6rpx;
+			background-color: currentColor;
+		}
+	}
+
+	&-right {
+		flex: 1;
+		background-color: #fff;
+		overflow: auto;
+	}
+}
+</style>

+ 48 - 0
components/fs-space/fs-space.vue

@@ -0,0 +1,48 @@
+<template>
+	<view class="fs-space" :class="{ 'gutter-v': gutter }" :style="{ gap: size }"><slot></slot></view>
+</template>
+
+<script>
+/**
+ * 间距组件
+ * @description 间距组件(设置组件之间的间距)
+ * @property {String} size 间距大小
+ * @property {String} direction 间距方向
+ * @property {String} justify 间距方向
+ * @property {Boolean} gutter 下边距
+ */
+export default {
+	name: 'fs-space'
+}
+</script>
+
+<script setup>
+const props = defineProps({
+	size: {
+		type: String,
+		default: '20rpx'
+	},
+	direction: {
+		type: String,
+		default: 'row',
+		validator(value) {
+			return ['row', 'column'].includes(value)
+		}
+	},
+	justify: {
+		type: String,
+		default: 'flex-start'
+	},
+	gutter: Boolean
+})
+</script>
+
+<style lang="scss" scoped>
+.fs-space {
+	display: flex;
+	flex-direction: v-bind(direction);
+	flex-wrap: wrap;
+	justify-content: v-bind(justify);
+	align-items: center;
+}
+</style>

+ 46 - 0
components/fs-swipe-action-group/fs-swipe-action-group.vue

@@ -0,0 +1,46 @@
+<template>
+	<view><slot></slot></view>
+</template>
+
+<script>
+/**
+ * 滑动面板组件
+ * @description 滑动面板组件
+ * @property {Boolean} autoClose 是否自动关闭其他swipe按钮组
+ */
+export default {
+	name: 'fs-swipe-action-group'
+}
+</script>
+
+<script setup>
+import { provide, reactive } from 'vue'
+
+const props = defineProps({
+	autoClose: {
+		type: Boolean,
+		default: true
+	}
+})
+const state = reactive({
+	children: []
+})
+
+const updateChildren = child => {
+	state.children.push(child)
+}
+const toggle = () => {
+	if (props.autoClose) {
+		state.children.forEach(child => {
+			child.updateState()
+		})
+	}
+}
+
+provide('swipeGroup', {
+	updateChildren,
+	toggle
+})
+</script>
+
+<style lang="scss" scoped></style>

+ 164 - 0
components/fs-swipe-action/fs-swipe-action.vue

@@ -0,0 +1,164 @@
+<template>
+	<movable-area class="fs-swipe-box">
+		<movable-view
+			v-if="state.swipeViewWidth"
+			class="fs-swipe-view"
+			@change="change"
+			@touchend="touchend"
+			@touchstart="touchstart"
+			direction="horizontal"
+			:disabled="disabled"
+			:x="state.moveX"
+			:style="{ width: state.swipeViewWidth }"
+		>
+			<view class="fs-swipe-content"><slot></slot></view>
+			<view
+				class="fs-swipe-option"
+				v-for="(item, index) in options"
+				:key="index"
+				:style="{ backgroundColor: item.bgColor }"
+				@click="handleOption(item)"
+			>
+				<view class="fs-swipe-option-text" :style="{ width: optionWidth + 'px', backgroundColor: item.bgColor }">
+					{{ item.name }}
+				</view>
+			</view>
+		</movable-view>
+	</movable-area>
+</template>
+
+<script>
+/**
+ * 滑动面板组件
+ * @description 滑动面板组件
+ * @property {Array} options 操作选项
+ * @property {null} optionData 操作选项数据
+ * @property {Number} optionWidth 操作选项宽度(单位px)
+ * @property {Boolean} disabled 操作选项数据
+ * @property {Number} swipeRate 滑动比例阈值,大于此值会自动打开或关闭
+ */
+export default {
+	name: 'fs-swipe-action'
+}
+</script>
+
+<script setup>
+import { inject, onMounted, reactive, ref, nextTick, getCurrentInstance } from 'vue'
+
+const props = defineProps({
+	disabled: Boolean,
+	optionWidth: {
+		type: Number,
+		default: 80
+	},
+	options: {
+		type: Array,
+		default: () => []
+	},
+	optionData: null,
+	swipeRate: {
+		type: Number,
+		default: 1 / 4
+	}
+})
+const emits = defineEmits(['clickOption'])
+
+const state = reactive({
+	moveX: 0,
+	scrollX: 0,
+	swipeViewWidth: 0,
+	status: false,
+	moving: false,
+	allOptionWidth: props.optionWidth * props.options.length
+})
+const swipeGroup = inject('swipeGroup', {})
+const updateState = () => {
+	if (state.moving) {
+		state.moving = false
+	} else {
+		state.moveX = 0
+	}
+}
+swipeGroup.updateChildren({
+	updateState
+})
+
+onMounted(() => {
+	uni
+		.createSelectorQuery()
+		.in(getCurrentInstance().ctx)
+		.select('.fs-swipe-box')
+		.boundingClientRect(data => {
+			state.swipeViewWidth = data.width + state.allOptionWidth + 'px'
+		})
+		.exec()
+})
+
+function change(e) {
+	state.scrollX = e.detail.x
+}
+function touchstart() {
+	state.moving = true
+	swipeGroup.toggle()
+}
+function touchend() {
+	state.moveX = state.scrollX
+
+	setTimeout(() => {
+		if (state.status) {
+			//打开状态
+			if (state.moveX >= -state.allOptionWidth * (1 - Number(props.swipeRate))) {
+				handleClose()
+			} else {
+				handleOpen()
+			}
+		} else {
+			if (state.moveX <= -state.allOptionWidth * Number(props.swipeRate)) {
+				handleOpen()
+			} else {
+				handleClose()
+			}
+		}
+	}, 0)
+}
+function handleOpen() {
+	state.moveX = -state.allOptionWidth
+	state.status = true
+}
+function handleClose() {
+	state.moveX = 0
+	state.status = false
+}
+function handleOption(item) {
+	emits('clickOption', { option: item, data: props.optionData })
+	handleClose()
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-swipe-box {
+	width: auto;
+	height: auto;
+	overflow: hidden;
+	position: relative;
+}
+
+.fs-swipe-view {
+	display: flex;
+	position: relative;
+	height: inherit;
+	width: 100%;
+}
+
+.fs-swipe-content {
+	flex: 1;
+}
+
+.fs-swipe-option {
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	text-align: center;
+	color: #fff;
+}
+</style>

+ 190 - 0
components/fs-swiper/fs-swiper.vue

@@ -0,0 +1,190 @@
+<template>
+	<swiper
+		:current="current"
+		:indicator-dots="indicatorDots"
+		:indicator-color="indicatorColor"
+		:indicator-active-color="indicatorActiveColor"
+		:autoplay="autoplay"
+		:interval="interval"
+		:duration="duration"
+		:circular="circular"
+		:vertical="vertical"
+		:previous-margin="previousMargin"
+		:next-margin="nextMargin"
+		@change="handleChange"
+		@transition="handleTransition"
+		class="fs-swiper"
+		:class="{ 'fs-swiper-card': mode === 'card', 'gutter-v': gutter }"
+		:style="{ height: height }"
+	>
+		<template v-if="mode === 'card'">
+			<swiper-item class="fs-swiper-item-box" v-for="(item, index) in list" :key="index" @click="handleClick(item)">
+				<view class="fs-swiper-item" :class="{ 'card-cur': index === curIndex }">
+					<fs-avatar shape="square" radius width="100%" height="100%" :src="item[keyMap.src]"></fs-avatar>
+					<view class="fs-swiper-item-text line1" v-if="showTitle">{{ item[keyMap.title] }}</view>
+				</view>
+			</swiper-item>
+		</template>
+		<template v-else>
+			<swiper-item class="fs-swiper-item-box" v-for="(item, index) in list" :key="index" @click="handleClick(item)">
+				<view class="fs-swiper-item">
+					<fs-avatar shape="square" width="100%" height="100%" :src="item[keyMap.src]"></fs-avatar>
+					<view class="fs-swiper-item-text line1" v-if="showTitle">{{ item[keyMap.title] }}</view>
+				</view>
+			</swiper-item>
+		</template>
+	</swiper>
+</template>
+
+<script>
+/**
+ * 轮播图组件
+ * @description 轮播图组件
+ * @property {Array} list 滚动列表
+ * @property {Object} keyMap 属性映射
+ * @property {Number} current 当前所在滑块的index
+ * @property {Boolean} indicatorDots 是否显示指示器
+ * @property {String} indicatorColor 指示器颜色
+ * @property {String} indicatorActiveColor 指示器高亮颜色
+ * @property {Boolean} autoplay 自动播放
+ * @property {Boolean} vertical 竖直滚动
+ * @property {Number} interval 播放间隔时长(单位ms)
+ * @property {Number} duration 滚动动画持续时长(单位ms)
+ * @property {String} previousMargin 前边距,可用于露出前一项的一小部分
+ * @property {String} nextMargin 后边距,可用于露出后一项的一小部分
+ * @property {String} height 滚动项高度
+ * @property {String} mode 模式
+ * @property {Boolean} gutter 是否有下边距
+ * @property {Boolean} showTitle 是否显示标题
+ * @event {Function} change change事件
+ * @event {Function} transition transition事件
+ */
+export default {
+	name: 'fs-swiper'
+}
+</script>
+
+<script setup>
+import { ref } from 'vue'
+
+const props = defineProps({
+	current: {
+		type: Number,
+		default: 0
+	},
+	indicatorDots: {
+		type: Boolean,
+		default: true
+	},
+	indicatorColor: {
+		type: String,
+		default: 'rgba(0, 0, 0, .3)'
+	},
+	indicatorActiveColor: {
+		type: String,
+		default: '#fff'
+	},
+	autoplay: {
+		type: Boolean,
+		default: true
+	},
+	circular: {
+		type: Boolean,
+		default: true
+	},
+	interval: {
+		type: Number,
+		default: 3000
+	},
+	duration: {
+		type: Number,
+		default: 1000
+	},
+	vertical: {
+		type: Boolean,
+		default: false
+	},
+	previousMargin: {
+		type: String,
+		default: '0'
+	},
+	nextMargin: {
+		type: String,
+		default: '0'
+	},
+	height: {
+		type: String,
+		default: '350rpx'
+	},
+	list: {
+		type: Array,
+		default() {
+			return []
+		}
+	},
+	keyMap: {
+		type: Object,
+		default() {
+			return {
+				src: 'src',
+				title: 'title'
+			}
+		}
+	},
+	mode: {
+		type: String
+	},
+	gutter: Boolean,
+	showTitle: Boolean
+})
+const emits = defineEmits(['change', 'click', 'transition'])
+
+let curIndex = ref(0)
+const handleChange = e => {
+	curIndex.value = e.detail.current
+	emits('change', e)
+}
+const handleTransition = e => {
+	emits('transition', e)
+}
+const handleClick = item => {
+	emits('click', item)
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-swiper {
+	&-item {
+		width: 100%;
+		height: 100%;
+
+		&-text {
+			position: absolute;
+			left: 0;
+			right: 0;
+			bottom: 0;
+			background-color: rgba(0, 0, 0, 0.5);
+			color: #fff;
+			padding: 10rpx 20rpx;
+			font-size: 14px;
+			z-index: 10;
+		}
+	}
+}
+
+.fs-swiper-card {
+	.fs-swiper-item-box {
+		width: 610rpx !important;
+		left: 70rpx;
+		position: relative;
+	}
+	.fs-swiper-item {
+		transform: scale(0.9);
+		transition: all 0.2s ease-in 0s;
+		overflow: hidden;
+	}
+	.card-cur {
+		transform: scale(1);
+	}
+}
+</style>

+ 121 - 0
components/fs-switch/fs-switch.vue

@@ -0,0 +1,121 @@
+<template>
+	<view
+		class="fs-switch"
+		:class="[{ 'fs-switch-checked': modelValue, 'fs-switch-disabled': disabled }, 'fs-switch-' + size]"
+		@click="handleClick"
+	>
+		<view class="fs-switch-text">{{ modelValue ? activeText : inactiveText }}</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 开关组件
+ * @description 开关组件
+ * @property {String} activeColor switch的值为 on 的颜色
+ * @property {String} inactiveColor switch的值为 off 的颜色
+ * @property {Boolean} disabled 是否禁用
+ * @property {String} activeText switch的值为 on 的文字
+ * @property {String} inactiveText switch的值为 off 的文字
+ * @property {String} size = [small | default | large] 开关大小
+ * @event {Function} change change事件
+ */
+export default {
+	name: 'fs-switch'
+}
+</script>
+
+<script setup>
+import { watch, ref } from 'vue'
+
+const props = defineProps({
+	modelValue: Boolean,
+	disabled: Boolean,
+	activeColor: {
+		type: String,
+		default: '#0063F5'
+	},
+	inactiveColor: {
+		type: String,
+		default: '#fdfdfd'
+	},
+	activeText: String,
+	inactiveText: String,
+	size: {
+		type: String,
+		validator(value) {
+			return ['small', 'default', 'large'].includes(value)
+		}
+	}
+})
+const emits = defineEmits(['change', 'update:modelValue'])
+
+const handleClick = () => {
+	if (!props.disabled) {
+		emits('update:modelValue', !props.modelValue)
+		emits('change', !props.modelValue)
+	}
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-switch {
+	width: 80rpx;
+	height: 40rpx;
+	border-radius: 50rpx;
+	position: relative;
+	background-color: v-bind(inactiveColor);
+	border: 2rpx solid rgba(0, 0, 0, 0.12);
+	transition: background-color 0.1s;
+
+	&::before {
+		width: 36rpx;
+		height: 36rpx;
+		border-radius: 50%;
+		content: '';
+		position: absolute;
+		top: 0;
+		left: 0;
+		background-color: #fff;
+		box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
+		transition: transform 0.3s;
+		z-index: 10;
+	}
+
+	&-text {
+		position: absolute;
+		top: 50%;
+		left: 50%;
+		transform: translateY(-50%);
+		font-size: 12px;
+		transition: all 0.3s;
+		text-align: center;
+		width: 30rpx;
+		overflow: hidden;
+	}
+
+	&-disabled {
+		opacity: 0.5;
+	}
+
+	&-checked {
+		background-color: v-bind(activeColor);
+
+		&::before {
+			transform: translateX(40rpx);
+		}
+
+		.fs-switch-text {
+			color: #fff;
+			margin-left: -30rpx;
+		}
+	}
+
+	&-large {
+		transform: scale(1.2);
+	}
+	&-small {
+		transform: scale(0.8);
+	}
+}
+</style>

+ 259 - 0
components/fs-tab/fs-tab.vue

@@ -0,0 +1,259 @@
+<template>
+	<view class="text-center" :class="[sticky ? 'fs-tab-sticky' : '', gutter ? 'fs-tab-gutter' : '']">
+		<scroll-view :scroll-x="scrollable" :style="{ 'background-color': bgColor }">
+			<view class="fs-tab" :class="['fs-tab-' + type, colorType, round ? 'round' : '', center ? 'fs-tab-center' : '']">
+				<view
+					class="fs-tab-item"
+					:class="[type + '-item', type + '-item-' + colorType, { active: index == curIndex }]"
+					:style="itemStyle"
+					v-for="(item, index) in tabs"
+					:key="index"
+					@click="setActive(index)"
+				>
+					<slot :item="item" :index="index">{{ item.name }}</slot>
+					<view v-if="type === 'line' && index == curIndex" class="fs-tab-item-bar" :style="{ width: barWidth }"></view>
+				</view>
+			</view>
+		</scroll-view>
+	</view>
+</template>
+
+<script>
+/**
+ * 标签页组件
+ * @description 标签页组件
+ * @property {String} type = [line | card] 类型
+ * @property {Array} tabs 列表
+ * @property {String} colorType 高亮颜色类型
+ * @property {String} bgColor 背景颜色
+ * @property {String} barWidth 指示线宽度
+ * @property {Number} scrollThreshold 滑动阈值
+ * @property {Boolean} center 是否居中
+ * @property {Boolean} round 半角
+ * @property {Boolean} sticky 是否吸顶
+ * @property {Boolean} gutter 下边距
+ * @event {Function} change change事件
+ */
+export default {
+	name: 'fs-tab'
+}
+</script>
+
+<script setup>
+import { reactive, computed } from 'vue'
+
+const props = defineProps({
+	colorType: {
+		type: String,
+		default: 'primary',
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger'].includes(value)
+		}
+	},
+	bgColor: {
+		type: String,
+		default: '#fff'
+	},
+	barWidth: {
+		type: String,
+		default: '100%'
+	},
+	scrollThreshold: {
+		type: Number,
+		default: 5
+	},
+	type: {
+		type: String,
+		default: 'line',
+		validator(value) {
+			return ['line', 'card'].includes(value)
+		}
+	},
+	tabs: {
+		type: Array,
+		default() {
+			return []
+		}
+	},
+	center: Boolean,
+	round: Boolean,
+	sticky: Boolean,
+	gutter: Boolean,
+	modelValue: {
+		type: [String, Number],
+		default: 0
+	}
+})
+const emits = defineEmits(['change', 'update:modelValue'])
+
+const scrollable = computed(() => props.scrollThreshold <= props.tabs.length)
+const itemStyle = computed(() => (scrollable.value ? `flex: 0 0 ${88 / props.scrollThreshold}%;` : ''))
+const curIndex = computed(() => props.modelValue)
+
+const setActive = index => {
+	emits('update:modelValue', index)
+	emits('change', index)
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-tab {
+	display: flex;
+	height: 90rpx;
+	line-height: 90rpx;
+	background-color: #fff;
+	text-align: center;
+	white-space: nowrap;
+
+	&-line {
+		&.primary .active {
+			color: var(--primary);
+		}
+		&.danger .active {
+			color: var(--danger);
+		}
+		&.warning .active {
+			color: var(--warning);
+		}
+		&.success .active {
+			color: var(--success);
+		}
+		&.info .active {
+			color: var(--info);
+		}
+		&::after {
+			content: '';
+			position: absolute;
+			bottom: 0;
+			left: 0;
+			width: 200%;
+			transform: scale(0.5);
+			transform-origin: 0 0;
+			height: 1px;
+			background-color: var(--border-color);
+		}
+	}
+
+	&-card {
+		border: 2rpx solid var(--primary);
+		border-radius: var(--radius);
+		overflow: hidden;
+
+		&.round {
+			border-radius: 30px;
+		}
+		&.danger {
+			border-color: var(--danger);
+		}
+		&.warning {
+			border-color: var(--warning);
+		}
+		&.info {
+			border-color: var(--info);
+		}
+		&.success {
+			border-color: var(--success);
+		}
+
+		& .active {
+			color: #fff;
+		}
+		&.primary .active {
+			background-color: var(--primary);
+		}
+		&.danger .active {
+			background-color: var(--danger);
+		}
+		&.warning .active {
+			background-color: var(--warning);
+		}
+		&.success .active {
+			background-color: var(--success);
+		}
+		&.info .active {
+			background-color: var(--info);
+		}
+	}
+
+	&-center {
+		display: inline-flex;
+		width: auto;
+
+		& .fs-tab-item {
+			padding: 0 30px;
+		}
+	}
+	&-sticky {
+		position: sticky;
+		top: 0;
+		z-index: 10;
+	}
+	&-gutter {
+		margin-bottom: var(--gutter);
+	}
+}
+
+.fs-tab-item {
+	flex: 1;
+	position: relative;
+	box-sizing: border-box;
+	color: var(--content);
+
+	&-bar {
+		position: absolute;
+		bottom: 0;
+		height: 2px;
+		background-color: currentColor;
+		width: 100%;
+		left: 50%;
+		transform: translateX(-50%);
+		animation: width 0.5s;
+		z-index: 10;
+	}
+}
+
+.card-item-primary + .card-item-primary {
+	border-left: 1px solid var(--primary);
+}
+
+.card-item-danger + .card-item-danger {
+	border-left: 1px solid var(--danger);
+}
+
+.card-item-warning + .card-item-warning {
+	border-left: 1px solid var(--warning);
+}
+
+.card-item-success + .card-item-success {
+	border-left: 1px solid var(--success);
+}
+
+.card-item-info + .card-item-info {
+	border-left: 1px solid var(--info);
+}
+
+@keyframes width {
+	0% {
+		transform: translateX(-50%) scale(0);
+		opacity: 0;
+	}
+
+	100% {
+		transform: translateX(-50%) scale(1);
+	}
+}
+
+/* #ifdef H5 */
+// 通过样式穿透,隐藏H5下,scroll-view下的滚动条
+scroll-view ::v-deep ::-webkit-scrollbar {
+	display: none;
+	width: 0 !important;
+	height: 0 !important;
+	-webkit-appearance: none;
+	background: transparent;
+}
+::v-deep .fs-tab .uni-scroll-view-content {
+	display: flex;
+}
+/* #endif */
+</style>

+ 161 - 0
components/fs-tag/fs-tag.vue

@@ -0,0 +1,161 @@
+<template>
+	<view
+		class="fs-tag"
+		:class="[
+			{
+				'fs-tag-round': round,
+				'fs-tag-plain': plain,
+				'fs-tag-mark': mark,
+				'fs-tag-mark-reverse': markReverse,
+				'fs-tag-none': closed
+			},
+			'fs-tag-' + size,
+			'bg-' + type
+		]"
+		:style="[{ color: color, backgroundColor: bgColor }, customStyle]"
+	>
+		<slot></slot>
+		<fs-icon class="fs-tag-close" size="13px" type="icon-close" v-if="closable" @click="handleClosed"></fs-icon>
+	</view>
+</template>
+
+<script>
+/**
+ * 标签组件
+ * @description 标签组件
+ * @property {String} size = [default | medium | large] 标签大小
+ * @property {String} color 文字颜色
+ * @property {String} bgColor 背景色
+ * @property {Boolean} plain 是否镂空
+ * @property {Boolean} mark 标记
+ * @property {Boolean} markReverse 标记反转
+ * @property {Boolean} round 圆角
+ * @property {Boolean} closable 标签是否可移除
+ * @property {Object} customStyle 标签自定义样式
+ * @property {String} type = [primary | danger | warning | info | success | default] 标签颜色类型
+ */
+export default {
+	name: 'fs-tag'
+}
+</script>
+
+<script setup>
+import { ref } from 'vue'
+
+const props = defineProps({
+	plain: Boolean,
+	round: Boolean,
+	mark: Boolean,
+	markReverse: Boolean,
+	closable: Boolean,
+	type: {
+		type: String,
+		default: 'primary',
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger', 'default'].includes(value)
+		}
+	},
+	color: String,
+	bgColor: String,
+	size: {
+		type: String,
+		default: 'default',
+		validator(value) {
+			return ['default', 'medium', 'large'].includes(value)
+		}
+	},
+	customStyle: {
+		type: Object,
+		default() {
+			return {}
+		}
+	}
+})
+
+const closed = ref(false)
+const handleClosed = () => {
+	closed.value = true
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-tag {
+	display: inline-flex;
+	height: 38rpx;
+	line-height: 38rpx;
+	padding: 0 10rpx;
+	font-size: 11px;
+	color: #fff;
+	vertical-align: middle;
+	border-radius: 4rpx;
+	align-items: center;
+	white-space: nowrap;
+
+	&-plain {
+		background-color: transparent !important;
+		line-height: 32rpx;
+		border: 2rpx solid currentColor;
+
+		&.bg-default {
+			color: var(--disabled);
+		}
+		&.bg-primary {
+			color: var(--primary);
+		}
+		&.bg-success {
+			color: var(--success);
+		}
+		&.bg-warning {
+			color: var(--warning);
+		}
+		&.bg-info {
+			color: var(--info);
+		}
+		&.bg-danger {
+			color: var(--danger);
+		}
+	}
+
+	&-round {
+		border-radius: 30rpx;
+	}
+	&-mark {
+		border-radius: 0 25rpx 25rpx 0;
+	}
+	&-mark-reverse {
+		border-radius: 25rpx 0 0 25rpx;
+	}
+
+	&-medium {
+		font-size: 12px;
+		height: 48rpx;
+		line-height: 48rpx;
+		padding: 0 20rpx;
+		&.plain {
+			line-height: 48rpx;
+		}
+	}
+	&-large {
+		font-size: 13px;
+		height: 58rpx;
+		line-height: 58rpx;
+		padding: 0 30rpx;
+		&.fs-tag-plain {
+			line-height: 58rpx;
+		}
+		&.fs-tag-mark-reverse {
+			border-radius: 50rpx 0 0 50rpx;
+		}
+	}
+
+	&-close {
+		margin-left: 8rpx;
+	}
+	&-none {
+		display: none;
+	}
+}
+.bg-default {
+	background-color: #cfcfcf;
+}
+</style>

+ 87 - 0
components/fs-text/fs-text.vue

@@ -0,0 +1,87 @@
+<template>
+	<text :class="[colorType, decoration, { block }]" :style="styleStr" @click="handleClick">{{ formatText(text) }}</text>
+</template>
+
+<script>
+/**
+ * 文本组件
+ * @description 文本组件
+ * @property {String} text 显示的内容文本
+ * @property {String} mode = [price | phone | name] 文本处理的匹配模式
+ * @property {String} color 文本颜色
+ * @property {String} colorType = [primary | danger | warning | info | success | default] 文本亮颜色类型
+ * @property {String} link 跳转地址
+ * @property {String} linkType 跳转类型
+ * @property {Boolean} bolck 块状
+ * @property {Boolean} encrypt 是否加密(仅对mode=phone有效)
+ * @property {String} decoration = [underline | line-through] 文字装饰
+ 
+ */
+export default {
+	name: 'fs-text'
+}
+</script>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+	text: String,
+	mode: {
+		type: String,
+		validator(value) {
+			return ['price', 'phone', 'name'].includes(value)
+		}
+	},
+	colorType: {
+		type: String,
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger'].includes(value)
+		}
+	},
+	color: String,
+	link: String,
+	linkType: {
+		type: String,
+		default: 'navigateTo'
+	},
+	block: Boolean,
+	encrypt: Boolean,
+	decoration: {
+		type: String,
+		validator(value) {
+			return ['underline', 'line-through'].includes(value)
+		}
+	}
+})
+const emits = defineEmits(['click'])
+
+const styleStr = computed(() => {
+	return props.color ? `color: ${props.color};` : ''
+})
+
+const formatText = text => {
+	if (props.mode === 'price') {
+		return '¥' + text
+	} else if (props.mode === 'name') {
+		return text[0] + '**'
+	} else if (props.mode === 'phone' && props.encrypt) {
+		return text.slice(0, 3) + '****' + text.slice(-4)
+	}
+	return text
+}
+const handleClick = () => {
+	if (props.mode === 'phone') {
+		uni.makePhoneCall({
+			phoneNumber: props.text
+		})
+	} else if (props.link) {
+		uni[props.linkType]({
+			url: props.link
+		})
+	}
+	emits('click')
+}
+</script>
+
+<style lang="scss" scoped></style>

+ 34 - 0
components/fs-timeago/fs-timeago.vue

@@ -0,0 +1,34 @@
+<template>
+	<view @click="handleClick">{{ timeago(dateTime, format) }}</view>
+</template>
+
+<script>
+/**
+ * 多久之前组件
+ * @description 多久之前组件
+ * @property {String, Number, Object} dateTime 日期
+ * @property {String} format 格式化
+ */
+export default {
+	name: 'fs-timeago'
+}
+</script>
+
+<script setup>
+import timeago from '@/utils/timeago'
+
+const props = defineProps({
+	dateTime: [String, Number, Object],
+	format: {
+		type: String,
+		default: 'YYYY-MM-DD'
+	}
+})
+const emits = defineEmits(['click'])
+
+const handleClick = () => {
+	emits('click')
+}
+</script>
+
+<style></style>

+ 91 - 0
components/fs-timeline/fs-timeline.vue

@@ -0,0 +1,91 @@
+<template>
+	<view class="fs-timeline">
+		<view class="fs-timeline-item" v-for="(item, index) in options" :key="index">
+			<view class="fs-dot-box">
+				<slot name="dot" :item="item" :index="index">
+					<view
+						class="fs-dot"
+						:class="index === 0 ? 'bg-' + activeColorType : ''"
+						:style="{ backgroundColor: index === 0 ? activeColor : color }"
+					></view>
+				</slot>
+			</view>
+			<view class="fs-timeline-line"></view>
+			<view class="content"><slot :item="item" :index="index"></slot></view>
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 时间轴组件
+ * @description 时间轴组件
+ * @property {Array} options 选项列表
+ * @property {String} color 颜色
+ * @property {String} activeColor 高亮颜色
+ * @property {String} activeColorType = [primary | danger | warning | info | success] 高亮颜色类型
+ */
+export default {
+	name: 'fs-timeline'
+}
+</script>
+
+<script setup>
+const props = defineProps({
+	options: Array,
+	color: {
+		type: String,
+		default: '#969799'
+	},
+	activeColor: String,
+	activeColorType: {
+		type: String,
+		default: 'primary',
+		validator(value) {
+			return ['primary', 'success', 'info', 'warning', 'danger'].includes(value)
+		}
+	}
+})
+</script>
+
+<style lang="scss" scoped>
+.fs-timeline {
+	padding-left: 40rpx;
+
+	&-line {
+		position: absolute;
+		top: 0;
+		left: -20rpx;
+		width: 2rpx;
+		height: 100%;
+		background-color: #ebedf0;
+	}
+
+	&-item {
+		position: relative;
+
+		&:last-child {
+			.timeline-line {
+				display: none;
+			}
+		}
+	}
+}
+.fs-dot-box {
+	position: absolute;
+	left: -20rpx;
+	top: 30rpx;
+	background-color: #fff;
+	z-index: 10;
+	transform: translateX(-50%);
+}
+.fs-dot {
+	width: 15rpx;
+	height: 15rpx;
+	border-radius: 50%;
+	// background-color: #969799;
+}
+.content {
+	padding: var(--gutter) 0;
+}
+</style>

+ 192 - 0
components/fs-upload/fs-upload.vue

@@ -0,0 +1,192 @@
+<template>
+	<view class="fs-file-list">
+		<view v-for="(item, index) in modelValue" :key="index" class="fs-file-box">
+			<fs-icon type="icon-close" size="20px" colorType="danger" class="fs-file-del" @click="handleDel(index)"></fs-icon>
+			<fs-avatar
+				v-if="mediaType === 'image'"
+				:src="formatPath(item)"
+				:shape="shape"
+				:size="size"
+				:width="width"
+				:height="height"
+				radius
+				@click="handlePreview(formatPath(item))"
+			></fs-avatar>
+			<video v-else :src="formatPath(item)" controls class="fs-file-video"></video>
+		</view>
+		<view class="fs-file-box" @click="upload" v-if="modelValue.length < count">
+			<slot>
+				<fs-avatar :shape="shape" :size="size" :width="width" :height="height" radius bgColor="#EBEFF5">
+					<fs-icon type="icon-plus" size="50px"></fs-icon>
+				</fs-avatar>
+			</slot>
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 上传组件
+ * @description 上传组件
+ * @property {String} action 上传地址
+ * @property {String} name 文件对应的key,开发者在服务器端通过这个key可以获取到文件二进制内容
+ * @property {Object} HTTP 请求 Header, header 中不能设置 Referer
+ * @property {String} mediaType = [image | video] 媒体类型
+ * @property {Number} count 最多上传数量
+ * @property {String} shape 上传框形状
+ * @property {String} size 上传框大小
+ * @property {String} width 上传框宽度(优先级高于size)
+ * @property {String} height 上传框高度(优先级高于size)
+ * @property {String} height 上传框高度(优先级高于size)
+ * @property {String} height 上传框高度(优先级高于size)
+ * @property {Object} chooseDatachooseImage/chooseVideo参数
+ * @property {Object} formData 上传数据
+ * @property {String} pathKey	接口返回的图片路径的key(如果没有可设置成空)
+ * @property {Boolean} editable	图片编辑
+ * @property {Boolean} cloudUpload	是否云上传(需配置云服务空间)
+ */
+export default {
+	name: 'fs-upload'
+}
+</script>
+
+<script setup>
+import { getCurrentInstance } from 'vue'
+import utils from '@/utils/utils'
+import config from '@/utils/config'
+
+const { proxy } = getCurrentInstance()
+
+const props = defineProps({
+	action: String,
+	name: {
+		type: String,
+		default: 'file'
+	},
+	header: {
+		type: Object,
+		default: () => {}
+	},
+	count: {
+		type: Number,
+		default: 1
+	},
+	chooseData: {
+		type: Object,
+		default: () => {}
+	},
+	modelValue: {
+		type: Array,
+		default: () => []
+	},
+	mediaType: {
+		type: String,
+		default: 'image',
+		validator(value) {
+			return ['image', 'video'].includes(value)
+		}
+	},
+	shape: {
+		type: String,
+		default: 'square',
+		validator(value) {
+			return ['square', 'circle'].includes(value)
+		}
+	},
+	size: {
+		type: String,
+		default: '150rpx'
+	},
+	width: {
+		type: String,
+		default: '150rpx'
+	},
+	height: {
+		type: String,
+		default: '150rpx'
+	},
+	formData: {
+		type: Object,
+		default() {
+			return {}
+		}
+	},
+	pathKey: {
+		type: String,
+		default: 'filePath'
+	},
+	editable: Boolean,
+	cloudUpload: Boolean
+})
+const emit = defineEmits(['update:modelValue'])
+
+const handlePreview = current => {
+	uni.previewImage({
+		current,
+		urls: props.modelValue.map(item => formatPath(item))
+	})
+}
+const upload = async () => {
+	let methods = ''
+	if (props.mediaType === 'image') {
+		methods = 'chooseAndUploadImage'
+	} else {
+		methods = 'chooseAndUploadVideo'
+	}
+	utils[methods](
+		{
+			count: props.count,
+			...props.chooseData
+		},
+		{
+			url: props.action || proxy.$uploadUrl,
+			name: props.name,
+			header: props.header,
+			formData: props.formData,
+			editable: props.editable
+		},
+		props.cloudUpload
+	).then(res => {
+		const fileList = [...props.modelValue, ...res]
+		fileList.length > props.count && fileList.splice(props.count)
+		emit('update:modelValue', fileList)
+	})
+}
+const handleDel = index => {
+	props.modelValue.splice(index, 1)
+	emit('update:modelValue', props.modelValue)
+}
+
+const formatPath = item => {
+	const path = props.pathKey ? item[props.pathKey] : item
+	return utils.isHttp(path) ? path : config.baseUrl + path
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-file {
+	&-list {
+		display: flex;
+		flex-wrap: wrap;
+		padding-top: var(--gutter);
+	}
+	&-box {
+		position: relative;
+		margin-bottom: var(--gutter);
+		margin-left: var(--gutter);
+	}
+	&-video {
+		width: 300rpx;
+		height: 200rpx;
+	}
+	&-del {
+		position: absolute;
+		top: 0;
+		right: 0;
+		transform: translate(50%, -50%);
+		font-size: 22px;
+		color: var(--sub);
+		z-index: 10;
+	}
+}
+</style>

+ 173 - 0
components/fs-week-bar/fs-week-bar.vue

@@ -0,0 +1,173 @@
+<template>
+	<view class="fs-week-bar">
+		<view class="fs-week-arrow" @click="handleChange('left')">
+			<fs-icon type="icon-d-down" rotate="90" size="28rpx" :color="textColor"></fs-icon>
+		</view>
+		<view class="fs-week-list">
+			<view
+				class="fs-week-item"
+				:class="{ 'fs-week-item-active': state.activeDate === item.fullDate }"
+				v-for="(item, index) in state.week"
+				:key="index"
+				@click="handleClick(item)"
+			>
+				<view class="fs-week-item-hd">周{{ item.day }}</view>
+				<view class="fs-week-item-bd">{{ item.date }}</view>
+			</view>
+		</view>
+		<view class="fs-week-arrow" @click="handleChange('right')">
+			<fs-icon type="icon-d-down" rotate="-90" size="28rpx" :color="textColor"></fs-icon>
+		</view>
+	</view>
+</template>
+
+<script>
+/**
+ * 时间周组件
+ * @description 时间周组件
+ * @property {String} bgColor 背景颜色
+ * @property {String} textColor	文字颜色
+ * @property {String} activeColor 激活颜色
+ * @property {String} activeDate	激活日期
+ * @event {Function} change 左右箭头切换事件
+ */
+export default {
+	name: 'fs-week-bar'
+}
+</script>
+
+<script setup>
+import { ref, reactive, watch, nextTick } from 'vue'
+import dayjs from 'dayjs'
+
+const props = defineProps({
+	bgColor: {
+		type: String,
+		default: '#fff'
+	},
+	textColor: {
+		type: String,
+		default: '#666666'
+	},
+	activeColor: {
+		type: String,
+		default: '#165DFF'
+	},
+	activeDate: {
+		type: String,
+		default: dayjs().format('YYYY-MM-DD')
+	}
+})
+const emits = defineEmits(['change', 'click'])
+
+const state = reactive({
+	week: [],
+	radix: 0,
+	activeDate: '',
+	curDay: '',
+	curDate: ''
+})
+
+const dayMap = {
+	1: '一',
+	2: '二',
+	3: '三',
+	4: '四',
+	5: '五',
+	6: '六',
+	7: '日'
+}
+const initWeek = () => {
+	state.week = []
+	for (let i = 1; i < state.curDay; i++) {
+		const diffDay = state.curDay - i
+		const date = dayjs(state.curDate)
+			.subtract(diffDay, 'day')
+			.add(7 * state.radix, 'day')
+		state.week.push({
+			day: dayMap[i],
+			date: date.format('MM-DD'),
+			fullDate: date.format('YYYY-MM-DD')
+		})
+	}
+	for (let i = state.curDay; i <= 7; i++) {
+		const diffDay = i - state.curDay
+		const date = dayjs(state.curDate)
+			.add(diffDay, 'day')
+			.add(7 * state.radix, 'day')
+		state.week.push({
+			day: dayMap[i],
+			date: date.format('MM-DD'),
+			fullDate: date.format('YYYY-MM-DD')
+		})
+	}
+}
+const stopWatch = watch(
+	() => props.activeDate,
+	val => {
+		const date = val || undefined
+
+		state.activeDate = dayjs(date).format('YYYY-MM-DD')
+		state.curDate = dayjs(date).format('YYYY-MM-DD')
+		state.curDay = dayjs(date).day() || 7
+
+		// state.radix = Math.floor((dayjs(date).diff(dayjs(), 'day') + state.curDay) / 7)
+		initWeek()
+	},
+	{ immediate: true }
+)
+
+const handleChange = type => {
+	type === 'left' ? state.radix-- : state.radix++
+}
+watch(
+	() => state.radix,
+	val => {
+		initWeek()
+		emits('change', state.week)
+	}
+)
+
+const handleClick = item => {
+	stopWatch()
+	state.activeDate = item.fullDate
+	emits('click', item.fullDate)
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-week {
+	&-bar {
+		display: flex;
+		align-items: center;
+		background-color: v-bind(bgColor);
+		padding: 20rpx 0;
+	}
+
+	&-arrow {
+		padding: 0 10rpx;
+	}
+
+	&-list {
+		display: flex;
+		flex: 1;
+	}
+	&-item {
+		text-align: center;
+		flex: 1;
+		color: v-bind(textColor);
+
+		&-active {
+			color: v-bind(activeColor);
+		}
+
+		&-hd {
+			font-size: 15px;
+			font-weight: bold;
+		}
+		&-bd {
+			font-size: 13px;
+		}
+	}
+}
+</style>

+ 59 - 0
components/fs-wx-avatar/fs-wx-avatar.vue

@@ -0,0 +1,59 @@
+<template>
+	<!-- #ifdef MP-WEIXIN -->
+	<view
+		class="fs-wx-avatar"
+		:style="{width: width || size, height: height || size, border: borderStyle}"
+		@click="handleClick">
+		<open-data type="userAvatarUrl"></open-data>
+	</view>
+	<!-- #endif -->
+	<!-- #ifndef MP-WEIXIN -->
+	<view></view>
+	<!-- #endif -->
+</template>
+
+<script setup>
+import { computed } from 'vue'
+
+const props = defineProps({
+	width: String,
+	height: String,
+	size: {
+		type: String,
+		default: '140rpx'
+	},
+	border: Boolean,
+	borderWidth: {
+		type: String,
+		default: '4rpx'
+	},
+	borderColor: {
+		type: String,
+		default: '#fff'
+	},
+	link: String,
+	linkType: {
+		type: String,
+		default: 'navigateTo'
+	},
+})
+const emits = defineEmits(['click'])
+
+const borderStyle = computed(() => props.border ? `${props.borderWidth} solid ${props.borderColor}` : 'none')
+
+const handleClick = () => {
+	if (props.link) {
+		uni[props.linkType]({
+			url: props.link
+		})
+	}
+	emits('click')
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-wx-avatar{
+	border-radius: 50%;
+	overflow: hidden;
+}
+</style>

+ 38 - 0
hooks/useForm/index.js

@@ -0,0 +1,38 @@
+import { getCurrentInstance, onMounted } from 'vue'
+import Schema from 'async-validator'
+
+export default (rules = {}, formRef = 'formRef', messageRef = 'messageRef') => {
+	let refs = {}
+	let validator = new Schema(rules)
+
+	onMounted(() => {
+		refs = getCurrentInstance().refs
+	})
+	
+  return {
+    setRules(rules) {
+			validator = new Schema(rules)
+		},
+		validate(data = refs[formRef] && refs[formRef].model) {
+			if (data) {
+				return new Promise((resolve, reject) => {
+					validator.validate(data).then(() => {
+						resolve()
+					}).catch(({ errors, fields }) => {
+						if (refs[formRef].errorType === 'message') {
+							refs[messageRef].error(errors[0].message)
+						} else{
+							uni.showToast({
+								icon: 'none',
+								title: errors[0].message
+							})
+						}
+						reject(errors, fields)
+					})
+				})
+			} else{
+				return Promise.reject('请确认form是否加了ref、model属性')
+			}
+		}
+  }
+}

+ 25 - 0
hooks/useGeocoder/index.js

@@ -0,0 +1,25 @@
+export default () => {
+	const key = '6ZCBZ-I2GC4-BBIUZ-DNZVG-DXJA3-7WBS5'
+	const url = 'https://apis.map.qq.com/ws/geocoder/v1/'
+	
+	return {
+		address(address) {
+			return uni.request({
+				url,
+				data: {
+					address,
+					key
+				}
+			})
+		},
+		location(location){
+			return uni.request({
+				url,
+				data: {
+					location,
+					key
+				}
+			})
+		}
+	}
+}

+ 40 - 0
hooks/useLoadmore/index.js

@@ -0,0 +1,40 @@
+import {
+	reactive,
+	onMounted,
+	getCurrentInstance
+} from 'vue'
+import {
+	onReachBottom,
+	onShow,
+	onPullDownRefresh,
+	onReady
+} from '@dcloudio/uni-app'
+
+export default loadmoreRef => {
+	let refs = {}
+
+	const refresh = () => {
+		refs[loadmoreRef] && refs[loadmoreRef].refresh()
+	}
+
+	onMounted(() => {
+		refs = getCurrentInstance().refs
+	})
+
+	onPullDownRefresh(() => {
+		if (refs[loadmoreRef] && refs[loadmoreRef].pullDownRefresh) {
+			refs[loadmoreRef].hasMore = true
+			refs[loadmoreRef].query().then(() => uni.stopPullDownRefresh())
+		}
+	})
+
+	onReachBottom(() => {
+		if (refs[loadmoreRef] && refs[loadmoreRef].hasMore) {
+			refs[loadmoreRef].query(true)
+		}
+	})
+
+	return {
+		refresh
+	}
+}

+ 22 - 0
hooks/useQrcodeQuery/index.js

@@ -0,0 +1,22 @@
+import { ref } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+
+export default (separator = '?') => {
+	let urlQuery = ref('')
+	
+	onLoad(options => {
+		const scene = decodeURIComponent(options.q)
+		const arr = scene.split(separator)
+		const length = arr.length
+		urlQuery = arr[length - 1]
+	})
+	
+	return {
+		getQueryString (name) {
+			var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)", "i")
+			var r = urlQuery.match(reg)
+			if (r != null) return unescape(r[2])
+			return null
+		}
+	}
+}

+ 11 - 0
hooks/useScrollTop/index.js

@@ -0,0 +1,11 @@
+import { ref } from 'vue'
+import { onPageScroll } from '@dcloudio/uni-app'
+
+export default (sTop = 0) => {
+	const scrollTop = ref(sTop)
+	
+	onPageScroll(e => {
+		scrollTop.value = e.scrollTop
+	})
+	return scrollTop
+}

+ 64 - 0
hooks/useValidator/index.js

@@ -0,0 +1,64 @@
+export default () => {
+	return {
+		mobile(rule, value, callback) {
+			if (value === '') {
+				return new Error('请输入手机号')
+			}
+			if (!/^1\d{10}$/.test(value)) {
+				return new Error('请输入正确的手机号')
+			}
+			return true
+		},
+		name(rule, value, callback) {
+			if (value === '') {
+				return new Error('请输入名字')
+			}
+			if (!/^[a-zA-Z\u4e00-\u9fa5]+$/g.test(value)) {
+				return new Error('名字只能是汉字或字母')
+			}
+			return true
+		},
+		idCard(rule, value, callback) {
+			if (value === '') {
+				return new Error('请输入身份证号')
+			}
+			if (!
+				/^[1-9]\d{7}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}$|^[1-9]\d{5}[1-9]\d{3}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}([0-9]|X)$/
+					.test(value)) {
+				return new Error('请输入正确的身份证号')
+			}
+			return true
+		},
+
+
+		contacts(rule, value, callback) {
+			if (value === '') {
+				return new Error('请输入联系人')
+			}
+			if (!/^[a-zA-Z\u4e00-\u9fa5]+$/g.test(value)) {
+				return new Error('联系人只能是汉字或字母')
+			}
+			return true
+		},
+
+		fullname(rule, value, callback) {
+			if (value === '') {
+				return new Error('请输入姓名')
+			}
+			if (!/^[a-zA-Z\u4e00-\u9fa5]+$/g.test(value)) {
+				return new Error('姓名只能是汉字或字母')
+			}
+			return true
+		},
+
+		email(rule, value, callback) {
+			if (value === '') {
+				return new Error('请输入邮箱')
+			}
+			if (!/^([a-zA-Z]|[0-9])(\w|\-)+@[a-zA-Z0-9]+\.([a-zA-Z]{2,4})$/.test(value)) {
+				return new Error('邮箱格式不正确')
+			}
+			return true
+		}
+	}
+}

+ 18 - 0
index.html

@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+  <title></title>
+  <link rel="icon" href="/static/images/tool/logo.png" />
+  <!--preload-links-->
+  <!--app-context-->
+</head>
+
+<body>
+  <div id="app"><!--app-html--></div>
+  <script type="module" src="/main.js"></script>
+</body>
+
+</html>

+ 20 - 0
main.js

@@ -0,0 +1,20 @@
+import { store } from './stores'
+import App from './App'
+import http from './utils/http'
+import config from './utils/config'
+import Vant from 'vant';
+import 'vant/lib/index.css';
+
+import { createSSRApp } from 'vue'
+export function createApp() {
+	const app = createSSRApp(App)
+	app.use(store)
+	app.use(Vant);
+	app.config.globalProperties.$http = http
+	app.config.globalProperties.$uploadUrl = config.uploadUrl
+
+	return {
+		app,
+		store
+	}
+}

+ 80 - 0
manifest.json

@@ -0,0 +1,80 @@
+{
+    "name" : "中小学生演示版",
+    "appid" : "__UNI__7F40FCB",
+    "description" : "",
+    "versionName" : "1.0.0",
+    "versionCode" : "100",
+    "transformPx" : false,
+    /* 5+App特有相关 */
+    "app-plus" : {
+        "usingComponents" : true,
+        "nvueStyleCompiler" : "fs-uni",
+        "compilerVersion" : 3,
+        "splashscreen" : {
+            "alwaysShowBeforeRender" : true,
+            "waiting" : true,
+            "autoclose" : true,
+            "delay" : 0
+        },
+        /* 模块配置 */
+        "modules" : {},
+        /* 应用发布信息 */
+        "distribute" : {
+            /* android打包配置 */
+            "android" : {
+                "permissions" : [
+                    "<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
+                    "<uses-permission android:name=\"android.permission.VIBRATE\"/>",
+                    "<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
+                    "<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
+                    "<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.CAMERA\"/>",
+                    "<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
+                    "<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
+                    "<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
+                    "<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
+                    "<uses-feature android:name=\"android.hardware.camera\"/>",
+                    "<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
+                ]
+            },
+            /* ios打包配置 */
+            "ios" : {},
+            /* SDK配置 */
+            "sdkConfigs" : {}
+        }
+    },
+    /* 快应用特有相关 */
+    "quickapp" : {},
+    /* 小程序特有相关 */
+    "mp-weixin" : {
+        "appid" : "",
+        "setting" : {
+            "urlCheck" : false
+        },
+        "usingComponents" : true,
+        "optimization" : {
+            "subPackages" : true
+        },
+        "permission" : {
+            "scope.userLocation" : {
+                "desc" : "地图选择需要您的位置信息"
+            }
+        }
+    },
+    "mp-alipay" : {
+        "usingComponents" : true
+    },
+    "mp-baidu" : {
+        "usingComponents" : true
+    },
+    "mp-toutiao" : {
+        "usingComponents" : true
+    },
+    "uniStatistics" : {
+        "enable" : false
+    },
+    "vueVersion" : "3"
+}

+ 12 - 0
modules/common/detail.vue

@@ -0,0 +1,12 @@
+<template>
+    <view>
+        详情
+    </view>
+</template>
+
+<script setup>
+
+
+</script>
+
+<style lang="scss" scoped></style>

+ 442 - 0
modules/common/student-prosthetics/approval-progress.vue

@@ -0,0 +1,442 @@
+<script setup>
+import { ref } from 'vue'
+import TopNavBar from '@/business/top-nav-bar.vue'
+import { getLeaveApplicationApproval, getDiagnosisAttachments } from '@/services/student-prosthetics'
+
+const info = computed(() => {
+  const storage = JSON.parse(localStorage.getItem('studentInfo') || '{}')
+  return storage
+})
+
+// 审批进度数据
+const progressData = ref({
+  applyStatusStr: '审批中',
+  name: '',
+  schoolName: '',
+  gradeClass: '',
+  createdTime: '',
+  details: {
+    expectedStartDate: '',
+    expectedEndDate: '',
+    auditOpinion: '',
+    diseaseDiagnosis: ''
+  },
+  progressSteps: [
+    // {
+    //   nodeName: '家长提交',
+    //   operatorRole: '张三(家长)',
+    //   lastUpdatedTime: '2024-01-15 10:30',
+    //   action: '同意',
+    //   comment: '情况属实',
+    //   status: 'finish'
+    // },
+    // {
+    //   nodeName: '学校审批',
+    //   operatorRole: '李老师',
+    //   lastUpdatedTime: '2024-01-15 14:20',
+    //   action: '同意',
+    //   comment: '情况属实',
+    //   status: 'process'
+    // },
+    // {
+    //   nodeName: '教育局审批',
+    //   operatorRole: '',
+    //   lastUpdatedTime: '',
+    //   action: '',
+    //   comment: '',
+    //   status: 'pending'
+    // }
+  ],
+  attachments: [
+    // { url: 'https://fastly.jsdelivr.net/npm/@vant/assets/leaf.jpeg' },
+    // { url: 'https://fastly.jsdelivr.net/npm/@vant/assets/tree.jpeg' }
+  ]
+})
+
+// 计算当前激活的步骤索引
+const activeStep = ref(0)
+
+onLoad((options) => {
+  for (const key in progressData.value) {
+    if (!Object.hasOwn(info.value, key)) continue
+    progressData.value[key] = info.value[key]
+  }
+
+  getLeaveApplicationApproval({ applicationId: info.value.applicationId, tenantId: info.value.tenantId }).then((res) => {
+    if (res.success) {
+      const infos = (res.infos || []).map((item, index) => {
+        item.status = item.action ? 'finish' : 'process'
+        return item
+      })
+      progressData.value.progressSteps = infos
+      activeStep.value = infos.length - 1
+    }
+  })
+
+  getDiagnosisAttachments({ applicationId: info.value.applicationId, tenantId: info.value.tenantId }).then((res) => {
+    if (res.success) {
+      const infos = (res.infos || []).map((item) => {
+        if (item.fileUrl && item.fileUrl.length) {
+          return { url: item.fileUrl[0].temp }
+        } else {
+          return { url: item.fileUrlStr }
+        }
+      })
+      progressData.value.attachments = infos
+    }
+  })
+
+})
+</script>
+
+<template>
+  <div class="progress-page">
+    <TopNavBar></TopNavBar>
+
+    <!-- 申请信息头部 -->
+    <div class="progress-header">
+      <div class="status-tag">{{ progressData.applyStatusStr }}</div>
+      <div class="apply-info">
+        <div class="info-row">
+          <div class="label">学生姓名</div>
+          <div class="value">{{ progressData.name }}</div>
+        </div>
+        <div class="info-row">
+          <div class="label">学校</div>
+          <div class="value">{{ progressData.schoolName }}</div>
+        </div>
+        <div class="info-row">
+          <div class="label">班级</div>
+          <div class="value">{{ progressData.gradeClass }}</div>
+        </div>
+        <div class="info-row">
+          <div class="label">提交时间</div>
+          <div class="value">{{ progressData.createdTime }}</div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 审批进度 - 使用van-steps -->
+    <div class="section">
+      <div class="section-title">审批进度</div>
+      <div class="steps-container">
+        <van-steps direction="vertical" :active="activeStep">
+          <van-step v-for="(step, index) in progressData.progressSteps" :key="index">
+            <div>
+              <!-- 步骤标题区域 -->
+              <div class="step-header">
+                <div class="step-title">{{ step.nodeName }}</div>
+                <div class="step-status" :class="step.status">
+                  {{
+                    step.status === 'finish' ? '已完成' :
+                      step.status === 'process' ? '进行中' : '待处理'
+                  }}
+                </div>
+              </div>
+
+              <!-- 处理人信息 -->
+              <div class="step-info">
+                <div class="info-item">
+                  <span class="info-label">处理人:</span>
+                  <span class="info-value">{{ step.operatorRole || '待处理' }}</span>
+                </div>
+                <div v-if="step.lastUpdatedTime" class="info-item">
+                  <span class="info-label">日期:</span>
+                  <span class="info-value">{{ step.lastUpdatedTime }}</span>
+                </div>
+              </div>
+
+              <!-- 审批意见 -->
+              <div v-if="step.action || step.comment" class="step-opinion">
+                {{ step.action }} {{ step.comment }}
+              </div>
+
+              <!-- 空状态提示 -->
+              <div v-if="!step.operatorRole && !step.lastUpdatedTime" class="step-empty">
+                等待处理中...
+              </div>
+            </div>
+          </van-step>
+        </van-steps>
+      </div>
+    </div>
+
+    <!-- 申请详情 -->
+    <div class="section">
+      <div class="section-title">申请详情</div>
+      <div class="detail-list">
+        <div class="detail-item">
+          <div class="label">开始日期</div>
+          <div class="value">{{ progressData.details.expectedStartDate }}</div>
+        </div>
+        <div class="detail-item">
+          <div class="label">结束日期</div>
+          <div class="value">{{ progressData.details.expectedEndDate }}</div>
+        </div>
+        <div class="detail-item">
+          <div class="label">申请原因</div>
+          <div class="value">{{ progressData.details.auditOpinion }}</div>
+        </div>
+        <div class="detail-item">
+          <div class="label">疾病诊断</div>
+          <div class="value">{{ progressData.details.diseaseDiagnosis }}</div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 证明材料 -->
+    <div class="section">
+      <div class="section-title">证明材料</div>
+      <van-uploader v-model="progressData.attachments" multiple disabled :deletable="false" />
+      <!-- <div class="attachment-images">
+        <div class="image-item">
+          <div class="image-placeholder">图片1: 医院诊断证明</div>
+        </div>
+        <div class="image-item">
+          <div class="image-placeholder">图片2: 病历资料</div>
+        </div>
+      </div> -->
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.progress-page {
+  padding: 24rpx;
+  background-color: #f7f8fa;
+  min-height: 100vh;
+  padding-top: 120rpx;
+}
+
+.progress-header {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 32rpx 24rpx;
+  margin-bottom: 24rpx;
+  position: relative;
+
+  .status-tag {
+    position: absolute;
+    top: 24rpx;
+    right: 24rpx;
+    background-color: #ff9800;
+    color: #fff;
+    font-size: 24rpx;
+    padding: 4rpx 16rpx;
+    border-radius: 20rpx;
+  }
+
+  .apply-info {
+    .info-row {
+      display: flex;
+      padding: 12rpx 0;
+
+      .label {
+        width: 180rpx;
+        font-size: 28rpx;
+        color: #666;
+        flex-shrink: 0;
+      }
+
+      .value {
+        flex: 1;
+        font-size: 28rpx;
+        color: #333;
+      }
+    }
+  }
+}
+
+.section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 24rpx;
+
+  .section-title {
+    font-size: 32rpx;
+    font-weight: 600;
+    color: #333;
+    margin-bottom: 24rpx;
+    padding-bottom: 16rpx;
+    border-bottom: 1rpx solid #eee;
+  }
+}
+
+.steps-container {
+
+  .van-step {
+    padding: 32rpx 0 32rpx 0;
+    position: relative;
+
+    &:first-child {
+      &::before {
+        top: 72rpx;
+      }
+    }
+
+    &:last-child {
+      &::before {
+        bottom: auto;
+        height: 72rpx;
+      }
+    }
+
+    .van-step__circle {
+      width: 24rpx;
+      height: 24rpx;
+      background-color: #fff;
+      border: 2rpx solid #666;
+      box-shadow: 0 0 0 4rpx #fff;
+      z-index: 1;
+
+      &.van-step__circle--finish {
+        background-color: #4caf50;
+        border-color: #4caf50;
+      }
+
+      &.van-step__circle--process {
+        background-color: #ff9800;
+        border-color: #ff9800;
+      }
+    }
+
+    .van-step__line {
+      background-color: #e0e0e0;
+
+      &.van-step__line--vertical {
+        width: 2rpx;
+        left: 21rpx;
+      }
+    }
+  }
+
+  .van-step__title {
+    font-size: 0;
+    line-height: 0;
+  }
+
+}
+
+.step-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16rpx;
+
+  .step-title {
+    font-size: 30rpx;
+    font-weight: 500;
+    color: #333;
+  }
+
+  .step-status {
+    font-size: 26rpx;
+    padding: 4rpx 12rpx;
+    border-radius: 12rpx;
+
+    &.finish {
+      background-color: #e8f5e9;
+      color: #4caf50;
+    }
+
+    &.process {
+      background-color: #fff3e0;
+      color: #ff9800;
+    }
+
+    &.wait {
+      background-color: #f5f5f5;
+      color: #999;
+    }
+  }
+}
+
+.step-info {
+  margin-bottom: 16rpx;
+
+  .info-item {
+    font-size: 26rpx;
+    color: #666;
+    margin-bottom: 8rpx;
+    line-height: 1.4;
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+
+    .info-label {
+      color: #888;
+    }
+
+    .info-value {
+      color: #333;
+    }
+  }
+}
+
+.step-opinion {
+  background: #f9f9f9;
+  border-radius: 8rpx;
+  padding: 16rpx;
+  font-size: 28rpx;
+  color: #333;
+  border-left: 4rpx solid #4caf50;
+  margin-bottom: 8rpx;
+}
+
+.step-empty {
+  color: #999;
+  font-size: 26rpx;
+  padding: 16rpx 0;
+  font-style: italic;
+}
+
+.detail-list {
+  .detail-item {
+    display: flex;
+    padding: 20rpx 0;
+    border-bottom: 1rpx solid #f0f0f0;
+
+    &:last-child {
+      border-bottom: none;
+    }
+
+    .label {
+      width: 200rpx;
+      font-size: 28rpx;
+      color: #666;
+      flex-shrink: 0;
+    }
+
+    .value {
+      flex: 1;
+      font-size: 28rpx;
+      color: #333;
+    }
+  }
+}
+
+.attachment-images {
+  display: grid;
+  grid-template-columns: repeat(2, 1fr);
+  gap: 24rpx;
+
+  .image-item {
+    aspect-ratio: 1;
+    background: #f0f0f0;
+    border-radius: 12rpx;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border: 2rpx dashed #ddd;
+
+    .image-placeholder {
+      font-size: 26rpx;
+      color: #888;
+      text-align: center;
+      padding: 16rpx;
+    }
+  }
+}
+</style>

+ 452 - 0
modules/common/student-prosthetics/certificate.vue

@@ -0,0 +1,452 @@
+<script setup>
+import { computed, ref } from 'vue'
+import TopNavBar from '@/business/top-nav-bar.vue'
+import { getLeaveApplicationApproval, getDiagnosisAttachments } from '@/services/student-prosthetics'
+
+const info = computed(() => {
+  const storage = JSON.parse(localStorage.getItem('studentInfo') || '{}')
+  return storage
+})
+
+// 存证数据
+const evidenceData = ref({
+  certNo: '',
+  createdTime: '',
+  name: '',
+  studentNo: '',
+  schoolName: '',
+  gradeClass: '',
+  leaveType: null,
+  details: {
+    expectedStartDate: '',
+    expectedEndDate: '',
+    diseaseDiagnosis: '',
+    auditOpinion: ''
+  },
+  approvalRecords: [
+    // {
+    //   nodeName: '家长提交',
+    //   operatorRole: '张三(家长)',
+    //   lastUpdatedTime: '2024-01-15 10:30',
+    //   action: '同意',
+    //   comment: '情况属实',
+    //   status: 'finish'
+    // },
+    // {
+    //   nodeName: '学校审批',
+    //   operatorRole: '李老师',
+    //   lastUpdatedTime: '2024-01-15 14:20',
+    //   action: '同意',
+    //   comment: '情况属实',
+    //   status: 'process'
+    // },
+    // {
+    //   nodeName: '教育局审批',
+    //   operatorRole: '',
+    //   lastUpdatedTime: '',
+    //   action: '',
+    //   comment: '',
+    //   status: 'pending'
+    // }
+  ],
+  attachments: [
+    // { url: 'https://fastly.jsdelivr.net/npm/@vant/assets/leaf.jpeg' },
+    // { url: 'https://fastly.jsdelivr.net/npm/@vant/assets/tree.jpeg' }
+  ]
+})
+
+// 期限描述 
+const periodDescription = computed(() => {
+  if (!evidenceData.value.details.expectedStartDate || !evidenceData.value.details.expectedEndDate) return ''
+
+  const start = new Date(evidenceData.value.details.expectedStartDate)
+  const end = new Date(evidenceData.value.details.expectedEndDate)
+
+  const days = Math.ceil((end - start) / (1000 * 60 * 60 * 24))
+
+  return `${days}天`
+})
+
+// 计算当前激活的步骤索引
+const activeStep = ref(0)
+
+onLoad((options) => {
+  for (const key in evidenceData.value) {
+    if (!Object.hasOwn(info.value, key)) continue
+    evidenceData.value[key] = info.value[key]
+  }
+
+  getLeaveApplicationApproval({ applicationId: info.value.applicationId, tenantId: info.value.tenantId }).then((res) => {
+    if (res.success) {
+      const infos = (res.infos || []).map((item, index) => {
+        item.status = item.action ? 'finish' : 'process'
+        return item
+      })
+      evidenceData.value.approvalRecords = infos
+      activeStep.value = infos.length - 1
+    }
+  })
+
+  getDiagnosisAttachments({ applicationId: info.value.applicationId, tenantId: info.value.tenantId }).then((res) => {
+    if (res.success) {
+      const infos = (res.infos || []).map((item) => {
+        if (item.fileUrl && item.fileUrl.length) {
+          return { url: item.fileUrl[0].temp }
+        } else {
+          return { url: item.fileUrlStr }
+        }
+      })
+      evidenceData.value.attachments = infos
+    }
+  })
+})
+</script>
+
+<template>
+  <div class="evidence-page">
+    <TopNavBar></TopNavBar>
+
+    <!-- 存证标题 -->
+    <div class="evidence-header">
+      <div class="title">休学存证</div>
+      <div class="evidence-id">{{ evidenceData.certNo }}</div>
+      <div class="evidence-time">存证时间:{{ evidenceData.createdTime }}</div>
+    </div>
+
+    <!-- 学生信息 -->
+    <div class="section">
+      <div class="section-title">学生信息</div>
+      <div class="info-table">
+        <div class="info-row">
+          <div class="label">姓名</div>
+          <div class="value">{{ evidenceData.name }}</div>
+        </div>
+        <div class="info-row">
+          <div class="label">学号</div>
+          <div class="value">{{ evidenceData.studentNo }}</div>
+        </div>
+        <div class="info-row">
+          <div class="label">所属学校</div>
+          <div class="value">{{ evidenceData.schoolName }}</div>
+        </div>
+        <div class="info-row">
+          <div class="label">年级班级</div>
+          <div class="value">{{ evidenceData.gradeClass }}</div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 休学信息 -->
+    <div class="section">
+      <div class="section-title">休学信息</div>
+      <div class="info-table">
+        <div class="info-row">
+          <div class="label">休学类型</div>
+          <div class="value">{{ evidenceData.details.leaveType == 1 ? '休学' : '复学' }}</div>
+        </div>
+        <div class="info-row">
+          <div class="label">开始日期</div>
+          <div class="value">{{ evidenceData.details.expectedStartDate }}</div>
+        </div>
+        <div class="info-row">
+          <div class="label">结束日期</div>
+          <div class="value">{{ evidenceData.details.expectedEndDate }}</div>
+        </div>
+        <div class="info-row">
+          <div class="label">休学期限</div>
+          <div class="value">{{ periodDescription }}</div>
+        </div>
+        <div class="info-row">
+          <div class="label">疾病诊断</div>
+          <div class="value">{{ evidenceData.details.diseaseDiagnosis }}</div>
+        </div>
+        <div class="info-row">
+          <div class="label">申请原因</div>
+          <div class="value">{{ evidenceData.details.auditOpinion }}</div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 审批记录 - 使用van-steps -->
+    <div class="section">
+      <div class="section-title">审批记录</div>
+      <div class="approval-steps">
+        <van-steps direction="vertical" :active="activeStep">
+          <van-step v-for="(step, index) in evidenceData.approvalRecords" :key="index">
+            <div>
+              <!-- 步骤标题区域 -->
+              <div class="step-header">
+                <div class="step-title">{{ step.nodeName }}</div>
+                <div class="step-status" :class="step.status">
+                  {{
+                    step.status === 'finish' ? '已完成' :
+                      step.status === 'process' ? '进行中' : '待处理'
+                  }}
+                </div>
+              </div>
+
+              <!-- 处理人信息 -->
+              <div class="step-info">
+                <div class="info-item">
+                  <span class="info-label">处理人:</span>
+                  <span class="info-value">{{ step.operatorRole || '待处理' }}</span>
+                </div>
+                <div v-if="step.lastUpdatedTime" class="info-item">
+                  <span class="info-label">日期:</span>
+                  <span class="info-value">{{ step.lastUpdatedTime }}</span>
+                </div>
+              </div>
+
+              <!-- 审批意见 -->
+              <div v-if="step.action || step.comment" class="step-opinion">
+                {{ step.action }} {{ step.comment }}
+              </div>
+
+              <!-- 空状态提示 -->
+              <div v-if="!step.operatorRole && !step.lastUpdatedTime" class="step-empty">
+                等待处理中...
+              </div>
+            </div>
+          </van-step>
+        </van-steps>
+      </div>
+    </div>
+
+    <!-- 证明材料 -->
+    <div class="section">
+      <div class="section-title">证明材料</div>
+      <van-uploader v-model="evidenceData.attachments" multiple disabled :deletable="false" />
+      <!-- <div class="evidence-attachments">
+        <div class="attachment-item">
+          <van-icon name="description" size="36rpx" color="#1989fa" />
+          <span>医院诊断证明.pdf</span>
+        </div>
+        <div class="attachment-item">
+          <van-icon name="description" size="36rpx" color="#1989fa" />
+          <span>病历资料(1-5页).pdf</span>
+        </div>
+        <div class="attachment-item">
+          <van-icon name="picture" size="36rpx" color="#4caf50" />
+          <span>家长身份证复印件.jpg</span>
+        </div>
+      </div> -->
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.evidence-page {
+  padding: 24rpx;
+  background-color: #f7f8fa;
+  min-height: 100vh;
+  padding-top: 120rpx;
+}
+
+.evidence-header {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 32rpx 24rpx;
+  margin-bottom: 24rpx;
+  text-align: center;
+
+  .title {
+    font-size: 36rpx;
+    font-weight: 600;
+    color: #333;
+    margin-bottom: 16rpx;
+  }
+
+  .evidence-id {
+    font-size: 28rpx;
+    color: #1989fa;
+    margin-bottom: 8rpx;
+    font-weight: 500;
+  }
+
+  .evidence-time {
+    font-size: 24rpx;
+    color: #888;
+  }
+}
+
+.section {
+  background: #fff;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 24rpx;
+
+  .section-title {
+    font-size: 32rpx;
+    font-weight: 600;
+    color: #333;
+    margin-bottom: 24rpx;
+    padding-bottom: 16rpx;
+    border-bottom: 1rpx solid #eee;
+  }
+}
+
+.info-table {
+  .info-row {
+    display: flex;
+    padding: 16rpx 0;
+    border-bottom: 1rpx solid #f0f0f0;
+
+    &:last-child {
+      border-bottom: none;
+    }
+
+    .label {
+      width: 200rpx;
+      font-size: 28rpx;
+      color: #666;
+      flex-shrink: 0;
+    }
+
+    .value {
+      flex: 1;
+      font-size: 28rpx;
+      color: #333;
+    }
+  }
+}
+
+.approval-steps {
+
+  .van-step {
+    padding: 32rpx 0 32rpx 0;
+    position: relative;
+
+    &:first-child {
+      &::before {
+        top: 72rpx;
+      }
+    }
+
+    &:last-child {
+      &::before {
+        bottom: auto;
+        height: 72rpx;
+      }
+    }
+
+    .van-step__circle {
+      width: 24rpx;
+      height: 24rpx;
+      background-color: #fff;
+      border: 2rpx solid #666;
+      box-shadow: 0 0 0 4rpx #fff;
+      z-index: 1;
+
+      &.van-step__circle--finish {
+        background-color: #4caf50;
+        border-color: #4caf50;
+      }
+
+      &.van-step__circle--process {
+        background-color: #ff9800;
+        border-color: #ff9800;
+      }
+    }
+
+    .van-step__line {
+      background-color: #e0e0e0;
+
+      &.van-step__line--vertical {
+        width: 2rpx;
+        left: 21rpx;
+      }
+    }
+  }
+
+  .van-step__title {
+    font-size: 0;
+    line-height: 0;
+  }
+
+}
+
+.step-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12rpx;
+
+  .step-title {
+    font-size: 30rpx;
+    font-weight: 500;
+    color: #333;
+  }
+
+  .step-status {
+    font-size: 26rpx;
+    padding: 4rpx 12rpx;
+    border-radius: 12rpx;
+
+    &.finish {
+      background-color: #e8f5e9;
+      color: #4caf50;
+    }
+
+    &.process {
+      background-color: #e3f2fd;
+      color: #2196f3;
+    }
+  }
+}
+
+.step-desc {
+  font-size: 26rpx;
+  color: #666;
+  margin-bottom: 16rpx;
+  line-height: 1.4;
+}
+
+.step-opinion {
+  background: #f9f9f9;
+  border-radius: 8rpx;
+  padding: 16rpx;
+  margin-bottom: 16rpx;
+
+  .opinion-label {
+    font-size: 26rpx;
+    color: #666;
+    margin-bottom: 8rpx;
+    font-weight: 500;
+  }
+
+  .opinion-content {
+    font-size: 28rpx;
+    color: #333;
+    line-height: 1.4;
+  }
+}
+
+.evidence-attachments {
+  .attachment-item {
+    display: flex;
+    align-items: center;
+    padding: 24rpx;
+    margin-bottom: 16rpx;
+    background: #f9f9f9;
+    border-radius: 12rpx;
+    font-size: 28rpx;
+    color: #333;
+
+    .van-icon {
+      margin-right: 16rpx;
+      flex-shrink: 0;
+    }
+
+    span {
+      flex: 1;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    &:last-child {
+      margin-bottom: 0;
+    }
+  }
+}
+</style>

+ 744 - 0
modules/common/student-prosthetics/index.vue

@@ -0,0 +1,744 @@
+<script setup>
+import { ref, reactive, computed, watch, onMounted } from 'vue'
+import { showToast, showConfirmDialog } from 'vant'
+import TopNavBar from '@/business/top-nav-bar.vue'
+import { getStudentDetail, getStudentLeaveApplication, applyForResumption, withdrawApplication } from '@/services/student-prosthetics'
+import utils from '@/utils/utils'
+
+// 学生数据
+const students = ref([
+    {
+        id: 'ec03f17b3474469f99cf7039bb11e688',
+        name: '张三',
+        gradeClass: '',
+        schoolName: '',
+        studentNo: '',
+        studentStatusStr: '', // 状态:'在校' 或 '休学中'
+        applying: false, // 是否正在申请
+        applyStatusStr: '', // 申请状态
+        leaveType: null, // 申请类型:'1 休学' 或 '2 复学'
+        certNo: null // 存证id
+    },
+    {
+        id: 'e909040887a644a4b1b7bf2f0d6e9460',
+        name: '李四',
+        studentId: '',
+        gradeClass: '',
+        schoolName: '',
+        studentNo: '',
+        studentStatusStr: '休学中',
+        applying: false,
+        applyStatusStr: '',
+        leaveType: 1,
+        certNo: 'LV202401150001'
+    },
+    // {
+    //     id: 3,
+    //     name: '张小刚',
+    //     studentId: '2023030303',
+    //     gradeClass: '初三·2班',
+    //     schoolName: '太原市第五中学',
+    //     studentNo: '123',
+    //     studentStatusStr: '在校',
+    //     applying: true,
+    //     applyStatusStr: '审批中', // 申请状态
+    //     leaveType: 1,
+    //     certNo: null
+    // }
+])
+
+// 当前选中的学生
+const selectedStudent = ref(null)
+
+
+// 申请表单数据
+const formData = reactive({
+    leaveType: 1, //  1'休学' 或 2'复学'
+    diseaseDiagnosis: '',
+    expectedStartDate: '',
+    expectedEndDate: '',
+    auditOpinion: '',
+    stdLeaveAttachments: []
+})
+
+// 期限描述 
+const periodDescription = computed(() => {
+    if (!formData.expectedStartDate || !formData.expectedEndDate) return ''
+
+    const start = new Date(formData.expectedStartDate)
+    const end = new Date(formData.expectedEndDate)
+
+    const days = Math.ceil((end - start) / (1000 * 60 * 60 * 24))
+
+    return `共${days}天`
+})
+
+// 方法:选择学生
+const selectStudent = (student) => {
+    if (student.applying) {
+        showToast('该学生有正在审批中的申请,请等待审批完成')
+    }
+    selectedStudent.value = student
+    // 重置表单
+    resetForm()
+}
+
+// 重置表单
+const resetForm = () => {
+    if (selectedStudent.value) {
+        if (selectedStudent.value.studentStatusStr === '休学中') {
+            formData.leaveType = 2
+        } else {
+            formData.leaveType = 1
+        }
+        formData.diseaseDiagnosis = ''
+        formData.expectedStartDate = ''
+        formData.expectedEndDate = ''
+        formData.auditOpinion = ''
+        formData.stdLeaveAttachments = []
+    }
+}
+
+// 查看存证
+const viewEvidence = (student) => {
+    localStorage.setItem('studentInfo', JSON.stringify(student))
+    uni.navigateTo({
+        url: `/modules/common/student-prosthetics/certificate`
+    })
+}
+
+// 查看审批进度
+const viewProgress = (student) => {
+    localStorage.setItem('studentInfo', JSON.stringify(student))
+    uni.navigateTo({
+        url: `/modules/common/student-prosthetics/approval-progress`
+    })
+}
+
+const showDialog = ref(false)
+const auditOpinion = ref('')
+// 撤回申请
+const withdrawApply = () => {
+    if (!selectedStudent.value?.applying) return
+    auditOpinion.value = ''
+    showDialog.value = true
+}
+const handleConfirm = async () => {
+    if (!auditOpinion.value.trim()) {
+        showToast('请输入撤回原因')
+        return
+    }
+    try {
+        await showConfirmDialog({
+            title: '确认撤回',
+            message: '确定要撤回申请吗?撤回后需要重新提交'
+        })
+        withdrawApplication({
+            auditOpinion: auditOpinion.value,
+            auditorId: selectedStudent.value.applicantId,
+            auditorName: selectedStudent.value.applicantName,
+        }).then(res => {
+            if (res.success) {
+                showToast('撤回成功')
+                init(0)
+                init(1)
+                selectedStudent.value = null
+            }
+        })
+    } catch (error) {
+        // 用户取消
+    }
+}
+
+// 提交申请
+const submitApply = async () => {
+    // 表单验证
+    if (!formData.diseaseDiagnosis.trim()) {
+        showToast('请输入诊断记录')
+        return
+    }
+
+    if (!formData.expectedStartDate) {
+        showToast('请选择开始日期')
+        return
+    }
+
+    if (!formData.expectedEndDate) {
+        showToast('请选择结束日期')
+        return
+    }
+
+    if (!formData.auditOpinion.trim()) {
+        showToast('请输入申请原因')
+        return
+    }
+
+    if (!formData.stdLeaveAttachments || !formData.stdLeaveAttachments.length) {
+        showToast('请上传证明材料')
+        return
+    }
+
+    let params = {
+        studentId: selectedStudent.value.id,
+        studentName: selectedStudent.value.name,
+        applicantId: "02e168c19b7c456eafeb647853a079ae",
+        applicantName: "15296619861",
+        ...formData
+    }
+
+    params.expectedStartDate = params.expectedStartDate + ' 00:00:00'
+    params.expectedEndDate = params.expectedEndDate + ' 00:00:00'
+
+    if (params.stdLeaveAttachments && params.stdLeaveAttachments.length) {
+        params.stdLeaveAttachments = params.stdLeaveAttachments.map(item => {
+            return {
+                fileName: item.fileName,
+                fileUrl: item.fileUrl,
+                fileType: item.fileType
+            }
+        })
+    }
+
+    applyForResumption(params).then(res => {
+        if (res.success) {
+            showToast('提交成功,等待审批')
+            init(0)
+            init(1)
+            selectedStudent.value = null
+        }
+    })
+}
+
+// 上传文件
+const afterRead = async (file) => {
+    let files = []
+    if (Array.isArray(file)) {
+        files = file
+    } else {
+        files = [file]
+    }
+    console.log('afterRead', files)
+    const res = await utils.transferOssUrl(files) || []
+    formData.stdLeaveAttachments = formData.stdLeaveAttachments.map(item => {
+        if (item.file) {
+            const index = res.findIndex(file => file.objectUrl === item.objectUrl)
+            if (index !== -1) {
+                return res[index]
+            }
+        }
+        return item
+    })
+}
+
+// 删除文件
+const removeFile = () => { }
+
+// 日期选择器相关
+const showStartDatePicker = ref(false)
+const showEndDatePicker = ref(false)
+
+const onStartDateConfirm = ({ selectedValues }) => {
+    formData.expectedStartDate = selectedValues.join('-')
+    showStartDatePicker.value = false
+}
+
+const onEndDateConfirm = ({ selectedValues }) => {
+    formData.expectedEndDate = selectedValues.join('-')
+    showEndDatePicker.value = false
+}
+
+const getStudentDetailFun = (id) => {
+    return new Promise(async (resolve, reject) => {
+        const res1 = await getStudentDetail({ id })
+        const res2 = await getStudentLeaveApplication({ studentId: id })
+        if (res1.success && res2.success) {
+            const res1Info = res1.info || {}
+            const res2Info = res2.info || {}
+            resolve({
+                success: res1.success && res2.success,
+                info: {
+                    res1: {
+                        id: res1Info.id,
+                        name: res1Info.name,
+                        gradeClass: res1Info.gradeClass,
+                        schoolName: res1Info.schoolName,
+                        studentNo: res1Info.studentNo,
+                    },
+                    res2: {
+                        studentStatusStr: res2Info.studentStatusStr || '在校', // 状态:'在校' 或 '休学中'
+                        applying: res2Info.applyStatusStr ? true : false, // 是否正在申请
+                        applyStatusStr: res2Info.applyStatusStr, // 申请状态
+                        leaveType: res2Info.leaveType, // 申请类型:'1 休学' 或 '2 复学'
+                        certNo: res2Info.certNo, // 存证id
+                        createdTime: res2Info.createdTime || '', // 申请时间  存证时间
+                        details: {
+                            diseaseDiagnosis: res2Info.diseaseDiagnosis,
+                            expectedStartDate: res2Info.expectedStartDate,
+                            expectedEndDate: res2Info.expectedEndDate,
+                            auditOpinion: res2Info.auditOpinion
+                        },
+                        applicationId: res2Info.id,
+                        applicantId: res2Info.applicantId,
+                        applicantName: res2Info.applicantName,
+                        tenantId: res2Info.tenantId
+                    }
+                }
+            })
+        } else {
+            reject()
+        }
+    })
+}
+
+const init = (num = 0) => {
+    const id = students.value[num]?.id
+    getStudentDetailFun(id).then((res) => {
+        if (res.success) {
+            students.value[num] = Object.assign({}, students.value[num], res.info.res1, res.info.res2)
+            // students.value[num].applying = false // 硬编码 需删除
+            // students.value[num].certNo = '100000' // 硬编码 需删除
+            // students.value[num].studentStatusStr = '休学中' // 硬编码 需删除
+        }
+    })
+}
+
+onMounted(() => {
+    init(0)
+    init(1)
+})
+</script>
+
+<template>
+    <div class="apply-page">
+        <TopNavBar></TopNavBar>
+
+        <!-- 选择学生部分 -->
+        <div class="section">
+            <div class="section-title">选择学生</div>
+            <div class="student-list">
+                <div v-for="student in students" :key="student.id" class="student-item"
+                    :class="{ active: selectedStudent?.id === student.id }" @click="selectStudent(student)">
+                    <div class="student-info">
+                        <div class="name-status">
+                            <span class="name">{{ student.name }}</span>
+                            <span class="status-badge" :class="student.studentStatusStr">
+                                {{ student.studentStatusStr }}
+                            </span>
+                        </div>
+                        <div class="class-info">{{ student.gradeClass }}</div>
+
+                        <div v-if="student.certNo" class="extra-info">
+                            <div class="evidence-id">存证编号:{{ student.certNo }}</div>
+                            <van-button v-if="student.studentStatusStr === '休学中'" size="mini" type="primary"
+                                @click.stop="viewEvidence(student)">
+                                查看存证
+                            </van-button>
+                        </div>
+
+                        <div v-if="student.applying" class="extra-info">
+                            <div class="applying-status">{{ student.applyStatusStr }}</div>
+                            <van-button size="mini" type="primary" @click.stop="viewProgress(student)">
+                                查看进度
+                            </van-button>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+
+        <!-- 学生信息和申请表单(只在选中学生时显示) -->
+        <div v-if="selectedStudent" class="form-container">
+            <!-- 学生信息 -->
+            <div class="section">
+                <div class="section-title">学生信息</div>
+                <div class="info-grid">
+                    <div class="info-row">
+                        <div class="info-label">姓名</div>
+                        <div class="info-value">{{ selectedStudent.name }}</div>
+                    </div>
+                    <div class="info-row">
+                        <div class="info-label">学号</div>
+                        <div class="info-value">{{ selectedStudent.studentNo }}</div>
+                    </div>
+                    <div class="info-row">
+                        <div class="info-label">所属学校</div>
+                        <div class="info-value">{{ selectedStudent.schoolName }}</div>
+                    </div>
+                    <div class="info-row">
+                        <div class="info-label">当前年级</div>
+                        <div class="info-value">{{ selectedStudent.gradeClass }}</div>
+                    </div>
+                </div>
+            </div>
+
+            <!-- 审批中的提示 -->
+            <div v-if="selectedStudent.applying" class="section warning">
+                <div class="warning-text">
+                    该学生有正在审批中的休学申请,请等待审批完成后再发起新的申请。
+                </div>
+                <div class="apply-info">
+                    <div class="apply-row">
+                        <div class="apply-label">申请类型</div>
+                        <div class="apply-value">{{ selectedStudent.leaveType == 1 ? '休学' : '复学' }}</div>
+                    </div>
+                    <div class="apply-row">
+                        <div class="apply-label">提交时间</div>
+                        <div class="apply-value">{{ selectedStudent?.createdTime || '' }}</div>
+                    </div>
+                    <div class="apply-row">
+                        <div class="apply-label">当前状态</div>
+                        <div class="apply-value status-text">{{ selectedStudent.applyStatusStr }}</div>
+                    </div>
+                </div>
+                <div class="action-buttons">
+                    <van-button type="primary" @click="viewProgress(selectedStudent)">查看审批进度</van-button>
+                    <van-button v-if="selectedStudent.leaveType === 1" type="default" @click="withdrawApply">
+                        撤回申请
+                    </van-button>
+                </div>
+            </div>
+
+            <!-- 申请表单(只在非审批中状态显示) -->
+            <div v-else class="section">
+                <div class="section-title">申请信息</div>
+
+                <!-- 业务类型 -->
+                <div class="form-group">
+                    <div class="form-label">业务类型</div>
+                    <div class="radio-group">
+                        <van-radio-group v-model="formData.leaveType" direction="horizontal">
+                            <van-radio :name="1" :disabled="selectedStudent.studentStatusStr === '休学中'">休学</van-radio>
+                            <van-radio :name="2" :disabled="selectedStudent.studentStatusStr !== '休学中'">复学</van-radio>
+                        </van-radio-group>
+                    </div>
+                </div>
+
+                <!-- 诊断记录 -->
+                <div class="form-group">
+                    <div class="form-label">诊断记录</div>
+                    <van-field v-model="formData.diseaseDiagnosis" type="textarea" placeholder="请输入医院诊断结果" rows="2"
+                        maxlength="200" show-word-limit />
+                </div>
+                <van-notice-bar left-icon="info-o" class="tw-mt-[10rpx]">
+                    请务必按照医院诊断证明书上的结论据实填写
+                </van-notice-bar>
+
+                <!-- 开始日期 -->
+                <div class="form-group">
+                    <div class="form-label">开始日期</div>
+                    <van-field v-model="formData.expectedStartDate" readonly placeholder="请选择开始日期"
+                        @click="showStartDatePicker = true" />
+                </div>
+
+                <!-- 结束日期 -->
+                <div class="form-group">
+                    <div class="form-label">结束日期</div>
+                    <van-field v-model="formData.expectedEndDate" readonly placeholder="请选择结束日期"
+                        @click="showEndDatePicker = true" />
+                </div>
+
+                <!-- 期限描述 -->
+                <div class="form-group">
+                    <div class="form-label">期限描述</div>
+                    <van-field v-model="periodDescription" placeholder="系统将根据起止日期自动计算期限" readonly />
+                </div>
+                <van-notice-bar color="#1989fa" background="#ecf9ff" left-icon="info-o" class="tw-mt-[10rpx]">
+                    系统将根据起止日期自动计算期限
+                </van-notice-bar>
+
+                <!-- 申请原因 -->
+                <div class="form-group">
+                    <div class="form-label">申请原因</div>
+                    <van-field v-model="formData.auditOpinion" type="textarea" placeholder="请详细说明申请原因" rows="3"
+                        maxlength="200" show-word-limit />
+                </div>
+
+                <!-- 日期选择器 -->
+                <van-popup v-model:show="showStartDatePicker" position="bottom">
+                    <van-date-picker title="选择开始日期" @confirm="onStartDateConfirm"
+                        @cancel="showStartDatePicker = false" />
+                </van-popup>
+
+                <van-popup v-model:show="showEndDatePicker" position="bottom">
+                    <van-date-picker title="选择结束日期" @confirm="onEndDateConfirm" @cancel="showEndDatePicker = false" />
+                </van-popup>
+            </div>
+
+            <!-- 证明材料 -->
+            <div v-if="!selectedStudent.applying" class="section">
+                <div class="section-title">证明材料</div>
+                <div class="upload-list">
+                    <!-- 病历资料 -->
+                    <div class="upload-item">
+                        <div class="upload-title">医院诊断证明(必传)</div>
+                        <div class="upload-title">病历资料(支持多页)</div>
+                        <div class="upload-title">家长身份证复印件</div>
+                        <van-uploader v-model="formData.stdLeaveAttachments" multiple :max-count="9"
+                            :after-read="afterRead" @delete="removeFile" />
+                    </div>
+                </div>
+            </div>
+
+            <!-- 提交按钮 -->
+            <div v-if="!selectedStudent.applying" class="submit-section">
+                <van-button type="primary" size="large" @click="submitApply">提交申请</van-button>
+            </div>
+        </div>
+    </div>
+
+    <van-dialog v-model:show="showDialog" title="撤回申请原因" showConfirmButton showCancelButton @confirm="handleConfirm">
+        <van-field v-model="auditOpinion" type="textarea" placeholder="请输入撤回申请原因" rows="3" maxlength="200"
+            show-word-limit />
+    </van-dialog>
+</template>
+
+<style lang="scss" scoped>
+.apply-page {
+    padding: 24rpx;
+    background-color: #f7f8fa;
+    min-height: 100vh;
+    padding-top: 120rpx;
+}
+
+.section {
+    background: #fff;
+    border-radius: 16rpx;
+    padding: 24rpx;
+    margin-bottom: 24rpx;
+
+    &.warning {
+        border-left: 8rpx solid #ff4444;
+        background-color: #fff7f7;
+    }
+}
+
+.section-title {
+    font-size: 32rpx;
+    font-weight: 600;
+    color: #333;
+    margin-bottom: 24rpx;
+    padding-bottom: 16rpx;
+    border-bottom: 1rpx solid #eee;
+}
+
+.student-list {
+    .student-item {
+        padding: 24rpx;
+        margin-bottom: 16rpx;
+        border-radius: 12rpx;
+        border: 2rpx solid #eee;
+        background: #fff;
+
+        &.active {
+            border-color: #1989fa;
+            background-color: #f0f9ff;
+        }
+
+        &:last-child {
+            margin-bottom: 0;
+        }
+
+        .student-info {
+            .name-status {
+                display: flex;
+                align-items: center;
+                justify-content: space-between;
+                margin-bottom: 8rpx;
+
+                .name {
+                    font-size: 32rpx;
+                    font-weight: 500;
+                    color: #333;
+                }
+
+                .status-badge {
+                    font-size: 24rpx;
+                    padding: 4rpx 12rpx;
+                    border-radius: 12rpx;
+
+                    &.在校 {
+                        background-color: #e8f5e9;
+                        color: #4caf50;
+                    }
+
+                    &.休学中 {
+                        background-color: #fff3e0;
+                        color: #ff9800;
+                    }
+                }
+            }
+
+            .class-info {
+                font-size: 28rpx;
+                color: #666;
+                margin-bottom: 12rpx;
+            }
+
+            .extra-info {
+                display: flex;
+                justify-content: space-between;
+                align-items: center;
+                margin-top: 12rpx;
+                padding-top: 12rpx;
+                border-top: 1rpx solid #eee;
+
+                .evidence-id {
+                    font-size: 26rpx;
+                    color: #888;
+                }
+
+                .applying-status {
+                    font-size: 24rpx;
+                    padding: 4rpx 12rpx;
+                    border-radius: 12rpx;
+                    background-color: #fff3e0;
+                    color: #ff9800;
+                }
+            }
+        }
+    }
+}
+
+.info-grid {
+    .info-row {
+        display: flex;
+        padding: 16rpx 0;
+        border-bottom: 1rpx solid #f0f0f0;
+
+        &:last-child {
+            border-bottom: none;
+        }
+
+        .info-label {
+            width: 200rpx;
+            font-size: 28rpx;
+            color: #666;
+        }
+
+        .info-value {
+            flex: 1;
+            font-size: 28rpx;
+            color: #333;
+        }
+    }
+}
+
+.warning-text {
+    font-size: 28rpx;
+    color: #ff4444;
+    margin-bottom: 24rpx;
+    line-height: 1.4;
+}
+
+.apply-info {
+    background: #fff;
+    border-radius: 12rpx;
+    padding: 16rpx;
+    margin-bottom: 24rpx;
+
+    .apply-row {
+        display: flex;
+        padding: 12rpx 0;
+
+        .apply-label {
+            width: 200rpx;
+            font-size: 28rpx;
+            color: #666;
+        }
+
+        .apply-value {
+            flex: 1;
+            font-size: 28rpx;
+            color: #333;
+
+            &.status-text {
+                color: #ff9800;
+                font-weight: 500;
+            }
+        }
+    }
+}
+
+.action-buttons {
+    display: flex;
+    gap: 24rpx;
+
+    .van-button {
+        flex: 1;
+    }
+}
+
+.form-container {
+    .form-group {
+        margin-top: 32rpx;
+
+        .form-label {
+            font-size: 28rpx;
+            color: #333;
+            margin-bottom: 12rpx;
+            font-weight: 500;
+        }
+
+        .radio-group {
+            .van-radio {
+                margin-right: 48rpx;
+            }
+        }
+
+        :deep(.van-field) {
+            background-color: #fafafa;
+            border-radius: 8rpx;
+
+            .van-field__control {
+                font-size: 28rpx;
+            }
+        }
+    }
+}
+
+.upload-list {
+    .upload-item {
+        margin-bottom: 32rpx;
+
+        &:last-child {
+            margin-bottom: 0;
+        }
+
+        .upload-title {
+            font-size: 28rpx;
+            color: #333;
+            margin-bottom: 16rpx;
+            font-weight: 500;
+        }
+
+        :deep(.van-uploader) {
+            .van-uploader__upload {
+                width: 160rpx;
+                height: 160rpx;
+                margin: 0;
+            }
+
+            .van-uploader__preview {
+                margin: 0 16rpx 16rpx 0;
+            }
+
+            .van-uploader__preview-image {
+                width: 160rpx;
+                height: 160rpx;
+                border-radius: 8rpx;
+            }
+        }
+    }
+}
+
+.submit-section {
+    margin-top: 48rpx;
+    padding: 24rpx;
+
+    :deep(.van-button) {
+        height: 88rpx;
+        font-size: 32rpx;
+        border-radius: 44rpx;
+    }
+}
+</style>

+ 12 - 0
modules/index/detail.vue

@@ -0,0 +1,12 @@
+<template>
+    <view>
+        详情
+    </view>
+</template>
+
+<script setup>
+
+
+</script>
+
+<style lang="scss" scoped></style>

+ 26 - 0
modules/my/about/agreement.vue

@@ -0,0 +1,26 @@
+<template>
+	<view class="wrap">
+		<rich-text :nodes="agreement"></rich-text>
+	</view>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { getAgreement } from '@/services/common'
+
+const agreement = ref({})
+const fetchAgreement = () => {
+	getAgreement().then(res => {
+		agreement.value = res.data.content
+	})
+}
+fetchAgreement()
+</script>
+
+<style lang="scss" scoped>
+	.wrap {
+		min-height: 100vh;
+		background-color: #fff;
+		padding: 0 30rpx;
+	}
+</style>

+ 26 - 0
modules/my/about/policy.vue

@@ -0,0 +1,26 @@
+<template>
+	<view class="wrap">
+		<rich-text :nodes="policy"></rich-text>
+	</view>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { getPolicy } from '@/services/common'
+
+const policy = ref({})
+const fetchPolicy = () => {
+	getPolicy().then(res => {
+		policy.value = res.data.content
+	})
+}
+fetchPolicy()
+</script>
+
+<style lang="scss" scoped>
+	.wrap {
+		min-height: 100vh;
+		background-color: #fff;
+		padding: 0 30rpx;
+	}
+</style>

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio