ming 3 年 前
コミット
fae4d42441
100 ファイル変更8228 行追加0 行削除
  1. 24 0
      .gitignore
  2. 17 0
      App.vue
  3. 236 0
      common/common.scss
  4. 3 0
      common/iconfont.css
  5. 26 0
      common/variable.scss
  6. 75 0
      components/fs-action/fs-action.vue
  7. 17 0
      components/fs-avatar-group/fs-avatar-group.vue
  8. 142 0
      components/fs-avatar/fs-avatar.vue
  9. 59 0
      components/fs-back-top/fs-back-top.vue
  10. 81 0
      components/fs-badge/fs-badge.vue
  11. 220 0
      components/fs-button/fs-button.vue
  12. 72 0
      components/fs-captcha/fs-captcha.vue
  13. 66 0
      components/fs-card/fs-card.vue
  14. 76 0
      components/fs-cell-group/fs-cell-group.vue
  15. 188 0
      components/fs-cell/fs-cell.vue
  16. 84 0
      components/fs-checkbox-button/fs-checkbox-button.vue
  17. 48 0
      components/fs-checkbox-cell/fs-checkbox-cell.vue
  18. 93 0
      components/fs-checkbox-group/fs-checkbox-group.vue
  19. 88 0
      components/fs-checkbox/fs-checkbox.vue
  20. 42 0
      components/fs-col/fs-col.vue
  21. 97 0
      components/fs-collapse-item/fs-collapse-item.vue
  22. 71 0
      components/fs-collapse/fs-collapse.vue
  23. 227 0
      components/fs-comment/fs-comment.vue
  24. 24 0
      components/fs-container/fs-container.vue
  25. 28 0
      components/fs-date-format/fs-date-format.vue
  26. 45 0
      components/fs-divide-list/fs-divide-list.vue
  27. 80 0
      components/fs-divider/fs-divider.vue
  28. 90 0
      components/fs-dropdown-item/fs-dropdown-item.vue
  29. 44 0
      components/fs-dropdown/fs-dropdown.vue
  30. BIN
      components/fs-empty/empty.png
  31. 32 0
      components/fs-empty/fs-empty.vue
  32. 107 0
      components/fs-fab/fs-fab.vue
  33. 210 0
      components/fs-field/fs-field.vue
  34. 100 0
      components/fs-form-item/fs-form-item.vue
  35. 49 0
      components/fs-form/fs-form.vue
  36. 52 0
      components/fs-grid-item/fs-grid-item.vue
  37. 53 0
      components/fs-grid/fs-grid.vue
  38. 26 0
      components/fs-gutter/fs-gutter.vue
  39. 72 0
      components/fs-icon/fs-icon.vue
  40. 210 0
      components/fs-icon/icon.css
  41. 190 0
      components/fs-index-list/fs-index-list.vue
  42. 159 0
      components/fs-keyboard/car.vue
  43. 50 0
      components/fs-keyboard/fs-keyboard.vue
  44. 46 0
      components/fs-keyboard/number.vue
  45. 96 0
      components/fs-license-plate/fs-license-plate.vue
  46. 58 0
      components/fs-loading/fs-loading.vue
  47. 74 0
      components/fs-loadmore/fs-loadmore.vue
  48. 37 0
      components/fs-mask/fs-mask.vue
  49. 108 0
      components/fs-message/fs-message.vue
  50. 194 0
      components/fs-modal/fs-modal.vue
  51. 121 0
      components/fs-notice-bar/fs-notice-bar.vue
  52. 38 0
      components/fs-panel/fs-panel.vue
  53. 102 0
      components/fs-popup/fs-popup.vue
  54. 89 0
      components/fs-radio-button/fs-radio-button.vue
  55. 52 0
      components/fs-radio-cell/fs-radio-cell.vue
  56. 73 0
      components/fs-radio-group/fs-radio-group.vue
  57. 93 0
      components/fs-radio/fs-radio.vue
  58. 121 0
      components/fs-rate/fs-rate.vue
  59. 91 0
      components/fs-readmore/fs-readmore.vue
  60. 28 0
      components/fs-row/fs-row.vue
  61. 168 0
      components/fs-search/fs-search.vue
  62. 83 0
      components/fs-sidebar/fs-sidebar.vue
  63. 31 0
      components/fs-swipe-action-group/fs-swipe-action-group.vue
  64. 142 0
      components/fs-swipe-action/fs-swipe-action.vue
  65. 144 0
      components/fs-swiper/fs-swiper.vue
  66. 41 0
      components/fs-switch/fs-switch.vue
  67. 252 0
      components/fs-tab/fs-tab.vue
  68. 143 0
      components/fs-tag/fs-tag.vue
  69. 26 0
      components/fs-timeago/fs-timeago.vue
  70. 74 0
      components/fs-timeline/fs-timeline.vue
  71. 162 0
      components/fs-upload/fs-upload.vue
  72. 59 0
      components/fs-wx-avatar/fs-wx-avatar.vue
  73. 38 0
      hooks/useForm/index.js
  74. 32 0
      hooks/useLoadmore/index.js
  75. 22 0
      hooks/useQrcodeQuery/index.js
  76. 11 0
      hooks/useScrollTop/index.js
  77. 10 0
      hooks/useValidator/index.js
  78. 14 0
      index.html
  79. 15 0
      main.js
  80. 72 0
      manifest.json
  81. 18 0
      package-lock.json
  82. 20 0
      package.json
  83. 126 0
      pages.json
  84. 53 0
      pages/index/index.vue
  85. 74 0
      pages/login/login.vue
  86. 76 0
      pages/login/login1.vue
  87. 139 0
      pages/login/login2.vue
  88. 96 0
      pages/login/login3.vue
  89. 104 0
      pages/login/login4.vue
  90. 81 0
      pages/login/login5.vue
  91. 97 0
      pages/login/login6.vue
  92. 41 0
      pages/login/wxLogin.vue
  93. 72 0
      pages/my/addrbook/detail.vue
  94. 20 0
      pages/my/addrbook/list.vue
  95. 82 0
      pages/my/address/add.vue
  96. 96 0
      pages/my/address/list.vue
  97. 164 0
      pages/my/feedback.vue
  98. 59 0
      pages/my/licensePlate/add.vue
  99. 36 0
      pages/my/licensePlate/list.vue
  100. 141 0
      pages/my/my.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?

+ 17 - 0
App.vue

@@ -0,0 +1,17 @@
+<script>
+	export default {
+		onLaunch: function() {
+			console.log('App Launch')
+		},
+		onShow: function() {
+			console.log('App Show')
+		},
+		onHide: function() {
+			console.log('App Hide')
+		}
+	}
+</script>
+
+<style lang="scss">
+	@import './common/common.scss';
+</style>

+ 236 - 0
common/common.scss

@@ -0,0 +1,236 @@
+@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;
+}
+
+.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;
+}
+.bdb{
+	border-bottom: 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;
+}
+
+.flex{
+	display: flex;
+}
+.flex-grow{
+	flex-grow: 1;
+}
+.justify-between{
+	justify-content: space-between;
+}
+.align-center{
+	align-items: center;
+}
+
+.shadow{
+	box-shadow: 0 0 10px 2px rgba(65, 65, 70, 0.2);
+}
+
+.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;
+}
+
+.line1{
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+}
+.line2{
+	max-height: 42px;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	display: -webkit-box;
+	-webkit-line-clamp: 2;
+	-webkit-box-orient: vertical;
+}
+
+.container{
+  display: flex;
+  flex-direction: column;
+  height: calc(100vh - var(--window-top));
+}
+.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;
+}
+.sticky{
+	position: sticky;
+	top: 0;
+}
+
+.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: 110rpx;
+			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;
+}

ファイルの差分が大きいため隠しています
+ 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: 			#f8f8f8;
+	--title: 					#222222;
+	--content: 				#666666;
+	--sub: 						#999999;
+	--disabled: 			#bcbec3;
+	--divider: 				#f8f8f8;
+
+	--gutter: 				20rpx;
+	--tighten-gutter: 20rpx;
+	--gutter-v: 			20rpx;
+
+	--title-size: 		17px;
+	--content-size: 	15px;
+	--sub-size: 			13px;
+
+	--radius: 				12rpx;
+}

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

@@ -0,0 +1,75 @@
+<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 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', ''])
+
+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>

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

@@ -0,0 +1,17 @@
+<template>
+	<view class="fs-avatar-group">
+		<slot></slot>
+	</view>
+</template>
+
+<script setup>
+import { provide } from 'vue'
+
+const props = defineProps({
+	margin: {
+		type: String,
+		default: '-30rpx'
+	}
+})
+provide('marginLeft', props.margin)
+</script>

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

@@ -0,0 +1,142 @@
+<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': marginLeft
+		}"
+		@click="handleClick"
+	>
+		<image class="fs-avatar-img" :src="src" v-if="src" :lazy-load="lazyLoad" :mode="imageMode" />
+		<view v-else class="fs-avatar-slot" :class="['bg-' + bgColorType]" :style="{backgroundColor:bgColor}">
+			<slot></slot>
+		</view>
+	</view>
+</template>
+
+<script setup>
+import { computed, inject } from 'vue'
+
+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'
+	},
+	radius: Boolean,
+	link: String,
+	linkType: {
+		type: String,
+		default: 'navigateTo'
+	},
+	fixed: Boolean,
+	right: {
+		type: String,
+		default: '30rpx'
+	},
+	bottom: {
+		type: String,
+		default: '30rpx'
+	}
+})
+
+const emits = defineEmits(['click'])
+
+const marginLeft = inject('marginLeft', 0)
+
+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-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;
+	}
+	
+	& + &{
+		margin-left: -20rpx;
+	}
+}
+</style>

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

@@ -0,0 +1,59 @@
+<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 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: .5;
+	z-index: 100;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	margin-bottom: var(--window-bottom);
+}
+</style>

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

@@ -0,0 +1,81 @@
+<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 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>

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

@@ -0,0 +1,220 @@
+<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,
+    ]"
+    @click="handleClick"
+  >
+		<view class="fs-loader" v-if="loading"></view>
+    <template v-else>
+    	<slot></slot>
+    </template>
+  </button>
+</template>
+
+<script setup>
+	import { computed, useAttrs } from 'vue'
+	
+	const props = defineProps({
+    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'])
+		
+	const handleClick = e =>  {
+	  if (props.link && !props.disabled) {
+	    uni[props.linkType]({
+	      url: props.link
+	    })
+	  }
+	  !props.disabled && emits('click')
+	}
+</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>

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

@@ -0,0 +1,72 @@
+<template>
+	<fs-button
+		v-if="type === 'button'"
+		size="medium" 
+		plain 
+		round 
+		@click="getCaptcha" 
+		:disabled="state.sending">
+		{{state.timerText}}
+	</fs-button>
+	<view v-else class="primary" @click="getCaptcha" >{{state.timerText}}</view>
+</template>
+
+<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)
+		}
+	}
+})
+const emits = defineEmits(['start', 'end'])
+
+const state = reactive({
+	timerText: '获取验证码',
+	sending: false,
+	timerId: null
+})
+
+const getCaptcha = () => {
+	if (!state.sending) {
+		if (!/^1[3456789]\d{9}$/.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>

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

@@ -0,0 +1,66 @@
+<template>
+	<view class="fs-card" :class="{'fs-card-full': full, 'fs-card-gutter': gutter}">
+		<view class="fs-card-box" :class="{'fs-card-radius': radius, shadow}">
+			<view class="fs-card-title" v-if="slots.title || title">
+				<slot name="title">{{title}}</slot>
+			</view>
+			<view class="fs-card-content">
+				<slot></slot>
+			</view>
+			<view class="fs-card-ft" v-if="slots.footer">
+				<slot name="footer"></slot>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script setup>
+import { useSlots } from 'vue'
+const props = defineProps({
+	title: String,
+	full: Boolean,
+	gutter: Boolean,
+	radius: {
+		type: Boolean,
+		default: true
+	},
+	shadow: {
+		type: Boolean,
+		default: false
+	},
+})
+
+const slots = useSlots()
+</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);
+	}
+	
+	&-full{
+		margin-left: 0;
+		margin-right: 0;
+	}
+	&-gutter{
+		margin-bottom: var(--gutter-v);
+	}
+}
+</style>

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

@@ -0,0 +1,76 @@
+<template>
+	<view class="fs-cell-group" :class="{full, radius}" :style="{backgroundColor:bgColor || '#fff'}">
+		<slot />
+	</view>
+</template>
+
+<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,
+	radius: {
+		type: Boolean,
+		default: true
+	},
+	reverse: Boolean,
+	align: {
+		type: String,
+		default: 'center'
+	},
+	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);
+	}
+}
+</style>

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

@@ -0,0 +1,188 @@
+<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" :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 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: ''
+		},
+		arrowColorType: {
+			type: String,
+			validator(value) {
+				return ['primary', 'success', 'info', 'warning', 'danger'].includes(value)
+			}
+		},
+		border: Boolean,
+		tighten: Boolean,
+		gutter: Boolean,
+		radius: Boolean,
+		reverse: 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;
+		}
+	}
+	
+	&.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>

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

@@ -0,0 +1,84 @@
+<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 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>

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

@@ -0,0 +1,48 @@
+<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 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>

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

@@ -0,0 +1,93 @@
+<template>
+	<view class="fs-checkbox-group" :class="{inline}">
+		<slot></slot>
+	</view>
+</template>
+
+<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 => {
+		item.selected = state.selectedValue.indexOf(item.value) > -1
+	})
+}
+const updateChildren = child => {
+	state.children.push(child)
+	checkStrategy()
+}
+const updateValue = value => {
+	const 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>

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

@@ -0,0 +1,88 @@
+<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="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 setup>
+import { inject, watch, toRefs, ref, computed } from 'vue'
+	
+const props = defineProps({
+	label: String,
+	icon: String,
+	iconSize: {
+		type: String,
+		default: '40rpx'
+	},
+	value: {
+		type: null,
+		required: true
+	},
+	checkedColor: String,
+	checkedColorType: {
+		type: String,
+		default: 'primary'
+	}
+})
+
+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>

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

@@ -0,0 +1,42 @@
+<template>
+	<view class="fs-col" :class="['fs-col-' + span,{gutter}]" :style="styleStr">
+		<slot></slot>
+	</view>
+</template>
+
+<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>

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

@@ -0,0 +1,97 @@
+<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 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 .1s;
+	flex-shrink: 0;
+}
+</style>

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

@@ -0,0 +1,71 @@
+<template>
+	<view class="fs-collapse">
+		<slot></slot>
+	</view>
+</template>
+
+<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>

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

@@ -0,0 +1,227 @@
+<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>
+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>

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

@@ -0,0 +1,24 @@
+<template>
+	<view class="container">
+		<slot name="header"></slot>
+		<view class="main">
+			<slot></slot>
+		</view>
+		<slot name="footer"></slot>
+	</view>
+</template>
+
+<script>
+	export default {
+		name:"fs-container",
+		data() {
+			return {
+				
+			};
+		}
+	}
+</script>
+
+<style>
+
+</style>

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

@@ -0,0 +1,28 @@
+<template>
+	<view>
+		{{dateFormat}}
+	</view>
+</template>
+
+<script setup>
+import 'dayjs'
+
+const props = defineProps({
+	date: String,
+	format: {
+		type: String,
+		default: 'YYYY-MM-DD'
+	}
+})
+const emits = defineEmits(['click'])
+
+const dateFormat = dayjs(props.date).format(props.format)
+
+const handleClick = () => {
+	emits('click')
+}
+</script>
+
+<style>
+
+</style>

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

@@ -0,0 +1,45 @@
+<template>
+	<view class="fs-divide-list" :class="{'gutter-v': gutter}">
+		<fs-grid :columnNum="list.length" :padding="false">
+			<view v-for="item in list" class="fs-divide-list-item">
+				<fs-grid-item>
+					<slot :item="item"></slot>
+				</fs-grid-item>
+			</view>
+		</fs-grid>
+	</view>
+</template>
+
+<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>

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

@@ -0,0 +1,80 @@
+<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 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>

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

@@ -0,0 +1,90 @@
+<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 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;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+	background-color: #fff;
+	z-index: 100;
+	padding: 0 20rpx;
+	
+	&-title{
+		display: flex;
+		align-items: center;
+		
+		.visible{
+			transform: rotate(180deg);
+		}
+	}
+	&-text{
+		margin-right: 10rpx;
+	}
+	&-icon{
+		transition: all .2s;
+	}
+	
+	&-content{
+		position: absolute;
+		left: 0;
+		right: 0;
+		top: 80rpx;
+		transform: scaleY(0);
+		transform-origin: left top;
+		transition: all .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


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

@@ -0,0 +1,32 @@
+<template>
+	<view class="fs-empty-box">
+		<image :src="src" mode="widthFix" style="width: 400rpx;"></image>
+		<view class="content">{{text}}</view>
+	</view>
+</template>
+
+<script setup>
+import empty from './empty.png'
+
+const props = defineProps({
+	src: {
+		type: String,
+		default: empty
+	},
+	text: {
+		type: String,
+		default: '暂无数据'
+	}
+})
+</script>
+
+<style lang="scss" scoped>
+.fs-empty-box{
+	text-align: center;
+	padding: 200rpx 30rpx;
+	
+	.content{
+		margin-top: 20rpx;
+	}
+}
+</style>

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

@@ -0,0 +1,107 @@
+<template>
+	<view class="fs-fab">
+		<view class="fs-fab-btn" :style="{right, bottom}">
+			<view class="fs-fab-option" :class="{'fs-fab-scale': visible}">
+				<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 name="option"></slot>
+			</view>
+			
+			<fs-avatar :size="size" @click="handleToggle">
+				<fs-icon type="icon-plus2" :size="iconSize" class="fs-fab-plus" :class="{'fs-fab-visible':visible}"></fs-icon>
+			</fs-avatar>
+		</view>
+		
+		<fs-mask v-model="visible"></fs-mask>
+	</view>
+</template>
+
+<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'
+	}
+})
+
+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: 1000;
+	}
+	
+	&-plus{
+		transition: all .2s;
+	}
+	&-visible{
+		transform: rotate(315deg);
+	}
+	
+	&-option{
+		display: flex;
+		flex-direction: column;
+		margin-bottom: 30rpx;
+		transition: all .2s;
+		transform: translateY(-50%);
+		opacity: 0;
+		z-index: -1;
+		
+		&-gutter{
+			margin-top: 20rpx;
+		}
+	}
+	&-scale{
+		transform: translateY(0);
+		opacity: 1;
+		z-index: 1000;
+	}
+}
+</style>

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

@@ -0,0 +1,210 @@
+<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-guanbi2fill" 
+				size="20px"
+				@touchstart="handleClear" 
+				v-if="clearable">
+			</fs-icon>
+			<view v-if="slots.after" style="padding-left: 10rpx;"><slot name="after"></slot></view>
+		</view>
+	</view>
+</template>
+
+<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,
+	required: 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, .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>

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

@@ -0,0 +1,100 @@
+<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 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>

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

@@ -0,0 +1,49 @@
+<template>
+	<view>
+		<slot></slot>
+	</view>
+</template>
+
+<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>

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

@@ -0,0 +1,52 @@
+<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 setup>
+import { inject } from 'vue'
+
+const emits = defineEmits(['click'])
+
+const gird = inject('fsGrid', {})
+
+const { border, padding, bgColor, radius } = gird
+
+const handleClick = () => {
+	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>

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

@@ -0,0 +1,53 @@
+<template>
+	<view 
+		class="fs-grid" 
+		:class="{'fs-grid-border': border}"
+		:style="styleStr">
+		<slot></slot>
+	</view>
+</template>
+
+<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>

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

@@ -0,0 +1,26 @@
+<template>
+	<view class="fs-gutter" :style="styleStr"></view>
+</template>
+
+<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>

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

@@ -0,0 +1,72 @@
+<template>
+	<view 
+		:class="[type, colorType, source === 'inner' ? 'fsfont' : 'iconfont']"
+		:style="style"
+		@click="handleClick"
+	>
+	</view>
+</template>
+
+<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{
+  display: inline-block;
+  vertical-align: middle;
+  line-height: 1;
+}
+</style>
+

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

@@ -0,0 +1,210 @@
+@font-face {
+  font-family: "fsfont"; /* Project id 2762084 */
+  src: url('https://at.alicdn.com/t/font_2762084_jro7vpctpbi.woff2?t=1637659970198') format('woff2'),
+       url('https://at.alicdn.com/t/font_2762084_jro7vpctpbi.woff?t=1637659970198') format('woff'),
+       url('https://at.alicdn.com/t/font_2762084_jro7vpctpbi.ttf?t=1637659970198') format('truetype');
+}
+
+.fsfont {
+  font-family: "fsfont" !important;
+  font-size: 16px;
+  font-style: normal;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+.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-notice1: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-notice:before {
+  content: "\e6cc";
+}
+
+.icon-sort-down:before {
+  content: "\e843";
+}
+
+.icon-error:before {
+  content: "\e602";
+}
+
+.icon-arrow-up:before {
+  content: "\e60c";
+}
+
+.icon-close1:before {
+  content: "\e637";
+}
+
+.icon-square:before {
+  content: "\e6d5";
+}
+
+.icon-squarecheck:before {
+  content: "\e6d6";
+}
+
+.icon-loading:before {
+  content: "\e601";
+}
+
+.icon-plus2: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-plus1: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: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:before {
+  content: "\e670";
+}
+
+.icon-version:before {
+  content: "\e60b";
+}
+
+.icon-checked:before {
+  content: "\e61e";
+}
+
+.icon-msg: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";
+}

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

@@ -0,0 +1,190 @@
+<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 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: 1000;
+	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, .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>

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

@@ -0,0 +1,159 @@
+<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"
+						v-text="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>

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

@@ -0,0 +1,50 @@
+<template>
+	<view>
+		<keyboardCar
+			v-if="mode === 'car'"
+			:index="index"
+			v-model="modelValue"
+			@change="handleChange"
+			@close="handleClose">
+		</keyboardCar>
+		
+		<keyboardNum
+			v-if="mode === 'number'"
+			:index="index"
+			v-model="modelValue"
+			@change="handleChange"
+			@close="handleClose"
+		/>
+	</view>
+</template>
+
+<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 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>

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

@@ -0,0 +1,96 @@
+<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 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(''))
+}
+
+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: 70rpx;
+		height: 80rpx;		
+		line-height: 80rpx;		
+		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: 9px;
+			text-align: center;
+			position: absolute;
+			left: 0;
+			right: 0;
+			top: 50%;
+			transform: translateY(-50%);
+		}
+	}
+}
+</style>

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

@@ -0,0 +1,58 @@
+<template>
+	<view class="fs-loading" :style="{backgroundColor: bgColor}">
+		<view>
+			<view class="loader"></view>
+			<slot></slot>
+		</view>
+	</view>
+</template>
+
+<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>

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

@@ -0,0 +1,74 @@
+<template>
+	<view>
+		<slot></slot>
+		<fs-empty v-if="!state.loading && !state.dataList.length"></fs-empty>
+		<fs-divider v-if="!state.hasMore">{{nomore}}</fs-divider>
+	</view>
+</template>
+
+<script setup>
+import { reactive } from 'vue'
+
+const props = defineProps({
+	modelValue: {
+		type: Array,
+		default() {
+			return []
+		}
+	},
+	fetchList: Function,
+	pageSize: {
+		type: Number,
+		default: 20
+	},
+	pullDownRefresh: Boolean,
+	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
+		state.loading = false
+		emits('update:modelValue', state.dataList)
+	})
+}
+
+const refresh = () => {
+	state.dataList = []
+	emits('update:modelValue', state.dataList)
+	query()
+}
+
+defineExpose({
+	query,
+	refresh,
+	hasMore: state.hasMore,
+	pullDownRefresh: props.pullDownRefresh
+})
+</script>
+
+<style>
+
+</style>

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

@@ -0,0 +1,37 @@
+<template>
+	<view class="fs-mask" :style="{'zIndex': zIndex}" v-if="modelValue" @click="handleMask"></view>
+</template>
+
+<script setup>
+const props = defineProps({
+	modelValue: Boolean,
+	zIndex: {
+		type: Number,
+		default: 999
+	},
+	maskClickable: {
+		type: Boolean,
+		default: true
+	}
+})
+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: rgba(0, 0, 0, 0.5);
+	z-index: 999;
+}
+</style>

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

@@ -0,0 +1,108 @@
+<template>
+	<view 
+		class="fs-message" 
+		:class="[{show:state.options.show},'bg-'+state.options.type]">
+		{{state.options.message}}
+	</view>
+</template>
+
+<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: 0;
+	left: 0;
+	right: 0;
+	padding: 20rpx;
+	color: #fff;
+	transition: all .1s;
+	transform: translateY(-100%);
+	text-align: center;
+	z-index: 1000;
+}
+.show{
+  transform: translateY(0);
+}
+</style>

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

@@ -0,0 +1,194 @@
+<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-error" color="#fff" size="50rpx"></fs-icon>
+			</view>
+		</view>
+		<fs-mask v-model="modelValue" @close="handleClose" :maskClickable="maskClickable"></fs-mask>
+	</view>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+
+const props = defineProps({
+	modelValue: Boolean,
+	width: {
+		type: String,
+		default: '80vw'
+	},
+	maskClickable: {
+		type: Boolean,
+		default: true
+	},
+	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: 1000;
+		transition: all .2s;
+		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>

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

@@ -0,0 +1,121 @@
+<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-notice1" :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-error"
+			@click="handleClose">
+		</fs-icon>
+	</view>
+</template>
+
+<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>

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

@@ -0,0 +1,38 @@
+<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 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>

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

@@ -0,0 +1,102 @@
+<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" v-model="modelValue" @close="handleClose" :maskClickable="maskClickable"></fs-mask>
+	</view>
+</template>
+
+<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: 1000;
+		transition: all .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>

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

@@ -0,0 +1,89 @@
+<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 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>

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

@@ -0,0 +1,52 @@
+<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 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>

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

@@ -0,0 +1,73 @@
+<template>
+	<view class="fs-radio-group" :class="{inline}">
+		<slot></slot>
+	</view>
+</template>
+
+<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>

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

@@ -0,0 +1,93 @@
+<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="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 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,
+	checkedColor: String,
+	checkedColorType: {
+		type: String,
+		default: 'primary'
+	},
+	checked: Boolean
+})
+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>

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

@@ -0,0 +1,121 @@
+<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>
+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>

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

@@ -0,0 +1,91 @@
+<template>
+	<view class="fs-readmore" :style="{height: isOpen ? 'auto' : `${height + 70}rpx`,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>
+	export default {
+		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() {
+			uni.createSelectorQuery().in(this).select('.fs-readmore-content').boundingClientRect(data => {
+				this.visible = data.height > this.height
+			}).exec()
+		},
+		computed: {
+			height() {
+				return parseFloat(this.maxHeight)
+			}
+		},
+		methods: {
+			handleToggle() {
+				this.isOpen = !this.isOpen
+			}
+		}
+	}
+</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 .2s;
+		margin-left: 6rpx;
+	}
+	.isOpen{
+		transform: rotate(180deg);
+	}
+}
+</style>

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

@@ -0,0 +1,28 @@
+<template>
+	<view class="fs-row" :style="{marginLeft: margin,marginRight:margin}">
+		<slot></slot>
+	</view>
+</template>
+
+<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>

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

@@ -0,0 +1,168 @@
+<template>
+	<view class="fs-search-box" :style="{backgroundColor: bgColor}">
+		<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="32rpx"></fs-icon>
+				</slot>
+			</view>
+			<view class="fs-icon fs-icon-close" v-if="modelValue" @click="handleClear">
+				<fs-icon type="icon-close" color="#666666"></fs-icon>
+			</view>
+		</view>
+		<view 
+			v-if="showAction"
+			class="fs-cancel" 
+			:class="[actionColorType]" 
+			:style="{color:actionColor}"
+			@click="handleAction" 
+		>
+			{{actionText}}
+		</view>
+	</view>
+</template>
+
+<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: 62rpx;
+  padding-right: 60rpx;
+  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>

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

@@ -0,0 +1,83 @@
+<template>
+	<view class="fs-side-bar">
+		<view class="fs-side-bar-left">
+			<view 
+				class="fs-side-bar-item line1"
+				:class="{'fs-side-bar-active': activeId === item[valueKey]}"
+				v-for="(item, index) in list"
+				:key="index"
+				@click="handleClick(item, index)">
+				{{item.title}}
+			</view>
+		</view>
+		<view class="fs-side-bar-right">
+			<slot></slot>
+		</view>
+	</view>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+
+const props = defineProps({
+	list: {
+		type: Array,
+		default() {
+			return []
+		}
+	},
+	value: null,
+	valueKey: {
+		type: String,
+		default: 'id'
+	}
+})
+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{
+		width: 200rpx;
+		flex-shrink: 0;
+		background-color: #fafafa;
+		overflow: auto;
+	}
+	
+	&-item{
+		padding: 26rpx var(--gutter);
+		position: relative;
+	}
+	&-active{
+		background-color: #fff;
+		color: var(--primary);
+		&::before{
+			position: absolute;
+			content: '';
+			left: 0;
+			top: 0;
+			height: 100%;
+			width: 4rpx;
+			background-color: currentColor;
+		}
+	}
+	
+	&-right{
+		flex: 1;
+		background-color: #fff;
+		overflow: auto;
+	}
+}
+</style>

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

@@ -0,0 +1,31 @@
+<template>
+	<view>
+		<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()
+	})
+}
+
+provide('swipeGroup', {
+	updateChildren,
+	toggle
+})
+</script>
+
+<style lang="scss" scoped>
+
+</style>

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

@@ -0,0 +1,142 @@
+<template>
+	<movable-area class="fs-swipe-box">
+		<movable-view 
+			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 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
+})
+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
+	
+	nextTick(() => {
+		if (state.status) { //打开状态
+			if (state.moveX >= -state.allOptionWidth * 2 / 3) {
+				handleClose()
+			} else {
+				handleOpen()
+			}
+		} else {
+			if (state.moveX <= -state.allOptionWidth / 3) {
+				handleOpen()
+			} else {
+				handleClose()
+			}
+		}
+	})
+}
+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>

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

@@ -0,0 +1,144 @@
+<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">
+				<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>
+			</swiper-item>
+		</template>
+		<template v-else>
+			<swiper-item class="fs-swiper-item-box" v-for="(item, index) in list" :key="index" @click="handleClick">
+				<view class="fs-swiper-item">
+					<fs-avatar shape="square" width="100%" height="100%" :src="item[keyMap.src]"></fs-avatar>
+				</view>
+			</swiper-item>
+		</template>
+	</swiper>
+</template>
+
+<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'
+			}
+		}
+	},
+	mode: {
+		type: String
+	},
+	gutter: 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%;
+	}
+}
+
+.fs-swiper-card{
+	.fs-swiper-item-box{
+		width: 610rpx !important;
+		left: 70rpx;
+	}
+	.fs-swiper-item{
+		transform: scale(.9);
+		transition: all 0.2s ease-in 0s;
+		overflow: hidden;
+	}
+	.card-cur{
+		transform: scale(1)
+	}
+}
+</style>

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

@@ -0,0 +1,41 @@
+<template>
+	<switch 
+		class="fs-switch" 
+		:checked="checked" 
+		:color="color" 
+		:disabled="disabled" 
+		:class="[size]"
+		@change="change">
+	</switch>
+</template>
+
+<script setup>
+const props = defineProps({
+	checked: Boolean,
+	disabled: Boolean,
+	color: String,
+	size: {
+		type: String,
+		validator(value) {
+			return ['small', 'medium'].includes(value)
+		}
+	}
+})
+const emits = defineEmits(['change'])
+
+const change = e => {
+	emits('change', e.detail.value)
+}
+</script>
+
+<style lang="scss" scoped>
+.fs-switch{
+	transform: scale(0.8);
+	&.medium{
+		transform: scale(0.6);
+	}
+	&.small{
+		transform: scale(0.5);
+	}
+}
+</style>

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

@@ -0,0 +1,252 @@
+<template>
+	<view class="text-center" :class="[sticky ? 'fs-tab-sticky' : '']">
+		<scroll-view
+			:scroll-x="scrollable" 
+			:style="{'background-color':bgColor}">
+			<view
+			class="fs-tab"
+			:class="[
+				'fs-tab-' + type,
+				colorType,
+				round ? 'round' : '',
+				center ? 'fs-tab-center' : '',
+				gutter ? 'fs-tab-gutter' : '',
+			]">
+				<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)">
+						{{item.name}}
+						<view 
+							v-if="type === 'line' && index == curIndex" 
+							class="fs-tab-item-bar" 
+							:style="{width: barWidth}">
+						</view>
+				</view>
+			</view>
+		</scroll-view>
+	</view>
+</template>
+
+<script setup>
+import { reactive, computed } from 'vue'
+
+const props = defineProps({
+	color: String,
+	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}%;` : '1')
+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 .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>

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

@@ -0,0 +1,143 @@
+<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-close1"
+			v-if="closable"
+			@click="handleClosed">
+		</fs-icon>
+	</view>
+</template>
+
+<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: String,
+	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>

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

@@ -0,0 +1,26 @@
+<template>
+	<view @click="handleClick">
+		{{timeago(dateTime, format)}}
+	</view>
+</template>
+
+<script setup>
+import timeago from '@/utils/timeago'
+
+const props = defineProps({
+	dateTime: [String, Number],
+	format: {
+		type: String,
+		default: 'YYYY-MM-DD'
+	}
+})
+const emits = defineEmits(['click'])
+
+const handleClick = () => {
+	emits('click')
+}
+</script>
+
+<style>
+
+</style>

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

@@ -0,0 +1,74 @@
+<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 : '#969799'}">
+					</view>
+				</slot>
+			</view>
+			<view class="fs-timeline-line"></view>
+			<view class="content"><slot :item="item" :index="index"></slot></view>
+		</view>
+	</view>
+</template>
+
+<script setup>
+const props = defineProps({
+	options: Array,
+	colorType: String,
+	color: String,
+	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>

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

@@ -0,0 +1,162 @@
+<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"
+				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" v-if="modelValue.length < count">
+			<fs-avatar
+				:shape="shape"
+				:size="size"
+				radius
+				bgColor="#EBEFF5"
+				@click="upload">
+				<fs-icon type="icon-plus2" size="50px"></fs-icon>
+			</fs-avatar>
+		</view>
+	</view>
+</template>
+
+<script setup>
+import utils from '@/utils/utils'
+import config from '@/utils/config'
+
+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'
+	},
+	formData: {
+		type: Object,
+		default() {
+			return {}
+		}
+	},
+	pathKey: {
+		type: String,
+		default: 'filePath'
+	},
+	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,
+			name: props.name,
+			header: props.header,
+			formData: props.formData
+		},
+		props.cloudUpload
+	).then(res => {
+		const fileList = [...props.modelValue, ...res]
+		fileList.length > props.count && fileList.splice(props.count)
+		console.log(fileList);
+		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>

+ 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属性')
+			}
+		}
+  }
+}

+ 32 - 0
hooks/useLoadmore/index.js

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

+ 10 - 0
hooks/useValidator/index.js

@@ -0,0 +1,10 @@
+export default () => {
+	return {
+		mobile(rule, value) {
+			if(!/^1\d{10}$/.test(value)) {
+				return new Error('请输入正确的手机号')
+			}
+			return true
+		},
+	}
+}

+ 14 - 0
index.html

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

+ 15 - 0
main.js

@@ -0,0 +1,15 @@
+import store from './store'
+import App from './App'
+import http from './utils/http'
+
+import { createSSRApp } from 'vue'
+export function createApp() {
+  const app = createSSRApp(App)
+	app.use(store)
+	
+	app.config.globalProperties.$http = http
+	
+  return {
+    app
+  }
+}

+ 72 - 0
manifest.json

@@ -0,0 +1,72 @@
+{
+    "name" : "fs-uni",
+    "appid" : "__UNI__13DEB40",
+    "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
+    },
+    "mp-alipay" : {
+        "usingComponents" : true
+    },
+    "mp-baidu" : {
+        "usingComponents" : true
+    },
+    "mp-toutiao" : {
+        "usingComponents" : true
+    },
+    "uniStatistics" : {
+        "enable" : false
+    },
+    "vueVersion" : "3"
+}

+ 18 - 0
package-lock.json

@@ -0,0 +1,18 @@
+{
+  "name": "fs-uni",
+  "version": "1.0.0",
+  "lockfileVersion": 1,
+  "requires": true,
+  "dependencies": {
+    "async-validator": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.0.2.tgz",
+      "integrity": "sha512-wPFnOgf9uIu/7uvptlX7PepSf2ArGt60Wng0bYrQ08eZVFG65LRLQpHKQebWEyAYtJcdPN31kndy4nS0jVnf0Q=="
+    },
+    "dayjs": {
+      "version": "1.10.6",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.6.tgz",
+      "integrity": "sha512-AztC/IOW4L1Q41A86phW5Thhcrco3xuAA+YX/BLpLWWjRcTj5TOt/QImBLmCKlrF7u7k47arTnOyL6GnbG8Hvw=="
+    }
+  }
+}

+ 20 - 0
package.json

@@ -0,0 +1,20 @@
+{
+  "name": "fs-uni",
+  "version": "1.0.0",
+  "description": "",
+  "main": "main.js",
+  "dependencies": {
+    "async-validator": "^4.0.2",
+    "dayjs": "^1.10.6"
+  },
+  "devDependencies": {},
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "repository": {
+    "type": "git",
+    "url": "https://git.sxidc.com/front-end/fs-uni-next.git"
+  },
+  "author": "",
+  "license": "ISC"
+}

+ 126 - 0
pages.json

@@ -0,0 +1,126 @@
+{
+	"pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
+		{
+			"path": "pages/index/index",
+			"style": {
+				"navigationBarTitleText": "首页"
+			}
+		}, {
+			"path": "pages/my/version",
+			"style": {
+				"navigationBarTitleText": "版本记录",
+				"enablePullDownRefresh": false
+			}
+
+		}, {
+			"path": "pages/my/feedback",
+			"style": {
+				"navigationBarTitleText": "意见反馈",
+				"enablePullDownRefresh": false
+			}
+
+		}, {
+			"path": "pages/my/userInfo",
+			"style": {
+				"navigationBarTitleText": "个人信息",
+				"enablePullDownRefresh": false
+			}
+
+		}
+
+		, {
+			"path": "pages/login/login",
+			"style": {
+				"navigationBarTitleText": "",
+				"enablePullDownRefresh": false
+			}
+
+		}
+
+		, {
+			"path": "pages/my/notice",
+			"style": {
+				"navigationBarTitleText": "我的消息",
+				"enablePullDownRefresh": false
+			}
+
+		}, {
+			"path": "pages/my/org",
+			"style": {
+				"navigationBarTitleText": "组织架构",
+				"enablePullDownRefresh": false
+			}
+
+		}
+
+		, {
+			"path": "pages/my/addrbook/list",
+			"style": {
+				"navigationBarTitleText": "通讯录",
+				"enablePullDownRefresh": false
+			}
+
+		}, {
+			"path": "pages/my/addrbook/detail",
+			"style": {
+				"navigationBarTitleText": "通讯录",
+				"enablePullDownRefresh": false
+			}
+
+		}, {
+			"path": "pages/my/address/list",
+			"style": {
+				"navigationBarTitleText": "地址管理",
+				"enablePullDownRefresh": false
+			}
+
+		}, {
+			"path": "pages/my/address/add",
+			"style": {
+				"navigationBarTitleText": "地址管理",
+				"enablePullDownRefresh": false
+			}
+
+		}, {
+			"path": "pages/my/licensePlate/list",
+			"style": {
+				"navigationBarTitleText": "车辆管理",
+				"enablePullDownRefresh": false
+			}
+
+		}, {
+			"path": "pages/my/licensePlate/add",
+			"style": {
+				"navigationBarTitleText": "车辆管理",
+				"enablePullDownRefresh": false
+			}
+
+		}
+
+		, {
+			"path": "pages/my/my",
+			"style": {
+				"navigationBarTitleText": "个人中心",
+				"enablePullDownRefresh": false
+			}
+
+		}
+	],
+	"globalStyle": {
+		"navigationBarTextStyle": "black",
+		"navigationBarTitleText": "uni-app",
+		"navigationBarBackgroundColor": "#F8F8F8",
+		"backgroundColor": "#F8F8F8"
+	},
+	"tabBar": {
+		"list": [{
+				"pagePath": "pages/index/index",
+				"text": "首页"
+			},
+			{
+				"pagePath": "pages/my/my",
+				"text": "我的"
+			}
+		]
+	}
+}

+ 53 - 0
pages/index/index.vue

@@ -0,0 +1,53 @@
+<template>
+	<view>
+		<fs-swiper :list="list1"></fs-swiper>
+		
+		<fs-grid :columnNum="4">
+			<fs-grid-item>
+				<view class="text-center">
+					<fs-avatar size="90rpx" src="/static/images/menu/menu1.png"></fs-avatar>
+					<view class="menu-item">菜单1</view>
+				</view>
+			</fs-grid-item>
+			<fs-grid-item>
+				<view class="text-center">
+					<fs-avatar size="90rpx" src="/static/images/menu/menu2.png"></fs-avatar>
+					<view class="menu-item">菜单2</view>
+				</view>
+			</fs-grid-item>
+			<fs-grid-item>
+				<view class="text-center">
+					<fs-avatar size="90rpx" src="/static/images/menu/menu3.png"></fs-avatar>
+					<view class="menu-item">菜单3</view>
+				</view>
+			</fs-grid-item>
+			<fs-grid-item>
+				<view class="text-center">
+					<fs-avatar size="90rpx" src="/static/images/menu/menu4.png"></fs-avatar>
+					<view class="menu-item">菜单4</view>
+				</view>
+			</fs-grid-item>
+		</fs-grid>
+	</view>
+</template>
+
+<script setup>
+const list1 = [
+	{
+		src: '/static/images/banner.png'
+	},
+	{
+		src: '/static/images/banner.png'
+	},
+	{
+		src: '/static/images/banner.png'
+	}
+]
+</script>
+
+<style lang="scss" scoped>
+.menu-item{
+	margin-top: 6rpx;
+	font-size: 14px;
+}
+</style>

+ 74 - 0
pages/login/login.vue

@@ -0,0 +1,74 @@
+<template>
+	<view class="login-box">
+		<view class="text-center">
+			<image src="/static/images/login/logo.png" mode="widthFix" style="width: 500rpx"></image>
+		</view>
+		<view class="login-top">
+			<view>欢迎回来!</view>
+		</view>
+		<fs-form ref="loginRef" :model="loginModel">
+			<fs-field class="radius" placeholder="请输入账号" v-model="loginModel.name">
+				<template #before>
+					<fs-icon type="icon-user" color="#666666"></fs-icon>
+				</template>
+			</fs-field>
+			<fs-gutter height="40rpx" bgColor="#F6F7FB"></fs-gutter>
+			<fs-field class="radius" placeholder="请输入密码" v-model="loginModel.password">
+				<template #before>
+					<fs-icon type="icon-password" color="#666666"></fs-icon>
+				</template>
+			</fs-field>
+			<fs-gutter height="100rpx" bgColor="#F6F7FB"></fs-gutter>
+			<fs-button full radius @click="handleLogin">登录</fs-button>
+		</fs-form>
+	</view>
+</template>
+
+<script setup>
+import { ref, reactive } from 'vue'
+import useForm from '@/hooks/useForm'
+
+const loginRules = {
+	name: {
+		required: true,
+		message: '请输入账号'
+	},
+	password: {
+		required: true,
+		message: '请输入密码'
+	}
+}
+const loginModel = reactive({
+	name: '',
+	password: ''
+})
+const loginRef = ref(null)
+
+const loginForm = useForm(loginRules, 'loginRef')
+const handleLogin = () => {
+	loginForm.validate().then(() => {
+		console.log('success')
+	})
+}
+</script>
+
+<style lang="scss" scoped>
+page{
+	background-color: #F6F7FB;
+}
+
+.login-box{
+	padding: 30rpx;
+}
+.login-top{
+	color: #041B3D;
+	font-size: 24px;
+	font-family: Source Han Sans SC;
+	margin: 50rpx 0;
+	
+	&-sub{
+		font-size: 18px;
+		color: #b0b0b1;
+	}
+}
+</style>

+ 76 - 0
pages/login/login1.vue

@@ -0,0 +1,76 @@
+<template>
+	<view class="login-box">
+		<view class="login-top">
+			<view>您好</view>
+			<view>欢迎登录</view>
+		</view>
+		<fs-form ref="loginRef" :model="loginModel">
+			<fs-form-item>
+				<template #before>
+					<fs-icon type="icon-user"></fs-icon>
+				</template>
+				<fs-field placeholder="请输入账号" v-model="loginModel.name"></fs-field>
+			</fs-form-item>
+			<fs-form-item>
+				<template #before>
+					<fs-icon type="icon-password"></fs-icon>
+				</template>
+				<fs-field placeholder="请输入密码" v-model="loginModel.password"></fs-field>
+			</fs-form-item>
+			<fs-gutter height="100rpx" bgColor="#fff"></fs-gutter>
+			<fs-button full @click="handleLogin">登录</fs-button>
+		</fs-form>
+	</view>
+</template>
+
+<script setup>
+import { ref, reactive } from 'vue'
+import useForm from '@/hooks/useForm'
+import { useStore } from 'vuex'
+
+const store = useStore()
+
+const loginRules = {
+	name: {
+		required: true,
+		message: '请输入账号'
+	},
+	password: {
+		required: true,
+		message: '请输入密码'
+	}
+}
+const loginModel = reactive({
+	name: '',
+	password: ''
+})
+const loginRef = ref(null)
+
+const loginForm = useForm(loginRules, 'loginRef')
+const handleLogin = () => {
+	loginForm.validate().then(() => {
+		store.dispatch('login', {
+			...loginModel
+		}).then(res => {
+			uni.navigateBack()
+		})
+	})
+}
+</script>
+
+<style>
+page{
+	background-color: #fff;
+}
+</style>
+<style lang="scss" scoped>
+.login-box{
+	padding: 30rpx;
+}
+.login-top{
+	color: #222222;
+	font-size: 30px;
+	font-weight: bold;
+	margin: 100rpx 0;
+}
+</style>

+ 139 - 0
pages/login/login2.vue

@@ -0,0 +1,139 @@
+<template>
+	<view class="layout-box">
+		<view class="login-bg pr">
+			<image src="/static/images/login/login-sw.jpg" mode="widthFix" style="width: 100%;"></image>
+			<view class="bg-text">
+				<view class="bg-text-hd">您好</view>
+				<view class="bg-text-bd">欢迎登录</view>
+			</view>
+		</view>
+		<view class="login-shadow">
+			<fs-tab :tabs="tabs" v-model="curTab" barWidth="96rpx"></fs-tab>
+			<fs-form ref="loginRef" :model="curLoginModel">
+				<view v-show="curTab === 0">
+					<fs-form-item>
+						<fs-field placeholder="请输入手机号" type="number" v-model="loginModel1.phone" maxlength=11></fs-field>
+					</fs-form-item>
+					<fs-form-item>
+						<fs-field type="number" placeholder="请输入验证码" v-model="loginModel1.code" maxlength=6>
+							<template #after>
+								<fs-captcha :mobile="loginModel1.phone" @start="sendCaptcha" @end="endCaptcha"></fs-captcha>
+							</template>
+						</fs-field>
+					</fs-form-item>
+				</view>
+				<view v-show="curTab === 1">
+					<fs-form-item>
+						<fs-field placeholder="账号" v-model="loginModel2.name" maxlength=11></fs-field>
+					</fs-form-item>
+					<fs-form-item>
+						<fs-field placeholder="密码" type="password" v-model="loginModel2.password" maxlength=20></fs-field>
+					</fs-form-item>
+				</view>
+			</fs-form>
+			
+			<fs-cell justify="right">
+				<navigator class="primary fs12" slot="title" url="./forgetPwd" v-show="curTab === 0">忘记密码?</navigator>
+				<navigator class="primary fs12" slot="value" url="./register">去注册</navigator>
+			</fs-cell>
+		</view>
+		<view class="login-btn">
+			<fs-button block round type="primary" @click="handleLogin">登录</fs-button>
+		</view>
+	</view>
+</template>
+
+<script setup>
+import { ref, reactive, computed, watch } from 'vue'
+import useForm from '@/hooks/useForm'
+import useValidator from '@/hooks/useValidator'
+import { useStore } from 'vuex'
+
+const store = useStore()
+const tabs = [
+	{
+		name: '验证码登录',
+	},
+	{
+		name: '密码登录',
+	}
+]
+const curTab = ref(0)
+
+const validator = useValidator()
+const loginRules1 = {
+	phone: { validator: validator.mobile },
+	code: { required: true, message: '请输入验证码'}
+}
+const loginModel1 = reactive({
+	phone: '',
+	code: ''
+})
+const loginRules2 = {
+	name: { required: true, message: '请输入账号'},
+	password: { required: true, message: '请输入密码'}
+}
+const loginModel2 = reactive({
+	name: '',
+	password: ''
+})
+const curLoginModel = computed(() => curTab.value === 0 ? loginModel1 : loginModel2)
+
+const loginRef = ref(null)
+const loginForm = useForm(loginRules1, 'loginRef')
+watch(curTab, () => {
+	loginForm.setRules(curTab.value === 0 ? loginRules1 : loginRules2)
+})
+const handleLogin = () => {
+	loginForm.validate().then(() => {
+		store.dispatch('login', {
+			...loginModel
+		}).then(res => {
+			uni.navigateBack()
+		})
+	})
+} 
+
+const sendCaptcha = () => {
+	console.log('sendCaptcha');
+}
+const endCaptcha = () => {
+	console.log('endCaptcha');
+}
+</script>
+
+<style>
+page {
+	background-color: #fff;
+}
+</style>
+<style lang="scss" scoped>
+.login-bg{
+	margin-top: 20rpx;
+}
+.bg-text{
+	position: absolute;
+	left: 80rpx;
+	top: 0;
+	
+	&-hd{
+		font-size: 25px;
+		line-height: 20px;
+		margin-bottom: 20rpx;
+	}
+	&-bd{
+		font-size: 16px;
+	}
+}
+.login-shadow {
+	border-radius: 40rpx;
+	overflow: hidden;
+	padding-bottom: 60rpx;
+	// box-shadow: 0 0 8px 1px rgba(65, 65, 70, 0.2);
+}
+
+.login-btn {
+	padding: 0 100rpx;
+	margin-top: -50rpx;
+}
+</style>

+ 96 - 0
pages/login/login3.vue

@@ -0,0 +1,96 @@
+<template>
+	<view>
+		<image src="/static/images/login/login-car.png" mode="widthFix" style="width: 100vw;"></image>
+		
+		<view class="login-box">
+			<view class="login-box-top">手机号登录</view>
+			<fs-form ref="loginRef" :model="loginModel">
+				<fs-form-item>
+					<fs-field placeholder="请输入手机号" v-model="loginModel.phone"></fs-field>
+				</fs-form-item>
+				<fs-form-item>
+					<fs-field placeholder="请输入验证码" v-model="loginModel.code">
+						<template #after>
+							<fs-captcha :mobile="loginModel.phone" @start="sendCaptcha" @end="endCaptcha"></fs-captcha>
+						</template>
+					</fs-field>
+				</fs-form-item>
+				<fs-gutter height="160rpx" bgColor="#fff"></fs-gutter>
+				<fs-button full round @click="handleLogin">登录</fs-button>
+				<fs-gutter height="60rpx" bgColor="#fff"></fs-gutter>
+			</fs-form>
+		</view>
+	</view>
+</template>
+
+<script setup>
+import { ref, reactive } from 'vue'
+import useForm from '@/hooks/useForm'
+import useValidator from '@/hooks/useValidator'
+import { useStore } from 'vuex'
+
+const store = useStore()
+const validator = useValidator()
+const loginRules = {
+	phone: { validator: validator.mobile},
+	code: {
+		required: true,
+		message: '请输入密码'
+	}
+}
+const loginModel = reactive({
+	phone: '',
+	code: ''
+})
+const loginRef = ref(null)
+const loginForm = useForm(loginRules, 'loginRef')
+const handleLogin = () => {
+	loginForm.validate().then(() => {
+		store.dispatch('login', {
+			...loginModel
+		}).then(res => {
+			uni.navigateBack()
+		})
+	})
+}
+
+const sendCaptcha = () => {
+	console.log('sendCaptcha');
+}
+const endCaptcha = () => {
+	console.log('endCaptcha');
+}
+</script>
+
+<style>
+page{
+	background-color: #fff;
+	height: 100%;
+	overflow: hidden;
+}
+</style>
+<style lang="scss" scoped>
+.login-box{
+	position: fixed;
+	left: 40rpx;
+	right: 40rpx;
+	top: 260rpx;
+	background-color: #fff;
+	border-radius: 20rpx;
+	padding: 60rpx;
+	overflow: hidden;
+	
+	&-top{
+		font-size: 20px;
+		color: #222;
+		font-weight: 500;
+		margin: 40rpx 0;
+	}
+}
+.login-top{
+	color: #222222;
+	font-size: 30px;
+	font-weight: bold;
+	margin: 100rpx 0;
+}
+</style>

+ 104 - 0
pages/login/login4.vue

@@ -0,0 +1,104 @@
+<template>
+	<view class="login-box">
+		<view class="login-bg"></view>
+		<view class="login-bg2"></view>
+		<view class="login-top">
+			<view>登录</view>
+			<view class="login-top-sub">欢迎再次回来~</view>
+		</view>
+		<fs-form ref="loginRef" :model="loginModel">
+			<fs-field bgColor="#f8f8f8" round placeholder="请输入账号" v-model="loginModel.name">
+				<template #before>
+					<fs-icon type="icon-user" color="#666666"></fs-icon>
+				</template>
+			</fs-field>
+			<fs-gutter height="40rpx" bgColor="#fff"></fs-gutter>
+			<fs-field bgColor="#f8f8f8" round placeholder="请输入密码" v-model="loginModel.password">
+				<template #before>
+					<fs-icon type="icon-password" color="#666666"></fs-icon>
+				</template>
+			</fs-field>
+			<fs-gutter height="100rpx" bgColor="#fff"></fs-gutter>
+			<fs-button full round @click="handleLogin" :customStyle="{background: 'linear-gradient(to right, #00c6fc, #9adcf1)'}">登录</fs-button>
+		</fs-form>
+	</view>
+</template>
+
+<script setup>
+import { ref, reactive } from 'vue'
+import useForm from '@/hooks/useForm'
+import { useStore } from 'vuex'
+
+const store = useStore()
+const loginRules = {
+	name: {
+		required: true,
+		message: '请输入账号'
+	},
+	password: {
+		required: true,
+		message: '请输入密码'
+	}
+}
+const loginModel = reactive({
+	name: '',
+	password: ''
+})
+const loginRef = ref(null)
+
+const loginForm = useForm(loginRules, 'loginRef')
+const handleLogin = () => {
+	loginForm.validate().then(() => {
+		store.dispatch('login', {
+			...loginModel
+		}).then(res => {
+			uni.navigateBack()
+		})
+	})
+}
+</script>
+
+<style>
+page{
+	background-color: #fff;
+}
+</style>
+<style lang="scss" scoped>
+.login{
+	&-bg {
+		position: fixed;
+		top: -250rpx;
+		right: -250rpx;
+		width: 600rpx;
+		height: 600rpx;
+		border-radius: 100%;
+		background-color: #00baef;
+		z-index: 2
+	}
+	
+	&-bg2 {
+		position: fixed;
+		top: -150rpx;
+		right: -300rpx;
+		width: 600rpx;
+		height: 600rpx;
+		border-radius: 100%;
+		background-color: #ade8f9;
+		z-index: 1;
+	}
+}
+.login-box{
+	padding: 30rpx;
+}
+.login-top{
+	color: #222222;
+	font-size: 30px;
+	font-weight: bold;
+	margin: 150rpx 0;
+	
+	&-sub{
+		font-size: 16px;
+		color: #b0b0b1;
+	}
+}
+</style>

+ 81 - 0
pages/login/login5.vue

@@ -0,0 +1,81 @@
+<template>
+	<view class="login-box">
+		<view class="text-center">
+			<image src="/static/images/login/logo.png" mode="widthFix" style="width: 500rpx"></image>
+		</view>
+		<view class="login-top">
+			<view>欢迎回来!</view>
+		</view>
+		<fs-form ref="loginRef" :model="loginModel">
+			<fs-field class="radius" placeholder="请输入账号" v-model="loginModel.name">
+				<template #before>
+					<fs-icon type="icon-user" color="#666666"></fs-icon>
+				</template>
+			</fs-field>
+			<fs-gutter height="40rpx" bgColor="#F6F7FB"></fs-gutter>
+			<fs-field class="radius" placeholder="请输入密码" v-model="loginModel.password">
+				<template #before>
+					<fs-icon type="icon-password" color="#666666"></fs-icon>
+				</template>
+			</fs-field>
+			<fs-gutter height="100rpx" bgColor="#F6F7FB"></fs-gutter>
+			<fs-button full radius @click="handleLogin">登录</fs-button>
+		</fs-form>
+	</view>
+</template>
+
+<script setup>
+import { ref, reactive } from 'vue'
+import useForm from '@/hooks/useForm'
+import { useStore } from 'vuex'
+
+const store = useStore()
+const loginRules = {
+	name: {
+		required: true,
+		message: '请输入账号'
+	},
+	password: {
+		required: true,
+		message: '请输入密码'
+	}
+}
+const loginModel = reactive({
+	name: '',
+	password: ''
+})
+const loginRef = ref(null)
+
+const loginForm = useForm(loginRules, 'loginRef')
+const handleLogin = () => {
+	loginForm.validate().then(() => {
+		store.dispatch('login', {
+			...loginModel
+		}).then(res => {
+			uni.navigateBack()
+		})
+	})
+}
+</script>
+
+<style>
+page{
+	background-color: #F6F7FB;
+}
+</style>
+<style lang="scss" scoped>
+.login-box{
+	padding: 30rpx;
+}
+.login-top{
+	color: #041B3D;
+	font-size: 24px;
+	font-family: Source Han Sans SC;
+	margin: 50rpx 0;
+	
+	&-sub{
+		font-size: 18px;
+		color: #b0b0b1;
+	}
+}
+</style>

+ 97 - 0
pages/login/login6.vue

@@ -0,0 +1,97 @@
+<template>
+	<view class="login-box">
+		<view class="top-right-corner"></view>
+		<view class="lower-left-corner"></view>
+		<view class="login-top">欢迎登录</view>
+		<fs-form ref="loginRef" :model="loginModel">
+			<fs-field bgColor="#f8f7fc" placeholder="请输入账号" v-model="loginModel.name">
+				<template #before>
+					<fs-icon type="icon-user" color="#666666"></fs-icon>
+				</template>
+			</fs-field>
+			<fs-gutter height="40rpx" bgColor="#fff"></fs-gutter>
+			<fs-field bgColor="#f8f7fc" placeholder="请输入密码" v-model="loginModel.password">
+				<template #before>
+					<fs-icon type="icon-password" color="#666666"></fs-icon>
+				</template>
+			</fs-field>
+			<fs-gutter height="100rpx" bgColor="#fff"></fs-gutter>
+			<fs-button full round @click="handleLogin" type="danger">登录</fs-button>
+		</fs-form>
+	</view>
+</template>
+
+<script setup>
+import { ref, reactive } from 'vue'
+import useForm from '@/hooks/useForm'
+import { useStore } from 'vuex'
+
+const store = useStore()
+const loginRules = {
+	name: {
+		required: true,
+		message: '请输入账号'
+	},
+	password: {
+		required: true,
+		message: '请输入密码'
+	}
+}
+const loginModel = reactive({
+	name: '',
+	password: ''
+})
+const loginRef = ref(null)
+
+const loginForm = useForm(loginRules, 'loginRef')
+const handleLogin = () => {
+	loginForm.validate().then(() => {
+		store.dispatch('login', {
+			...loginModel
+		}).then(res => {
+			uni.navigateBack()
+		})
+	})
+}
+</script>
+
+<style>
+page{
+	background-color: #fff;
+}
+</style>
+<style lang="scss" scoped>
+.top-right-corner {
+	position: fixed;
+	top: -280rpx;
+	right: -280rpx;
+	width: 600rpx;
+	height: 600rpx;
+	background-color: #fff;
+	border: 100rpx solid #ffdddf;
+	border-radius: 100%;
+	z-index: 1;
+}
+
+.lower-left-corner {
+	position: fixed;
+	bottom: -450rpx;
+	left: -200rpx;
+	width: 600rpx;
+	height: 600rpx;
+	background-color: #fff;
+	border: 100rpx solid #c7e1fa;
+	transform: rotate(-45deg);
+	z-index: 1;
+}
+.login-box{
+	padding: 30rpx;
+}
+.login-top{
+	font-size: 50rpx;
+	letter-spacing: 5rpx;
+	font-weight: bold;
+	text-align: center;
+	margin: 300rpx 0 100rpx;
+}
+</style>

+ 41 - 0
pages/login/wxLogin.vue

@@ -0,0 +1,41 @@
+<template>
+	<view class="">
+		<image src="/static/images/login/login-bg.png" mode="widthFix" style="width: 100vw;"></image>
+		<view class="login-box">
+			<fs-button block type="success" radius @click="getUserProfile">微信登录</fs-button>
+		</view>
+	</view>
+</template>
+
+<script setup>
+import { useStore } from 'vuex'
+
+const store = useStore()
+
+const getUserProfile = () => {
+	wx.getUserProfile({
+		desc: '用于完善会员资料',
+		success: res => {
+			store.dispatch('wxLogin').then(res => {
+				
+			})
+		}
+	})
+}
+</script>
+
+<style>
+page{
+	background-color: #fff;
+	height: 100%;
+	overflow: hidden;
+}
+</style>
+<style lang="scss" scoped>
+.login-box{
+	position: fixed;
+	bottom: 100rpx;
+	left: 0;
+	right: 0;
+}
+</style>

+ 72 - 0
pages/my/addrbook/detail.vue

@@ -0,0 +1,72 @@
+<template>
+	<view class="container">
+		<view class="main">
+			<fs-cell-group justify="right" border full>
+				<fs-cell title="姓名" :value="detail.name"></fs-cell>
+				<fs-cell title="电话" :value="detail.phone"></fs-cell>
+				<fs-cell title="职位" :value="detail.position"></fs-cell>
+				<!-- <fs-cell title="照片">
+					<fs-avatar slot="value" :src="detail.icon && config.imgBaseUrl + detail.icon || config.defaultStaffLogo"></fs-avatar>
+				</fs-cell> -->
+				<fs-cell title="入职日期" :value="detail.enterDate"></fs-cell>
+			</fs-cell-group>
+		</view>
+		
+		<fs-grid class="text-center" style="padding-bottom: 60rpx;" bgColor="transparent">
+			<fs-grid-item @click="handleCall">
+				<fs-avatar>
+					<fs-icon type="icon-call" size="20px"></fs-icon>
+				</fs-avatar>
+				<view class="content fs12">拨打电话</view>
+			</fs-grid-item>
+			<fs-grid-item @click="handleSave">
+				<fs-avatar colorType="success">
+					<fs-icon type="icon-tongxunlu" size="20px"></fs-icon>
+				</fs-avatar>
+				<view class="content fs12">保存到通讯录</view>
+			</fs-grid-item>
+			<fs-grid-item @click="handleDelete">
+				<fs-avatar colorType="error">
+					<fs-icon type="icon-user-minus" size="20px"></fs-icon>
+				</fs-avatar>
+				<view class="content fs12">移除联系人</view>
+			</fs-grid-item>
+		</fs-grid>
+	</view>
+</template>
+
+<script setup>
+const detail = getApp().globalData.addrbookDetail
+
+const handleCall = () => {
+	uni.makePhoneCall({
+		phoneNumber: detail.phone
+	})
+}
+const handleSave = () => {
+	uni.addPhoneContact({
+		firstName: detail.name,
+		mobilePhoneNumber: detail.phone
+	})
+}
+const handleDelete = () => {
+	uni.showModal({
+		title: '您确定移除该联系人吗?'
+	}).then(res => {
+		if (res.confirm) {
+			uni.showToast({
+				title: '移除成功'
+			})
+			setTimeout(() => {
+				uni.navigateBack()
+			}, 1500)
+		}
+	})
+}
+</script>
+
+<style lang="scss">
+page{
+	height: 100%;
+}
+</style>

ファイルの差分が大きいため隠しています
+ 20 - 0
pages/my/addrbook/list.vue


+ 82 - 0
pages/my/address/add.vue

@@ -0,0 +1,82 @@
+<template>
+	<view class="container">
+		<fs-form ref="formRef" :model="formModel" class="main">
+			<fs-form-item label="联系人">
+				<fs-field placeholder="姓名" v-model="formModel.name"></fs-field>
+			</fs-form-item>
+			<fs-form-item label="手机号">
+				<fs-field placeholder="手机号" v-model="formModel.phone"></fs-field>
+			</fs-form-item>
+			<fs-cell titleWidth="120rpx" title="地址" :value="formModel.address" border arrow @click="chooseAddress"></fs-cell>
+			<fs-form-item label="门牌号">
+				<fs-field placeholder="例:1号楼1单元101室" v-model="formModel.detail"></fs-field>
+			</fs-form-item>
+			<fs-cell border title="设为默认地址" justify="right">
+				<template #value>
+					<fs-switch checked @change="change"></fs-switch>
+				</template>
+			</fs-cell>
+		</fs-form>
+		
+		<view class="layout-box">
+			<fs-button block round @click="handleSubmit">添加</fs-button>
+		</view>
+	</view>
+</template>
+
+<script setup>
+import { ref, reactive } from 'vue'
+import useForm from '@/hooks/useForm'
+import useValidator from '@/hooks/useValidator'
+
+const validator = useValidator()
+
+const formRules = {
+	name: {
+		required: true,
+		message: '请输入姓名'
+	},
+	phone: { validator: validator.mobile },
+	address: {
+		required: true,
+		message: '请选择地址'
+	}
+}
+const formModel = ref({
+	name: '',
+	phone: '',
+	address: '',
+	detail: '',
+	default: true
+})
+const formRef = ref(null)
+
+if (getApp().globalData.addressDetail) {
+	formModel.value = getApp().globalData.addressDetail
+}
+
+const chooseAddress = () => {
+	uni.chooseLocation().then(res => {
+		// const [error, data] = res
+		console.log(res);
+		formModel.address = res.address
+	})
+}
+const change = e => {
+	formModel.default = e
+}
+
+const form = useForm(formRules, 'formRef')
+const handleSubmit = () => {
+	form.validate().then(() => {
+		console.log('success')
+	})
+}
+</script>
+
+<style lang="scss">
+page{
+	height: 100%;
+	background-color: #fff;
+}
+</style>

+ 96 - 0
pages/my/address/list.vue

@@ -0,0 +1,96 @@
+<template>
+	<view class="container">
+		<view class="main">
+			<fs-card gutter v-for="item in list" :key="item.id">
+				<view class="layout-box">
+					<view class="title">{{item.name}} {{item.phone}}</view>
+					<view class="content">{{item.address}}</view>
+				</view>
+				<template #footer>
+					<view class="flex justify-between align-center">
+						<fs-radio-group v-model="item.default" v-if="item.default == 1">
+							<fs-radio label="默认地址" value="1" checked></fs-radio>
+						</fs-radio-group>
+						<fs-radio-group v-else>
+							<fs-radio label="设为默认地址" value="1"></fs-radio>
+						</fs-radio-group>
+						<view class="flex align-center">
+							<view class="flex align-center" @click="handleEdit(item)">
+								<fs-icon type="icon-edit"></fs-icon>
+								<view>编辑</view>
+							</view>
+							<view class="flex align-center" style="margin-left: 30rpx;" @click="handleDel(item)">
+								<fs-icon type="icon-del"></fs-icon>
+								<view>删除</view>
+							</view>
+						</view>
+					</view>
+				</template>
+			</fs-card>
+			
+			<fs-empty v-if="!list.length && loaded"></fs-empty>
+		</view>
+		
+		<fs-modal
+			v-model="visible" 
+			content="您确定要删除该地址吗" 
+			:beforeClose="beforeClose">
+		</fs-modal>
+		
+		<view class="layout-box">
+			<fs-button block round link="./add">添加地址</fs-button>
+		</view>
+	</view>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+
+const list = ref([])
+const loaded = ref(false)
+let visible = ref(false)
+
+setTimeout(() => {
+	list.value = [
+		{
+			name: '陈女士',
+			phone: '18734354541',
+			address: '山西省太原市小店区晋阳街和泰花园南区5-2-1006',
+			default: '1'
+		},
+		{
+			name: '王先生',
+			phone: '18734354542',
+			address: '山西省太原市小店区晋阳街和泰花园南区5-2-1006',
+			default: '0'
+		}
+	]
+	
+	loaded.value = true
+}, 0)
+
+const handleEdit = item => {
+	getApp().globalData.addressDetail = item
+	uni.navigateTo({
+		url: './add'
+	})
+}
+
+// 删除相关逻辑
+let addressItem = ref({})
+const handleDel = item => {
+	visible.value = true
+	addressItem.value = item
+}
+const beforeClose = (action, done) => {
+	setTimeout(() => {
+		done()
+	}, 2000)
+}
+</script>
+
+<style lang="scss">
+page{
+	height: 100%;
+}
+</style>

+ 164 - 0
pages/my/feedback.vue

@@ -0,0 +1,164 @@
+<template>
+	<view class="container">
+		<fs-tab :tabs="tabs" v-model="tabActive"></fs-tab>
+		<view class="main" v-show="tabActive === 0">
+			<fs-panel title="请选择您遇到的问题">
+				<template #content>
+					<fs-radio-group v-model="curProblem">
+						<fs-radio-cell 
+							v-for="(item,index) in problemList" 
+							:key="index"
+							:label="item.label"
+							:value="item.label">
+						</fs-radio-cell>
+					</fs-radio-group>
+					<!-- <fs-cell
+						border
+						justify="right" 
+						:title="item.title" 
+						@click="selectProblem(index)"
+						v-for="(item, index) in problemList">
+						<template #value>
+							<fs-icon type="icon-right" colorType="primary" v-show="curProblemIndex === index"></fs-icon>
+						</template>
+					</fs-cell> -->
+				</template>
+			</fs-panel>
+
+			<fs-panel title="请补充详情问题和意见(必填)">
+				<template #content>
+					<view class="textarea-box">
+						<fs-field type="textarea" height="200rpx" placeholder="请输入问题描述..." v-model="state.detail"></fs-field>
+						<view class="input-num">{{state.detail.length}}/140</view>
+					</view>
+				</template>
+			</fs-panel>
+
+			<fs-panel title="请上传相关问题图片">
+				<template #content>
+					<fs-upload v-model="state.photoList" cloudUpload></fs-upload>
+				</template>
+			</fs-panel>
+
+			<fs-button block round @click="handleSubmit">提交</fs-button>
+			<fs-gutter bgColor="#fff"></fs-gutter>
+		</view>
+		<view class="main" v-show="tabActive === 1">
+			<div v-for="item in state.adviceList" :key="item.id">
+				<div class="list-hd title-hd title-color">{{item.type}}</div>
+				<div class="list-bd list-color">回复:{{item.replyContent}}</div>
+			</div>
+			<fs-empty v-if="!state.adviceList.length"></fs-empty>
+		</view>
+	</view>
+</template>
+
+<script setup>
+import { ref, reactive } from 'vue'
+import { addAdvice, getAdviceList } from '@/services/common'
+
+const tabs = [
+	{
+		name: '提建议',
+	},
+	{
+		name: '反馈',
+	}
+]
+const tabActive = ref(0)
+
+const problemList = [
+	{
+		label: '功能异常:功能故障或不可用'
+	},
+	{
+		label: '产品建议:用的不爽,我有建议'
+	},
+	{
+		label: '安全问题:密码、隐私、欺诈等'
+	},
+	{
+		label: '其他问题'
+	},
+]
+const curProblem = ref(problemList[0].label)
+
+const state = reactive({
+	detail: '',
+	photoList: [],
+	adviceList: []
+})
+
+const fetchList = () => {
+	getAdviceList().then(res => {
+		console.log('feed', res);
+		// state.adviceList = res.data
+	})
+}
+// fetchList()
+
+const handleSubmit = () => {
+	console.log(curProblem.value);
+	if (!state.detail) {
+		return uni.showToast({
+			title: '请输入问题描述',
+			icon: 'none'
+		})
+	}
+	const images = state.photoList.map(item => item.name).join(',')
+
+	addAdvice({
+		type: curProblem.value,
+		content: state.detail,
+		images
+	}).then(res => {
+		uni.showToast({
+			icon: 'none',
+			title: '提交成功'
+		})
+		setTimeout(function() {
+			uni.navigateBack()
+		}, 1500)
+	})
+}
+</script>
+
+<style>
+	page{
+		height: 100%;
+	}
+</style>
+<style lang="scss" scoped>
+.main{
+	background-color: white;
+}
+.textarea-box {
+	width: 100%;
+	height: 240rpx;
+	background: #fff;
+	box-sizing: border-box;
+	position: relative;
+}
+
+.input-num {
+	position: absolute;
+	right: 30rpx;
+	bottom: 20rpx;
+	color: #AAAAAA;
+	font-size: 30rpx;
+	z-index: 10;
+}
+.title-hd{
+	margin: 0rpx 30rpx;
+}
+.list-bd{
+	background-color: #F2F2F2;
+	border-radius: 8rpx;
+	padding: 10rpx 10rpx 40rpx 10rpx;
+	margin: 0 30rpx;
+}
+.list-color{
+	color: #858585;
+	background-color: #F0F0F0;
+}
+</style>

+ 59 - 0
pages/my/licensePlate/add.vue

@@ -0,0 +1,59 @@
+<template>
+	<view class="container">
+		<view class="main">
+			<fs-license-plate v-model="carNo"></fs-license-plate>
+		</view>
+		<view class="layout-box">
+			<fs-button round full @click="handleAdd" :disabled="carNo.length < 6" v-if="type === 'add'">添加车牌</fs-button>
+			<fs-row v-else>
+				<fs-col span="6">
+					<fs-button round block @click="handleEdit">更新车牌</fs-button>
+				</fs-col>
+				<fs-col span="6">
+					<fs-button round block type="danger" @click="handleDel">删除车牌</fs-button>
+				</fs-col>
+			</fs-row>
+		</view>
+	</view>
+</template>
+
+<script setup>
+import { ref, onUnmounted } from 'vue'
+
+let carNo = ref('')
+let type = ref('add')
+
+if (getApp().globalData.carDetail) {
+	type.value = 'edit'
+	carNo.value = getApp().globalData.carDetail
+}
+
+const handleAdd = () => {
+	
+}
+const handleEdit = () => {
+	
+}
+const handleDel = () => {
+	uni.showModal({
+		title: '您确定要删除该车牌吗?'
+	}).then(res => {
+		if (res.confirm) {
+			uni.showToast({
+				title: '删除成功'
+			})
+			setTimeout(() => {
+				uni.navigateBack()
+			}, 1500)
+		}
+	})
+}
+
+onUnmounted(() => {
+	getApp().globalData.carDetail = null
+})
+</script>
+
+<style lang="scss">
+
+</style>

+ 36 - 0
pages/my/licensePlate/list.vue

@@ -0,0 +1,36 @@
+<template>
+	<view class="container">
+		<view class="main">
+			<fs-cell-group border arrow>
+				<fs-cell title="晋A7877H" @click="handleEdit('晋A7877H')"></fs-cell>
+			</fs-cell-group>
+			<fs-empty v-if="!carList.length && loaded"></fs-empty>
+		</view>
+		<view class="layout-box">
+			<fs-button round full link="./add">添加车辆</fs-button>
+		</view>
+	</view>
+</template>
+
+<script setup>
+import { ref } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+
+let carList = ref([])
+let loaded = ref(false)
+
+const handleEdit = item => {
+	getApp().globalData.carDetail = item
+	uni.navigateTo({
+		url: './add'
+	})
+}
+
+onShow(() => {
+	console.log('onshow');
+})
+</script>
+
+<style lang="scss">
+
+</style>

+ 141 - 0
pages/my/my.vue

@@ -0,0 +1,141 @@
+<template>
+	<view>
+		<view class="top pr">
+			<image src="/static/images/my/my-bg2.png" mode="widthFix" style="width: 100%;"></image>
+			<view class="userInfo" v-if="userInfo.name">
+				<fs-cell justify="left" bgColor="transparent">
+					<template #title>
+						<fs-avatar border :src="userInfo.photo" size="120rpx" link="./userInfo" v-if="userInfo.photo"></fs-avatar>
+						<fs-wx-avatar v-else></fs-wx-avatar>
+					</template>
+					<template #value>
+						<view class="cell-right">
+							<view class="title">{{userInfo.name || userInfo.nickName}}</view>
+							<view class="sub">{{userInfo.phone}}</view>
+						</view>
+					</template>
+				</fs-cell>
+			</view>
+			<navigator url="../login/login" class="login" v-else>登录</navigator>
+			
+			<view class="top-menu">
+				<fs-card>
+					<fs-grid :columnNum="4">
+						<fs-grid-item>
+							<fs-icon type="icon-image" size="40rpx"></fs-icon>
+							<view class="content">菜单1</view>
+						</fs-grid-item>
+						<fs-grid-item>
+							<fs-icon type="icon-image" size="40rpx"></fs-icon>
+							<view class="content">菜单2</view>
+						</fs-grid-item>
+						<fs-grid-item>
+							<fs-icon type="icon-image" size="40rpx"></fs-icon>
+							<view class="content">菜单3</view>
+						</fs-grid-item>
+						<fs-grid-item>
+							<fs-icon type="icon-image" size="40rpx"></fs-icon>
+							<view class="content">菜单4</view>
+						</fs-grid-item>
+					</fs-grid>
+				</fs-card>
+			</view>
+		</view>
+
+		<fs-cell-group arrow border>
+			<fs-cell link="./licensePlate/list" value="车辆管理">
+				<template #title>
+					<fs-icon type="icon-car" colorType="primary"></fs-icon>
+				</template>
+			</fs-cell>
+			<fs-cell link="./address/list" value="地址管理">
+				<template #title>
+					<fs-icon type="icon-location" colorType="primary"></fs-icon>
+				</template>
+			</fs-cell>
+			<fs-cell link="./org" value="组织架构">
+				<template #title>
+					<fs-icon type="icon-org" colorType="primary"></fs-icon>
+				</template>
+			</fs-cell>
+			<fs-cell link="./addrbook/list" value="通讯录">
+				<template #title>
+					<fs-icon type="icon-tongxunlu" colorType="primary"></fs-icon>
+				</template>
+			</fs-cell>
+			<fs-cell link="./notice" value="我的消息">
+				<template #title>
+					<fs-icon type="icon-notice" colorType="primary"></fs-icon>
+				</template>
+			</fs-cell>
+			<fs-cell link="./feedback" value="意见反馈">
+				<template #title>
+					<fs-icon type="icon-feedback" colorType="primary"></fs-icon>
+				</template>
+			</fs-cell>
+			<fs-cell link="./version" value="版本记录">
+				<template #title>
+					<fs-icon type="icon-version" colorType="primary"></fs-icon>
+				</template>
+			</fs-cell>
+		</fs-cell-group>
+		
+		<fs-gutter height="120rpx"></fs-gutter>
+		<fs-button type="primary" round block @click="handleLogout">退出登录</fs-button>
+		<fs-gutter height="60rpx"></fs-gutter>
+	</view>
+</template>
+
+<script setup>
+import { computed, ref } from 'vue'
+import { useStore } from 'vuex'
+
+const store = useStore()
+const userInfo = computed(() => store.state.userInfo)
+
+const handleLogout = () => {
+	store.dispatch('logout').then(res => {
+		console.log('logout')
+	})
+}
+</script>
+
+<style lang="scss" scoped>
+.top{
+	.userInfo {
+		position: absolute;
+		left: 20rpx;
+		top: 40rpx;
+	
+		.title,
+		.sub {
+			color: #fff;
+		}
+	}
+	
+	&-menu{
+		margin-top: -70rpx;
+		margin-bottom: 40rpx;
+		position: relative;
+		z-index: 100;
+	}
+}
+
+.login {
+	position: absolute;
+	left: 50%;
+	top: 50%;
+	transform: translate(-50%, 50%);
+	margin-top: -50px;
+	color: #fff;
+	border: 1px solid #fff;
+	height: 40px;
+	line-height: 40px;
+	width: 100px;
+	border-radius: 20px;
+	text-align: center;
+}
+.logout{
+	margin: 0 var(--gutter);
+}
+</style>

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません