Przeglądaj źródła

脚手架组件导入

huangxw 6 miesięcy temu
rodzic
commit
b4fd5cb79a
46 zmienionych plików z 3608 dodań i 5 usunięć
  1. 67 0
      src/components/ut-ad-item/ut-ad-item.vue
  2. 121 0
      src/components/ut-ad-swiper/ut-ad-swiper.vue
  3. 62 0
      src/components/ut-album/ut-album.vue
  4. 54 0
      src/components/ut-avater-icon/ut-avater-icon.vue
  5. 95 0
      src/components/ut-base-model/ut-base-model.vue
  6. 23 0
      src/components/ut-button-add/ut-button-add.vue
  7. 154 0
      src/components/ut-checkbox-cln/ut-checkbox-cln.vue
  8. 53 0
      src/components/ut-datetime-picker/ut-datetime-picker.vue
  9. 32 0
      src/components/ut-empty/ut-empty.vue
  10. 64 0
      src/components/ut-file/ut-file.vue
  11. 139 0
      src/components/ut-files-upload/ut-files-upload.vue
  12. 48 0
      src/components/ut-image/ut-image.vue
  13. 176 0
      src/components/ut-index-list/ut-index-list.vue
  14. 27 0
      src/components/ut-loading-view/ut-loading-view.vue
  15. 66 0
      src/components/ut-marquee-animation/ut-marquee-animation.vue
  16. 98 0
      src/components/ut-navbar-model/ut-navbar-model.vue
  17. 10 0
      src/components/ut-navbar/ut-navbar.vue
  18. 63 0
      src/components/ut-picker-date/ut-picker-date.vue
  19. 73 0
      src/components/ut-picker-region/ut-picker-region.vue
  20. 173 0
      src/components/ut-picker/ut-picker.vue
  21. 198 0
      src/components/ut-pickup-date/ut-pickup-date.vue
  22. 99 0
      src/components/ut-search/ut-search.vue
  23. 34 0
      src/components/ut-select-down/ut-select-down.vue
  24. 122 0
      src/components/ut-tabar/ut-tabar.vue
  25. 68 0
      src/components/ut-tabs-card/ut-tabs-card.vue
  26. 114 0
      src/components/ut-tabs-dict/ut-tabs-dict.vue
  27. 87 0
      src/components/ut-tabs-items/ut-tabs-items.vue
  28. 55 0
      src/components/ut-tabs-pages/ut-tabs-pages.vue
  29. 77 0
      src/components/ut-tabs-xpf/ut-tabs-xpf.vue
  30. 68 0
      src/components/ut-tabs/ut-tabs.vue
  31. 36 0
      src/components/ut-tag-dict/ut-tag-dict.vue
  32. 44 0
      src/components/ut-title/ut-title.vue
  33. 99 0
      src/components/ut-upload-image/ut-upload-image.vue
  34. 139 0
      src/components/ut-upload/ut-upload.vue
  35. 22 0
      src/config.ts
  36. 1 1
      src/pages.json
  37. 34 0
      src/utils/auth.ts
  38. 305 0
      src/utils/common.ts
  39. 31 0
      src/utils/constant.ts
  40. 12 2
      src/utils/errorCode.ts
  41. 66 0
      src/utils/form-mixins.ts
  42. 111 0
      src/utils/public.ts
  43. 7 2
      src/utils/ruoyi.ts
  44. 49 0
      src/utils/storage.ts
  45. 132 0
      src/utils/upload.ts
  46. 0 0
      stats.html

+ 67 - 0
src/components/ut-ad-item/ut-ad-item.vue

@@ -0,0 +1,67 @@
+<template>
+    <!-- 轮播图 -->
+    <view class="swiper-item" @click.stop="adRoute">
+        <image class="swiper-img" :src="item?.adImg" mode="heightFix" />
+    </view>
+</template>
+<script setup lang="ts" name="ad-item">
+// 移除了不必要的ref导入
+
+interface AdItem {
+    adImg: string;
+    adType: string;
+    adUrl: string;
+}
+
+interface Props {
+    item: AdItem;
+    index: number;
+    count: number;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+    item: () => ({} as AdItem),
+    index: 0,
+    count: 1
+});
+
+const adRoute = () => {
+    if (props.item?.adType == '1') {
+        uni.$u.route({
+            type: 'navigateTo',
+            url: props.item?.adUrl,
+            params: {
+                ad: 1
+            }
+        });
+    }
+    if (props.item?.adType == '2') {
+        uni.$u.route({
+            type: 'navigateTo',
+            url: '/pages/tool/webview/index',
+            params: {
+                url: props.item?.adUrl
+            }
+        });
+    }
+};
+</script>
+<style lang="scss" scoped>
+.swiper-item {
+    position: absolute;
+    left: 0;
+    top: 0;
+    right: 0;
+    bottom: 0;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    overflow: hidden;
+}
+
+.swiper-img {
+    width: 100%;
+    height: 100%;
+}
+</style>

+ 121 - 0
src/components/ut-ad-swiper/ut-ad-swiper.vue

@@ -0,0 +1,121 @@
+<template>
+    <view v-if="style === '1' && list.length" :class="{ 'up-border-bottom': borderBottom }" :style="cStyle">
+        <slot name="title"></slot>
+        <view class="p-rtv">
+            <swiper class="ad-swiper" :style="{ height }" @change="changeIndex" :display-multiple-items="count" autoplay circular
+                :indicator-dots="false">
+                <swiper-item v-for="(item, index) in list" :key="index">
+                    <ut-ad-item :item="item" :index="index" :count="count"></ut-ad-item>
+                </swiper-item>
+            </swiper>
+            <view v-if="isDot"  class="dot-wrap">
+                <view v-for="(item, index) in list" :key="index" class="dot-item" :class="{ checked: current === index }"></view>
+            </view>
+        </view>
+    </view>
+</template>
+<script setup lang="ts" name="ut-ad-swiper">
+import { useClientRequest } from '@/utils/request';
+import { getCurrentPage } from '@/utils/public';
+
+interface AdItem {
+    adImg: string;
+    adType: string;
+    adUrl: string;
+    id: string;
+}
+
+interface Props {
+    adPage: string;
+    style: string;
+    adPosition: string;
+    height: string;
+    margin: string;
+    padding: string;
+    count: number;
+    borderBottom: boolean;
+    cStyle: Record<string, any>;
+    pageSize: number;
+    isDot: boolean;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+    adPage: '',
+    style: '1',
+    adPosition: '1',
+    height: '200rpx',
+    margin: '0',
+    padding: '0',
+    count: 1,
+    borderBottom: false,
+    cStyle: () => ({}),
+    pageSize: 6,
+    isDot: false
+});
+
+const current = ref(0);
+const changeIndex = (e: { detail: { current: number } }) => {
+    current.value = e.detail.current;
+};
+
+const list = ref<AdItem[]>([]);
+const params = ref({
+    pageNum: 1,
+    pageSize: 6,
+    adPage: props?.adPage || getCurrentPage().route,
+    status: 1,
+    stype: props.style,
+    adPosition: props.adPosition
+});
+
+const getList = async () => {
+    try {
+        const res: any = await useClientRequest.get('/api/ads', { params: params.value });
+        if (res?.code !== 200) return;
+        list.value = res.rows;
+    } catch (error) {
+        console.error('Failed to fetch ads:', error);
+    }
+};
+
+const refresh = () => {
+    getList();
+};
+
+defineExpose({
+    refresh
+});
+
+onMounted(() => {
+    getList();
+});
+</script>
+<style lang="scss" scoped>
+.ad-swiper {
+    height: 200rpx;
+}
+.dot-wrap {
+    position: absolute;
+    bottom: 10rpx;
+    left: 0;
+    z-index: 10;
+    right: 0;
+    height: 6rpx;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+
+    .dot-item {
+        width: 6rpx;
+        height: 6rpx;
+        border-radius: 3rpx;
+        background-color: #ccc;
+        margin: 0 4rpx;
+
+        &.checked {
+            width: 30rpx;
+            transition: all .2s;
+        }
+    }
+}
+</style>

+ 62 - 0
src/components/ut-album/ut-album.vue

@@ -0,0 +1,62 @@
+<template>
+    <template v-if="urls.length === 1">
+        <view class="ut-album-single" :style="{ width: singleSize, height: singleSize }" @click="previewImg(keyName ? urls[0][keyName] : urls[0])">
+            <image :style="{ width: singleSize, height: singleSize }" :src="keyName ? urls[0][keyName] : urls[0]" mode="aspectFill"></image>
+        </view>
+    </template>
+    <template v-else>
+        <up-album :urls="urls" :singleMode="singleMode" :space="space" :rowCount="rowCount" :previewFullImage="previewFullImage" :keyName="keyName"
+            :unit="unit" :singleSize="singleSize" :multipleSize="multipleSize">
+        </up-album>
+    </template>
+</template>
+<script setup>
+import {
+    ref
+} from 'vue';
+const props = defineProps({
+    urls: {
+        type: Array,
+        default: []
+    },
+    singleMode: {
+        type: String,
+        default: 'aspectFill'
+    },
+    space: {
+        type: String,
+        default: '14rpx'
+    },
+    rowCount: {
+        type: Number,
+        default: 4
+    },
+    keyName: {
+        type: String,
+        default: ''
+    },
+    unit: {
+        type: String,
+        default: 'rpx'
+    },
+    singleSize: {
+        type: String,
+        default: '160rpx'
+    },
+    multipleSize: {
+        type: String,
+        default: '160rpx'
+    },
+    previewFullImage: {
+        type: Boolean,
+        default: true
+    }
+})
+const previewImg = (url) => {
+	if (url && props.previewFullImage) {
+		uni.previewImage({
+		    urls: [url]
+		});
+	}
+};
+</script>

+ 54 - 0
src/components/ut-avater-icon/ut-avater-icon.vue

@@ -0,0 +1,54 @@
+<template>
+    <view v-if="isEdit" @click.stop="emit('click')" class="p-rtv" :style="{ background: bgColor, border: border ? '1px solid #F8F8F8' : 'none' }">
+        <up-avatar :size="size" :src="src || defaultSrc" :shape="shape"></up-avatar>
+        <image v-if="isEdit" class="edit-icon-box" src="https://ta.zycpzs.cn/oss-file/smart-trace/szyy/images/card/edit_icon.png" mode="aspectFit" />
+    </view>
+    <view v-else  class="p-rtv">
+        <up-avatar :size="size" :src="src || defaultSrc" :shape="shape"></up-avatar>
+    </view>
+</template>
+<script setup>
+const props = defineProps({
+    src: {
+        type: String,
+        default: ''
+    },
+    size: {
+        type: String,
+        default: '116rpx'
+    },
+    defaultSrc: {
+        type: String,
+        default: 'https://ta.zycpzs.cn/oss-file/smart-trace/szyy/images/common/avatar.png'
+    },
+    shape: {
+        type: String,
+        default: 'square' // 'circle' or 'square'
+    },
+    border: {
+        type: Boolean,
+        default: false
+    },
+    // 背景颜色
+    bgColor: {
+        type: String,
+        default: '#EDEDED'
+    },
+    isEdit: {
+        type: Boolean,
+        default: true
+    }
+});
+const emit = defineEmits(['click']);
+</script>
+<style lang="scss" scoped>
+.edit-icon-box {
+    position: absolute;
+    right: 0rpx;
+    bottom: 0rpx;
+    width: 32rpx;
+    height: 32rpx;
+    background-color: #2A6D52;
+    border-radius: 50%;
+}
+</style>

+ 95 - 0
src/components/ut-base-model/ut-base-model.vue

@@ -0,0 +1,95 @@
+<template>
+    <u-popup :show="show" :mode="mode" round="30rpx" closeable @close="close" :safeAreaInsetBottom="safeAreaInsetBottom" @open="open">
+        <view class="popup-body pd-30" :class="{ 'bg-jb': hasBg, 'mode-bottom': mode === 'bottom' }" :style="customStyle">
+            <view v-if="showTitle" class="title f-s-30 c-333 f-w-5 mb-30">
+                <slot name="title">
+                    {{ title }}
+                </slot>
+            </view>
+            <view class="f-s-28 f-c-3">
+                <slot name="content">
+					{{ content }}
+				</slot>
+            </view>
+            <view v-if="showFooter" style="padding-top: 20rpx;">
+                <slot name="footer">
+                    <view class="d-flex j-sb">
+                        <u-button color="#F2F2F2" style="color: #333;width: 260rpx;" type="primary" @click="close" :text="cancelText"></u-button>
+                        <u-button style="width: 260rpx;" type="primary" @click="confirm" :text="confirmText"></u-button>
+                    </view>
+                </slot>
+            </view>
+        </view>
+    </u-popup>
+</template>
+
+<script setup lang="ts" name="ut-base-model">
+interface Props {
+    show: boolean;
+    title: string;
+    confirmText: string;
+    cancelText: string;
+    hasBg: boolean;
+    showFooter: boolean;
+    showTitle: boolean;
+    mode: string;
+    safeAreaInsetBottom: boolean;
+    content: string;
+    customStyle: string;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+    show: true,
+    title: '系统提示',
+    confirmText: '确认',
+    cancelText: '取消',
+    hasBg: false,
+    showFooter: true,
+    showTitle: true,
+    mode: 'center',
+    safeAreaInsetBottom: false,
+    content: '',
+    customStyle: ''
+});
+
+const emit = defineEmits<{
+    'update:show': [value: boolean];
+    close: [];
+    open: [];
+    confirm: [];
+}>();
+
+const close = () => {
+    emit("update:show", false);
+    emit("close");
+};
+
+const open = () => {
+    emit("open");
+};
+
+const confirm = () => {
+    emit("confirm");
+    emit("update:show", false);
+};
+</script>
+
+<style lang="scss" scoped>
+.popup-body {
+	width: 88vw;
+	box-sizing: border-box;
+	background-size: contain;
+	background-repeat: no-repeat;
+	background-position: top center;
+
+	&.mode-bottom {
+		width: 100%;
+	}
+
+	&.bg-jb {
+		// 从下到上渐变
+		border-radius: 30rpx;
+		background: linear-gradient(0deg, #EFFAF4 0%, #fff 100%);
+	}
+}
+</style>

+ 23 - 0
src/components/ut-button-add/ut-button-add.vue

@@ -0,0 +1,23 @@
+<template>
+    <up-button @click="clickBtn" :customStyle="{ borderColor: '#AAC5BA' }" color="#F9FDFB">
+        <view class="d-flex a-c c-primary">
+            <image class="small-icon mr-20" src="https://ta.zycpzs.cn/oss-file/smart-trace/szyy/images/common/add_icon.png" mode="widthFix" />
+            <slot>
+                {{ text }}
+            </slot>
+        </view>
+    </up-button>
+</template>
+<script setup>
+const props = defineProps({
+  text: {
+    type: String,
+    default: ''
+  }
+});
+const emit = defineEmits(['click']);
+const clickBtn = () => {
+  console.log('clickBtn');
+  emit('click');
+};
+</script>

+ 154 - 0
src/components/ut-checkbox-cln/ut-checkbox-cln.vue

@@ -0,0 +1,154 @@
+<template>
+    <u-popup :show="showModel" :mode="mode" round="30rpx" closeable @close="close" :safeAreaInsetBottom="safeAreaInsetBottom" @open="open">
+        <view class="popup-body pd-30" :class="{ 'bg-jb': hasBg, 'mode-bottom': mode === 'bottom' }" :style="customStyle">
+            <view v-if="showTitle" class="title f-s-30 c-333 f-w-5 mb-30">
+                <slot name="title">
+                    {{ title }}
+                </slot>
+            </view>
+            <scroll-view scroll-y class="content-info">
+                <view class="f-s-28 f-c-3">
+                    <slot name="content">
+                        <template v-for="(item, index) in tabs" :key="index">
+                            <view class="mb-20 pd-20 d-flex a-c checkbox-item_view" @click="clickItem(item.value)" :class="{ checked: checkeds[item.value] }">
+                                <view class="flex1 ov-hd" :class="{ 'c-primary': checkeds[item.value] }">{{ item.label }}</view>
+                                <view @click.stop>
+                                    <up-checkbox activeColor="#2a6d52" usedAlone v-model:checked="checkeds[item.value]"></up-checkbox>
+                                </view>
+                            </view>
+                        </template>
+                    </slot>
+                </view>
+            </scroll-view>
+            <view v-if="showFooter" style="padding-top: 20rpx;">
+                <slot name="footer">
+                    <view class="d-flex j-sb">
+                        <u-button color="#F2F2F2" class="mr-30" style="color: #333;" type="primary" @click="close" :text="cancelText"></u-button>
+                        <u-button  type="primary" @click="confirm" :text="confirmText"></u-button>
+                    </view>
+                </slot>
+            </view>
+        </view>
+    </u-popup>
+</template>
+<script setup>
+import { ref, watch } from 'vue';
+
+const props = defineProps({
+    show: {
+        type: Boolean,
+        default: false
+    },
+    modelValue: {
+        type: Array,
+        default: () => []
+    },
+    title: {
+        type: String,
+        default: '系统提示'
+    },
+    confirmText: {
+        type: String,
+        default: '确认选择'
+    },
+    cancelText: {
+        type: String,
+        default: '取消'
+    },
+    hasBg: {
+        type: Boolean,
+        default: false
+    },
+    showFooter: {
+        type: Boolean,
+        default: true
+    },
+    showTitle: {
+        type: Boolean,
+        default: true
+    },
+    mode: {
+        type: String,
+        default: 'bottom'
+    },
+    customStyle: {
+        type: Object,
+        default: () => ({})
+    },
+    safeAreaInsetBottom: {
+        type: Boolean,
+        default: true
+    },
+    tabs: {
+        type: Array,
+        default: () => []
+    }
+});
+const showModel = ref(props.show);
+const emit = defineEmits(['close', 'confirm', 'open', 'update:show',]);
+const checkeds = ref({});
+const close = () => {
+    emit('update:show', false);
+    emit('close');
+};
+const confirm = () => {
+    // 这里可以处理选中的数据变成数组
+    const selectedValues = Object.keys(checkeds.value).filter(key => checkeds.value[key]);
+    emit('update:modelValue', selectedValues);
+    emit('confirm', selectedValues);
+    emit('update:show', false);
+};
+const open = () => {
+    emit('open');
+};
+const clickItem = (value) => {
+    checkeds.value[value] = !checkeds.value[value];
+};
+watch(
+    () => props.show,
+    (newVal) => {
+        showModel.value = newVal;
+    }
+);
+watch(
+    () => props.modelValue,
+    (newVal) => {
+        checkeds.value = {};
+        newVal.forEach(item => {
+            checkeds.value[item] = true;
+        });
+    },
+    { immediate: true }
+);
+</script>
+<style lang="scss" scoped>
+.popup-body {
+    width: 88vw;
+    box-sizing: border-box;
+    background-size: contain;
+    background-repeat: no-repeat;
+    background-position: top center;
+
+    &.mode-bottom {
+        width: 100%;
+    }
+
+    &.bg-jb {
+        // 从下到上渐变
+        border-radius: 30rpx;
+        background: linear-gradient(0deg, #effaf4 0%, #fff 100%);
+    }
+}
+.content-info {
+    max-height: 60vh;
+    min-height: 200rpx;
+}
+.checkbox-item_view {
+    border: 1rpx solid #e1eee9;
+    border-radius: 10rpx;
+
+    &.checked {
+        border-color: #2a6d52;
+    }
+}
+</style>

+ 53 - 0
src/components/ut-datetime-picker/ut-datetime-picker.vue

@@ -0,0 +1,53 @@
+<template>
+    <view @click.stop="showTime = true" class="ut-datetime-picker" :class="{ 'ut-datetime-picker--border': border }">
+        <slot></slot>
+    </view>
+    <up-datetime-picker v-model:show="showTime" :minDate="minDate" :maxDate="maxDate" ref="datetimePickerRef" :title="title" v-model="form.startTime" :mode="mode" @cancel="cancel" confirmColor="#2a6d52" @confirm="confirm"></up-datetime-picker>
+</template>
+<script setup lang="ts">
+import { parseTime } from '@/utils/ruoyi';
+
+type DateTimeMode = 'time' | 'datetime' | 'date' | 'year-month';
+
+interface Props {
+    title: string;
+    modelValue: string | number;
+    mode: DateTimeMode;
+    border: boolean;
+    hasInput: boolean;
+    minDate: number;
+    maxDate: number;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+    title: '选择时间',
+    modelValue: '',
+    mode: 'datetime' as DateTimeMode,
+    border: false,
+    hasInput: false,
+    minDate: new Date(2020, 0, 1).getTime(),
+    maxDate: new Date(2030, 0, 1).getTime()
+});
+
+const emit = defineEmits<{
+    'update:modelValue': [value: string | null];
+    change: [value: string | null];
+}>();
+
+const showTime = ref(false);
+const datetimePickerRef = ref(null);
+const form = ref({
+    startTime: props.modelValue ? new Date(props.modelValue) : Date.now()
+});
+
+const confirm = (value: { value: number }) => {
+    const startTime = parseTime(value.value, '{y}-{m}-{d} {h}:{i}');
+    showTime.value = false;
+    emit('update:modelValue', startTime);
+    emit('change', startTime);
+};
+
+const cancel = () => {
+    showTime.value = false;
+};
+</script>

+ 32 - 0
src/components/ut-empty/ut-empty.vue

@@ -0,0 +1,32 @@
+<template>
+    <view class="ut-empty d-flex flex-cln j-c a-c pd-30">
+        <view class="ut-empty__icon">
+            <image :style="{ width, height }" :src="image" mode="aspectFit"></image>
+        </view>
+        <view class="ut-empty__text c-ccc f-s-24" style="margin-top: -50rpx;">
+            <slot>
+                {{ text }}
+            </slot>
+        </view>
+    </view>
+</template>
+<script setup>
+const props = defineProps({
+    text: {
+        type: String,
+        default: '暂无数据',
+    },
+    width: {
+        type: String,
+        default: '300rpx',
+    },
+    height: {
+        type: String,
+        default: '300rpx',
+    },
+    image: {
+        type: String,
+        default: 'https://ta.zycpzs.cn/oss-file/smart-trace/szyy/images/common/no_data.png',
+    },
+});
+</script>

+ 64 - 0
src/components/ut-file/ut-file.vue

@@ -0,0 +1,64 @@
+<template>
+    <!-- 下载 -->
+    <view :style="{ margin }">
+        <template v-for="file in files" :key="index">
+            <view class="d-flex a-c file-item-box mb-10" @click="downloadFile(file)">
+                <view class="mr-20">
+					<template v-if="['doc', 'docx', 'DOC', 'DOCX'].includes(getFileSuffix(file?.fileName))">
+                       <image class="file-icon" src="https://ta.zycpzs.cn/oss-file/smart-trace/szyy/images/common/doc.png" mode="widthFix" />
+					</template>
+					<template v-if="['pdf', 'PDF'].includes(getFileSuffix(file?.fileName))">
+                       <image class="file-icon" src="https://ta.zycpzs.cn/oss-file/smart-trace/szyy/images/common/pdf.png" mode="widthFix" />
+					</template>
+                </view>
+                <view class="flex1 ov-hd mr-20">
+                    <view class="f-s-28 c-light-black f-w-5 mb-8">{{ file?.fileName }}</view>
+                    <view v-if="file?.size" class="f-s-24 c-999">大小:{{changeByte(file?.size)}}</view>
+                </view>
+                <view class="d-flex flex-cln">
+                    <image class="small-icon mb-5" src="https://ta.zycpzs.cn/oss-file/smart-trace/szyy/images/common/download.png" mode="widthFix" />
+                    <view class="f-s-22 c-primary">下载</view>
+                </view>
+            </view>
+        </template>
+    </view>
+</template>
+<script setup lang="ts">
+// 移除了不必要的ref导入
+import { changeByte, getFileSuffix } from '@/utils/ruoyi';
+import { exportDataFn } from '@/utils/upload';
+
+interface FileItem {
+    fileName: string;
+    size?: number;
+    url?: string;
+}
+
+interface Props {
+    files: FileItem[];
+    margin: string;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+    files: () => [],
+    margin: '0'
+});
+
+const downloadFile = (file: FileItem) => {
+    exportDataFn(file);
+};
+
+</script>
+<style lang="scss" scoped>
+.file-item-box {
+	padding: 24rpx 30rpx;
+	background-color: #fff;
+	border: 1rpx solid $u-border-color;
+	border-radius: 16rpx;
+}
+
+.file-icon {
+	width: 56rpx;
+	height: 56rpx;
+}
+</style>

+ 139 - 0
src/components/ut-files-upload/ut-files-upload.vue

@@ -0,0 +1,139 @@
+<template>
+    <view class="">
+        <up-button v-if="fileList.length < count" class="mb-10" @click="addFiles" :customStyle="{ color: '#2a6d52', backgroundColor: '#F9FDFB', borderColor: '#E1EEE9' }">
+            <up-icon class="mr-5" name="plus-circle" color="#2a6d52" size="30rpx"></up-icon>
+            <span>{{ btnText }}</span>
+        </up-button>
+        <template v-for="(file, index) in fileList" :key="index">
+            <view class="d-flex a-c file-item-box mb-10" @click="downloadFile(file)">
+                <view class="mr-20">
+                    <template v-if="['doc', 'docx', 'DOC', 'DOCX'].includes(getFileSuffix(file?.fileName))">
+                        <image class="file-icon" src="https://ta.zycpzs.cn/oss-file/smart-trace/szyy/images/common/doc.png" mode="widthFix" />
+                    </template>
+                    <template v-if="['pdf', 'PDF'].includes(getFileSuffix(file?.fileName))">
+                        <image class="file-icon" src="https://ta.zycpzs.cn/oss-file/smart-trace/szyy/images/common/pdf.png" mode="widthFix" />
+                    </template>
+                </view>
+                <view class="flex1 ov-hd mr-20">
+                    <view class="f-s-28 c-light-black f-w-5 mb-8">{{ file?.fileName }}</view>
+                    <view v-if="file?.size" class="f-s-24 c-999">大小:{{changeByte(file?.size)}}</view>
+                </view>
+                <view class="d-flex flex-cln j-c a-c" @click="deleteFile(index)">
+                    <up-icon size="30rpx" color="#f56c6c" name="trash-fill"></up-icon>
+                    <view class="f-s-22 c-danger">删除</view>
+                </view>
+            </view>
+        </template>
+    </view>
+</template>
+
+<script setup>
+import { ref } from 'vue';
+import upload, { exportDataFn } from '@/utils/upload';
+import { changeByte, getFileSuffix } from '@/utils/ruoyi';
+const props = defineProps({
+    modelValue: {
+        type: [Array, Object],
+        default: () => []
+    },
+    btnText: {
+        type: String,
+        default: '上传文件'
+    },
+    disabled: {
+        type: Boolean,
+        default: false
+    },
+	maxSize: {
+		type: Number,
+		default: 10
+	},
+	count: {
+		type: Number,
+		default: 5 // 默认最多上传5个文件
+	},
+	extension: {
+		type: Array,
+		default: () => ['PDF', 'pdf', 'doc', 'docx', 'DOC', 'DOCX'] // 默认支持的文件类型
+	}
+});
+const fileList = ref(props.modelValue || []);
+const emit = defineEmits(['update:modelValue']);
+const addFiles = async () => {
+	if (props.disabled) return;
+	uni.chooseMessageFile({
+		count: props.count - fileList.value.length,
+		type: 'file',
+		extension: props.extension,
+		success (res) {
+			const tempFiles = res.tempFiles;
+            uploadFiles(tempFiles)
+		},
+		fail (err) {
+			console.error(err);
+			uni.showToast({
+				title: '选择文件失败',
+				icon: 'none'
+			});
+		}
+	});
+};
+const deleteFile = (index) => {
+    fileList.value.splice(index, 1);
+    emit('update:modelValue', fileList.value);
+};
+// 上传多个文件
+const uploadFiles = async (files) => {
+    const uploadedFiles = [];
+    for (const file of files) {
+        if (file.size > props.maxSize * 1024 * 1024) {
+            uni.showToast({
+                title: `文件 ${file.name} 超过最大限制 ${props.maxSize}MB`,
+                icon: 'none'
+            });
+            continue;
+        }
+        try {
+            const res = await upload({
+                filePath: file.path,
+                url: '/resource/oss/upload',
+                formData: {
+                    fileName: file.name,
+                }
+            });
+            if (res.code === 200) {
+                uploadedFiles.push({
+                    ...res.data,
+                    fileName: file.name,
+                });
+            } else {
+                uni.showToast({
+                    title: `上传失败: ${res.message}`,
+                    icon: 'none'
+                });
+            }
+        } catch (error) {
+            uni.showToast({
+                title: '上传失败',
+                icon: 'none'
+            });
+        }
+    }
+    fileList.value = [...fileList.value, ...uploadedFiles];
+    emit('update:modelValue', fileList.value);
+};
+</script>
+
+<style lang="scss" scoped>
+.file-item-box {
+	padding: 24rpx 30rpx;
+	background-color: #fff;
+	border: 1rpx solid $u-border-color;
+	border-radius: 16rpx;
+}
+
+.file-icon {
+	width: 56rpx;
+	height: 56rpx;
+}
+</style>

+ 48 - 0
src/components/ut-image/ut-image.vue

@@ -0,0 +1,48 @@
+<template>
+    <template v-if="preview">
+        <view class="ut-image p-rtv" @click.stop="previewImg" :style="{ width, height }">
+            <up-image :src="value" mode="aspectFit" :width="width" :height="height" :showLoading="false" :lazyLoad="lazyLoad"></up-image>
+        </view>
+    </template>
+    <template v-else>
+        <view class="ut-image p-rtv" :style="{ width, height }">
+            <!-- <image :src="value" mode="aspectFit" :style="{ width, height }" :lazy-load="lazyLoad"></image> -->
+            <up-image :src="value" mode="aspectFit" :width="width" :height="height" :showLoading="false" :lazyLoad="lazyLoad"></up-image>
+        </view>
+    </template>
+</template>
+<script setup lang="ts" name="ut-image">
+// 移除了不必要的ref和watch导入
+
+interface Props {
+    width: string;
+    height: string;
+    value: string;
+    preview: boolean;
+    borderRadius: string;
+    lazyLoad: boolean;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+    width: '326rpx',
+    height: '200rpx',
+    value: '',
+    preview: false,
+    borderRadius: '16rpx',
+    lazyLoad: true
+});
+
+const previewImg = () => {
+    if (props.value && props.preview) {
+        uni.previewImage({
+            urls: [props.value]
+        });
+    }
+};
+</script>
+<style lang="scss" scoped>
+.ut-image {
+    border-radius: 16rpx;
+    overflow: hidden;
+}
+</style>

+ 176 - 0
src/components/ut-index-list/ut-index-list.vue

@@ -0,0 +1,176 @@
+<template>
+    <scroll-view class="ut-index-list_scroll" scroll-y scroll-with-animation :scroll-into-view="intoview">
+        <template v-for="(item, index) in list" :key="index">
+            <view class="ut-index-list__item">
+                <view :id="item[letterField] + 'xxxxxx'" style="height: 1px;"></view>
+                <up-sticky :zIndex="10">
+                    <view class="ut-index-list__item__letter">{{ item[letterField] }}</view>
+                </up-sticky>
+                <view class="ut-index-list__item__content">
+                    <slot name="content" :item="item" :index="rowIndex"></slot>
+                </view>
+            </view>
+        </template>
+    </scroll-view>
+    <view class="ut-navber_letter_view d-flex flex-cln j-c j-c">
+        <view class="p-rtv">
+            <view class="ut-navber_letter-box" @touchstart.prevent="touchStart" @touchmove.prevent="touchMove" @touchend.prevent="touchEnd" @touchcancel.prevent="touchEnd">
+                <template v-for="(item, index) in list" :key="index">
+                    <view class="ut-navber_letter p-rtv" :class="{ active: index === cindex }">
+                        {{ item[letterField] }}
+                    </view>
+                </template>
+            </view>
+            <up-transition :show="touching">
+                <view v-if="list[cindex]" class="touching_letter" :style="{ top: setLetter(cindex) }">
+                    <text class="f-w-b c-fff touching_letter_text">{{ list[cindex]?.[letterField] }}</text>
+                </view>
+            </up-transition>
+        </view>
+    </view>
+</template>
+<script setup name="ut-index-list">
+import { sleep } from 'uview-plus';
+import { ref, getCurrentInstance, toRefs, onMounted } from 'vue';
+import { debounce } from 'uview-plus';
+const instance = getCurrentInstance();
+const props = defineProps({
+    list: {
+        type: Array,
+        default: () => []
+    },
+    letterField: {
+        type: String,
+        default: 'firstChar'
+    },
+    letterItem: {
+        type: String,
+        default: 'rows'
+    },
+    showLetter: {
+        type: Object,
+        default: () => ({})
+    }
+});
+const intoview = ref('');
+const itemHeight = ref(uni.$u.getPx('36rpx'));
+const letterHeight = ref(uni.$u.getPx('100rpx'));
+const letterInfo = ref({});
+const touching = ref(false);
+// 根据e.target.offsetTop计算出当前滚动到的位置
+// 获取index选中
+const getSelectIndex = (pageY) => {
+    let { top, height } = letterInfo.value;
+    let index = 0;
+    if (pageY < top) {
+        index = 0;
+    } else if (pageY >= top + height) {
+        // 如果超出了,取最后一个字母
+        index = props.list.length - 1;
+    } else {
+        index = Math.floor((pageY - top) / itemHeight.value);
+    }
+    if (index > props.list.length - 1) {
+        index = props.list.length - 1;
+    }
+    return index;
+};
+const setLetter = (index) => {
+    let scrollTop = itemHeight.value * index - (letterHeight.value - itemHeight.value) / 2;
+    return scrollTop + 'px';
+};
+// 设置index选中
+const setSelectIndex = (index) => {
+    // letter.value = props.list[index][props.letterField];
+    intoview.value = props.list[index][props.letterField] + 'xxxxxx';
+};
+const cindex = ref(-1);
+const touchStart = (e) => {
+    const touchData = e.changedTouches[0];
+    if (!touchData) return;
+    touching.value = true;
+    cindex.value = getSelectIndex(touchData.pageY);
+};
+const touchMove = (e) => {
+    const touchData = e.changedTouches[0];
+    if (!touchData) return;
+    if (!touching.value) {
+        touching.value = true;
+    }
+    cindex.value = getSelectIndex(touchData.pageY);
+};
+const touchEnd = (e) => {
+    // letter.value = '';
+    sleep(500).then(() => {
+        touching.value = false;
+        setSelectIndex(cindex.value);
+        cindex.value = -1;
+    });
+};
+
+onMounted(() => {
+    setTimeout(() => {
+        const query = uni.createSelectorQuery().in(instance.proxy);
+        query
+            .select('.ut-navber_letter-box')
+            .boundingClientRect((data) => {
+                letterInfo.value = data;
+            })
+            .exec();
+    }, 500);
+});
+</script>
+<style lang="scss" scoped>
+.ut-index-list_scroll {
+    height: 100%;
+}
+
+.ut-index-list__item__letter {
+    padding: 10rpx 0;
+    font-size: 26rpx;
+    color: #999;
+    background-color: #fff;
+}
+
+.ut-navber_letter_view {
+    position: absolute;
+    right: 0;
+    top: 0;
+    bottom: 0;
+    width: 36rpx;
+    z-index: 30;
+
+    .ut-navber_letter {
+        font-size: 24rpx;
+        color: #333;
+        width: 36rpx;
+        height: 36rpx;
+        text-align: center;
+
+        &.active {
+            background-color: $u-primary;
+            border-radius: 50%;
+            color: #fff;
+        }
+    }
+}
+
+.touching_letter {
+    position: absolute;
+    right: 60rpx;
+    width: 100rpx;
+    height: 100rpx;
+    border-radius: 200rpx 200rpx 0 200rpx;
+    background-color: #c9c9c9;
+    transform: rotate(-45deg);
+    display: flex;
+    justify-content: center;
+    align-items: center;
+
+    .touching_letter_text {
+        line-height: 1;
+        font-size: 56rpx;
+        transform: rotate(45deg);
+    }
+}
+</style>

+ 27 - 0
src/components/ut-loading-view/ut-loading-view.vue

@@ -0,0 +1,27 @@
+<template>
+    <view class="p-rtv">
+        <slot></slot>
+        <view v-if="loading" class="loading-box d-flex a-c j-c">
+            <up-loading-icon color="#2A6D52"></up-loading-icon>
+        </view>
+    </view>
+</template>
+<script setup>
+const props = defineProps({
+    loading: {
+        type: Boolean,
+        default: false
+    }
+})
+</script>
+<style lang="scss" scoped>
+.loading-box {
+    position: absolute;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background-color: rgba(255, 255, 255, .8);
+    z-index: 9999;
+}
+</style>

+ 66 - 0
src/components/ut-marquee-animation/ut-marquee-animation.vue

@@ -0,0 +1,66 @@
+<template>
+    <view class="css-swiper p-rtv ov-hd" :style="{ height: auto ? setheight(count, list.length, height) : 'auto' }">
+        <view class="css-swiper-inner" :class="{ 'swiper-animation': auto && list.length > count }" :style="{ animationDuration: interval * list.length * 1.5 + 'ms' }">
+            <template v-if="auto && list.length > count">
+                <template v-for="(item, index) in list.concat(list)" :key="index">
+                    <slot name="item" :item="item"></slot>
+                </template>
+            </template>
+            <template v-else>
+                <template v-for="(item, index) in list" :key="index">
+                    <slot name="item" :item="item"></slot>
+                </template>
+            </template>
+        </view>
+    </view>
+</template>
+<script setup name="ut-marquee-animation">
+import { ref, onMounted, getCurrentInstance } from 'vue';
+const instance = getCurrentInstance();
+const props = defineProps({
+    list: {
+        type: Array,
+        default: () => []
+    },
+    count: {
+        type: Number,
+        default: 6
+    },
+    auto: {
+        type: Boolean,
+        default: true
+    },
+    interval: {
+        type: Number,
+        default: 2000
+    },
+    height: {
+        type: Number,
+        default: 110
+    }
+})
+const setheight = (count, len, height) => {
+    if (len > count) {
+        return count * height + 'rpx'
+    } else {
+        return 'auto'
+    }
+}
+</script>
+<style lang="scss" scoped>
+.css-swiper-inner {
+    &.swiper-animation {
+        animation: marqueeAnimation linear infinite;
+    }
+}
+
+@keyframes marqueeAnimation {
+    0% {
+        transform: translateY(0);
+    }
+
+    100% {
+        transform: translateY(-50%);
+    }
+}
+</style>

+ 98 - 0
src/components/ut-navbar-model/ut-navbar-model.vue

@@ -0,0 +1,98 @@
+<template>
+    <view id="navbarModel" class="navbar-model"></view>
+    <view v-show="show" @click="close" class="transition model-drop" :style="{ top: modelPtn.top + 'px' }">
+        <view @click.stop class="model-drop-content pd-24" :class="{}">
+            <slot name="title">
+                <view class="f-s-32 c-999 mb-16">{{ title }}</view>
+            </slot>
+            <slot></slot>
+            <view v-if="showFooter">
+                <slot name="footer">
+                    <view class="d-flex j-sb" style="padding-top: 20rpx;">
+                        <u-button color="#F2F2F2" style="color: #333;" type="primary" @click="close" :text="cancelText"></u-button>
+                        <view class="pd-10"></view>
+                        <u-button type="primary" @click="confirm" :text="confirmText"></u-button>
+                    </view>
+                </slot>
+            </view>
+        </view>
+    </view>
+</template>
+<script setup name="ut-navbar-model">
+import { ref, onMounted, getCurrentInstance, watch } from 'vue';
+const vsible = ref(false);
+const emit = defineEmits(['confirm', 'update:show', 'close']);
+const instance = getCurrentInstance();
+const props = defineProps({
+    show: {
+        type: Boolean,
+        default: false
+    },
+    width: {
+        type: String,
+        default: '100%'
+    },
+    title: {
+        type: String,
+        default: ''
+    },
+    showFooter: {
+        type: Boolean,
+        default: true
+    },
+    // 确认文字
+    confirmText: {
+        type: String,
+        default: '确认'
+    },
+    // 取消文字
+    cancelText: {
+        type: String,
+        default: '取消'
+    }
+});
+const modelPtn = ref({
+    top: 0
+})
+const close = () => {
+    emit('update:show', false);
+    emit('close');
+}
+const confirm = () => {
+    emit('confirm', true);
+    emit('update:show', false);
+}
+onMounted(() => {
+    const query = uni.createSelectorQuery().in(instance.proxy);
+    query
+        .select(".navbar-model")
+        .boundingClientRect((data) => {
+            modelPtn.value = data;
+        })
+        .exec();
+})
+watch(() => props.show, (val) => {
+    vsible.value = val;
+}, { immediate: true });
+</script>
+<style lang="scss" scoped>
+.navbar-model {
+    position: relative;
+    height: 1rpx;
+}
+
+.model-drop {
+    position: fixed;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background-color: rgba(0, 0, 0, .6);
+    z-index: 999;
+}
+
+.model-drop-content {
+    box-sizing: border-box;
+    width: 100%;
+    background-color: #fff;
+}
+</style>

+ 10 - 0
src/components/ut-navbar/ut-navbar.vue

@@ -0,0 +1,10 @@
+<template>
+	<view class="">xx</view>
+</template>
+
+<script>
+	
+</script>
+
+<style>
+</style>

+ 63 - 0
src/components/ut-picker-date/ut-picker-date.vue

@@ -0,0 +1,63 @@
+<template>
+    <picker :value="date" mode="date" :start="start" :end="end" @change="onChange" :fields="fields">
+        <slot>
+            <view class="date-item" :style="cStyle">
+                <text v-if="date" class="c-333 f-s-26">{{ date }}</text>
+                <text v-else class="c-999 f-s-26">{{ placeholder }}</text>
+            </view>
+        </slot>
+    </picker>
+</template>
+<script setup name="ut-picker-date">
+import { watch, ref } from 'vue';
+const props = defineProps({
+    modelValue: {
+        type: String,
+        default: ''
+    },
+    placeholder: {
+        type: String,
+        default: '请选择日期'
+    },
+    disabled: {
+        type: Boolean,
+        default: false
+    },
+    fields: {
+        type: String,
+        default: 'date'
+    },
+    cStyle: {
+        type: String,
+        default: ''
+    },
+    start: {
+        type: String,
+        default: ''
+    },
+    end: {
+        type: String,
+        default: ''
+    }
+})
+const emit = defineEmits(['update:modelValue']);
+const date = ref(props.modelValue);
+const onChange = (e) => {
+    date.value = e.detail.value;
+    emit('update:modelValue', date.value);
+    emit('change', date.value);
+}
+watch(() => props.modelValue, (newVal) => {
+    date.value = newVal;
+});
+</script>
+<style lang="scss" scoped>
+.date-item {
+    width: 100%;
+    height: 54rpx;
+    text-align: center;
+    line-height: 54rpx;
+    background-color: #f7f7f7;
+    border-radius: 8rpx;
+}
+</style>

+ 73 - 0
src/components/ut-picker-region/ut-picker-region.vue

@@ -0,0 +1,73 @@
+<template>
+    <picker mode="region" @change="regionChange" :value="region" custom-item="全部" level="region">
+        <view class="d-flex">
+            <up-input class="flex1" v-model="regiontext" :placeholder="placeholder" border="none" readonly></up-input>
+            <up-icon color="#2A6D52" size="20rpx" style="transform: rotate(90deg);" name="play-right-fill"></up-icon>
+        </view>
+    </picker>
+</template>
+<script setup lang="ts" name="ut-picker-region">
+// 移除了未使用的API导入,如果需要使用API,可以导入 useClientRequest
+
+interface RegionData {
+    adcodeName?: string;
+    adcode?: string;
+    province?: string;
+    city?: string;
+    district?: string;
+}
+
+interface Props {
+    modelValue: RegionData;
+    defaultText: string;
+    placeholder: string;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+    modelValue: () => ({}),
+    defaultText: '',
+    placeholder: '请选择地区'
+});
+
+const emit = defineEmits<{
+    change: [value: RegionData];
+    'update:modelValue': [value: RegionData];
+}>();
+
+const region: string[] = [];
+const regiontext = ref('');
+
+const regionChange = (e: { detail: { code: string[]; value: string[] } }) => {
+    const { code, value } = e.detail;
+    console.log(code, value);
+    // region.value = value;  // region is not reactive
+    regiontext.value = value.join('');
+    
+    const obj: RegionData = {
+        ...form.value,
+        adcodeName: value.join(''),
+        adcode: code[code.length - 1],
+        province: value[0],
+        city: value[1],
+        district: value[2]
+    };
+    
+    emit('update:modelValue', obj);
+    emit('change', obj);
+};
+
+const form = ref<RegionData>({});
+
+watch(() => props.modelValue, (ov) => {
+    if (ov) {
+        form.value = { ...ov };
+        regiontext.value = (ov?.province || '') + (ov?.city || '') + (ov?.district || '');
+    }
+}, { immediate: true });
+
+watch(() => props.defaultText, (ov) => {
+    if (ov) {
+        regiontext.value = regiontext.value || ov;
+    }
+}, { immediate: true });
+</script>

+ 173 - 0
src/components/ut-picker/ut-picker.vue

@@ -0,0 +1,173 @@
+<template>
+    <view @click.stop="showModel = true">
+        <slot></slot>
+    </view>
+    <u-popup :show="showModel" :mode="mode" round="30rpx" closeable @close="close" :safeAreaInsetBottom="safeAreaInsetBottom" @open="open">
+        <view class="popup-body pd-30" :class="{ 'bg-jb': hasBg, 'mode-bottom': mode === 'bottom' }" :style="customStyle">
+            <view v-if="showTitle" class="title f-s-30 c-333 f-w-5 mb-30">
+                <slot name="title">
+                    {{ title }}
+                </slot>
+            </view>
+            <scroll-view scroll-y class="content-info">
+                <view class="f-s-28 f-c-3">
+                    <slot name="content">
+                        <template v-for="(item, index) in tabs" :key="index">
+                            <view class="mb-20 pd-20 d-flex a-c checkbox-item_view" @click="clickItem(item.value)" :class="{ checked: checkeds[item.value] }">
+                                <view class="flex1 ov-hd" :class="{ 'c-primary': checkeds[item.value] }">{{ item.label }}</view>
+                                <view @click.stop>
+                                    <up-checkbox activeColor="#2a6d52" usedAlone v-model:checked="checkeds[item.value]"></up-checkbox>
+                                </view>
+                            </view>
+                        </template>
+                    </slot>
+                </view>
+            </scroll-view>
+            <view v-if="showFooter" style="padding-top: 20rpx;">
+                <slot name="footer">
+                    <view class="d-flex j-sb">
+                        <u-button color="#F2F2F2" class="mr-30" style="color: #333;" type="primary" @click="close" :text="cancelText"></u-button>
+                        <u-button type="primary" @click="confirm" :text="confirmText"></u-button>
+                    </view>
+                </slot>
+            </view>
+        </view>
+    </u-popup>
+</template>
+<script setup>
+import { ref, watch } from 'vue';
+
+const props = defineProps({
+    show: {
+        type: Boolean,
+        default: false
+    },
+    modelValue: {
+        type: Array,
+        default: () => []
+    },
+    str: {
+        type: String,
+        default: ''
+    },
+    title: {
+        type: String,
+        default: '系统提示'
+    },
+    confirmText: {
+        type: String,
+        default: '确认选择'
+    },
+    cancelText: {
+        type: String,
+        default: '取消'
+    },
+    hasBg: {
+        type: Boolean,
+        default: false
+    },
+    showFooter: {
+        type: Boolean,
+        default: true
+    },
+    showTitle: {
+        type: Boolean,
+        default: true
+    },
+    mode: {
+        type: String,
+        default: 'bottom'
+    },
+    customStyle: {
+        type: Object,
+        default: () => ({})
+    },
+    safeAreaInsetBottom: {
+        type: Boolean,
+        default: true
+    },
+    tabs: {
+        type: Array,
+        default: () => []
+    },
+    valueType: {
+        type: String,
+        default: 'arr'
+    }
+});
+const showModel = ref(props.show);
+const emit = defineEmits(['close', 'confirm', 'open', 'update:show', 'update:modelValue', 'update:str', 'change']);
+const checkeds = ref({});
+const close = () => {
+    showModel.value = false;
+    emit('update:show', false);
+    emit('close');
+};
+const confirm = () => {
+    // 这里可以处理选中的数据变成数组
+    const selectedValues = Object.keys(checkeds.value).filter((key) => checkeds.value[key]);
+    emit('update:modelValue', selectedValues);
+    emit('update:str', selectedValues.join(','));
+    emit('change', selectedValues);
+    showModel.value = false;
+    emit('update:show', false);
+};
+const open = () => {
+    emit('open');
+};
+const clickItem = (value) => {
+    checkeds.value[value] = !checkeds.value[value];
+};
+
+watch(
+    () => props.modelValue,
+    (newVal) => {
+        checkeds.value = {};
+        newVal.forEach((item) => {
+            checkeds.value[item.value] = true;
+        });
+    },
+    { immediate: true }
+);
+watch(
+    () => props.str,
+    (newVal) => {
+        if (!newVal) return;
+        checkeds.value = {};
+        newVal.split(',').forEach((item) => {
+            checkeds.value[item] = true;
+        });
+    }
+);
+</script>
+<style lang="scss" scoped>
+.popup-body {
+    width: 88vw;
+    box-sizing: border-box;
+    background-size: contain;
+    background-repeat: no-repeat;
+    background-position: top center;
+
+    &.mode-bottom {
+        width: 100%;
+    }
+
+    &.bg-jb {
+        // 从下到上渐变
+        border-radius: 30rpx;
+        background: linear-gradient(0deg, #effaf4 0%, #fff 100%);
+    }
+}
+.content-info {
+    max-height: 60vh;
+    min-height: 200rpx;
+}
+.checkbox-item_view {
+    border: 1rpx solid #e1eee9;
+    border-radius: 10rpx;
+
+    &.checked {
+        border-color: #2a6d52;
+    }
+}
+</style>

+ 198 - 0
src/components/ut-pickup-date/ut-pickup-date.vue

@@ -0,0 +1,198 @@
+<template>
+    <u-popup :show="show" :mode="mode" round="30rpx" closeable @close="close" :safeAreaInsetBottom="safeAreaInsetBottom" @open="open">
+        <view class="popup-body pd-30" :class="{ 'bg-jb': hasBg, 'mode-bottom': mode === 'bottom' }">
+            <view class="title f-s-30 c-333 f-w-5 mb-30">
+                {{ title }}
+            </view>
+            <view class="f-s-28 f-c-3 pickup-date-content d-flex">
+                <scroll-view class="left-scroll" scroll-y>
+                    <view>
+                        <template v-for="(item, index) in dates" :key="index">
+                            <view @click="changeLeftNavs(item)" class="left-item-nav pd-20 d-flex a-c j-c" :class="{ checked: item.date === form.date }">{{ item.alias }}</view>
+                        </template>
+                    </view>
+                </scroll-view>
+                <scroll-view class="flex1 right-scroll" scroll-y>
+                    <view class="pd-20">
+                        <up-radio-group v-model="form.time" placement="column" @change="groupChange">
+                            <up-radio v-for="(item, index) in times" activeColor="#2A6D52" :key="index" :name="item.end" :label="item.alias">{{ item.alias }}</up-radio>
+                        </up-radio-group>
+                    </view>
+                </scroll-view>
+            </view>
+            <view v-if="showFooter" style="padding-top: 20rpx;">
+                <slot name="footer">
+                    <view class="d-flex j-sb">
+                        <u-button color="#F2F2F2" style="color: #333;width: 260rpx;" type="primary" @click="close" :text="cancelText"></u-button>
+                        <u-button style="width: 260rpx;" type="primary" @click="confirm" :text="confirmText"></u-button>
+                    </view>
+                </slot>
+            </view>
+        </view>
+    </u-popup>
+</template>
+
+<script setup>
+import { ref, watch, defineProps, defineEmits } from 'vue'
+
+const props = defineProps({
+	show: {
+		type: Boolean,
+		default: true,
+	},
+	title: {
+		type: String,
+		default: '请选择上门取件时间'
+	},
+	confirmText: {
+		type: String,
+		default: '确认'
+	},
+	cancelText: {
+		type: String,
+		default: '取消'
+	},
+	hasBg: {
+		type: Boolean,
+		default: false,
+	},
+	showFooter: {
+		type: Boolean,
+		default: true,
+	},
+	mode: {
+		type: String,
+		default: 'center',
+	},
+	safeAreaInsetBottom: {
+		type: Boolean,
+		default: false,
+	},
+})
+const dates = ref([])
+const times = ref([])
+const activeLeft = ref('')
+const form = ref({
+    date: '',
+    time: ''
+})
+const emit = defineEmits(['update:show', 'close', 'open', 'confirm', 'change'])
+
+const showModel = ref(false)
+// 获取从今天开始的一周日期, 日期格式为:2021-09-01,如果是今天,明天,后天,其余别名当前日期,日期别名为:今天,明天,后天,数组元素为 { date: '2021-09-01', alias: '今天' }
+const getDates = () => {
+    const date = new Date()
+    const dates = []
+    for (let i = 0; i < 7; i++) {
+        const item = {}
+        const curDate = new Date(date.getTime() + i * 24 * 60 * 60 * 1000)
+        const curDateStr = curDate.toISOString().split('T')[0]
+        if (i === 0) {
+            item.alias = '今天'
+        } else if (i === 1) {
+            item.alias = '明天'
+        } else if (i === 2) {
+            item.alias = '后天'
+        } else {
+            item.alias = curDateStr
+        }
+        item.date = curDateStr
+        dates.push(item)
+    }
+    return dates
+}
+// 根据日期获取时间段如果传入的日期是今天,时间段为当前时间之后的时间段,如果是明天,后天,时间段为早上8点到晚上20点,时间段格式为:08:00~09:00,09:00~10:00, 值是后一位时间 间隔传入参数n分钟数组元素为 { start: '08:00', end: '09:00', alias: '08:00~09:00' }
+// 当前时间之后m分钟
+const getTimes = (date, n = 60, m = 60) => {
+    const times = []
+    const curDate = new Date()
+    const curDateStr = curDate.toISOString().split('T')[0]
+    const curHour = curDate.getHours()
+    const curMinute = curDate.getMinutes()
+    const curTime = curHour * 60 + curMinute
+    let start = 8
+    let end = 20
+    if (date === curDateStr) {
+        start = curHour + 1
+        if (curMinute > 30) {
+            start += 1
+        }
+    }
+    for (let i = start; i < end; i++) {
+        const item = {}
+        item.start = `${i < 10 ? '0' + i : i}:00`
+        item.end = `${i + 1 < 10 ? '0' + (i + 1) : i + 1}:00`
+        item.alias = `${item.start}~${item.end}`
+        times.push(item)
+    }
+    return times
+}
+watch(() => props.show, (val) => {
+	showModel.value = val
+}, { immediate: true })
+
+const close = () => {
+	emit("update:show", false)
+	emit("close")
+}
+const changeLeftNavs = (item) => {
+    form.value.date = item.date
+    times.value = getTimes(item.date)
+    form.value.time = ''
+}
+const open = () => {
+    dates.value = getDates()
+    form.value.date = dates.value[0].date
+    times.value = getTimes(form.value.date)
+	emit("open")
+}
+
+const confirm = () => {
+    const obj = {
+        date: form.value.date,
+        time: form.value.time,
+        dateAlias: dates.value.find(item => item.date === form.value.date).alias,
+        timeAlias: times.value.find(item => item.end === form.value.time).alias
+    }
+    emit('change', obj)
+    emit("confirm", obj)
+	emit("update:show", false)
+}
+</script>
+
+<style lang="scss" scoped>
+.popup-body {
+	width: 86vw;
+	box-sizing: border-box;
+
+	&.mode-bottom {
+		width: 100%;
+	}
+
+	&.bg-jb {
+		// 从下到上渐变
+		border-radius: 30rpx;
+		background: linear-gradient(0deg, #EFFAF4 0%, #fff 100%);
+	}
+}
+.pickup-date-content {
+    height: 40vh;
+}
+
+.left-scroll {
+    width: 260rpx;
+    height: 100%;
+    background-color: #f4f4f4;
+}
+.right-scroll {
+    height: 100%;
+    background-color: #f9f9f9;
+}
+
+.left-item-nav {
+  &.checked {
+    background-color: #2A6D52;
+    color: #fff;
+  }
+}
+</style>

+ 99 - 0
src/components/ut-search/ut-search.vue

@@ -0,0 +1,99 @@
+<template>
+    <!-- 搜索框 -->
+    <view class="search-input d-flex a-c" :class="{ 'up-border': border }" :style="{ margin, background: bgColor, height }">
+        <up-input v-model="value" ref="searchInputRef" clearable type="text" :focused="focused" border="none" @change="inputSearch" @clear="clear" @confirm="search" confirmType="search" :fontSize="fontSize" :maxlength="maxlength" :placeholder="placeholder">
+            <template #suffix>
+                <view @click.stop="search" class="d-flex j-c a-c" style="padding: 0 26rpx;">
+                    <image class="search_icon" src="https://ta.zycpzs.cn/oss-file/smart-trace/szyy/images/common/search_icon.png" mode="widthFix" />
+                </view>
+            </template>
+        </up-input>
+    </view>
+</template>
+<script setup name="search">
+import { defineProps, defineEmits, ref, watch } from 'vue';
+import { debounce } from 'uview-plus';
+const props = defineProps({
+    modelValue: {
+        type: String,
+        default: ''
+    },
+    margin: {
+        type: String,
+        default: '24rpx'
+    },
+    placeholder: {
+        type: String,
+        default: '请输入搜索内容'
+    },
+    maxlength: {
+        type: Number,
+        default: 140
+    },
+    fontSize: {
+        type: String,
+        default: '30rpx'
+    },
+    height: {
+        type: String,
+        default: '86rpx'
+    },
+    border: {
+        type: Boolean,
+        default: true
+    },
+    // 背景颜色
+    bgColor: {
+        type: String,
+        default: '#fff'
+    }
+});
+const value = ref(props.modelValue);
+const emit = defineEmits(['search', 'update:modelValue', 'change']);
+const inputSearch = (e) => {
+    debounce(() => {
+        emit('update:modelValue', e);
+        emit('change', e);
+    }, 500);
+};
+const search = (e) => {
+    debounce(() => {
+        emit('update:modelValue', value.value);
+        emit('search', value.value);
+    }, 500);
+};
+const clear = () => {
+    emit('update:modelValue', '');
+    emit('change', '');
+    emit('search', '');
+};
+const searchInputRef = ref(null);
+const focused = ref(false);
+const doFocus = () => {
+    // focused.value = true;
+    searchInputRef.value.doFocus();
+};
+// 监听输入框的输入事件,触发更新modelValue的值
+watch(
+    () => props.modelValue,
+    (val) => {
+        value.value = val;
+    }
+);
+defineExpose({
+    doFocus
+});
+</script>
+<style lang="scss" scoped>
+.search-input {
+    height: 86rpx;
+    background-color: #fff;
+    border-radius: 84rpx;
+    padding-left: 30rpx;
+}
+
+.search_icon {
+    width: 38rpx;
+    height: 38rpx;
+}
+</style>

+ 34 - 0
src/components/ut-select-down/ut-select-down.vue

@@ -0,0 +1,34 @@
+<template>
+    <view @click.stop="clickItem" class="d-flex a-c flex1 ov-hd">
+        <view v-if="text" class="f-s-36 c-333 mr-10">
+            <slot>
+                {{ text }}
+            </slot>
+        </view>
+        <view v-else class="f-s-36 c-999 mr-10">{{ placeholder }}</view>
+        <view class="right-fill-icon">
+          <up-icon color="#333" size="26rpx" name="play-right-fill"></up-icon>
+        </view>
+    </view>
+</template>
+<script setup name="ut-select-down">
+const emit = defineEmits(['click'])
+const props = defineProps({
+    text: {
+        type: String,
+        default: ''
+    },
+    placeholder: {
+        type: String,
+        default: '请选择规格'
+    }
+})
+const clickItem = () => {
+    emit('click')
+}
+</script>
+<style lang="scss" scoped>
+.right-fill-icon {
+    transform: rotate(90deg);
+}
+</style>

+ 122 - 0
src/components/ut-tabar/ut-tabar.vue

@@ -0,0 +1,122 @@
+<template>
+    <view class="tabar-wrap fixded-tabar">
+        <image class="btm_bg" src="https://ta.zycpzs.cn/oss-file/smart-trace/szyy/images/bottom/btm_bg.png" mode="aspectFit" />
+        <view class="tabar-box d-flex">
+            <template v-for="(item, index) in navs" :key="index">
+                <view v-if="!item.v" class="d-flex flex-cln j-c a-c tabar-item flex1" @click="goPage(item.url)">
+                    <image class="tabar-icon" :src="mapTabarIcon(item)" mode="aspectFit" />
+                    <view class="taber-text f-s-26" :class="{ 'active': item.key === value }">{{ item.name }}</view>
+                </view>
+                <view v-else class="d-flex j-c a-c tabar-item flex1 flex-cln p-rtv" @click="goPage(item.url)">
+                    <image class="tabar-v-icon" :src="mapTabarIcon(item)" mode="aspectFit" />
+                    <view class="dot" :class="{ 'active': item.key === value }"></view>
+                </view>
+            </template>
+        </view>
+        <!-- <view class="pd-10 bg-fff"></view> -->
+        <view class="safe-area bg-fff"></view>
+    </view>
+</template>
+
+<script setup name="ut-tabar">
+import { ref, onMounted, getCurrentInstance } from 'vue';
+const instance = getCurrentInstance();
+const props = defineProps({
+    bgColor: {
+        type: String,
+        default: '#fff'
+    },
+    value: {
+        type: Number,
+        default: 'home'
+    }
+})
+const mapTabarIcon = (item) => {
+    const { icon, key, v } = item
+    if (key === props.value && !v) {
+        return `https://ta.zycpzs.cn/oss-file/smart-trace/szyy/images/bottom/${icon}1.png`
+    } else {
+        return `https://ta.zycpzs.cn/oss-file/smart-trace/szyy/images/bottom/${icon}.png`
+    }
+}
+const navs = ref([
+    { name: '价格', icon: 'home', url: '/pages/index/index', key: 'home' },
+    { name: '行情', icon: 'find', url: '/pages/find/index', key: 'find' },
+    { name: '商城', icon: 'cart', url: '/pages/cart/index', key: 'cart', v: true },
+    { name: '服务', icon: 'list', url: '/pages/list/index', key: 'list' },
+    { name: '我的', icon: 'user', url: '/pages/user/index', key: 'user' },
+])
+const goPage = (url) => {
+    uni.$u.route({
+        type: 'switchTab',
+        url
+    })
+}
+const emit = defineEmits(['tabarheight'])
+onMounted(() => {
+    const query = uni.createSelectorQuery().in(instance.proxy);
+    query
+        .select(".fixded-tabar")
+        .boundingClientRect((data) => {
+           emit('tabarheight', data.height)
+        })
+        .exec();
+})
+</script>
+
+<style lang="scss" scoped>
+.tabar-wrap {
+    padding-top: 80rpx;
+}
+
+.btm_bg {
+    position: absolute;
+    width: 750rpx;
+    height: 204rpx;
+    left: 0;
+    right: 0;
+    top: 0;
+    // 去掉点击事件
+    pointer-events: none;
+    user-select: none;
+}
+
+.tabar-box {
+    position: relative;
+    height: 100rpx;
+}
+
+.tabar-icon {
+    width: 60rpx;
+    height: 60rpx;
+}
+
+.tabar-v-icon {
+    width: 116rpx;
+    height: 116rpx;
+    transform: translateY(-10rpx);
+}
+
+.dot {
+    width: 8rpx;
+    height: 8rpx;
+    border-radius: 50%;
+    background: transparent;
+
+    &.active {
+        background: $u-primary;
+    }
+}
+.fixded-tabar {
+    position: fixed;
+    left: 0;
+    right: 0;
+    bottom: 0;
+}
+.taber-text {
+    color: #CDCDCD;
+    &.active {
+        color: $u-primary;
+    }
+}
+</style>

+ 68 - 0
src/components/ut-tabs-card/ut-tabs-card.vue

@@ -0,0 +1,68 @@
+<template>
+    <view class="ut-tabs-card d-flex a-c j-sb p-rtv" :style="cStyle">
+        <template v-for="(item, index) in tabs" :key="index">
+            <view class="tabs-item-card d-flex a-c"  @click="clickItem(item.value)" :class="{ checked: checked === item.value }">
+                <view class="left-ball">
+                    <image style="width: 50rpx; height: 50rpx;" :src="`https://ta.zycpzs.cn/oss-file/smart-trace/szyy/images/price/${ checked === item.value ? item.icon + 1 : item.icon }.png`" mode="widthFix" />
+                </view>
+                <view class="pl-10 pr-14 f-s-28">{{ item.label }}</view>
+            </view>
+        </template>
+    </view>
+</template>
+<script setup name="ut-tabs-card">
+import { defineProps, ref, watch, onMounted, getCurrentInstance } from 'vue';
+const instance = getCurrentInstance();
+const emit = defineEmits(['change', 'update:modelValue'])
+const props = defineProps({
+    tabs: {
+        type: Array,
+        default: () => []
+    },
+    modelValue: {
+        type: String,
+        default: ''
+    },
+    cStyle: {
+        type: Object,
+        default: () => ({})
+    }
+})
+const checked = ref('')
+const clickItem = (val) => {
+    emit('update:modelValue', val)
+    emit('change', val)
+}
+
+onMounted(() => {
+    checked.value = props.modelValue
+})
+watch(() => props.modelValue, (val) => {
+    checked.value = val
+}, { immediate: true })
+</script>
+<style lang="scss" scoped>
+.ut-tabs-card {
+    padding: 24rpx 14rpx;
+}
+.tabs-item-card {
+    margin: 0 10rpx;
+    border-radius: 27rpx;
+    color: #999;
+    font-size: 28rpx;
+    background-color: #F2F2F2;
+    border: 2rpx solid #F2F2F2;
+
+    &.checked {
+        color: #fff;
+        background-color: $u-primary;
+        border-color: $u-primary;
+    }
+}
+
+.left-ball {
+    width: 50rpx;
+    height: 50rpx;
+    border-radius: 50%;
+}
+</style>

+ 114 - 0
src/components/ut-tabs-dict/ut-tabs-dict.vue

@@ -0,0 +1,114 @@
+<template>
+    <template v-if="cStyle === 'fill'">
+        <up-tabs ref="upTabsRef" class="fill-tabs" :list="tabs" keyName="label" @change="change"
+            lineColor="rgba(0,0,0,0)">
+            <template #content="{ item, keyName, index }">
+                <view class="tab-fill" :style="{ background: index === current ? lineColor : '#F7F7F7' }"
+                    :class="{ checked: index === current }">{{ item[keyName] }}</view>
+            </template>
+        </up-tabs>
+    </template>
+    <template v-else>
+        <up-tabs ref="upTabsRef" :list="tabs" keyName="label" @change="change" :lineWidth="curLineWidth"
+            :current="current" :lineHeight="lineHeight" :itemStyle="itemStyle" :lineColor="lineColor"
+            :inactiveStyle="inactiveStyle" :activeStyle="activeStyle">
+        </up-tabs>
+    </template>
+</template>
+<script setup name="ut-tabs-dict">
+import { toRefs, ref, onMounted, watch } from 'vue';
+const props = defineProps({
+    tabs: {
+        type: Array,
+        default: () => []
+    },
+    modelValue: {
+        type: String,
+        default: ''
+    },
+    inactiveStyle: {
+        type: Object,
+        default: () => ({
+            color: '#999',
+            fontSize: '30rpx'
+        })
+    },
+    activeStyle: {
+        type: Object,
+        default: () => ({
+            color: '#2A6D52',
+            fontSize: '30rpx',
+            fontWeight: 500
+        })
+    },
+    lineHeight: {
+        type: String,
+        default: '2px'
+    },
+    lineWidth: {
+        type: String,
+        default: '60rpx'
+    },
+    fontSize: {
+        type: String,
+        default: '30rpx'
+    },
+    itemStyle: {
+        type: Object,
+        default: () => ({
+            padding: '10rpx 20rpx'
+        })
+    },
+    isAutoWidth: {
+        type: Boolean,
+        default: false
+    },
+    lineColor: {
+        type: String,
+        default: '#2A6D52'
+    },
+    cStyle: {
+        type: String,
+        default: ''
+    }
+});
+const upTabsRef = ref(null);
+const emit = defineEmits(['change', 'update:modelValue']);
+const change = (item) => {
+    emit('update:modelValue', item.value);
+    emit('change', item.value);
+};
+const setCurLineWidthset = (index) => {
+    const item = props.tabs[index];
+    const width = item?.label?.length * 30;
+    curLineWidth.value = width + 'rpx';
+};
+const curLineWidth = ref(props.lineWidth);
+const current = ref(0);
+watch(() => props.modelValue, (val) => {
+    current.value = props.tabs.findIndex(item => item.value === val);
+    if (props.isAutoWidth && current.value !== -1) {
+        setCurLineWidthset(current.value);
+    }
+}, { immediate: true });
+</script>
+<style lang="scss" scoped>
+.tab-fill {
+    display: inline-block;
+    white-space: nowrap;
+    font-size: 26rpx;
+    color: #999;
+    height: 54rpx;
+    line-height: 54rpx;
+    padding: 0 16rpx;
+    background: #F7F7F7;
+    border-radius: 8rpx;
+
+    &.checked {
+        background: #2A6D52;
+        color: #fff;
+    }
+}
+</style>
+
+<style lang="scss"></style>

+ 87 - 0
src/components/ut-tabs-items/ut-tabs-items.vue

@@ -0,0 +1,87 @@
+<template>
+    <view class="d-flex">
+        <scroll-view class="flex1 ov-hd scroll-x-info" scroll-x scroll-with-animation :scroll-into-view="intoView">
+            <template v-for="(item, index) in items" :key="index">
+                <view class="item-box p-rtv" :id="'xxdd' + item.id">
+                    <view class="item-in d-flex a-c j-c p-rtv" :class="{ checked: item.id === curChecked && item.id }" @click="clickItem(item)">
+                        <text>{{ item.varietyName }}</text>
+                        <view @click.stop="deleteRow(index, item)" class="checked-icon d-flex a-c j-c">
+                            <up-icon size="20rpx" color="#F56C6C" name="close"></up-icon>
+                        </view>
+                    </view>
+                </view>
+            </template>
+        </scroll-view>
+    </view>
+</template>
+<script setup name="ut-tabs-items">
+import { ref, getCurrentInstance, toRefs, watch } from 'vue';
+const props = defineProps({
+    items: {
+        type: Array,
+        default: () => []
+    },
+    checked: {
+        type: String,
+        default: ''
+    }
+})
+const curChecked = ref('');
+const intoView = ref('');
+const emit = defineEmits(['click', 'change', 'update:checked', 'delete']);
+const clickItem = (item) => {
+    emit('update:checked', item.id);
+    emit('change', item);
+    emit('click', item);
+};
+const deleteRow = (index, item) => {
+    emit('delete', { index, item});
+};
+watch(() => props.checked, (val) => {
+    console.log('val', val);
+    if (val) {
+        curChecked.value = val;
+        intoView.value = 'xxdd' + val;
+    }
+}, { immediate: true });
+</script>
+<style lang="scss" scoped>
+.item-box {
+    padding-right: 20rpx;
+    display: inline-block;
+    &::after {
+        content: '';
+        position: absolute;
+        left: 0;
+        bottom: 0;
+        right: 0;
+        height: 1rpx;
+        background-color: #F7F7F7;
+    }
+}
+
+.item-in {
+    height: 64rpx;
+    padding: 0 34rpx;
+    font-size: 30rpx;
+    font-size: 400;
+    background-color: #F7F7F7;
+    border-radius: 10rpx 10rpx 0 0;
+    border-width: 1rpx;
+    border-style: solid;
+    border-color: #F7F7F7;
+
+    &.checked {
+        border-color: $u-primary;
+        color: $u-primary;
+        background-color: #fff;
+    }
+}
+.checked-icon {
+    position: absolute;
+    right: 0;
+    top: 0;
+    width: 30rpx;
+    height: 30rpx;
+}
+</style>

+ 55 - 0
src/components/ut-tabs-pages/ut-tabs-pages.vue

@@ -0,0 +1,55 @@
+<template>
+    <view class="ut-tabs-page d-flex p-rtv ov-hd" :style="cStyle">
+        <template v-for="(item, index) in tabs" :key="index">
+            <view class="tabs-item-page d-flex a-c"  @click="clickItem(item.value)" :class="{ checked: checked === item.value }">
+                {{ item.label }}
+            </view>
+        </template>
+    </view>
+</template>
+<script setup name="ut-tabs-card">
+import { defineProps, ref, watch, onMounted, getCurrentInstance } from 'vue';
+const instance = getCurrentInstance();
+const emit = defineEmits(['change', 'update:modelValue'])
+const props = defineProps({
+    tabs: {
+        type: Array,
+        default: () => []
+    },
+    modelValue: {
+        type: String,
+        default: ''
+    },
+    cStyle: {
+        type: Object,
+        default: () => ({})
+    }
+})
+const checked = ref('')
+const clickItem = (val) => {
+    emit('update:modelValue', val)
+    emit('change', val)
+}
+
+onMounted(() => {
+    checked.value = props.modelValue
+})
+watch(() => props.modelValue, (val) => {
+    checked.value = val
+}, { immediate: true })
+</script>
+<style lang="scss" scoped>
+.tabs-item-page {
+    padding: 26rpx;
+    color: #999;
+    font-size: 30rpx;
+
+    &.checked {
+       border-radius: 8rpx 8rpx 0 0;
+       box-shadow: 6rpx -6rpx 16rpx 0px #E6E6E6, -6rpx -6rpx 16rpx 0px #E6E6E6;
+       color: $u-primary;
+       font-weight: 500;
+       transition: box-shadow 0.3s;
+    }
+}
+</style>

+ 77 - 0
src/components/ut-tabs-xpf/ut-tabs-xpf.vue

@@ -0,0 +1,77 @@
+<template>
+    <view class="ut-tabs-xpf d-flex a-c j-sb p-rtv" :style="cStyle">
+        <template v-for="(item, index) in tabs" :key="index">
+            <view class="tabs-item-x p-rtv" :style="{ color: checked === item.value ? color : '#999'  }" @click="clickItem(item.value)" :class="{ checked: checked === item.value }">
+                {{ item.label }}
+                <!-- 动画 -->
+                <up-transition :show="checked === item.value">
+                    <view class="arrow-dot" :style="dotStyle"></view>
+                </up-transition>
+            </view>
+        </template>
+
+    </view>
+</template>
+<script setup name="ut-tabs-xpf">
+import { defineProps, ref, watch, onMounted, getCurrentInstance } from 'vue';
+const instance = getCurrentInstance();
+const emit = defineEmits(['change', 'update:modelValue'])
+const props = defineProps({
+    tabs: {
+        type: Array,
+        default: () => []
+    },
+    modelValue: {
+        type: String,
+        default: ''
+    },
+    cStyle: {
+        type: Object,
+        default: () => ({})
+    },
+    dotStyle: {
+        type: Object,
+        default: () => ({})
+    },
+    color: {
+        type: String,
+        default: '#2D7357'
+    }
+})
+const checked = ref('')
+const clickItem = (val) => {
+    emit('update:modelValue', val)
+    emit('change', val)
+}
+
+onMounted(() => {
+    checked.value = props.modelValue
+})
+watch(() => props.modelValue, (val) => {
+    checked.value = val
+}, { immediate: true })
+</script>
+<style lang="scss" scoped>
+.tabs-item-x {
+    padding: 12rpx 19rpx 20rpx;
+    color: #999;
+    font-size: 30rpx;
+
+    &.checked {
+        color: #2D7357;
+        font-weight: 600;
+    }
+}
+
+.arrow-dot {
+    position: absolute;
+    left: 0;
+    right: 0;
+    bottom: -10rpx;
+    width: 20rpx;
+    height: 20rpx;
+    background: #E4F2ED;
+    transform: rotate(45deg);
+    margin: auto;
+}
+</style>

+ 68 - 0
src/components/ut-tabs/ut-tabs.vue

@@ -0,0 +1,68 @@
+<template>
+    <view class="tebs-wrap" :style="{ margin: margin }">
+        <template v-for="(item, index) in tabs" :key="index">
+            <view class="teb-item d-flex a-c j-c flex1 p-rtv" :class="{ checked: item.value === modelValue }" @click="clickItem(item)">
+                <view class="p-rtv">
+                    {{ item.name }}
+                    <view class="badge-box">
+                        <up-badge max="99" :value="item.badge" :offset="[0, 0]"></up-badge>
+                    </view>
+                </view>
+            </view>
+        </template>
+    </view>
+</template>
+<script setup>
+import { toRefs } from 'vue';
+const props = defineProps({
+  tabs: {
+    type: Array,
+    default: () => [],
+  },
+  modelValue: {
+    type: String,
+    default: "",
+  },
+  margin: {
+    type: String,
+    default: "24rpx",
+  },
+});
+const { tabs } = toRefs(props);
+const emit = defineEmits(["change", 'update:modelValue']);
+const clickItem = (item) => {
+  emit("update:modelValue", item.value);
+  emit("change", item.value);
+};
+</script>
+<style lang="scss" scoped>
+.tebs-wrap {
+  display: flex;
+  border: 1rpx solid $u-primary;
+  border-radius: 10rpx;
+  overflow: hidden;
+}
+
+.teb-item {
+  padding: 20rpx;
+  font-size: 36rpx;
+  font-weight: 400;
+  background-color: #E9F0ED;
+  color: $u-primary;
+
+  &.checked {
+    background-color: $u-primary;
+    color: #fff;
+  }
+}
+.badge-box {
+  position: absolute;
+  top: 0;
+  left: 100%;
+  bottom: 0;
+  margin: auto;
+  transform: translateX(10rpx);
+  display: flex;
+  align-items: center;
+}
+</style>

+ 36 - 0
src/components/ut-tag-dict/ut-tag-dict.vue

@@ -0,0 +1,36 @@
+<template>
+    <view class="ut-tag_view" :style="getTagClassStyle(options, value)">{{ selectDictLabel(options, value) }}</view>
+</template>
+<script setup>
+const props = defineProps({
+    options: {
+        type: Array,
+        default: () => []
+    },
+    value: {
+        type: [String, Number],
+        default: ''
+    }
+});
+// 获取自定中的样式elTagClass方法
+const getTagClassStyle = (options, value) => {
+    const option = options.find(item => item.value === value);
+    if (option) {
+        return option?.elTagClass ? JSON.parse(option?.elTagClass) : null;
+    }
+    return {};
+};
+
+</script>
+<style lang="scss" scoped>
+.ut-tag_view {
+    display: inline-block;
+    padding: 0rpx 16rpx;
+    height: 34rpx;
+    line-height: 34rpx;
+    border-radius: 8rpx;
+    background-color: #f5f5f5;
+    color: #333;
+    font-size: 24rpx;
+}
+</style>

+ 44 - 0
src/components/ut-title/ut-title.vue

@@ -0,0 +1,44 @@
+<template>
+  <view class="ut-title d-flex a-c">
+    <view class="ut-subicon mr-20" :style="{ width: lineWidth, background: lineColor, height: fontSize }"></view>
+    <slot>
+      <view class="f-s-34 c-light-black f-w-5 ut-title-text" :style="{ fontSize: fontSize, color: color }">{{ props.text
+        }}</view>
+    </slot>
+  </view>
+</template>
+<script setup name="ut-title">
+const props = defineProps({
+  text: {
+    type: String,
+    default: ''
+  },
+  fontSize: {
+    type: String,
+    default: '34rpx'
+  },
+  color: {
+    type: String,
+    default: '#333'
+  },
+  lineColor: {
+    type: String,
+    default: '#2a6d52'
+  },
+  lineWidth: {
+    type: String,
+    default: '4rpx'
+  }
+});
+
+</script>
+<style lang="scss" scoped>
+.ut-subicon {
+  width: 4rpx;
+  background-color: $u-primary;
+}
+
+.ut-title-text {
+  line-height: 1.2;
+}
+</style>

+ 99 - 0
src/components/ut-upload-image/ut-upload-image.vue

@@ -0,0 +1,99 @@
+<template>
+    <view v-if="preview" class="ut-upload-image p-rtv" @click="previewImg" :style="{ width, height }">
+        <up-image :src="value" mode="aspectFit" :width="width" :height="height"></up-image>
+    </view>
+    <view v-else class="ut-upload-image p-rtv" @click="choose" :style="{ width, height }">
+        <up-image v-if="value" :src="value" mode="aspectFit" :width="width" :height="height"></up-image>
+        <image v-else class="ut-cover-image" :src="cover" mode="widthFix" />
+    </view>
+</template>
+<script setup>
+import { ref, watch } from 'vue';
+import upload from '@/utils/upload';
+
+const props = defineProps({
+    cover: {
+        type: String,
+        default: ''
+    },
+    width: {
+        type: String,
+        default: '326rpx'
+    },
+    height: {
+        type: String,
+        default: '200rpx'
+    },
+    uploadText: {
+        type: String,
+        default: '上传图片'
+    },
+    modelValue: {
+        type: String,
+        default: ''
+    },
+    disabled: {
+        type: Boolean,
+        default: false
+    },
+    preview: {
+        type: Boolean,
+        default: false
+    }
+});
+const emit = defineEmits(['update:modelValue', '']);
+const value = ref();
+// 监听modelValue的变化
+watch(() => props.modelValue, (val) => {
+    value.value = val;
+}, { immediate: true });
+const choose = () => {
+    if (props.disabled) {
+        return;
+    }
+    uni.chooseImage({
+        count: 1,
+        sizeType: ['compressed'],
+        sourceType: ['album', 'camera'],
+        success: (res) => {
+            const tempFilePaths = res.tempFilePaths;
+            upload({
+                filePath: tempFilePaths[0],
+                url: '/resource/oss/upload'
+            }).then((res) => {
+                console.log(res);
+                emit('update:modelValue', res.data.url);
+                emit('change', res.data.url);
+            });
+        }
+    });
+};
+const previewImg = () => {
+	if (value.value) {
+		uni.previewImage({
+		    urls: [value.value]
+		});
+	}
+};
+</script>
+<style lang="scss" scoped>
+.ut-upload-image {
+    border-radius: 16rpx;
+    border: 1rpx dashed #ccc;
+}
+
+.ut-cover-image {
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+}
+.ut-image {
+    position: absolute;
+    left: 0;
+    top: 0;
+    width: 100%;
+    height: 100%;
+}
+</style>

+ 139 - 0
src/components/ut-upload/ut-upload.vue

@@ -0,0 +1,139 @@
+<template>
+	<u-upload :width="width" :height="height" :fileList="fileList" :accept="accept" :uploadIcon="uploadIcon"
+		:uploadText="uploadText" @afterRead="afterRead" @delete="deletePic" :multiple="multiple"
+		:maxCount="maxCount">
+	</u-upload>
+</template>
+
+<script setup lang="ts">
+import upload from '@/utils/upload';
+
+interface FileItem {
+    url: string;
+}
+
+interface UploadEvent {
+    file: Array<{ url: string }>;
+}
+
+interface DeleteEvent {
+    index: number;
+}
+
+interface Props {
+    modelValue: string | string[] | Record<string, any> | null;
+    maxCount: number;
+    width: string;
+    height: string;
+    multiple: boolean;
+    uploadText: string;
+    uploadIcon: string;
+    accept: string;
+    isArr: boolean;
+    keyUrl: string;
+    isObject: boolean;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+    modelValue: () => [],
+    maxCount: 1,
+    width: '200rpx',
+    height: '200rpx',
+    multiple: true,
+    uploadText: '点击上传',
+    uploadIcon: 'plus',
+    accept: 'image',
+    isArr: false,
+    keyUrl: '',
+    isObject: false
+});
+
+const emit = defineEmits<{
+    change: [value: any];
+    'update:modelValue': [value: any];
+}>();
+
+const fileList = ref<FileItem[]>([]);
+
+watch(() => props.modelValue, (ov) => {
+    if (ov) {
+        if (props.isObject) {
+            fileList.value = [ov as FileItem];
+        } else if (props.isArr) {
+            if (props.keyUrl) {
+                fileList.value = (ov as any[]).map(url => ({ url: url[props.keyUrl] }));
+            } else {
+                fileList.value = (ov as string[]).map(url => ({ url }));
+            }
+        } else {
+            const list = (ov as string).split(',');
+            fileList.value = list.map(item => ({
+                url: item
+            }));
+        }
+    }
+});
+
+const deletePic = (event: DeleteEvent) => {
+    fileList.value.splice(event.index, 1);
+    const urls = fileList.value.map(({ url }) => url);
+    const imgs = urls.toString();
+    
+    if (props.isObject) {
+        emit('update:modelValue', null);
+        emit('change', null);
+    } else if (props.isArr) {
+        if (props.keyUrl) {
+            emit('update:modelValue', fileList.value.map(({ url }) => ({ [props.keyUrl]: url })));
+            emit('change', fileList.value.map(({ url }) => ({ [props.keyUrl]: url })));
+        } else {
+            emit('update:modelValue', urls);
+            emit('change', urls);
+        }
+    } else {
+        emit('update:modelValue', imgs);
+        emit('change', imgs);
+    }
+};
+
+const afterRead = async (event: UploadEvent) => {
+    const files = event.file;
+    const promises = files.map(({ url }) => upload({
+        filePath: url,
+        url: '/resource/oss/upload'
+    }));
+    
+    try {
+        const res = await Promise.all(promises);
+        const list: FileItem[] = [];
+        res.forEach(({ code, data }) => {
+            if (code === 200) {
+                list.push(data);
+            }
+        });
+        
+        const urls = fileList.value.concat(list);
+        const imgs = urls.map(({ url }) => url).toString();
+        
+        if (props.isObject) {
+            emit('update:modelValue', urls[0]);
+            emit('change', urls[0]);
+        } else if (props.isArr) {
+            if (props.keyUrl) {
+                emit('update:modelValue', urls.map(({ url }) => ({ [props.keyUrl]: url })));
+                emit('change', urls.map(({ url }) => ({ [props.keyUrl]: url })));
+            } else {
+                emit('update:modelValue', urls.map(({ url }) => url));
+                emit('change', urls.map(({ url }) => url));
+            }
+        } else {
+            emit('update:modelValue', imgs);
+            emit('change', imgs);
+        }
+    } catch (error) {
+        console.error('Upload failed:', error);
+    }
+};
+</script>
+
+<style></style>

+ 22 - 0
src/config.ts

@@ -0,0 +1,22 @@
+// 环境配置
+export type EnvType = 'develop' | 'trial' | 'release';
+
+// 根据实际项目环境设置
+export const envWx: EnvType = 'develop'; // 可以根据实际需要修改为 'trial' 或 'release'
+
+// 项目配置
+interface ProjectConfig {
+    baseUrl: string;
+    clientId: string;
+    tenantId: string;
+    appid: string;
+}
+
+const config: ProjectConfig = {
+    baseUrl: import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000',
+    clientId: 'your-client-id',
+    tenantId: 'your-tenant-id',
+    appid: 'your-app-id'
+};
+
+export default config;

+ 1 - 1
src/pages.json

@@ -5,7 +5,7 @@
 			"^u--(.*)": "uview-plus/components/u-$1/u-$1.vue",
 			"^up-(.*)": "uview-plus/components/u-$1/u-$1.vue",
 			"^u-([^-].*)": "uview-plus/components/u-$1/u-$1.vue",
-			"^ut-(.*)": "@/components/ut-$1/ut-$1.vue",
+			"^ut-(.*)": "@/src/components/ut-$1/ut-$1.vue",
 			 "^(?!z-paging-refresh|z-paging-load-more)z-paging(.*)": "z-paging/components/z-paging$1/z-paging$1.vue"
 		}
 	},

+ 34 - 0
src/utils/auth.ts

@@ -0,0 +1,34 @@
+/*
+ * @Description: 
+ * @Version: 
+ * @Author: 孙子呷约
+ * @Date: 2023-10-24 12:36:14
+ */
+
+import { envWx, EnvType } from '@/config';
+
+type TokenKeyMap = Record<EnvType, string>;
+
+const mapTokenKey: TokenKeyMap = {
+    develop: 'develop-token',
+    trial: 'develop-token',
+    release: 'App-Token',
+};
+
+const TokenKey: string = mapTokenKey[envWx];
+
+export function getToken(): string {
+    return uni.getStorageSync(TokenKey) || '';
+}
+
+export function setToken(token: string): void {
+    uni.setStorageSync(TokenKey, token);
+}
+
+export function removeToken(): void {
+    uni.removeStorageSync(TokenKey);
+}
+
+export function setStorageOrgId(orgId: string): void {
+    uni.setStorageSync('orgId', orgId);
+}

+ 305 - 0
src/utils/common.ts

@@ -0,0 +1,305 @@
+import { getToken } from './auth';
+
+// 定义类型接口
+interface VipOptions {
+    vipReviewStatus: string;
+    vipEndDate: string;
+    vipLevelName: string;
+    vipLevel: number;
+}
+
+interface VipResult {
+    isVip: boolean;
+    text: string;
+    level?: number;
+}
+
+interface StoreModule {
+    dispatch: (action: string, payload?: any) => Promise<any>;
+    state: any;
+}
+
+interface LocationResult {
+    name: string;
+    address: string;
+    latitude: number;
+    longitude: number;
+}
+
+/**
+ * 显示消息提示框
+ * @param content 提示的标题
+ */
+export function toast(content: string): void {
+    uni.showToast({
+        icon: 'none',
+        title: content
+    });
+}
+
+export function isChinese(text: string): boolean {
+    const pattern = /[\u4E00-\u9FA5\uF900-\uFA2D]/;
+    return pattern.test(text);
+}
+
+/**
+ * 显示模态弹窗
+ * @param content 提示的标题
+ */
+export function showConfirm(content: string): Promise<UniApp.ShowModalRes> {
+    return new Promise((resolve, reject) => {
+        uni.showModal({
+            title: '提示',
+            content: content,
+            cancelText: '取消',
+            confirmText: '确定',
+            success: function (res) {
+                resolve(res);
+            }
+        });
+    });
+}
+
+/**
+ * 参数处理
+ * @param params 参数
+ */
+export function tansParams(params: Record<string, any>): string {
+    let result = '';
+    for (const propName of Object.keys(params)) {
+        const value = params[propName];
+        const part = encodeURIComponent(propName) + '=';
+        if (value !== null && value !== '' && typeof value !== 'undefined') {
+            if (typeof value === 'object') {
+                for (const key of Object.keys(value)) {
+                    if (value[key] !== null && value[key] !== '' && typeof value[key] !== 'undefined') {
+                        const params = propName + '[' + key + ']';
+                        const subPart = encodeURIComponent(params) + '=';
+                        result += subPart + encodeURIComponent(value[key]) + '&';
+                    }
+                }
+            } else {
+                result += part + encodeURIComponent(value) + '&';
+            }
+        }
+    }
+    return result;
+}
+
+/**
+ * 更新当前用户信息
+ */
+export const updateUserInfo = async (
+    store: StoreModule, 
+    storeType: string[] = ['user', 'cpy', 'cpyLi', 'vip', 'card']
+): Promise<void> => {
+    let res: any = null;
+    if (storeType.includes('user')) {
+        res = await store.dispatch('getUserInfoBytoken');
+    }
+    if (storeType.includes('cpy')) {
+        res?.data?.currentCpyid && (await store.dispatch('cpyDetail', res?.data?.currentCpyid));
+
+        res?.data?.currentCpyid && (await store.dispatch('memberAuditCount', res?.data?.currentCpyid));
+    }
+    if (storeType.includes('cpyLi')) {
+        await store.dispatch('getMyCpyList');
+    }
+    if (storeType.includes('vip')) {
+        // 判断是否申请过会员
+        +store.state.cpy?.cpyInfo?.extraInfo?.vipApply && (await store.dispatch('vip/vipDetail'));
+    }
+    if (storeType.includes('card')) {
+        // 获取当前名片信息
+        await store.dispatch('getCurrentCard');
+        
+    }
+};
+
+// 判断是否是会员并且会员是否过期
+export function isVip(options: VipOptions | null): VipResult {
+    if (!options) {
+        return {
+            isVip: false,
+            text: '非会员单位'
+        };
+    }
+    const { vipReviewStatus, vipEndDate, vipLevelName, vipLevel } = options;
+    if (vipReviewStatus !== '1') {
+        return {
+            isVip: false,
+            text: '非会员单位'
+        };
+    } else {
+        const now = new Date().getTime();
+        const end = new Date(vipEndDate).getTime();
+        if (now > end) {
+            return {
+                isVip: false,
+                text: '非会员单位',
+                level: vipLevel
+            };
+        } else {
+            return {
+                isVip: true,
+                text: vipLevelName,
+                level: vipLevel
+            };
+        }
+    }
+}
+
+// 返回上一页失败返回首页
+export function navigateBackOrHome(): void {
+    const pages = getCurrentPages();
+    if (pages.length === 1) {
+        uni.switchTab({
+            url: '/pages/index/index'
+        });
+    } else {
+        uni.navigateBack();
+    }
+}
+
+// 提示uni.showToast
+export function showToast(title: string): void {
+    uni.showToast({
+        icon: 'none',
+        title
+    });
+}
+
+// 保存图片临时路径
+export function saveImagePath(url: string): void {
+    uni.saveImageToPhotosAlbum({
+        filePath: url,
+        success: function () {
+            uni.showToast({
+                title: '保存成功',
+                icon: 'success'
+            });
+        },
+        fail: function () {
+            uni.showToast({
+                title: '保存失败',
+                icon: 'none'
+            });
+        }
+    });
+}
+
+// 分享图片临时路径
+export function shareImagePath(url: string): void {
+    uni.saveImageToPhotosAlbum({
+        filePath: url,
+        success: function () {
+            uni.showToast({
+                title: '分享成功',
+                icon: 'success'
+            });
+        },
+        fail: function () {
+            uni.showToast({
+                title: '分享失败',
+                icon: 'none'
+            });
+        }
+    });
+}
+
+// 根据截止时间计算剩余多少天 如果为负数则返回0
+export function getRemainingDays(endTime: string | Date): number {
+    const now = new Date().getTime();
+    const end = new Date(endTime).getTime();
+    const remainingTime = end - now;
+    if (remainingTime < 0) {
+        return 0;
+    } else {
+        return Math.floor(remainingTime / (1000 * 60 * 60 * 24));
+    }
+}
+
+// 图片预览
+export function uniPreviewImage(url: string): void {
+    uni.previewImage({
+        urls: [url],
+        current: url
+    });
+}
+
+export const emitBus = (bus: string, data: any = null): void => {
+    uni.$emit(bus, data);
+};
+
+// 播放语音
+export function playVoice(url: string): UniApp.InnerAudioContext {
+    const audio = uni.createInnerAudioContext();
+    audio.src = url;
+    audio.onPlay(() => {
+        console.log('开始播放');
+    });
+    audio.onError((res) => {
+        console.log('播放失败', res);
+    });
+    return audio;
+}
+
+// 选择地址判断是否授权,如果授权则返回地址信息,否则引导授权	uni.chooseLocation
+export function chooseAddress(): Promise<LocationResult> {
+    return new Promise((resolve, reject) => {
+        uni.chooseLocation({
+            success: (res) => {
+                resolve(res as LocationResult);
+            },
+            fail: (err) => {
+                if (err.errMsg.includes('chooseLocation:fail auth deny')) {
+                    uni.showModal({
+                        title: '提示',
+                        content: '请授权地理位置权限后再试',
+                        showCancel: false,
+                        success: () => {
+                            // 引导用户去设置页面开启权限
+                            uni.openSetting({
+                                success: (settingRes) => {
+                                    if (settingRes.authSetting['scope.userLocation']) {
+                                        // 用户授权成功后再次调用选择地址
+                                        uni.chooseLocation({
+                                            success: (res) => {
+                                                resolve(res as LocationResult);
+                                            },
+                                            fail: (err) => {
+                                                reject(err);
+                                            }
+                                        });
+                                    } else {
+                                        reject(new Error('用户未授权地理位置权限'));
+                                    }
+                                }
+                            });
+                        }
+                    });
+                } else {
+                    reject(err);
+                }
+            }
+        });
+    });
+}
+
+// 需要token跳转页面
+export function navigateToWithToken(url: string): void {
+    if (!getToken()) {
+        uni.$u.route({
+            type: 'reLaunch',
+            url: '/pages/login/login',
+            params: {
+                redirect: encodeURIComponent(url)
+            }
+        });
+        return;
+    }
+	uni.$u.route({
+		type: 'navigateTo',
+		url: url
+	});
+}

+ 31 - 0
src/utils/constant.ts

@@ -0,0 +1,31 @@
+interface ConstantType {
+    sex: string;
+    avatar: string;
+    name: string;
+    nickname: string;
+    phone: string;
+    userId: string;
+    his: string;
+    cpyInfo: string;
+    myCpyList: string;
+    currentCpyid: string;
+    currentCardId: string;
+    currentCard: string;
+}
+
+const constant: ConstantType = {
+    sex: 'vuex_sex',
+    avatar: 'vuex_avatar',
+    name: 'vuex_name',
+    nickname: 'vuex_nickname',
+    phone: 'vuex_phone',
+    userId: 'vuex_userId',
+    his: 'vuex_his',
+    cpyInfo: 'vuex_cpyInfo',
+    myCpyList: 'vuex_myCpyList',
+    currentCpyid: 'vuex_currentCpyid',
+    currentCardId: 'vuex_currentCardId', // 当前信用代码
+    currentCard: 'vuex_currentCard', //
+};
+
+export default constant;

+ 12 - 2
src/utils/errorCode.ts

@@ -1,6 +1,16 @@
-export default {
+interface ErrorCodeMap {
+  [key: string]: string;
+  '401': string;
+  '403': string;
+  '404': string;
+  'default': string;
+}
+
+const errorCode: ErrorCodeMap = {
   '401': '认证失败,无法访问系统资源',
   '403': '当前操作没有权限',
   '404': '访问资源不存在',
   'default': '系统未知错误,请反馈给管理员'
-}
+};
+
+export default errorCode;

+ 66 - 0
src/utils/form-mixins.ts

@@ -0,0 +1,66 @@
+// 表单相关的工具函数
+export const formUtils = {
+    hideKeyboard(): void {
+        uni.hideKeyboard();
+    },
+    
+    selectPlace(): Promise<UniApp.ChooseLocationSuccess> {
+        return new Promise((resolve, reject) => {
+            uni.chooseLocation({
+                success: (res) => {
+                    resolve(res);
+                },
+                fail: (error) => {
+                    console.log(error);
+                    reject(error);
+                }
+            });
+        });
+    },
+    
+    formatter(type: string, value: number): string {
+        if (type === 'year') {
+            return `${value}年`;
+        }
+        if (type === 'month') {
+            return `${value}月`;
+        }
+        if (type === 'day') {
+            return `${value}日`;
+        }
+        return value.toString();
+    },
+    
+    showOptions(itemList: string[]): Promise<number> {
+        return new Promise((resolve, reject) => {
+            uni.showActionSheet({
+                itemList,
+                success(res) {
+                    resolve(res.tapIndex);
+                },
+                fail(error) {
+                    reject(error);
+                }
+            });
+        });
+    },
+    
+    setCipByNum(val: string, startNum: number, num: number, isCip: boolean = false): string {
+        if (isCip) {
+            return val;
+        }
+        if (!val) return '-';
+        const a = val.slice(0, startNum);
+        const b = val.substring(startNum + num);
+        return a + '*'.repeat(num) + b;
+    }
+};
+
+// 向后兼容的导出
+export const formMixins = {
+    components: {},
+    data() {
+        return {};
+    },
+    methods: formUtils
+};

+ 111 - 0
src/utils/public.ts

@@ -0,0 +1,111 @@
+// 全局uni对象
+export function debounce<T extends (...args: any[]) => any>(fn: T, delay: number = 500): (...args: Parameters<T>) => void {
+    let timer: number | null = null;
+    return function (...args: Parameters<T>) {
+        if (timer !== null) {
+            clearTimeout(timer);
+        }
+        timer = setTimeout(() => {
+            fn.apply(null, args);
+            timer = null;
+        }, delay);
+    };
+}
+
+export function calculateAge(birthday: string): number {
+    const today = new Date(); // 获取当前日期时间
+    const birthdate = new Date(birthday); // 将生日字符串转换为日期格式
+
+    let age = today.getFullYear() - birthdate.getFullYear(); // 根据年份计算年龄
+
+    if (today < birthdate || today.getMonth() < birthdate.getMonth()) {
+        age--; // 如果今天的月份小于生日的月份或者今天的日期小于生日的日期,则年龄需要减1
+    }
+
+    return age;
+}
+
+// 获取当前路由信息
+export function getCurrentPage(): any {
+    const pages = getCurrentPages();
+    return pages[pages.length - 1];
+}
+
+export function copyText(text: string): void {
+    uni.setClipboardData({
+        data: text,
+        success: function () {
+            // 可以添加用户友好的提示,例如使用uni.showToast提示复制成功
+            uni.showToast({
+                title: '复制成功',
+                icon: 'success',
+                duration: 2000
+            });
+        },
+        fail: function () {
+            console.log('复制失败');
+            // 可以添加错误处理或用户友好的提示
+        }
+    });
+}
+
+// 去登录
+export function goLogin(): void {
+    uni.$u.route({
+        type: 'reLaunch',
+        url: '/pages/login/login',
+        params: {
+            redirect: getCurrentPage()?.route
+        }
+    });
+}
+
+export const setCipByNum = (val: string, startNum: number, num: number, isCip: boolean = false): string => {
+    if (isCip) {
+        return val;
+    }
+    if (!val) return '-';
+    const a = val.slice(0, startNum);
+    const b = val.substring(startNum + num);
+    return a + '*'.repeat(num) + b;
+};
+
+export const handleContact = (): void => {
+    // 判断是否是微信
+    if (uni.$u.platform === 'weixin') {
+        try {
+            uni.openCustomerServiceChat({
+                extInfo: { url: 'https://work.weixin.qq.com/kfid/kfcaf0368dcb1cbb94d' },
+                corpId: 'wwbad9dcbcb6a57196', // 客服消息接收方 corpid
+                success: (res: any) => {
+                    console.log('打开客服会话成功', res);
+                },
+                fail: (err: any) => {
+                    console.error('打开客服会话失败', err);
+                }
+            });
+        } catch (error) {
+            console.error('客服功能不可用', error);
+        }
+    }
+};
+
+// 拨打电话
+export const makePhoneCall = (phoneNumber: string): void => {
+    if (!phoneNumber) {
+        uni.showToast({
+            title: '电话号码不能为空',
+            icon: 'none'
+        });
+        return;
+    }
+    uni.makePhoneCall({
+        phoneNumber: phoneNumber,
+        success: () => {
+            console.log('拨打电话成功');
+        },
+        fail: (err) => {
+            console.error('拨打电话失败', err);
+        }
+    });
+};

+ 7 - 2
src/utils/ruoyi.ts

@@ -1,5 +1,3 @@
-
-
 // 日期格式化
 export function parseTime(time: any, pattern?: string) {
     if (arguments.length === 0 || !time) {
@@ -342,3 +340,10 @@ export const getProperty = (obj: any, path: string) => {
 export const generateNumber = (num: number, lan = 7): string => {
     return num.toString().padStart(lan, '0');
 };
+
+// 获取文件后缀名
+export const getFileSuffix = (fileName: string): string => {
+    if (!fileName) return '';
+    const lastDotIndex = fileName.lastIndexOf('.');
+    return lastDotIndex !== -1 ? fileName.substring(lastDotIndex + 1) : '';
+};

+ 49 - 0
src/utils/storage.ts

@@ -0,0 +1,49 @@
+import constant from './constant';
+import { envWx, EnvType } from '@/config';
+
+// 存储变量名
+type StorageKeyMap = Record<EnvType, string>;
+
+const mapTokenKey: StorageKeyMap = {
+    develop: 'storage_data',
+    trial: 'storage_data',
+    release: 'storage_data-app',
+};
+
+const storageKey: string = mapTokenKey[envWx];
+
+// 存储节点变量名
+const storageNodeKeys: string[] = [...Object.values(constant)];
+
+// 存储的数据
+let storageData: Record<string, any> = uni.getStorageSync(storageKey) || {};
+
+interface StorageInterface {
+    set: (key: string, value: any) => void;
+    get: (key: string) => any;
+    remove: (key: string) => void;
+    clean: () => void;
+}
+
+const storage: StorageInterface = {
+    set: function(key: string, value: any): void {
+        if (storageNodeKeys.indexOf(key) !== -1) {
+            let tmp: Record<string, any> = uni.getStorageSync(storageKey);
+            tmp = tmp ? tmp : {};
+            tmp[key] = value;
+            uni.setStorageSync(storageKey, tmp);
+        }
+    },
+    get: function(key: string): any {
+        return storageData[key] || '';
+    },
+    remove: function(key: string): void {
+        delete storageData[key];
+        uni.setStorageSync(storageKey, storageData);
+    },
+    clean: function(): void {
+        uni.removeStorageSync(storageKey);
+    }
+};
+
+export default storage;

+ 132 - 0
src/utils/upload.ts

@@ -0,0 +1,132 @@
+import { getToken } from '@/utils/auth';
+import errorCode from '@/utils/errorCode';
+import { tansParams } from '@/utils/common';
+import config from '@/config';
+import { getCurrentPage } from '@/utils/public';
+
+interface UploadConfig {
+    url: string;
+    filePath: string;
+    name?: string;
+    header?: Record<string, any>;
+    headers?: Record<string, any>;
+    formData?: Record<string, any>;
+    params?: Record<string, any>;
+    timeout?: number;
+}
+
+interface UploadResult {
+    code: number;
+    data?: any;
+    msg?: string;
+}
+
+const timeout = 10000;
+const { baseUrl, clientId, appid } = config;
+
+const upload = (uploadConfig: UploadConfig): Promise<UploadResult> => {
+    // 是否需要设置 token
+    const isToken = (uploadConfig.headers || {}).isToken === false;
+    uploadConfig.header = uploadConfig.header || {};
+    if (getToken() && !isToken) {
+        uploadConfig.header['Authorization'] = 'Bearer ' + getToken();
+    }
+    uploadConfig.header['Clientid'] = clientId; // 默认值
+    uploadConfig.header['xid'] = appid; // 默认值
+    
+    // get请求映射params参数
+    if (uploadConfig.params) {
+        let url = uploadConfig.url + '?' + tansParams(uploadConfig.params);
+        url = url.slice(0, -1);
+        uploadConfig.url = url;
+    }
+    
+    return new Promise((resolve, reject) => {
+        uni.uploadFile({
+            timeout: uploadConfig.timeout || timeout,
+            url: baseUrl + uploadConfig.url,
+            filePath: uploadConfig.filePath,
+            name: uploadConfig.name || 'file',
+            header: uploadConfig.header,
+            formData: uploadConfig.formData,
+            success: (res) => {
+                const result: UploadResult = JSON.parse(res.data);
+                const code = result.code || 200;
+                const msg = errorCode[code.toString()] || result.msg || errorCode['default'];
+                
+                if (code === 200) {
+                    resolve(result);
+                } else if (code === 401) {
+                    uni.hideLoading();
+                    // 跳转到登录页面
+                    uni.$u.route({
+                        type: 'reLaunch',
+                        url: '/pages/login/login',
+                        params: {
+                            redirect: getCurrentPage()?.route || ''
+                        }
+                    });
+                    reject('无效的会话,或者会话已过期,请重新登录。');
+                } else if (code === 500) {
+                    uni.showToast({
+                        title: msg,
+                        icon: 'none'
+                    });
+                    reject('500');
+                } else if (code !== 200) {
+                    uni.showToast({
+                        title: msg,
+                        icon: 'none'
+                    });
+                    reject(code);
+                }
+            },
+            fail: (error) => {
+                console.log(error);
+                let message = '上传失败';
+                if (error.errMsg) {
+                    if (error.errMsg.includes('timeout')) {
+                        message = '系统接口请求超时';
+                    } else if (error.errMsg.includes('fail')) {
+                        message = '网络连接异常';
+                    }
+                }
+                uni.showToast({
+                    title: message,
+                    icon: 'none'
+                });
+                reject(error);
+            }
+        });
+    });
+};
+
+// 导出数据函数(简化版本)
+export const exportDataFn = (config: any): Promise<void> => {
+    return new Promise((resolve, reject) => {
+        if (config.url) {
+            uni.downloadFile({
+                url: config.url,
+                success: (result) => {
+                    uni.openDocument({
+                        filePath: result.tempFilePath,
+                        showMenu: true,
+                        success: () => {
+                            resolve();
+                        },
+                        fail: (error) => {
+                            reject(error);
+                        }
+                    });
+                },
+                fail: (error) => {
+                    reject(error);
+                }
+            });
+        } else {
+            reject(new Error('No URL provided'));
+        }
+    });
+};
+
+export default upload;

Plik diff jest za duży
+ 0 - 0
stats.html


Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików