浏览代码

feat: 把hello项目文件搬过来

菲鸽 2 年之前
父节点
当前提交
b90a409737
共有 45 个文件被更改,包括 2398 次插入11 次删除
  1. 30 0
      pages.config.ts
  2. 3 0
      src/components/fly-header/fly-header.vue
  3. 7 0
      src/components/fly-login/README.md
  4. 二进制
      src/components/fly-login/defaultAvatar.png
  5. 120 0
      src/components/fly-login/fly-login.vue
  6. 二进制
      src/components/fly-login/screenshot.png
  7. 3 0
      src/components/fly-navbar/README.md
  8. 71 0
      src/components/fly-navbar/fly-navbar.vue
  9. 62 0
      src/hooks/useNavbarWeixin.ts
  10. 25 0
      src/hooks/useWeixinShare.ts
  11. 42 0
      src/pages/demo/base/mp-weixin-share.vue
  12. 19 0
      src/pages/demo/base/navbar.vue
  13. 40 0
      src/pages/demo/base/pinia.vue
  14. 67 0
      src/pages/demo/base/request.vue
  15. 145 0
      src/pages/demo/base/throughout.vue
  16. 16 0
      src/pages/demo/base/uni-ui-icons.vue
  17. 14 0
      src/pages/demo/base/uni-ui.vue
  18. 20 0
      src/pages/demo/base/unocss-icons.vue
  19. 15 0
      src/pages/demo/base/unocss.vue
  20. 22 0
      src/pages/demo/base/vconsole.vue
  21. 81 0
      src/pages/demo/index.vue
  22. 130 0
      src/pages/demo/page/clock.vue
  23. 152 0
      src/pages/demo/page/clock2.vue
  24. 88 0
      src/pages/demo/page/floating-bubble.vue
  25. 83 0
      src/pages/demo/page/i18n.vue
  26. 29 0
      src/pages/demo/page/img-min/index.vue
  27. 127 0
      src/pages/demo/page/lottery.vue
  28. 216 0
      src/pages/demo/page/lottery/big-wheel.vue
  29. 196 0
      src/pages/demo/page/lottery/nine-grid.vue
  30. 181 0
      src/pages/demo/page/lottery2.vue
  31. 266 0
      src/pages/demo/page/sign.vue
  32. 39 0
      src/pages/my/components/wx-login.vue
  33. 35 0
      src/pages/my/index.vue
  34. 二进制
      src/static/tabbar/example.png
  35. 二进制
      src/static/tabbar/exampleHL.png
  36. 二进制
      src/static/tabbar/home.png
  37. 二进制
      src/static/tabbar/homeHL.png
  38. 二进制
      src/static/tabbar/personal.png
  39. 二进制
      src/static/tabbar/personalHL.png
  40. 28 0
      src/store/count.ts
  41. 1 0
      src/store/index.ts
  42. 9 3
      src/store/user.ts
  43. 1 6
      src/typings.d.ts
  44. 2 2
      src/utils/http.ts
  45. 13 0
      src/utils/index.ts

+ 30 - 0
pages.config.ts

@@ -15,4 +15,34 @@ export default defineUniPages({
       '^uv-(.*)': '@climblee/uv-ui/components/uv-$1/uv-$1.vue',
     },
   },
+  tabBar: {
+    color: '#999999',
+    selectedColor: '#018d71',
+    backgroundColor: '#F8F8F8',
+    borderStyle: 'black',
+    height: '50px',
+    fontSize: '10px',
+    iconWidth: '24px',
+    spacing: '3px',
+    list: [
+      {
+        iconPath: 'static/tabbar/home.png',
+        selectedIconPath: 'static/tabbar/homeHL.png',
+        pagePath: 'pages/index/index',
+        text: '首页',
+      },
+      {
+        iconPath: 'static/tabbar/example.png',
+        selectedIconPath: 'static/tabbar/exampleHL.png',
+        pagePath: 'pages/demo/index',
+        text: '示例',
+      },
+      {
+        iconPath: 'static/tabbar/personal.png',
+        selectedIconPath: 'static/tabbar/personalHL.png',
+        pagePath: 'pages/my/index',
+        text: '我的',
+      },
+    ],
+  },
 })

+ 3 - 0
src/components/fly-header/fly-header.vue

@@ -0,0 +1,3 @@
+<template>
+  <view class="text-green-500"> header </view>
+</template>

+ 7 - 0
src/components/fly-login/README.md

@@ -0,0 +1,7 @@
+# fly-login
+
+点击“点击显示微信头像”按钮后,出现的半屏登录弹窗,可以在任意页面引入。
+
+仿“掘金小册”小程序。
+
+![掘金小册登录](screenshot.png)

二进制
src/components/fly-login/defaultAvatar.png


+ 120 - 0
src/components/fly-login/fly-login.vue

@@ -0,0 +1,120 @@
+<template>
+  <view class="fly-login" v-if="modelValue">
+    <view class="fly-login-mask" />
+    <view class="fly-login-content px-4">
+      <view class="font-bold h-16 leading-16">获取您的昵称、头像</view>
+      <view
+        class="rounded-full bg-light-600 w-6 h-6 text-center absolute top-4 right-4"
+        @click="onClose"
+      >
+        <view class="i-carbon-close text-gray-700" />
+      </view>
+
+      <view
+        class="flex items-center h-16 leading-16 border-b-gray-400 border-b-solid border-[1rpx]"
+      >
+        <text class="mr-4 flex-shrink-0">头像</text>
+        <button
+          class="bg-transparent flex items-center after:b-none w-full h-12 leading-12"
+          open-type="chooseAvatar"
+          @chooseavatar="onChooseAvatar"
+        >
+          <image class="w-8 h-8 rounded-full" :src="avatarUrl"></image>
+          <text class="ml-auto i-carbon-chevron-right"></text>
+        </button>
+      </view>
+
+      <view
+        class="flex items-center h-16 leading-16 border-b-gray-400 border-b-solid border-1 mt-4"
+      >
+        <text class="mr-4 flex-shrink-0">昵称</text>
+        <input type="nickname" placeholder="请输入昵称" @change="onChange" @blur="onChange" />
+      </view>
+
+      <button
+        size="default"
+        type="default"
+        style="color: #fff; background-color: #1aad19; border-color: #1aad19"
+        class="text-center leading-12 w-40 my-4"
+        @click="onSubmit"
+      >
+        确定
+      </button>
+    </view>
+  </view>
+</template>
+<script lang="ts" setup>
+import { useUserStore } from '@/store'
+import defaultAvatarUrl from './defaultAvatar.png'
+
+const emit = defineEmits(['update:modelValue'])
+defineProps<{ modelValue: boolean }>()
+
+const userStore = useUserStore()
+
+const avatarUrl = ref(defaultAvatarUrl)
+const nickname = ref('')
+
+const onClose = () => {
+  emit('update:modelValue', false)
+}
+
+const onChooseAvatar = (e) => {
+  const { avatarUrl: url } = e.detail
+  avatarUrl.value = url
+  // 这里就要上传,加快速度,提升体验(用户多次选择头像就多次上传吧,总有取舍)
+  console.log(url)
+}
+
+const onChange = (e) => {
+  const { value } = e.detail
+  nickname.value = value
+  console.log(value)
+}
+
+const onSubmit = () => {
+  // 1、上传刚刚的图片,并返回网络地址
+  // 2、把用户信息存起来
+  if (avatarUrl.value === defaultAvatarUrl) {
+    uni.showToast({
+      title: '请选择头像',
+      icon: 'none',
+    })
+    return
+  }
+  if (!nickname.value) {
+    uni.showToast({
+      title: '请填写昵称',
+      icon: 'none',
+    })
+    return
+  }
+
+  emit('update:modelValue', false)
+  console.log('保存用户信息')
+  userStore.setUserInfo({ nickname: nickname.value, avatar: avatarUrl.value })
+}
+</script>
+
+<style lang="scss" scoped>
+.fly-login {
+  position: fixed;
+  inset: 0;
+
+  .fly-login-mask {
+    position: fixed;
+    inset: 0;
+    background-color: rgb(0 0 0 / 30%);
+  }
+
+  .fly-login-content {
+    position: fixed;
+    right: 0;
+    bottom: var(--window-bottom);
+    left: 0;
+    background-color: #fff;
+    border-top-left-radius: 16px;
+    border-top-right-radius: 16px;
+  }
+}
+</style>

二进制
src/components/fly-login/screenshot.png


+ 3 - 0
src/components/fly-navbar/README.md

@@ -0,0 +1,3 @@
+# fly-navbar
+
+建议本导航栏组件在设置 `"navigationStyle": "custom"` 的页面使用,目前支持微信小程序的页面滚动动画。

+ 71 - 0
src/components/fly-navbar/fly-navbar.vue

@@ -0,0 +1,71 @@
+<template>
+  <!-- 自定义导航栏: 默认透明不可见, scroll-view 滚动到 50 时展示 -->
+  <view class="fly-navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
+    <!-- 1/3,多于1个页面,用返回图标 -->
+    <navigator v-if="pages.length > 1" open-type="navigateBack" class="left-icon">
+      <view class="bg-gray-500/80 rounded-full w-8 h-8 flex items-center justify-center">
+        <button class="i-carbon-chevron-left text-white w-7 h-7"></button>
+      </view>
+    </navigator>
+    <!-- 2/3,只有1个页面,如果不是tabbar,需要首页图标 -->
+    <!-- 这种情况一般出现在用户直接打开分享出去的详情页面,或者使用redirectTo等API -->
+    <navigator
+      v-else-if="!isTabbar"
+      open-type="switchTab"
+      url="/pages/index/index"
+      class="left-icon"
+    >
+      <view class="bg-gray-500/80 rounded-full w-8 h-8 flex items-center justify-center">
+        <button class="i-carbon-home text-white w-6 h-6"></button>
+      </view>
+    </navigator>
+    <!-- 3/3,如果当前页就是tabbar页,不用去首页,也就是什么图标都不需要 -->
+    <view class="title">{{ title || '' }}</view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { getIsTabbar } from '@/utils/index'
+
+defineProps<{ title?: string }>()
+// 获取页面栈
+const pages = getCurrentPages()
+const isTabbar = getIsTabbar()
+console.log({ isTabbar, pagesLen: pages.length })
+
+// 获取屏幕边界到安全区域距离
+const { safeAreaInsets } = uni.getSystemInfoSync()
+</script>
+
+<style lang="scss" scoped>
+.fly-navbar {
+  position: fixed;
+  top: 0;
+  left: 0;
+  z-index: 9;
+  width: 750rpx;
+  color: #000;
+  background-color: transparent;
+
+  .left-icon {
+    position: absolute;
+    left: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 44px;
+    height: 44px;
+    font-size: 44rpx;
+    color: #000;
+  }
+
+  .title {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 44px;
+    font-size: 32rpx;
+    color: transparent;
+  }
+}
+</style>

+ 62 - 0
src/hooks/useNavbarWeixin.ts

@@ -0,0 +1,62 @@
+import { onReady } from '@dcloudio/uni-app'
+import { getIsTabbar } from '@/utils/index'
+
+export default () => {
+  // 获取页面栈
+  const pages = getCurrentPages()
+  const isTabbar = getIsTabbar()
+
+  // 页面滚动到底部时的操作,通常用于加载更多数据
+  const onScrollToLower = () => {}
+  // 获取屏幕边界到安全区域距离
+  const { safeAreaInsets } = uni.getSystemInfoSync()
+
+  // #ifdef MP-WEIXIN
+  // 基于小程序的 Page 类型扩展 uni-app 的 Page
+  type PageInstance = Page.PageInstance & WechatMiniprogram.Page.InstanceMethods<any>
+  // 获取当前页面实例,数组最后一项
+  const pageInstance = getCurrentPages().at(-1) as PageInstance
+
+  // 页面渲染完毕,绑定动画效果
+  onReady(() => {
+    // 动画效果,导航栏背景色
+    pageInstance.animate(
+      '.fly-navbar',
+      [{ backgroundColor: 'transparent' }, { backgroundColor: '#f8f8f8' }],
+      1000,
+      {
+        scrollSource: '#scroller',
+        timeRange: 1000,
+        startScrollOffset: 0,
+        endScrollOffset: 50,
+      },
+    )
+    // 动画效果,导航栏标题
+    pageInstance.animate(
+      '.fly-navbar .title',
+      [{ color: 'transparent' }, { color: '#000' }],
+      1000,
+      {
+        scrollSource: '#scroller',
+        timeRange: 1000,
+        startScrollOffset: 0,
+        endScrollOffset: 50,
+      },
+    )
+    // 动画效果,导航栏返回按钮
+    pageInstance.animate('.fly-navbar .left-icon', [{ color: '#fff' }, { color: '#000' }], 1000, {
+      scrollSource: '#scroller',
+      timeRange: 1000,
+      startScrollOffset: 0,
+      endScrollOffset: 50,
+    })
+  })
+  // #endif
+
+  return {
+    pages,
+    isTabbar,
+    onScrollToLower,
+    safeAreaInsets,
+  }
+}

+ 25 - 0
src/hooks/useWeixinShare.ts

@@ -0,0 +1,25 @@
+import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
+
+export default () => {
+  return {
+    /** 激活“分享给好友” */
+    onShareAppMessage: onShareAppMessage(
+      (options: Page.ShareAppMessageOption): Page.CustomShareContent => {
+        console.log('options:', options)
+        return {
+          title: '自定义分享标题',
+          path: '/pages/index/index?id=xxx',
+          imageUrl:
+            'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/pretty-girl.png',
+        }
+      },
+    ),
+    /** 激活“分享到朋友圈”, 注意:需要先激活“分享给好友” */
+    onShareTimeline: onShareTimeline((): Page.ShareTimelineContent => {
+      return {
+        title: '自定义分享标题',
+        query: 'a=1&b=2',
+      }
+    }),
+  }
+}

+ 42 - 0
src/pages/demo/base/mp-weixin-share.vue

@@ -0,0 +1,42 @@
+<route lang="json5">
+{
+  layout: 'demo',
+  style: { navigationBarTitleText: '微信分享' },
+}
+</route>
+
+<template>
+  <view class="text-green">微信分享页</view>
+  <view class="text-green-500">请在微信小程序中体验,或者开发者工具</view>
+  <view>1) 默认是不激活”发送给朋友“和”分享到朋友圈“的,如下图</view>
+  <image
+    src="https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/wx-share/wx-share-before.png"
+    mode="widthFix"
+  />
+  <view>2) 增加了onShareAppMessage和onShareTimeline后,就可以微信分享了,如下图</view>
+  <image
+    src="https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/wx-share/wx-share-after.png"
+    mode="widthFix"
+  />
+</template>
+
+<script lang="ts" setup>
+import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
+/** 激活“分享给好友” */
+onShareAppMessage((options: Page.ShareAppMessageOption): Page.CustomShareContent => {
+  console.log('options:', options)
+  return {
+    title: '自定义分享标题',
+    path: '/pages/index/index?id=xxx',
+    imageUrl:
+      'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/pretty-girl.png',
+  }
+})
+/** 激活“分享到朋友圈”, 注意:需要先激活“分享给好友” */
+onShareTimeline((): Page.ShareTimelineContent => {
+  return {
+    title: '自定义分享标题',
+    query: 'a=1&b=2',
+  }
+})
+</script>

+ 19 - 0
src/pages/demo/base/navbar.vue

@@ -0,0 +1,19 @@
+<route lang="json5">
+{
+  style: { navigationBarTitleText: '自定义导航栏', navigationStyle: 'custom' },
+}
+</route>
+
+<template>
+  <fly-navbar />
+  <view class="bg-green-300 min-h-20" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
+    <view class="p-4"> 自定义导航栏,设置"navigationStyle":"custom" </view>
+    <view class="p-4"> 通常页面顶部有一个图片或背景色 </view>
+  </view>
+  <fly-content :line="20" />
+</template>
+
+<script lang="ts" setup>
+// 获取屏幕边界到安全区域距离
+const { safeAreaInsets } = uni.getSystemInfoSync()
+</script>

+ 40 - 0
src/pages/demo/base/pinia.vue

@@ -0,0 +1,40 @@
+<route lang="json5">
+{
+  layout: 'demo',
+  style: { navigationBarTitleText: 'pinia+持久化' },
+}
+</route>
+
+<template>
+  <view class="flex justify-center items-center text-blue-500 mt-4 mb-4">
+    <view class="w-20">Count: {{ countStore.count }}</view>
+    <button class="ml-2 mr-2" @click="countStore.decrement">-1</button>
+    <button class="ml-2 mr-2" @click="countStore.increment">+1</button>
+    <button class="ml-2 mr-2" @click="countStore.reset">重置</button>
+  </view>
+  <view class="m-8 text-4 leading-8">
+    <view class="text-center">{{ userStore.userInfo }}</view>
+    <view class="text-center">请观察小程序的store,可以看到是可以正常设置的</view>
+    <button @click="setUserInfo">设置UserInfo</button>
+    <button @click="clearUserInfo" class="mt-4">清除UserInfo</button>
+    <button @click="resetUserStore" class="mt-4">reset UserStore</button>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { useCountStore, useUserStore } from '@/store'
+
+const countStore = useCountStore()
+
+const userStore = useUserStore()
+
+const setUserInfo = () => {
+  userStore.setUserInfo({ nickname: 'fly', avatar: '', token: 'abcdef' })
+}
+const clearUserInfo = () => {
+  userStore.clearUserInfo()
+}
+const resetUserStore = () => {
+  userStore.reset()
+}
+</script>

+ 67 - 0
src/pages/demo/base/request.vue

@@ -0,0 +1,67 @@
+<route lang="json5">
+{
+  layout: 'demo',
+  style: {
+    navigationBarTitleText: '请求',
+  },
+}
+</route>
+
+<template>
+  <view class="mt-6">
+    <!-- http://localhost:9100/#/pages/index/request -->
+    <button @click="getFoo" class="my-4">测试 GET 请求</button>
+    <view class="text-xl">请求数据如下</view>
+    <view class="text-green h-10">{{ JSON.stringify(data) }}</view>
+    <view class="text-xl">完整数据</view>
+    <view class="text-green h-20">{{ JSON.stringify(originalData) }}</view>
+    <button @click="postFoo" class="my-4">测试 POST 请求</button>
+    <view class="text-xl">请求数据如下</view>
+    <view class="text-green h-10">{{ JSON.stringify(data2) }}</view>
+
+    <button class="my-8" type="warn" @click="reset">一键清空数据</button>
+
+    <view class="my-2">使用的是 laf 云后台</view>
+    <view class="text-green-400">我的推荐码,可以获得佣金</view>
+    <!-- #ifdef H5 -->
+    <view class="my-2 text-center">
+      <a class="my-2 text-center" :href="recommendUrl" target="_blank">{{ recommendUrl }}</a>
+    </view>
+    <!-- #endif -->
+
+    <!-- #ifndef H5 -->
+    <view class="my-2 text-left text-sm">{{ recommendUrl }}</view>
+    <!-- #endif -->
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { getFooAPI, postFooAPI, IFooItem } from '@/service/foo'
+import { IResData } from '@/typings'
+
+const recommendUrl = ref('http://laf.run/signup?code=ohaOgIX')
+
+onLoad(() => {
+  getFoo()
+  postFoo()
+})
+const originalData = ref<IResData<IFooItem>>()
+const data = ref<IFooItem>()
+const getFoo = async () => {
+  const res = await getFooAPI('菲鸽')
+  data.value = res.result
+  originalData.value = res
+}
+
+const data2 = ref<IFooItem>()
+const postFoo = async () => {
+  const res = await postFooAPI('菲鸽2')
+  data2.value = res.result
+}
+
+const reset = () => {
+  data.value = undefined
+  data2.value = undefined
+  originalData.value = undefined
+}
+</script>

+ 145 - 0
src/pages/demo/base/throughout.vue

@@ -0,0 +1,145 @@
+<route lang="json5">
+{
+  style: {
+    navigationBarTitleText: '通屏+下拉刷新+自定义导航栏',
+    enablePullDownRefresh: false,
+    backgroundColor: '#23c09c', // 这个背景色要与页面的.top-section的背景图差不多,这样下拉刷新看起来才比较协调
+    'app-plus': {
+      titleNView: {
+        type: 'transparent',
+      },
+    },
+    'mp-weixin': {
+      navigationStyle: 'custom',
+    },
+  },
+}
+</route>
+
+<template>
+  <!-- #ifdef MP-WEIXIN -->
+  <view class="fly-navbar" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
+    <!-- 1/3,多于1个页面,用返回图标 -->
+    <navigator v-if="pages.length > 1" open-type="navigateBack" class="left-icon">
+      <view class="i-carbon-chevron-left text-current"></view>
+    </navigator>
+    <!-- 2/3,只有1个页面,如果不是tabbar,需要首页图标 -->
+    <!-- 这种情况一般出现在用户直接打开分享出去的详情页面,或者使用redirectTo等API -->
+    <navigator
+      v-else-if="!isTabbar"
+      open-type="switchTab"
+      url="/pages/index/index"
+      class="left-icon"
+    >
+      <view class="i-carbon-home text-current"></view>
+    </navigator>
+    <!-- 3/3,如果当前页就是tabbar页,不用去首页,也就是什么图标都不需要 -->
+    <view class="title">{{ '我是标题' }}</view>
+  </view>
+  <!-- #endif -->
+
+  <scroll-view
+    enable-back-to-top
+    scroll-y
+    class="scroll-view-bg flex-1 h-full"
+    id="scroller"
+    refresher-enabled
+    @scrolltolower="onScrollToLower"
+    @refresherrefresh="onRefresherRefresh"
+    :refresher-triggered="isTriggered"
+  >
+    <view class="top-section" :style="{ paddingTop: safeAreaInsets?.top + 'px' }">
+      <view class="pt-1">顶部区域</view>
+      <view>可以是标题,也可以是个人中心头像等</view>
+      <view>建议本区域高度不低于200rpx</view>
+    </view>
+    <view class="p-2 leading-6 bg-white">
+      注意,上面的导航栏渐变效果仅微信端支持,且上面的导航栏无法抽为组件引入使用,否则滚动效果没有了。如果不只是微信小程序使用,可以
+      onPageScroll 实现全端效果一样,另外如果是app端,还可以配置 titleNView。参考
+      https://uniapp.dcloud.net.cn/tutorial/page.html#onpagescroll 。
+    </view>
+    <view class="bg-white">
+      <fly-content :line="30" />
+    </view>
+  </scroll-view>
+</template>
+
+<script lang="ts" setup>
+import useNavbarWeixin from '@/hooks/useNavbarWeixin'
+import { onPullDownRefresh } from '@dcloudio/uni-app'
+
+const { pages, isTabbar, onScrollToLower, safeAreaInsets } = useNavbarWeixin()
+
+// 发现原生下拉刷新效果并不好,在微信里面只有顶部导航栏下拉才生效,页面区域下拉不生效,体验不好,结合自定义下拉刷新效果很好
+onPullDownRefresh(() => {
+  setTimeout(function fn() {
+    console.log('refresh - onPullDownRefresh')
+    // 关闭动画
+    uni.stopPullDownRefresh()
+  }, 1000)
+})
+
+// 当前下拉刷新状态
+const isTriggered = ref(false)
+// 自定义下拉刷新被触发
+const onRefresherRefresh = async () => {
+  // 开始动画
+  isTriggered.value = true
+  setTimeout(function fn() {
+    console.log('refresh - onRefresherRefresh')
+    // 关闭动画
+    isTriggered.value = false
+  }, 1000)
+}
+</script>
+
+<style lang="scss">
+.scroll-view-bg {
+  // 这个背景色要与.top-section的背景图差不多,这样下拉刷新看起来才比较协调
+  background-color: #23c09c;
+}
+
+// 这个区域最好要大于200rpx,效果会更好
+.top-section {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  min-height: 200rpx;
+  padding: 40rpx 0;
+  line-height: 2;
+  color: #fff;
+  background-image: url('https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/top-bg.png');
+  background-size: cover;
+}
+
+.fly-navbar {
+  position: fixed;
+  top: 0;
+  left: 0;
+  z-index: 9;
+  width: 750rpx;
+  color: #000;
+  background-color: transparent;
+
+  .left-icon {
+    position: absolute;
+    left: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 44px;
+    height: 44px;
+    font-size: 44rpx;
+    color: #000;
+  }
+
+  .title {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    height: 44px;
+    font-size: 32rpx;
+    color: transparent;
+  }
+}
+</style>

+ 16 - 0
src/pages/demo/base/uni-ui-icons.vue

@@ -0,0 +1,16 @@
+<route lang="json5">
+{
+  layout: 'demo',
+  style: { navigationBarTitleText: 'UniUI Icons 使用' },
+}
+</route>
+
+<template>
+  <view class="m-4">
+    <uni-icons type="contact" size="30"></uni-icons>
+    <uni-icons type="contact" size="30" color="red"></uni-icons>
+    <view class="text-blue-300"
+      >注意在微信小程序中,不支持改颜色,即设置了颜色也会变成默认的#333, BUG</view
+    >
+  </view>
+</template>

+ 14 - 0
src/pages/demo/base/uni-ui.vue

@@ -0,0 +1,14 @@
+<route lang="json5">
+{
+  layout: 'demo',
+  style: { navigationBarTitleText: 'UniUI 使用' },
+}
+</route>
+
+<template>
+  <uni-card>
+    <text>这是一个基础卡片示例,内容较少,此示例展示了一个没有任何属性不带阴影的卡片。</text>
+  </uni-card>
+  <view>微信里面下面的 uni-badge 显示不出来,BUG</view>
+  <uni-badge text="99"></uni-badge>
+</template>

+ 20 - 0
src/pages/demo/base/unocss-icons.vue

@@ -0,0 +1,20 @@
+<route lang="json5">
+{
+  layout: 'demo',
+  style: { navigationBarTitleText: 'UnoCss Icons 使用' },
+}
+</route>
+
+<template>
+  <view class="m-4">
+    <view class="mb-2">
+      这里只装了carbon的图表库,网址:
+      <a href="https://icones.js.org/collection/carbon" target="_blank"
+        >https://icones.js.org/collection/carbon </a
+      >(非H5环境,请使用浏览器打开)
+    </view>
+    <view class="i-carbon-car" />
+    <view class="i-carbon-car text-red" />
+    <button class="i-carbon-sun dark:i-carbon-moon" />
+  </view>
+</template>

+ 15 - 0
src/pages/demo/base/unocss.vue

@@ -0,0 +1,15 @@
+<route lang="json5">
+{
+  layout: 'demo',
+  style: { navigationBarTitleText: 'UnoCss 使用' },
+}
+</route>
+
+<template>
+  <view class="flex flex-col justify-center items-center text-5 h-8 leading-8 mt-20">
+    <view class="text-green-500">文字颜色 text-light-50</view>
+    <view class="text-red-500">文字颜色 text-red-500</view>
+    <view class="bg-green-500">背景色 bg-light-50</view>
+    <view class="bg-red-500">背景色 bg-red-500</view>
+  </view>
+</template>

+ 22 - 0
src/pages/demo/base/vconsole.vue

@@ -0,0 +1,22 @@
+<route lang="json5">
+{
+  layout: 'demo',
+  style: { navigationBarTitleText: '开启 vConsole' },
+}
+</route>
+
+<template>
+  <view class="text-5 h-8 leading-8">
+    <view class="text-red-500">在非正式版小程序里面已经集成了VConsole</view>
+    <view class="text-blue-500 mb-2">开启方式如下面</view>
+    <image
+      src="https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/vconsole/1.png"
+      mode="widthFix"
+    />
+    <view class="text-blue-500 m-2">然后页面上会出现一个 `vConsole` 的调试按钮,如下图</view>
+    <image
+      src="https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/vconsole/2.png"
+      mode="widthFix"
+    />
+  </view>
+</template>

+ 81 - 0
src/pages/demo/index.vue

@@ -0,0 +1,81 @@
+<template>
+  <view class="bg-slate-100 p-4">
+    <view class="bg-slate-100 w-full" v-for="item in listData" :key="item.id">
+      <view class="font-800">{{ item.title }}</view>
+      <view v-for="itemDetail in item.list" :key="itemDetail.path" class="mt-3">
+        <view
+          class="flex bg-white items-center justify-between p-3 mb-2"
+          @click="goDetailPage(itemDetail.path)"
+        >
+          <text class="flex-1 text-4 text-dark">{{ itemDetail.title }}</text>
+          <text class="i-carbon-chevron-right"></text>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts" name="TestIndex">
+import pagesJson from '@/pages.json'
+
+/** 基本功能 */
+const baseDemos = pagesJson.pages
+  .filter((e) => e.path.startsWith('pages/demo/base'))
+  .map((e) => ({
+    title: e.style?.navigationBarTitleText || '默认页面标题',
+    path: e.path,
+  }))
+
+/** 页面功能 */
+const pageDemos = pagesJson.pages
+  .filter((e) => e.path.startsWith('pages/demo/page'))
+  .map((e) => ({
+    title: e.style?.navigationBarTitleText || '默认页面标题',
+    path: e.path,
+  }))
+
+const listData = reactive([
+  {
+    id: 1,
+    title: '基础功能',
+    list: baseDemos,
+  },
+  {
+    id: 2,
+    title: '页面功能',
+    list: pageDemos,
+  },
+])
+
+const goDetailPage = (path: string) => {
+  const url = `/${path}`
+  uni.navigateTo({
+    url,
+  })
+}
+</script>
+
+<style>
+.content {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+}
+
+.logo {
+  width: 200rpx;
+  height: 200rpx;
+  margin: 200rpx auto 50rpx;
+}
+
+.text-area {
+  display: flex;
+  justify-content: center;
+}
+
+.title {
+  font-size: 36rpx;
+  color: #8f8f94;
+}
+</style>

+ 130 - 0
src/pages/demo/page/clock.vue

@@ -0,0 +1,130 @@
+<route lang="json5">
+{
+  layout: 'demo',
+  style: { navigationBarTitleText: '动态时钟' },
+}
+</route>
+
+<template>
+  <view class="mt-4 h-10 text-center">动态时钟</view>
+  <view class="clock-box">
+    <view class="clock" :style="{ '--ds': ds, '--dm': dm, '--dh': dh }">
+      <view class="clock-pane">
+        <text class="clock-num" :style="{ '--i': n }" v-for="n in 12" :key="n">{{ n }}</text>
+      </view>
+      <view class="clock-hour"></view>
+      <view class="clock-min"></view>
+      <view class="clock-sec"></view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+const d = new Date()
+const h = d.getHours()
+const m = d.getMinutes()
+const s = d.getSeconds()
+const ds = ref(s)
+const dm = ref(m + s / 60)
+const dh = ref(h + m / 60 + s / 3600)
+</script>
+
+<style lang="scss">
+.clock-box {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.clock {
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 350px;
+  height: 350px;
+  font-size: 24px;
+  border-radius: 20px;
+  box-shadow: 2px 2px 20px #0000001a;
+  --step: 60s;
+}
+
+.clock::before {
+  position: absolute;
+  width: 300px;
+  height: 300px;
+  content: '';
+  background: repeating-conic-gradient(from -0.5deg, #333 0 1deg, transparent 0deg 30deg),
+    repeating-conic-gradient(from -0.5deg, #ccc 0 1deg, transparent 0deg 6deg);
+  border-radius: 50%;
+  mask: radial-gradient(transparent 145px, red 0);
+}
+
+.clock-pane {
+  position: absolute;
+  width: 250px;
+  height: 250px;
+  transform: translateX(-125px);
+}
+
+.clock-num {
+  position: absolute;
+  offset-path: path(
+    'M250 125c0 69.036-55.964 125-125 125S0 194.036 0 125 55.964 0 125 0s125 55.964 125 125z'
+  );
+  offset-distance: calc(var(--i) * 10% / 1.2 - 25%);
+  offset-rotate: 0deg;
+}
+
+.clock-hour {
+  position: absolute;
+  width: 4px;
+  height: 60px;
+  background: #333;
+  transform: translateY(-50%) rotate(0);
+  transform-origin: center bottom;
+  animation: clock calc(var(--step) * 60 * 12) infinite linear;
+  animation-delay: calc(-1 * var(--step) * var(--dh) * 60);
+}
+
+.clock-min {
+  position: absolute;
+  width: 4px;
+  height: 90px;
+  background: #333;
+  transform: translateY(-50%) rotate(0);
+  transform-origin: center bottom;
+  animation: clock calc(var(--step) * 60) infinite linear;
+  animation-delay: calc(-1 * var(--step) * var(--dm));
+}
+
+.clock-sec {
+  position: absolute;
+  width: 2px;
+  height: 120px;
+  background: red;
+  transform: translateY(-50%) rotate(0);
+  transform-origin: center bottom;
+  animation: clock var(--step) infinite steps(60);
+  animation-delay: calc(-1 * var(--step) * var(--ds) / 60);
+}
+
+.clock-sec::after {
+  position: absolute;
+  bottom: 0;
+  left: 50%;
+  width: 10px;
+  height: 10px;
+  content: '';
+  background: #fff;
+  border: 4px solid #333;
+  border-radius: 50%;
+  transform: translate(-50%, 50%);
+}
+
+@keyframes clock {
+  to {
+    transform: translateY(-50%) rotate(360deg);
+  }
+}
+</style>

+ 152 - 0
src/pages/demo/page/clock2.vue

@@ -0,0 +1,152 @@
+<route lang="json5">
+{
+  layout: 'demo',
+  style: { navigationBarTitleText: '动态时钟-抗锯齿' },
+}
+</route>
+
+<template>
+  <view class="mt-4 h-10 text-center">动态时钟</view>
+  <view class="clock-box">
+    <view class="clock" :style="{ '--ds': ds, '--dm': dm, '--dh': dh }">
+      <view class="clock-pane">
+        <text class="clock-num" :style="{ '--i': n }" v-for="n in 12" :key="n">{{ n }}</text>
+      </view>
+      <view class="clock-scales">
+        <text class="clock-scale" :style="{ '--i': n }" v-for="n in 60" :key="n"></text>
+      </view>
+
+      <view class="clock-hour"></view>
+      <view class="clock-min"></view>
+      <view class="clock-sec"></view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+const d = new Date()
+const h = d.getHours()
+const m = d.getMinutes()
+const s = d.getSeconds()
+const ds = ref(s)
+const dm = ref(m + s / 60)
+const dh = ref(h + m / 60 + s / 3600)
+</script>
+
+<style lang="scss">
+.clock-box {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.clock {
+  position: relative;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 350px;
+  height: 350px;
+  font-size: 24px;
+  border-radius: 20px;
+  box-shadow: 2px 2px 20px #0000001a;
+  --step: 60s;
+}
+
+.clock-pane {
+  position: absolute;
+  width: 250px;
+  height: 250px;
+  transform: translateX(-125px);
+}
+
+.clock-scales {
+  position: absolute;
+  width: 250px;
+  height: 250px;
+  transform: translate(125px, -25px);
+}
+
+.clock-scale {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 2px;
+  height: 4px;
+  background: #ccc;
+  transform-origin: 0 150px;
+
+  &:nth-child(5n + 1) {
+    width: 4px;
+    height: 6px;
+    background: #333;
+  }
+}
+
+@for $i from 1 through 60 {
+  .clock-scale:nth-child(#{$i}) {
+    transform: rotate(#{($i - 1) * 6deg});
+  }
+}
+
+.clock-num {
+  position: absolute;
+  offset-path: path(
+    'M250 125c0 69.036-55.964 125-125 125S0 194.036 0 125 55.964 0 125 0s125 55.964 125 125z'
+  );
+  offset-distance: calc(var(--i) * 10% / 1.2 - 25%);
+  offset-rotate: 0deg;
+}
+
+.clock-hour {
+  position: absolute;
+  width: 4px;
+  height: 60px;
+  background: #333;
+  transform: translateY(-50%) rotate(0);
+  transform-origin: center bottom;
+  animation: clock calc(var(--step) * 60 * 12) infinite linear;
+  animation-delay: calc(-1 * var(--step) * var(--dh) * 60);
+}
+
+.clock-min {
+  position: absolute;
+  width: 4px;
+  height: 90px;
+  background: #333;
+  transform: translateY(-50%) rotate(0);
+  transform-origin: center bottom;
+  animation: clock calc(var(--step) * 60) infinite linear;
+  animation-delay: calc(-1 * var(--step) * var(--dm));
+}
+
+.clock-sec {
+  position: absolute;
+  width: 2px;
+  height: 120px;
+  background: red;
+  transform: translateY(-50%) rotate(0);
+  transform-origin: center bottom;
+  animation: clock var(--step) infinite steps(60);
+  animation-delay: calc(-1 * var(--step) * var(--ds) / 60);
+}
+
+.clock-sec::after {
+  position: absolute;
+  bottom: 0;
+  left: 50%;
+  width: 10px;
+  height: 10px;
+  content: '';
+  background: #fff;
+  border: 4px solid #333;
+  border-radius: 50%;
+  transform: translate(-50%, 50%);
+}
+
+@keyframes clock {
+  to {
+    transform: translateY(-50%) rotate(360deg);
+  }
+}
+</style>

+ 88 - 0
src/pages/demo/page/floating-bubble.vue

@@ -0,0 +1,88 @@
+<route lang="json5">
+{
+  layout: 'default',
+  style: { navigationBarTitleText: '页面悬浮球' },
+}
+</route>
+
+<template>
+  <view>
+    <movable-area class="movable-area">
+      <movable-view
+        :style="`--size:${ballSize}px`"
+        class="movable-view"
+        direction="all"
+        :x="x"
+        :y="y"
+        @change="onChange"
+        @touchend.prevent="onTouchEnd"
+      >
+        <view class="w-full h-full rounded-full bg-green-400"></view>
+      </movable-view>
+    </movable-area>
+    <view>页面其他元素</view>
+    <view>可以正常触发点击事件吗?答案是可以的</view>
+    <button @click="onClick">按钮</button>
+    <view>{{ x }}</view>
+    <view>{{ y }}</view>
+    <view @click="onSet">点击设置</view>
+  </view>
+</template>
+
+<script lang="ts" setup name="FloatingBubble">
+const { windowHeight, windowWidth } = uni.getSystemInfoSync()
+
+const ballSize = 60
+const x = ref(windowWidth - ballSize) // 靠右侧
+const y = ref(windowHeight - ballSize - 20) // 距离底部20px
+
+const middleX = (windowWidth - ballSize) / 2
+
+const onChange: UniHelper.MovableViewOnChange = (e) => {
+  const { x: _x, y: _y } = e.detail
+  x.value = _x
+  y.value = _y
+}
+// TODO: 期望最终落点不靠左右两边时,会自动回到两边,有一定的动画效果
+const onTouchEnd = (e) => {
+  console.log('onTouchEnd', e)
+  // TODO:为啥这里设置的不生效了,原生不会移动到设置的地方,onSet里面可以。这里直接执行onSet也不行
+  // 这里被我解决了
+  const tid = setTimeout(() => {
+    if (x.value < middleX) {
+      x.value = 0
+    } else {
+      x.value = windowWidth - ballSize
+    }
+    clearTimeout(tid)
+  }, 0)
+}
+
+const onClick = () => {
+  uni.showToast({
+    title: 'yes',
+    icon: 'none',
+  })
+}
+const onSet = () => {
+  x.value = 100
+  y.value = 100
+}
+</script>
+
+<style lang="scss">
+.movable-area {
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: 100;
+  width: 100%;
+  height: 100%;
+  pointer-events: none; // 设置area元素不可点击,则事件便会下移至页面下层元素
+  .movable-view {
+    width: var(--size);
+    height: var(--size);
+    pointer-events: auto; // 必须设置,否则无法点击
+  }
+}
+</style>

+ 83 - 0
src/pages/demo/page/i18n.vue

@@ -0,0 +1,83 @@
+<route lang="json5">
+{
+  layout: 'demo',
+  style: {
+    navigationBarTitleText: '%app.name%',
+  },
+}
+</route>
+
+<template>
+  <view class="center flex-col mt-6">
+    <view class="text-green-500">多语言测试</view>
+    <view class="m-4">{{ $t('app.name') }}</view>
+
+    <view class="text-green-500 mt-12">切换语言 </view>
+    <view class="uni-list">
+      <radio-group @change="radioChange" class="radio-group">
+        <label class="uni-list-cell uni-list-cell-pd" v-for="item in languages" :key="item.value">
+          <view>
+            <radio :value="item.value" :checked="item.value === current" />
+          </view>
+          <view>{{ item.name }}</view>
+        </label>
+      </radio-group>
+    </view>
+
+    <!-- http://localhost:9100/#/pages/index/i18n -->
+    <button @click="testI18n" class="mt-20 mb-44">测试弹窗</button>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import i18n from '@/locale/index'
+import { testI18n } from '@/utils/index'
+
+const current = ref(uni.getLocale())
+const languages = [
+  {
+    value: 'zh-Hans',
+    name: '中文',
+    checked: 'true',
+  },
+  {
+    value: 'en',
+    name: '英文',
+  },
+]
+
+const radioChange = (evt) => {
+  // console.log(evt)
+  current.value = evt.detail.value
+  // 下面2句缺一不可!!!
+  uni.setLocale(evt.detail.value)
+  i18n.global.locale = evt.detail.value
+}
+</script>
+
+<style lang="scss">
+.uni-list {
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  width: 100%;
+  background-color: #fff;
+  border-radius: 12px;
+}
+
+.radio-group {
+  width: 200px;
+  margin: 10px auto;
+  border-radius: 12px;
+}
+
+.uni-list-cell {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px;
+  background-color: #bcecd1;
+}
+</style>

+ 29 - 0
src/pages/demo/page/img-min/index.vue

@@ -0,0 +1,29 @@
+<route lang="json5">
+{
+  layout: 'demo',
+  style: { navigationBarTitleText: '图片压缩' },
+}
+</route>
+
+<template lang="">
+  <view class="m-4 text-center">
+    <view class="mb-2 text-orange-500">
+      原始图片是一个很大的,2.5M,build之后生成的图片只有1.1M,体积下降 56%
+    </view>
+    <image
+      src="https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/img-min/bg-1.png"
+      mode="scaleToFill"
+    />
+    <view class="mb-4">对比图如下2图,如果看不清请看代码原图</view>
+    <image
+      src="https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/img-min/before.png"
+      mode="widthFix"
+      class="w-full"
+    />
+    <image
+      src="https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/img-min/after.png"
+      mode="widthFix"
+      class="w-full"
+    />
+  </view>
+</template>

+ 127 - 0
src/pages/demo/page/lottery.vue

@@ -0,0 +1,127 @@
+<route lang="json5">
+{
+  layout: 'demo',
+  style: { navigationBarTitleText: '九宫格抽奖' },
+}
+</route>
+
+<template>
+  <view class="mt-4 h-10 text-center">九宫格抽奖</view>
+  <view class="lottery-box">
+    <view class="lottery-list">
+      <view
+        class="lottery-item"
+        :class="{
+          active: n === activeIndex,
+          btn: n === btnIndex, // 最中间那个是展示按钮
+        }"
+        v-for="n in numList"
+        :key="n"
+        @click="handleClick(n)"
+      >
+        <view v-if="n === btnIndex">点击抽奖</view>
+        <view v-else> {{ n }}</view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { reactive, computed } from 'vue'
+
+const giftLen = 8 // 九宫格有8个礼物
+const loop = 4 // 设置转多少圈,在最后一圈会慢下来
+const totalStep = giftLen * loop // 总的步数 32
+const lastLoopStep = totalStep - giftLen // 最后一圈,24
+const numList = [1, 2, 3, 8, -1, 4, 7, 6, 5]
+const btnIndex = numList[4] // 最中间那个是展示按钮
+
+const state = reactive({
+  lottery: 0, // 奖品
+  step: -1, // 目前转动的步数
+  stopStep: totalStep, // 停下来的时需要走的步数
+  speed: 2, // 转动速度,我们是通过定时器去实现转动效果的,所以这也就是定时器的执行频率
+  timer: null, // 定时器ID
+  loading: false,
+})
+// 通过目前转动的步数来对8取模得到当前转到的格子索引
+const activeIndex = computed(() => {
+  return (state.step % 8) + 1
+})
+
+function run() {
+  // 当前步数大于等于目标步数
+  if (state.step >= state.stopStep) {
+    // 清空定时器,停止转动
+    clearTimeout(state.timer)
+    // 将初始化步数为最终奖品的步数,转动速度也置为初始速度,下次才能正确转动
+    state.step = state.lottery
+    state.speed = 2
+    state.loading = false
+    console.log(`恭喜获得${activeIndex.value}号奖品`)
+    uni.showModal({
+      title: `恭喜获得${activeIndex.value}号奖品`,
+    })
+    return
+  }
+  // 转动到最后一圈时,增加speed,也就是定时器执行间隔时间变长,转动速度变慢
+  if (state.step > lastLoopStep + state.lottery) {
+    state.speed++
+  }
+  // 抽奖函数每执行一次,当前步数加一
+  state.step++
+  // 重新开启定时器执行抽奖函数
+  state.timer = setTimeout(run, state.speed * 30)
+}
+// 点击抽奖之后调用的函数
+function handleClick(n) {
+  if (n !== btnIndex) {
+    return
+  }
+  if (state.loading) return
+  state.loading = true
+  // 最终获得的奖品,实际业务中是通过接口获取的,这里使用随机数来模拟下
+  state.lottery = Math.ceil(Math.random() * giftLen)
+  console.log(state.lottery)
+  // 计算总共要转动的步数,转4圈后再转到奖品处
+  state.stopStep = state.lottery + totalStep
+  // 执行抽奖函数
+  run()
+}
+</script>
+
+<style lang="css">
+.lottery-box {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.lottery-list {
+  --size: 100px;
+
+  display: flex;
+  flex-wrap: wrap;
+  width: calc(3 * var(--size) + 3px);
+  border-right: 1px solid #ccc;
+  border-bottom: 1px solid #ccc;
+}
+
+.lottery-item {
+  width: var(--size);
+  height: var(--size);
+  line-height: var(--size);
+  text-align: center;
+  border-top: 1px solid #ccc;
+  border-left: 1px solid #ccc;
+}
+
+.lottery-item.active {
+  color: #fff;
+  background-color: red;
+}
+
+.lottery-item.btn {
+  cursor: pointer;
+}
+</style>

+ 216 - 0
src/pages/demo/page/lottery/big-wheel.vue

@@ -0,0 +1,216 @@
+<route lang="json5">
+{
+  layout: 'demo',
+  style: { navigationBarTitleText: '大转盘抽奖' },
+}
+</route>
+
+<template>
+  <view class="text-center">
+    <view class="container">
+      <view class="prize-list" :style="styleObj">
+        <view
+          class="prize-item"
+          v-for="(item, index) in prizeList"
+          :key="item.id"
+          :style="prizeStyle(index)"
+        >
+          <image :src="item.pic" class="gift-img" />
+          <text class="gift-name">{{ item.name }}</text>
+        </view>
+      </view>
+      <view class="lottery-btn" @click="start"> </view>
+    </view>
+    <view class="text-blue-600 my-2">目标是实现如下的效果,但是我感觉只用css还是太难了</view>
+    <image
+      src="https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery/target.png"
+      mode="widthFix"
+      width="552px"
+    />
+    <!-- <image :src="targetImg" mode="widthFix" width="552px" /> -->
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue'
+// TODO: fix 微信小程序里面会报错
+// import targetImg from 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery/target.png'
+
+// 后台配置的奖品数据
+const prizeList = [
+  {
+    id: 0,
+    name: '双肩包',
+    pic: 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/backpack.jpg',
+  },
+  {
+    id: 1,
+    name: '积木',
+    pic: 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/jimu.jpg',
+  },
+  {
+    id: 2,
+    name: '红包',
+    pic: 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/red-envelope.jpg',
+  },
+  {
+    id: 3,
+    name: '茶具',
+    pic: 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/tea-set.jpg',
+  },
+  {
+    id: 4,
+    name: '可爱脸',
+    pic: 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/tushetou.jpg',
+  },
+  {
+    id: 5,
+    name: '挖掘机',
+    pic: 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/wajueji.jpg',
+  },
+  {
+    id: 6,
+    name: '无辜脸',
+    pic: 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/xiaolian.jpg',
+  },
+  {
+    id: 7,
+    name: '烟灰缸',
+    pic: 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/yanhuigang.jpg',
+  },
+]
+let isRunning = false // 是否正在抽奖
+const baseRunAngle = 360 * 5 // 总共转动角度 至少5圈
+let prizeId = 0 // 中奖id
+
+// 平均每个奖品角度
+const rotateAngle = computed(() => {
+  const _degree = 360 / prizeList.length
+  return _degree
+})
+// 要执行总角度数
+const totalRunAngle = ref(baseRunAngle - (prizeId + 0.5) * rotateAngle.value)
+
+// 计算绘制转盘背景
+const bgColor = (() => {
+  const [c1, c2] = ['#5352b3', '#363589']
+  // repeating-conic-gradient(red 0 15deg, blue  15deg 30deg);
+  return `background: repeating-conic-gradient(${c1} 0 ${rotateAngle.value}deg,
+  ${c2} ${rotateAngle.value}deg ${2 * rotateAngle.value}deg);`
+})()
+
+const styleObj = ref(bgColor)
+// 每个奖品布局
+const prizeStyle = computed(() => {
+  const _degree = rotateAngle.value
+  return (i) => {
+    // 外框大小设置为90vw,里面是一半,45vw
+    return `
+              width: ${2 * 45 * Math.sin(((_degree / 2) * Math.PI) / 180)}vw;
+              height: 45vw;
+              transform: rotate(${_degree * i + _degree / 2}deg);
+              transform-origin: 50% 100%;
+            `
+  }
+})
+
+// 获取随机数
+const getRandomNum = () => {
+  const num = Math.floor(Math.random() * prizeList.length)
+  return num
+}
+
+const stopRun = () => {
+  isRunning = false
+  const prizeName = prizeList.find((e) => e.id === prizeId)!.name
+  uni.showModal({
+    title: `恭喜你中奖 ${prizeName}`,
+    success() {
+      styleObj.value = `${bgColor} transform: rotate(0deg);`
+    },
+  })
+}
+
+const startRun = () => {
+  console.log(isRunning, totalRunAngle.value)
+  // 设置动效
+  styleObj.value = `${bgColor} transform: rotate(${totalRunAngle.value}deg); transition: all 4s ease;`
+  setTimeout(stopRun, 4000)
+}
+const start = () => {
+  if (!isRunning) {
+    isRunning = true
+
+    console.log('开始抽奖,后台请求中奖奖品')
+    // 请求返回的奖品编号 这里使用随机数
+    prizeId = getRandomNum()
+    totalRunAngle.value = baseRunAngle - (prizeId + 0.5) * rotateAngle.value
+    console.log('中奖ID>>>', prizeId, prizeList[prizeId], totalRunAngle.value)
+    nextTick(() => {
+      startRun()
+    })
+  }
+}
+</script>
+
+<style lang="scss">
+.container {
+  position: relative;
+  width: 90vw;
+  height: 90vw;
+  margin: 20px auto;
+  border: 10px solid #98d3fc;
+  border-radius: 50%;
+}
+
+.prize-list {
+  box-sizing: border-box;
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  border-radius: 50%;
+
+  // 使用outline代替border可以省很多定位的问题
+  // outline: 10px solid #98d3fc;
+}
+
+.prize-item {
+  position: absolute;
+  top: 0;
+  right: 0;
+  left: 0;
+  display: flex;
+  flex-direction: column;
+  margin: auto;
+
+  // border: 2px solid red;
+}
+
+.prize-item .gift-img {
+  display: block;
+  width: 30%;
+  height: 20%;
+  margin: 20px auto 10px;
+  border-radius: 50%;
+}
+
+.prize-item .gift-name {
+  font-size: 12px;
+  line-height: 20px;
+  color: #fff;
+  text-align: center;
+}
+
+.lottery-btn {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  width: 80px;
+  height: 96px;
+  margin: auto;
+  cursor: pointer;
+  background: url('https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/btn-enable.png')
+    no-repeat center / 100% 100%;
+  transform: translate(-50%, -50%);
+}
+</style>

+ 196 - 0
src/pages/demo/page/lottery/nine-grid.vue

@@ -0,0 +1,196 @@
+<route lang="json5">
+{
+  layout: 'demo',
+  style: { navigationBarTitleText: '九宫格抽奖' },
+}
+</route>
+<template>
+  <view>
+    <view class="container">
+      <view
+        class="gift-item"
+        :class="{ active: currentIndex === index }"
+        v-for="(item, index) in prizeList"
+        :key="index"
+        @click="start(index)"
+      >
+        <image :src="item.pic" class="gift-img" />
+        <text v-if="index !== 4" class="gift-name">{{ item.name }}</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup>
+import { ref, computed } from 'vue'
+
+const currentIndex = ref(0) // 当前位置
+// 后台配置的奖品数据
+const prizeList = [
+  {
+    id: 0,
+    name: '双肩包',
+    pic: 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/backpack.jpg',
+  },
+  {
+    id: 1,
+    name: '积木',
+    pic: 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/jimu.jpg',
+  },
+  {
+    id: 2,
+    name: '红包',
+    pic: 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/red-envelope.jpg',
+  },
+  {
+    id: 3,
+    name: '茶具',
+    pic: 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/tea-set.jpg',
+  },
+  {
+    id: 5,
+    name: '可爱脸',
+    pic: 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/tushetou.jpg',
+  },
+  {
+    id: 6,
+    name: '挖掘机',
+    pic: 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/wajueji.jpg',
+  },
+  {
+    id: 7,
+    name: '无辜脸',
+    pic: 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/xiaolian.jpg',
+  },
+  {
+    id: 8,
+    name: '烟灰缸',
+    pic: 'https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery-prize/yanhuigang.jpg',
+  },
+]
+const startBtn = {
+  id: 4,
+  name: '开始按钮',
+  pic: 'https://img2.baidu.com/it/u=1497996119,382735686&fm=253',
+}
+// 九宫格中间位置插入开始按钮
+prizeList.splice(4, 0, startBtn)
+
+// 奖品高亮顺序
+const prizeSort = [0, 1, 2, 5, 8, 7, 6, 3]
+
+// 获取随机数
+const getRandomNum = () => prizeSort[Math.floor(Math.random() * prizeSort.length)]
+
+let isRunning = false // 是否正在抽奖
+let speed = 10 // 抽奖转动速度
+let timerIns = null // 定时器实例
+let currentRunCount = 0 // 已跑次数
+const totalRunCount = 32 // 总共跑动次数,8的倍数即可
+let prizeId = 0 // 中奖id(0-8,不能是4)
+
+// 要执行总步数
+const totalRunStep = computed(() => {
+  return totalRunCount + prizeSort.indexOf(prizeId)
+})
+
+const stopRun = () => {
+  // eslint-disable-next-line no-unused-expressions
+  timerIns && clearTimeout(timerIns)
+}
+const startRun = () => {
+  stopRun()
+  console.log(currentRunCount, totalRunStep.value)
+  // 要执行总步数
+  // 已走步数超过
+  if (currentRunCount > totalRunStep.value) {
+    isRunning = false
+    const prizeName = prizeList.find((e) => e.id === prizeId)!.name
+    uni.showModal({
+      title: `恭喜你中奖 ${prizeName}`,
+    })
+    return
+  }
+  currentIndex.value = prizeSort[currentRunCount % 8]
+  // 如果当前步数超过了2/3则速度慢下来
+  if (currentRunCount > Math.floor((totalRunCount * 2) / 3)) {
+    speed += Math.floor(currentRunCount / 3)
+    console.log('速度>>>>', speed)
+  }
+
+  timerIns = setTimeout(() => {
+    currentRunCount++
+    startRun()
+  }, speed)
+}
+
+const start = (i) => {
+  if (i === 4 && !isRunning) {
+    // 重置数据
+    currentRunCount = 0
+    speed = 100
+    isRunning = true
+
+    console.log('开始抽奖,后台请求中奖奖品')
+    // 请求返回的奖品编号 这里使用随机数 但不能为4
+    // const prizeId = getRandomNum()
+    // console.log('中奖ID>>>', prizeId, prizeList[prizeId])
+    // prizeId = prizeId
+    // 模拟接口延时返回 如果接口突然报错如何处理?直接调用stopRun()方法停止转动
+    setTimeout(() => {
+      prizeId = getRandomNum()
+      console.log('中奖ID>>>', prizeId, prizeList[prizeId])
+      // 拿到数据才可以跑
+    }, 2000)
+    startRun()
+  }
+}
+</script>
+<style lang="scss">
+.container {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: space-around;
+  width: 90vw;
+  height: 90vw;
+  margin: 20px auto;
+  background: #98d3fc;
+  border: 1px solid #98d3fc;
+}
+
+.gift-item {
+  position: relative;
+  box-sizing: border-box;
+  width: 30vw;
+  height: 30vw;
+  border: 2px solid #fff;
+}
+
+.gift-item:nth-of-type(5) {
+  cursor: pointer;
+}
+
+.gift-item .gift-img {
+  width: 100%;
+  height: 100%;
+}
+
+.gift-item .gift-name {
+  position: absolute;
+  bottom: 0;
+  left: 0;
+  width: 100%;
+  height: 20px;
+  font-size: 12px;
+  line-height: 20px;
+  color: #fff;
+  text-align: center;
+  background: rgb(0 0 0 / 50%);
+}
+
+.active {
+  border: 2px solid red;
+  box-shadow: 2px 2px 30px #fff;
+}
+</style>

+ 181 - 0
src/pages/demo/page/lottery2.vue

@@ -0,0 +1,181 @@
+<route lang="json5">
+{
+  layout: 'demo',
+  style: { navigationBarTitleText: '大转盘抽奖' },
+}
+</route>
+
+<template>
+  <view class="mt-4 h-10 text-center">大转盘抽奖</view>
+  <div class="lottery-box">
+    <div class="lottery-list">
+      <div class="lottery-item" v-for="(n, index) in giftLen" :key="n">
+        <div class="lottery-item-inner">
+          <div class="lottery-item-gift">奖品{{ index + 1 }}</div>
+        </div>
+      </div>
+      <div
+        class="pointer"
+        @click="handleClick"
+        :style="{ transform: `rotate(${state.stopDeg}deg)` }"
+      >
+        <div>开始</div>
+        <div>抽奖</div>
+      </div>
+    </div>
+  </div>
+  <view class="leading-8">
+    <view class="mt-8 text-center text-green-600">下面是调试过程图片</view>
+    <view class="mb-8 text-center text-green-600">欢迎感兴趣的玩家继续优化</view>
+    <view class="text-center text-blue-600">计算lottery-item-inner节点的padding-left值</view>
+    <image
+      src="https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery/lottery2-1.png"
+      mode="widthFix"
+      class="w-full"
+    />
+    <view class="text-center text-blue-600">调整lottery-item-gift节点</view>
+    <image
+      src="https://cip-shopping-page-0eysug01066a9e-1302818703.tcloudbaseapp.com/fly/lottery/lottery2-2.png"
+      mode="widthFix"
+      class="w-full"
+    />
+  </view>
+</template>
+
+<script lang="ts" setup>
+const giftLen = 8
+const deg = 360 / giftLen // 每份的角度
+const loop = 4 // 转多少圈,4圈
+const state = reactive({
+  lottery: 0, // 本次抽奖的奖品索引
+  lastLottery: 0, // 上一次抽奖的奖品索引
+  stopDeg: 0, // 最终要旋转的角度
+  loading: false,
+})
+
+function handleClick() {
+  if (state.loading) return
+  state.loading = true
+  // 最终获得的奖品索引,实际业务中是通过接口获取的,这里使用随机数0~9来模拟下
+  state.lottery = Math.floor(Math.random() * giftLen)
+  console.log(state.lottery)
+  // 最终的旋转角度,指针指向本次奖品的旋转角度+指针从上一次的奖品指向回归0的旋转角度+ 默认转动三圈
+  state.stopDeg += (state.lottery + (giftLen - state.lastLottery)) * deg + loop * 360
+
+  // uni不支持addEventListener所以改用下面的
+  setTimeout(() => {
+    state.lastLottery = state.lottery
+    state.loading = false
+    // alert(`恭喜获得奖品${state.lottery + 1}`)
+    uni.showModal({
+      title: `恭喜获得奖品${state.lottery + 1}`,
+    })
+  }, 3000)
+}
+</script>
+
+<style lang="scss">
+.lottery-box {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.lottery-list {
+  --size: 600rpx;
+  --half: calc(var(--size) / 2);
+  --len: 8; // 与代码 giftLen 长度一致
+  --deg: calc(360 / var(--len) * 1deg);
+  --deg-num: calc(360 / var(--len));
+
+  position: relative;
+  width: var(--size);
+  height: var(--size);
+  border: 2px solid #f55;
+  border-radius: 50%;
+}
+
+.lottery-item {
+  position: absolute;
+  top: 0;
+  left: var(--half);
+  width: var(--half);
+  height: var(--size);
+  overflow: hidden; // 把这个注释掉可以看到最初的模样
+  // background-color: #ff5350a1; // 放开这个可以看到最初的模样
+  transform-origin: left center;
+}
+
+.lottery-item-inner {
+  position: absolute;
+  top: 0;
+  left: calc(-1 * var(--half));
+  box-sizing: border-box;
+  width: var(--half);
+  height: var(--size);
+  padding-left: calc(((1 - sin(var(--deg-num))) * var(--size)));
+  font-size: 12px;
+  border-radius: var(--half) 0 0 var(--half);
+  transform: rotate(var(--deg));
+  transform-origin: right center;
+}
+
+.lottery-item-inner .lottery-item-gift {
+  display: block;
+  text-align: center;
+  transform: rotate(calc(-0.5 * var(--deg))) translateY(16px)
+    translateX(calc(0.5 * var(--half) * (1 - 1 / cos(0.5 * var(--deg)))));
+  transform-origin: center;
+}
+
+.lottery-item:nth-child(2n + 1) .lottery-item-inner {
+  background: #fef6e0a1;
+}
+
+.lottery-item:nth-child(2n) .lottery-item-inner {
+  background: #ffffffa1;
+}
+
+// TIPS: 与上面的--len一致
+@for $i from 1 through 8 {
+  .lottery-item:nth-child(#{$i}) {
+    transform: rotate(calc(($i - 1 - 0.5) * var(--deg)));
+  }
+}
+
+.pointer {
+  --pointer-size: 40px;
+  --pointer-padding: calc(var(--pointer-size) / 5);
+
+  position: absolute;
+  top: calc(var(--half) - var(--pointer-size) / 2 - var(--pointer-padding));
+  left: calc(var(--half) - var(--pointer-size) / 2 - var(--pointer-padding));
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: var(--pointer-size);
+  height: var(--pointer-size);
+  padding: var(--pointer-padding);
+  font-size: 12px;
+  text-align: center;
+  background-color: #ffffffd1;
+  border: 1px solid #ff5350;
+  border-radius: 50%;
+  transition: transform 3s cubic-bezier(0.2, 0.93, 0.43, 1);
+}
+
+.pointer::after {
+  --caret-size: 8px;
+
+  position: absolute;
+  bottom: calc(var(--pointer-size) + var(--pointer-padding) * 2);
+  left: calc(var(--pointer-size) / 2 - var(-caret-size) / 2);
+  content: '';
+  border-color: transparent;
+  border-style: solid;
+  border-width: calc(var(--caret-size) * 2) var(--caret-size);
+  border-bottom-color: #ff5350;
+  transform-origin: center;
+}
+</style>

+ 266 - 0
src/pages/demo/page/sign.vue

@@ -0,0 +1,266 @@
+<route lang="json5">
+{
+  layout: 'default',
+  style: { navigationBarTitleText: '签字板' },
+}
+</route>
+
+<template>
+  <view class="canvas-box flex flex-col box-border p-3" :class="{ 'full-screen': isFullScreen }">
+    <canvas
+      canvas-id="canvas"
+      class="w-full b b-dashed b-rd b-gray-300 canvas"
+      :disable-scroll="true"
+      @touchstart="touchStart"
+      @touchmove="touchMove"
+      @touchend="touchEnd"
+      @mousedown="touchStart"
+      @mousemove="touchMove"
+      @mouseup="touchEnd"
+    />
+    <view class="btns flex justify-between text-center box-border">
+      <view class="btn-box flex">
+        <view class="btn bg-gray-100 b-rd b-gray-300 c-gray-500 p-1" @click="handFullScreen">
+          {{ isFullScreen ? '退出全屏' : '全屏' }}
+        </view>
+        <view class="btn bg-gray-100 b-rd b-gray-300 c-gray-500 p-1 ml-2" @click="clear">清空</view>
+        <view class="btn bg-gray-100 b-rd b-gray-300 c-gray-500 p-1 ml-2" @click="withdraw">
+          撤回
+        </view>
+      </view>
+      <view class="btn-box flex">
+        <view class="btn bg-sky-500 b-rd c-white p-1" @click="save">保存</view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script lang="ts" setup name="sign">
+const isFullScreen = ref(false)
+const isSigned = ref(false)
+let ctx = null
+let isButtonDown = false
+let points = []
+let allPoints = []
+
+// 初始化画布
+function initCanvas() {
+  ctx = uni.createCanvasContext('canvas')
+  // 设置画笔样式
+  ctx.lineWidth = 4
+  ctx.lineCap = 'round'
+  ctx.lineJoin = 'round'
+}
+
+// 重设画板大小
+function onResize() {
+  initCanvas() // 重新初始化canvas
+}
+
+// 绘画
+function draw(w?) {
+  const point1 = points[0]
+  const point2 = points[1]
+
+  if (!w) {
+    allPoints[allPoints.length - 1].push(JSON.parse(JSON.stringify(points)))
+  }
+
+  points.shift()
+  ctx.moveTo(point1.X, point1.Y)
+  ctx.lineTo(point2.X, point2.Y)
+  ctx.stroke()
+  ctx.draw(true)
+  isSigned.value = true
+}
+
+// 触摸开始,获取到起点
+function touchStart() {
+  allPoints.push([])
+  ctx.beginPath() // 每次触摸开始,开启新的路径
+  isButtonDown = true
+}
+
+// 触摸移动,获取到路径点
+function touchMove(e) {
+  if (isButtonDown) {
+    let movePoint = {}
+    if (e.changedTouches[0].x) {
+      movePoint = { X: e.changedTouches[0].x, Y: e.changedTouches[0].y }
+    } else {
+      const X = e.changedTouches[0].pageX - e.currentTarget.offsetLeft
+      const Y = e.changedTouches[0].pageY - e.currentTarget.offsetTop
+      movePoint = { X, Y }
+    }
+    points.push(movePoint) // 存点
+    const len = points.length
+    if (len >= 2) {
+      draw() // 绘制路径
+    }
+  }
+}
+
+// 触摸结束,将未绘制的点清空防止对后续路径产生干扰
+function touchEnd() {
+  allPoints = allPoints.filter((e) => {
+    return e.length > 0
+  })
+  points = []
+  isButtonDown = false
+}
+
+// 清空, 传入true表示清空全部,不传传表示撤回一步
+function clear(reset?: boolean) {
+  if (reset) allPoints = []
+  ctx.clearRect(0, 0, 1000, 1000)
+  ctx.draw(true)
+  isSigned.value = false
+}
+
+// 全屏
+function handFullScreen() {
+  clear(true)
+  isFullScreen.value = !isFullScreen.value
+  const tid = setTimeout(() => {
+    onResize()
+    clearTimeout(tid)
+  }, 100)
+}
+
+// 撤回
+function withdraw() {
+  // 清除画布
+  clear()
+  if (allPoints.length <= 1) {
+    allPoints = []
+    points = []
+    return
+  }
+  // 删除最后一个路径
+  allPoints.pop()
+  // 循环路径,重新绘制
+  allPoints.forEach((e) => {
+    e.forEach((r) => {
+      points = JSON.parse(JSON.stringify(r))
+      draw(1)
+    })
+  })
+}
+
+// h5保存方法
+function saveCanvasAsImage(dataURL, imageName?) {
+  // 创建一个Image元素
+  const img = new Image()
+
+  // 设置img的src属性为数据URL
+  img.src = dataURL
+
+  // 创建一个链接元素用于下载图片
+  const link = document.createElement('a')
+
+  // 设置下载的文件名
+  link.download = imageName || 'canvas-image'
+
+  // 触发点击,下载图片
+  link.href = img.src
+  link.click()
+}
+
+// 保存
+const save = () => {
+  if (!isSigned.value) {
+    uni.showToast({
+      title: '请签名',
+      icon: 'none',
+    })
+    return
+  }
+
+  uni.canvasToTempFilePath({
+    canvasId: 'canvas',
+    success: (res) => {
+      // 获取图片路径
+      const { tempFilePath } = res
+      // 保存图片到相册
+      // #ifdef H5
+      const name = `sign-${new Date().getTime()}`
+      saveCanvasAsImage(tempFilePath, name)
+      // #endif
+
+      // #ifndef H5
+      uni.saveImageToPhotosAlbum({
+        filePath: tempFilePath,
+        success: () => {
+          uni.showToast({
+            title: '图片保存成功',
+          })
+        },
+        fail: (err) => {
+          console.error(err)
+
+          uni.showToast({
+            title: '图片保存失败',
+            icon: 'none',
+          })
+        },
+      })
+      // #endif
+    },
+    fail: () => {
+      uni.showToast({
+        title: '转换图片失败',
+        icon: 'none',
+      })
+    },
+  })
+}
+
+onMounted(() => {
+  initCanvas()
+})
+</script>
+
+<style lang="scss" scoped>
+$padding: 30rpx;
+
+.canvas-box {
+  .canvas {
+    height: 300rpx;
+    transition: height 0.3s;
+  }
+
+  .btns {
+    margin-top: 10rpx;
+    transition: transform 0.3s;
+
+    .btn {
+      width: auto;
+      height: 50rpx;
+    }
+  }
+}
+
+.full-screen {
+  flex-direction: row;
+  height: calc(100vh - 88rpx);
+
+  .canvas {
+    width: calc(100% - 100rpx);
+    height: 100%;
+    margin-left: 100rpx;
+  }
+
+  .btns {
+    position: absolute;
+    align-items: center;
+    width: calc(100vh - (88rpx + $padding * 2));
+    height: 100rpx;
+    transform: translate(100rpx, 0) rotate(90deg);
+    transform-origin: top left;
+
+    .btn-box {
+      flex-direction: row;
+    }
+  }
+}
+</style>

+ 39 - 0
src/pages/my/components/wx-login.vue

@@ -0,0 +1,39 @@
+<route lang="json5">
+{
+  style: { navigationBarTitleText: '登录' },
+}
+</route>
+
+<template>
+  <view class="p-4">
+    <view class="flex items-center leading-6" v-if="hasLogin">
+      <image class="w-8 h-8 rounded-full" :src="userStore.userInfo?.avatar"></image>
+      <view class="ml-2">{{ userStore.userInfo?.nickname }}</view>
+    </view>
+    <view class="flex items-center leading-6" v-else @click="show = true">
+      <view class="i-carbon-user-avatar"></view>
+      <view class="ml-2">点击显示微信头像</view>
+    </view>
+    <fly-login v-model="show" />
+    <fly-content :line="10" />
+    <button v-if="hasLogin" class="mt-2" @click="logout">退出登录</button>
+  </view>
+</template>
+
+<script lang="ts" setup name="WxLogin">
+import { useUserStore } from '@/store'
+
+const show = ref(false)
+const userStore = useUserStore()
+const hasLogin = computed(() => userStore.userInfo?.nickname)
+const logout = () => {
+  uni.showModal({
+    title: '确认退出当前账号?',
+    success: (res) => {
+      if (res.confirm) {
+        userStore.clearUserInfo()
+      }
+    },
+  })
+}
+</script>

+ 35 - 0
src/pages/my/index.vue

@@ -0,0 +1,35 @@
+<route lang="json5">
+{
+  style: { navigationBarTitleText: '我的' },
+}
+</route>
+<template>
+  <view class="ml-4">wx的openid: </view>
+  <view class="ml-4">{{ openId }}</view>
+  <wx-login />
+</template>
+
+<script lang="ts" setup>
+import { useUserStore } from '@/store'
+import { http } from '@/utils/http'
+import WxLogin from './components/wx-login.vue'
+
+const userStore = useUserStore()
+const openId = ref('')
+
+// 用户登录,获取openId
+uni.login({
+  provider: 'weixin',
+  success: async ({ code }) => {
+    const res = await http<{ session_key: string; openid: string }>({
+      method: 'GET',
+      url: '/weixin/jscode2session',
+      data: {
+        code,
+      },
+    })
+    openId.value = res.result.openid
+    userStore.setUserInfo({ openid: res.result.openid })
+  },
+})
+</script>

二进制
src/static/tabbar/example.png


二进制
src/static/tabbar/exampleHL.png


二进制
src/static/tabbar/home.png


二进制
src/static/tabbar/homeHL.png


二进制
src/static/tabbar/personal.png


二进制
src/static/tabbar/personalHL.png


+ 28 - 0
src/store/count.ts

@@ -0,0 +1,28 @@
+// src/store/useCountStore.ts
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+
+export const useCountStore = defineStore(
+  'count',
+  () => {
+    const count = ref(0)
+    const increment = () => {
+      count.value++
+    }
+    const decrement = () => {
+      count.value--
+    }
+    const reset = () => {
+      count.value = 0
+    }
+    return {
+      count,
+      decrement,
+      increment,
+      reset,
+    }
+  },
+  {
+    persist: true,
+  },
+)

+ 1 - 0
src/store/index.ts

@@ -16,3 +16,4 @@ export default store
 
 // 模块统一导出
 export * from './user'
+export * from './count'

+ 9 - 3
src/store/user.ts

@@ -1,24 +1,30 @@
 import { defineStore } from 'pinia'
 import { ref } from 'vue'
-import { UserInfo } from '../typings'
+import { IUserInfo } from '../typings'
+
+const initState = { nickname: '', avatar: '' }
 
 export const useUserStore = defineStore(
   'user',
   () => {
-    const userInfo = ref<UserInfo>({ nickname: '', avatar: '' })
+    const userInfo = ref<IUserInfo>({ ...initState })
 
-    const setUserInfo = (val: UserInfo) => {
+    const setUserInfo = (val: IUserInfo) => {
       userInfo.value = val
     }
 
     const clearUserInfo = () => {
       userInfo.value = undefined
     }
+    const reset = () => {
+      userInfo.value = { ...initState }
+    }
 
     return {
       userInfo,
       setUserInfo,
       clearUserInfo,
+      reset,
     }
   },
   {

+ 1 - 6
src/typings.d.ts

@@ -6,15 +6,10 @@ export type IResData<T> = {
   result: T
 }
 
-export type UserInfo = {
+export type IUserInfo = {
   nickname?: string
   avatar?: string
   /** 微信的 openid,非微信没有这个字段 */
   openid?: string
   token?: string
 }
-
-export type UserItem = {
-  username: string
-  age: number
-}

+ 2 - 2
src/utils/http.ts

@@ -1,7 +1,7 @@
 /* eslint-disable no-param-reassign */
 import qs from 'qs'
 import { useUserStore } from '@/store'
-import { IResData, UserInfo } from '@/typings'
+import { IResData, IUserInfo } from '@/typings'
 
 type CustomRequestOptions = UniApp.RequestOptions & { query?: Record<string, any> }
 
@@ -36,7 +36,7 @@ const httpInterceptor = {
     }
     // 4. 添加 token 请求头标识
     const userStore = useUserStore()
-    const { token } = userStore.userInfo as unknown as UserInfo
+    const { token } = userStore.userInfo as unknown as IUserInfo
     if (token) {
       options.header.Authorization = `Bearer ${token}`
     }

+ 13 - 0
src/utils/index.ts

@@ -1,5 +1,18 @@
+import pagesJson from '@/pages.json'
 import { translate as t } from '@/locale/index'
 
+console.log(pagesJson)
+
+/** 判断当前页面是否是tabbar页  */
+export const getIsTabbar = () => {
+  if (!Object.keys(pagesJson).includes('tabBar')) {
+    return false
+  }
+  const pages = getCurrentPages()
+  const currPath = pages.at(-1).route
+  return !!pagesJson.tabBar.list.find((e) => e.pagePath === currPath)
+}
+
 /**
  * test i18n in not .vue file
  */