初始化移动端提交

This commit is contained in:
chenbowen
2025-09-30 00:08:23 +08:00
parent 08784ca8f3
commit f2ffc65094
406 changed files with 55626 additions and 93 deletions

36
.env Normal file
View File

@@ -0,0 +1,36 @@
# 版本号
SHOPRO_VERSION=v2.4.1
# 后端接口 - 正式环境(通过 process.env.NODE_ENV 非 development
SHOPRO_BASE_URL=http://api-dashboard.yudao.iocoder.cn
# 后端接口 - 测试环境(通过 process.env.NODE_ENV = development
; SHOPRO_DEV_BASE_URL=http://127.0.0.1:48080
SHOPRO_DEV_BASE_URL=http://172.16.46.63:30081
### SHOPRO_DEV_BASE_URL=http://10.171.1.188:48080
### SHOPRO_DEV_BASE_URL = http://yunai.natapp1.cc
# 文件上传类型server - 后端上传, client - 前端直连上传,仅支持 S3 服务
SHOPRO_UPLOAD_TYPE=server
# 后端接口前缀(一般不建议调整)
SHOPRO_API_PATH=/admin-api
# 后端 websocket 接口前缀
SHOPRO_WEBSOCKET_PATH=/infra/ws
# 开发环境运行端口
SHOPRO_DEV_PORT=3000
# 客户端静态资源地址 空=默认使用服务端指定的CDN资源地址前缀 | local=本地 | http(s)://xxx.xxx=自定义静态资源地址前缀
SHOPRO_STATIC_URL=http://test.yudao.iocoder.cn
### SHOPRO_STATIC_URL = https://file.sheepjs.com
# 前端 H5 访问域名
SHOPRO_H5_URL=http://127.0.0.1:3000
# 是否开启直播 1 开启直播 | 0 关闭直播
SHOPRO_MPLIVE_ON=0
# 租户ID 默认 1
SHOPRO_TENANT_ID=1

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
unpackage/*
node_modules/*
.idea/*
deploy.sh
.hbuilderx/
.vscode/
**/.DS_Store
yarn.lock
package-lock.json
*.keystore
pnpm-lock.yaml

6
.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
/unpackage/*
/node_modules/**
/uni_modules/**
/public/*
**/*.svg
**/*.sh

10
.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"printWidth": 100,
"semi": true,
"vueIndentScriptAndStyle": true,
"singleQuote": true,
"trailingComma": "all",
"proseWrap": "never",
"htmlWhitespaceSensitivity": "strict",
"endOfLine": "auto"
}

32
App.vue Normal file
View File

@@ -0,0 +1,32 @@
<script setup>
import { onLaunch, onShow, onError } from '@dcloudio/uni-app';
import { ShoproInit } from './sheep';
onLaunch(() => {
// 隐藏原生导航栏 使用自定义底部导航
uni.hideTabBar({
fail: () => {},
});
// 加载Shopro底层依赖
ShoproInit();
});
onShow(() => {
// #ifdef APP-PLUS
// 获取urlSchemes参数
const args = plus.runtime.arguments;
if (args) {
}
// 获取剪贴板
uni.getClipboardData({
success: (res) => {},
});
// #endif
});
</script>
<style lang="scss">
@import '@/sheep/scss/index.scss';
</style>

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 lidongtony
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,93 +0,0 @@
# zt-uniapp
## Getting started
To make it easy for you to get started with GitLab, here's a list of recommended next steps.
Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)!
## Add your files
- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files
- [ ] [Add files using the command line](https://docs.gitlab.com/topics/git/add_files/#add-files-to-a-git-repository) or push an existing Git repository with the following command:
```
cd existing_repo
git remote add origin http://172.16.46.63:30001/gitlab/base-version/zt-uniapp.git
git branch -M main
git push -uf origin main
```
## Integrate with your tools
- [ ] [Set up project integrations](http://172.16.46.63:30001/gitlab/base-version/zt-uniapp/-/settings/integrations)
## Collaborate with your team
- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/)
- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html)
- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically)
- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/)
- [ ] [Set auto-merge](https://docs.gitlab.com/user/project/merge_requests/auto_merge/)
## Test and Deploy
Use the built-in continuous integration in GitLab.
- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/)
- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/)
- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html)
- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/)
- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html)
***
# Editing this README
When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template.
## Suggestions for a good README
Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information.
## Name
Choose a self-explaining name for your project.
## Description
Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors.
## Badges
On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge.
## Visuals
Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method.
## Installation
Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection.
## Usage
Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README.
## Support
Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc.
## Roadmap
If you have ideas for releases in the future, it is a good idea to list them in the README.
## Contributing
State if you are open to contributions and what your requirements are for accepting them.
For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self.
You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser.
## Authors and acknowledgment
Show your appreciation to those who have contributed to the project.
## License
For open source projects, say how it is licensed.
## Project status
If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers.

3
androidPrivacy.json Normal file
View File

@@ -0,0 +1,3 @@
{
"prompt" : "template"
}

17
index.html Normal file
View File

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

9
jsconfig.json Normal file
View File

@@ -0,0 +1,9 @@
{
"compilerOptions": {
"jsx": "preserve",
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}

18
main.js Normal file
View File

@@ -0,0 +1,18 @@
import App from './App';
import { createSSRApp } from 'vue';
import { setupPinia } from './sheep/store';
import uviewPlus from 'uview-plus';
export function createApp() {
const app = createSSRApp(App);
// 使用 uview-plus
app.use(uviewPlus);
setupPinia(app);
return {
app,
};
}

237
manifest.json Normal file
View File

@@ -0,0 +1,237 @@
{
"name": "移动端",
"appid": "__UNI__460BC4C",
"description": "基于 uni-app + Vue3 技术驱动的在线商城系统,内含诸多功能与丰富的活动,期待您的使用和反馈。",
"versionName": "2025.09",
"versionCode": "183",
"transformPx": false,
"app-plus": {
"usingComponents": true,
"nvueCompiler": "uni-app",
"nvueStyleCompiler": "uni-app",
"compilerVersion": 3,
"nvueLaunchMode": "fast",
"splashscreen": {
"alwaysShowBeforeRender": true,
"waiting": true,
"autoclose": true,
"delay": 0
},
"safearea": {
"bottom": {
"offset": "none"
}
},
"modules": {
"Payment": {},
"Share": {},
"VideoPlayer": {},
"OAuth": {}
},
"distribute": {
"android": {
"permissions": [
"<uses-feature android:name=\"android.hardware.camera\"/>",
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_MOCK_LOCATION\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.CALL_PHONE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
"<uses-permission android:name=\"android.permission.GET_TASKS\"/>",
"<uses-permission android:name=\"android.permission.INTERNET\"/>",
"<uses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\"/>",
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
"<uses-permission android:name=\"android.permission.READ_CONTACTS\"/>",
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.READ_SMS\"/>",
"<uses-permission android:name=\"android.permission.RECEIVE_BOOT_COMPLETED\"/>",
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
"<uses-permission android:name=\"android.permission.SEND_SMS\"/>",
"<uses-permission android:name=\"android.permission.SYSTEM_ALERT_WINDOW\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.WRITE_CONTACTS\"/>",
"<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SMS\"/>",
"<uses-permission android:name=\"android.permission.RECEIVE_USER_PRESENT\"/>"
],
"minSdkVersion": 21,
"schemes": "shopro"
},
"ios": {
"urlschemewhitelist": [
"baidumap",
"iosamap"
],
"dSYMs": false,
"privacyDescription": {
"NSPhotoLibraryUsageDescription": "需要同意访问您的相册选取图片才能完善该条目",
"NSPhotoLibraryAddUsageDescription": "需要同意访问您的相册才能保存该图片",
"NSCameraUsageDescription": "需要同意访问您的摄像头拍摄照片才能完善该条目",
"NSUserTrackingUsageDescription": "开启追踪并不会获取您在其它站点的隐私信息,该行为仅用于标识设备,保障服务安全和提升浏览体验"
},
"urltypes": "shopro",
"capabilities": {
"entitlements": {
"com.apple.developer.associated-domains": [
"applinks:shopro.sheepjs.com"
]
}
},
"idfa": true
},
"sdkConfigs": {
"speech": {},
"ad": {},
"oauth": {
"apple": {},
"weixin": {
"appid": "wxae7a0c156da9383b",
"UniversalLinks": "https://shopro.sheepjs.com/uni-universallinks/__UNI__082C0BA/"
}
},
"payment": {
"weixin": {
"__platform__": [
"ios",
"android"
],
"appid": "wxae7a0c156da9383b",
"UniversalLinks": "https://shopro.sheepjs.com/uni-universallinks/__UNI__082C0BA/"
},
"alipay": {
"__platform__": [
"ios",
"android"
]
}
},
"share": {
"weixin": {
"appid": "wxae7a0c156da9383b",
"UniversalLinks": "https://shopro.sheepjs.com/uni-universallinks/__UNI__082C0BA/"
}
}
},
"orientation": [
"portrait-primary"
],
"splashscreen": {
"androidStyle": "common",
"iosStyle": "common",
"useOriginalMsgbox": true
},
"icons": {
"android": {
"hdpi": "unpackage/res/icons/72x72.png",
"xhdpi": "unpackage/res/icons/96x96.png",
"xxhdpi": "unpackage/res/icons/144x144.png",
"xxxhdpi": "unpackage/res/icons/192x192.png"
},
"ios": {
"appstore": "unpackage/res/icons/1024x1024.png",
"ipad": {
"app": "unpackage/res/icons/76x76.png",
"app@2x": "unpackage/res/icons/152x152.png",
"notification": "unpackage/res/icons/20x20.png",
"notification@2x": "unpackage/res/icons/40x40.png",
"proapp@2x": "unpackage/res/icons/167x167.png",
"settings": "unpackage/res/icons/29x29.png",
"settings@2x": "unpackage/res/icons/58x58.png",
"spotlight": "unpackage/res/icons/40x40.png",
"spotlight@2x": "unpackage/res/icons/80x80.png"
},
"iphone": {
"app@2x": "unpackage/res/icons/120x120.png",
"app@3x": "unpackage/res/icons/180x180.png",
"notification@2x": "unpackage/res/icons/40x40.png",
"notification@3x": "unpackage/res/icons/60x60.png",
"settings@2x": "unpackage/res/icons/58x58.png",
"settings@3x": "unpackage/res/icons/87x87.png",
"spotlight@2x": "unpackage/res/icons/80x80.png",
"spotlight@3x": "unpackage/res/icons/120x120.png"
}
}
}
}
},
"quickapp": {},
"quickapp-native": {
"icon": "/static/logo.png",
"package": "com.example.demo",
"features": [
{
"name": "system.clipboard"
}
]
},
"quickapp-webview": {
"icon": "/static/logo.png",
"package": "com.example.demo",
"minPlatformVersion": 1070,
"versionName": "1.0.0",
"versionCode": 100
},
"mp-weixin": {
"appid": "wx66186af0759f47c9",
"setting": {
"urlCheck": false,
"minified": true,
"postcss": true
},
"optimization": {
"subPackages": true
},
"plugins": {},
"lazyCodeLoading": "requiredComponents",
"usingComponents": {},
"permission": {},
"requiredPrivateInfos": [
"chooseAddress"
]
},
"mp-alipay": {
"usingComponents": true
},
"mp-baidu": {
"usingComponents": true
},
"mp-toutiao": {
"usingComponents": true
},
"mp-jd": {
"usingComponents": true
},
"h5": {
"template": "index.html",
"router": {
"mode": "history",
"base": "/"
},
"sdkConfigs": {
"maps": {}
},
"async": {
"timeout": 20000
},
"title": "移动端",
"optimization": {
"treeShaking": {
"enable": true
}
}
},
"vueVersion": "3",
"_spaceID": "192b4892-5452-4e1d-9f09-eee1ece40639",
"locale": "zh-Hans",
"fallbackLocale": "zh-Hans"
}

105
package.json Normal file
View File

@@ -0,0 +1,105 @@
{
"id": "shopro",
"name": "shopro",
"displayName": "移动端",
"version": "2025.09",
"description": "移动端一套代码同时发行到iOS、Android、H5、微信小程序多个平台请使用手机扫码快速体验强大功能",
"scripts": {
"prettier": "prettier --write \"{pages,sheep}/**/*.{js,json,tsx,css,less,scss,vue,html,md}\""
},
"repository": "https://github.com/sheepjs/shop.git",
"keywords": [
"商城",
"B2C",
"商城模板"
],
"author": "",
"license": "MIT",
"bugs": {
"url": "https://github.com/sheepjs/shop/issues"
},
"homepage": "https://github.com/dcloudio/hello-uniapp#readme",
"dcloudext": {
"category": [
"前端页面模板",
"uni-app前端项目模板"
],
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "无",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "u",
"aliyun": "u"
},
"client": {
"App": {
"app-vue": "y",
"app-nvue": "u"
},
"H5-mobile": {
"Safari": "y",
"Android Browser": "y",
"微信浏览器(Android)": "y",
"QQ浏览器(Android)": "y"
},
"H5-pc": {
"Chrome": "y",
"IE": "y",
"Edge": "y",
"Firefox": "y",
"Safari": "y"
},
"小程序": {
"微信": "y",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
},
"Vue": {
"vue2": "u",
"vue3": "y"
}
}
}
},
"dependencies": {
"crypto-js": "^4.2.0",
"dayjs": "^1.11.7",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"luch-request": "^3.0.8",
"pinia": "^2.0.33",
"pinia-plugin-persist-uni": "^1.2.0",
"uview-plus": "^3.5.52",
"weixin-js-sdk": "^1.6.0"
},
"devDependencies": {
"prettier": "^2.8.7",
"vconsole": "^3.15.0"
}
}

92
pages.json Normal file
View File

@@ -0,0 +1,92 @@
{
"easycom": {
"autoscan": true,
"custom": {
"^s-(.*)": "@/sheep/components/s-$1/s-$1.vue",
"^su-(.*)": "@/sheep/ui/su-$1/su-$1.vue",
"^u-(.*)": "uview-plus/components/u-$1/u-$1.vue"
}
},
"pages": [{
"path": "pages/index/menu",
"aliasPath": "/",
"style": {
"navigationBarTitleText": "菜单",
"enablePullDownRefresh": true
},
"meta": {
"auth": true
}
},
{
"path": "pages/login/index",
"style": {
"navigationBarTitleText": "登录",
"enablePullDownRefresh": false
}
},
{
"path": "pages/index/user",
"style": {
"navigationBarTitleText": "我的",
"enablePullDownRefresh": true
},
"meta": {
"auth": true
}
},
{
"path": "pages/user/info",
"style": {
"navigationBarTitleText": "个人信息",
"enablePullDownRefresh": false
},
"meta": {
"auth": true
}
},
{
"path": "pages/public/about",
"style": {
"navigationBarTitleText": "关于我们",
"enablePullDownRefresh": false
}
},
{
"path": "pages/app/democontract/index",
"style": {
"navigationBarTitleText": "Demo Contract",
"enablePullDownRefresh": true
}
},
{
"path": "pages/app/democontract/form",
"style": {
"navigationBarTitleText": "Demo表单",
"enablePullDownRefresh": false
}
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "应用",
"navigationBarBackgroundColor": "#FFFFFF",
"backgroundColor": "#FFFFFF"
},
"tabBar": {
"color": "#999999",
"selectedColor": "#0055A2",
"borderStyle": "black",
"backgroundColor": "#ffffff",
"custom": true,
"list": [{
"pagePath": "pages/index/menu",
"text": "菜单"
},
{
"pagePath": "pages/index/user",
"text": "我的"
}
]
}
}

View File

@@ -0,0 +1,202 @@
<template>
<view class="form-container">
<u-form :model="formData" ref="formRef" :rules="rules" label-width="180rpx">
<u-form-item label="合同编号" prop="code">
<u-input v-model="formData.code" placeholder="请输入合同编号" />
</u-form-item>
<u-form-item label="合同名称" prop="name">
<u-input v-model="formData.name" placeholder="请输入合同名称" />
</u-form-item>
<u-form-item label="合同状态" prop="status">
<u-picker
v-model="formData.status"
:range="statusOptions"
range-key="text"
@change="onStatusChange"
>
<u-input
v-model="statusText"
disabled
placeholder="请选择合同状态"
suffix-icon="arrow-down"
/>
</u-picker>
</u-form-item>
<u-form-item label="签订日期" prop="signDate">
<uni-datetime-picker v-model="formData.signDate" type="date" />
</u-form-item>
<u-form-item label="合同开始日期" prop="startDate">
<uni-datetime-picker v-model="formData.startDate" type="date" />
</u-form-item>
<u-form-item label="合同结束日期" prop="endDate">
<uni-datetime-picker v-model="formData.endDate" type="date" />
</u-form-item>
<u-form-item label="合同金额" prop="amount">
<u-input v-model="formData.amount" placeholder="请输入合同金额" type="number" />
</u-form-item>
<u-form-item label="备注" prop="remark">
<u-textarea v-model="formData.remark" placeholder="请输入备注" />
</u-form-item>
<u-form-item label="岗位ID" prop="postId">
<u-input v-model="formData.postId" placeholder="请输入岗位ID" />
</u-form-item>
<!-- TODO: 附件上传 -->
</u-form>
<u-button
type="primary"
@click="submit"
class="submit-btn"
:loading="submitting"
:disabled="submitting"
>提交</u-button>
</view>
</template>
<script setup>
import { ref, reactive, computed, toRaw } from 'vue'
import DemoContractApi from '@/sheep/api/infra/democontract'
import { onLoad } from '@dcloudio/uni-app'
const formType = ref('create')
const formData = reactive({
id: undefined,
code: '',
name: '',
status: 0,
signDate: '',
startDate: '',
endDate: '',
amount: undefined,
remark: '',
postId: ''
})
const statusOptions = [
{ value: 0, text: '草稿' },
{ value: 1, text: '审核中' },
{ value: 2, text: '已通过' },
{ value: 3, text: '已拒绝' }
]
const statusText = computed(() => {
const option = statusOptions.find(item => item.value === formData.status)
return option ? option.text : '请选择合同状态'
})
const rules = {
code: [{ required: true, message: '合同编号不能为空', trigger: 'blur' }],
name: [{ required: true, message: '合同名称不能为空', trigger: 'blur' }],
amount: [{ required: true, message: '合同金额不能为空', trigger: 'blur' }],
postId: [{ required: true, message: '岗位ID不能为空', trigger: 'blur' }]
}
const formRef = ref(null)
const submitting = ref(false)
const formTitle = computed(() => {
return formType.value === 'create' ? '新增合同' : '修改合同'
})
onLoad((options) => {
if (options.type) {
formType.value = options.type
}
if (options.id) {
getDemo(options.id)
}
})
const getDemo = async (id) => {
const res = await DemoContractApi.getDemoContract(id)
if (!res || res.code !== 0) {
return
}
const data = res.data || {}
formData.id = data.id
formData.code = data.code || ''
formData.name = data.name || ''
formData.status = Number.isNaN(Number(data.status)) ? 0 : Number(data.status)
formData.signDate = data.signDate || ''
formData.startDate = data.startDate || ''
formData.endDate = data.endDate || ''
formData.amount = data.amount !== undefined && data.amount !== null ? String(data.amount) : ''
formData.remark = data.remark || ''
formData.postId = data.postId !== undefined && data.postId !== null ? String(data.postId) : ''
}
const onStatusChange = (e) => {
formData.status = statusOptions[e.detail.value].value
}
const validateForm = async () => {
if (!formRef.value || typeof formRef.value.validate !== 'function') {
throw new Error('表单未准备就绪')
}
try {
await formRef.value.validate()
return true
} catch (error) {
throw error
}
}
const buildPayload = () => {
const raw = toRaw(formData)
const payload = {
...raw,
status: Number(raw.status)
}
if (Number.isNaN(payload.status)) {
payload.status = 0
}
if (payload.amount !== undefined && payload.amount !== null && payload.amount !== '') {
const amountNumber = Number(payload.amount)
payload.amount = Number.isNaN(amountNumber) ? payload.amount : amountNumber
}
if (formType.value === 'create') {
delete payload.id
}
return payload
}
const submit = async () => {
if (submitting.value) {
return
}
try {
await validateForm()
} catch (error) {
return
}
const payload = buildPayload()
const apiFn = formType.value === 'create' ? DemoContractApi.createDemoContract : DemoContractApi.updateDemoContract
submitting.value = true
try {
const result = await apiFn(payload)
if (!result || result.code !== 0) {
return
}
uni.showToast({
title: formType.value === 'create' ? '新增成功' : '修改成功',
icon: 'success'
})
setTimeout(() => {
uni.navigateBack()
}, 600)
} finally {
submitting.value = false
}
}
</script>
<style lang="scss" scoped>
.form-container {
padding: 30rpx;
background: #fff;
}
.submit-btn {
margin-top: 30rpx;
}
</style>

View File

@@ -0,0 +1,192 @@
<template>
<view class="app-container">
<!-- 搜索工作栏 -->
<view class="search-section">
<u-search
placeholder="请输入合同名称"
v-model="queryParams.name"
:show-action="true"
action-text="搜索"
@custom="handleQuery"
@search="handleQuery"
></u-search>
<!-- 高级搜索 -->
<view class="advanced-search" v-if="showAdvanced">
<u-form :model="queryParams" label-width="120rpx">
<u-form-item label="合同编号">
<u-input v-model="queryParams.code" placeholder="请输入合同编号" />
</u-form-item>
<u-form-item label="合同金额">
<u-input v-model="queryParams.amount" placeholder="请输入合同金额" type="number" />
</u-form-item>
</u-form>
</view>
<view class="search-toggle">
<u-button type="info" size="small" @click="showAdvanced = !showAdvanced">
{{ showAdvanced ? '收起' : '高级搜索' }}
</u-button>
</view>
</view>
<!-- 操作工具栏 -->
<view class="action-bar">
<u-button type="primary" icon="plus" text="新增" @click="openForm('create')"></u-button>
</view>
<!-- 列表 -->
<u-list @scrolltolower="scrolltolower">
<u-list-item v-for="item in list" :key="item.id">
<u-cell :title="item.name" :label="item.code">
<template #value>
<view class="item-info">
<text class="info-text">状态: {{ getStatusText(item.status) }}</text>
<text class="info-text">金额: ¥{{ item.amount || 0 }}</text>
<text class="info-text" v-if="item.signDate">签订: {{ sheep.$helper.timeFormat(item.signDate, 'yyyy-mm-dd') }}</text>
</view>
</template>
<template #right-icon>
<view class="action-buttons">
<u-button type="primary" size="mini" @click="openForm('update', item.id)">编辑</u-button>
<u-button type="error" size="mini" @click="handleDelete(item.id)">删除</u-button>
</view>
</template>
</u-cell>
</u-list-item>
<u-loadmore :status="status" />
</u-list>
</view>
</template>
<script setup>
import sheep from '@/sheep'
import { ref, reactive, onMounted } from 'vue'
import DemoContractApi from '@/sheep/api/infra/democontract'
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
const loading = ref(false)
const total = ref(0)
const list = ref([])
const showAdvanced = ref(false)
const queryParams = reactive({
pageNo: 1,
pageSize: 10,
code: '',
name: '',
status: '',
signDate: [],
startDate: [],
endDate: [],
amount: '',
remark: '',
postId: ''
})
const status = ref('loadmore')
const getList = async () => {
loading.value = true
status.value = 'loading'
try {
const { data } = await DemoContractApi.getDemoContractPage(queryParams)
if (queryParams.pageNo === 1) {
list.value = data.list
} else {
list.value = list.value.concat(data.list)
}
total.value = data.total
if (list.value.length >= total.value) {
status.value = 'nomore'
} else {
status.value = 'loadmore'
}
} finally {
loading.value = false
uni.stopPullDownRefresh()
}
}
const handleQuery = () => {
queryParams.pageNo = 1
getList()
}
const openForm = (type, id) => {
let url = `/pages/app/democontract/form?type=${type}`
if (id) {
url += `&id=${id}`
}
uni.navigateTo({ url })
}
const handleDelete = async (id) => {
const [err, res] = await uni.showModal({
title: '提示',
content: '确定要删除吗?'
})
if (res && res.confirm) {
await DemoContractApi.deleteDemoContract(id)
uni.showToast({ title: '删除成功' })
handleQuery() // 重新加载列表
}
}
const getStatusText = (status) => {
// 根据实际状态值进行映射,这里需要根据字典或枚举值调整
const statusMap = {
0: '草稿',
1: '审核中',
2: '已通过',
3: '已拒绝'
}
return statusMap[status] || '未知'
}
const scrolltolower = () => {
if (status.value === 'loadmore') {
queryParams.pageNo++
getList()
}
}
onMounted(() => {
getList()
})
onPullDownRefresh(() => {
handleQuery()
})
onReachBottom(() => {
scrolltolower()
})
</script>
<style lang="scss" scoped>
.search-wrap {
padding: 20rpx;
background: #fff;
}
.action-bar {
padding: 20rpx;
}
.list-wrap {
.list-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx;
background: #fff;
border-bottom: 1rpx solid #eee;
}
.item-title {
font-size: 30rpx;
font-weight: bold;
}
.item-desc {
font-size: 26rpx;
color: #999;
margin-top: 10rpx;
}
}
</style>

452
pages/app/sign.vue Normal file
View File

@@ -0,0 +1,452 @@
<!-- 签到界面 -->
<template>
<s-layout title="签到有礼">
<s-empty v-if="state.loading" icon="/static/data-empty.png" text="签到活动还未开始" />
<view v-if="state.loading" />
<view class="sign-wrap" v-else-if="!state.loading">
<!-- 签到日历 -->
<view class="content-box calendar">
<view class="sign-everyday ss-flex ss-col-center ss-row-between ss-p-x-30">
<text class="sign-everyday-title">签到日历</text>
<view class="sign-num-box">
已连续签到 <text class="sign-num">{{ state.signInfo.continuousDay }}</text>
</view>
</view>
<view
class="list acea-row row-between-wrapper"
style="
padding: 0 30rpx;
height: 240rpx;
display: flex;
justify-content: space-between;
align-items: center;
"
>
<view class="item" v-for="(item, index) in state.signConfigList" :key="index">
<view
:class="
(index === state.signConfigList.length ? 'reward' : '') +
' ' +
(state.signInfo.continuousDay >= item.day ? 'rewardTxt' : '')
"
>
{{ item.day }}
</view>
<view
class="venus"
:class="
(index + 1 === state.signConfigList.length ? 'reward' : '') +
' ' +
(state.signInfo.continuousDay >= item.day ? 'venusSelect' : '')
"
>
</view>
<view class="num" :class="state.signInfo.continuousDay >= item.day ? 'on' : ''">
+ {{ item.point }}
</view>
</view>
</view>
<!-- 签到按钮 -->
<view class="myDateTable">
<view class="ss-flex ss-col-center ss-row-center sign-box ss-m-y-40">
<button
class="ss-reset-button sign-btn"
v-if="!state.signInfo.todaySignIn"
@tap="onSign"
>
签到
</button>
<button class="ss-reset-button already-btn" v-else disabled> 已签到 </button>
</view>
</view>
</view>
<!-- 签到说明 -->
<view class="bg-white ss-m-t-16 ss-p-t-30 ss-p-b-60 ss-p-x-40">
<view class="activity-title ss-m-b-30">签到说明</view>
<view class="activity-des">1.已累计签到{{ state.signInfo.totalDay }}</view>
<view class="activity-des">
2.据说连续签到第 {{ state.maxDay }} 天可获得超额积分要坚持签到哦~~
</view>
<view class="activity-des"> 3.积分可以在购物时抵现金结算的哦 ~~</view>
</view>
</view>
<!-- 签到结果弹窗 -->
<su-popup :show="state.showModel" type="center" round="10" :isMaskClick="false">
<view class="model-box ss-flex-col">
<view class="ss-m-t-56 ss-flex-col ss-col-center">
<text class="cicon-check-round"></text>
<view class="score-title">
<text v-if="state.signResult.point">{{ state.signResult.point }} 积分 </text>
<text v-if="state.signResult.experience"> {{ state.signResult.experience }} 经验</text>
</view>
<view class="model-title ss-flex ss-col-center ss-m-t-22 ss-m-b-30">
已连续打卡 {{ state.signResult.day }}
</view>
</view>
<view class="model-bg ss-flex-col ss-col-center ss-row-right">
<view class="title ss-m-b-64">签到成功</view>
<view class="ss-m-b-40">
<button class="ss-reset-button confirm-btn" @tap="onConfirm">确认</button>
</view>
</view>
</view>
</su-popup>
</s-layout>
</template>
<script setup>
import sheep from '@/sheep';
import { onReady } from '@dcloudio/uni-app';
import { reactive } from 'vue';
import SignInApi from '@/sheep/api/member/signin';
const headerBg = sheep.$url.css('/static/img/shop/app/sign.png');
const state = reactive({
loading: true,
signInfo: {}, // 签到信息
signConfigList: [], // 签到配置列表
maxDay: 0, // 最大的签到天数
showModel: false, // 签到弹框
signResult: {}, // 签到结果
});
// 发起签到
async function onSign() {
const { code, data } = await SignInApi.createSignInRecord();
if (code !== 0) {
return;
}
state.showModel = true;
state.signResult = data;
// 重新获得签到信息
await getSignInfo();
}
// 签到确认刷新页面
function onConfirm() {
state.showModel = false;
}
// 获得个人签到统计
async function getSignInfo() {
const { code, data } = await SignInApi.getSignInRecordSummary();
if (code !== 0) {
return;
}
state.signInfo = data;
state.loading = false;
}
// 获取签到配置
async function getSignConfigList() {
const { code, data } = await SignInApi.getSignInConfigList();
if (code !== 0) {
return;
}
state.signConfigList = data;
if (data.length > 0) {
state.maxDay = data[data.length - 1].day;
}
}
onReady(() => {
getSignInfo();
getSignConfigList();
});
</script>
<style lang="scss" scoped>
.header-box {
border-top: 2rpx solid rgba(#dfdfdf, 0.5);
}
// 日历
.calendar {
background: #fff;
.sign-everyday {
height: 100rpx;
background: rgba(255, 255, 255, 1);
border: 2rpx solid rgba(223, 223, 223, 0.4);
.sign-everyday-title {
font-size: 32rpx;
color: rgba(51, 51, 51, 1);
font-weight: 500;
}
.sign-num-box {
font-size: 26rpx;
font-weight: 500;
color: rgba(153, 153, 153, 1);
.sign-num {
font-size: 30rpx;
font-weight: 600;
color: #ff6000;
padding: 0 10rpx;
font-family: OPPOSANS;
}
}
}
// 年月日
.bar {
height: 100rpx;
.date {
font-size: 30rpx;
font-family: OPPOSANS;
font-weight: 500;
color: #333333;
line-height: normal;
}
}
.cicon-back {
margin-top: 6rpx;
font-size: 30rpx;
color: #c4c4c4;
line-height: normal;
}
.cicon-forward {
margin-top: 6rpx;
font-size: 30rpx;
color: #c4c4c4;
line-height: normal;
}
// 星期
.week {
.week-item {
font-size: 24rpx;
font-weight: 500;
color: rgba(153, 153, 153, 1);
flex: 1;
}
}
// 日历表
.myDateTable {
display: flex;
flex-wrap: wrap;
.dateCell {
width: calc(750rpx / 7);
height: 80rpx;
font-size: 26rpx;
font-weight: 400;
color: rgba(51, 51, 51, 1);
}
}
}
.is-sign {
width: 48rpx;
height: 48rpx;
position: relative;
.is-sign-num {
font-size: 24rpx;
font-family: OPPOSANS;
font-weight: 500;
line-height: normal;
}
.is-sign-image {
position: absolute;
left: 0;
top: 0;
width: 48rpx;
height: 48rpx;
}
}
.cell-num {
font-size: 24rpx;
font-family: OPPOSANS;
font-weight: 500;
color: #333333;
line-height: normal;
}
.cicon-title {
position: absolute;
right: -10rpx;
top: -6rpx;
font-size: 20rpx;
color: red;
}
// 签到按钮
.sign-box {
height: 140rpx;
width: 100%;
.sign-btn {
width: 710rpx;
height: 80rpx;
border-radius: 35rpx;
font-size: 30rpx;
font-weight: 500;
box-shadow: 0 0.2em 0.5em rgba(#ff6000, 0.4);
background: linear-gradient(90deg, #ff6000, #fe832a);
color: #fff;
}
.already-btn {
width: 710rpx;
height: 80rpx;
border-radius: 35rpx;
font-size: 30rpx;
font-weight: 500;
}
}
.model-box {
width: 520rpx;
// height: 590rpx;
background: linear-gradient(177deg, #ff6000 0%, #fe832a 100%);
// background: linear-gradient(177deg, var(--ui-BG-Main), var(--ui-BG-Main-gradient));
border-radius: 10rpx;
.cicon-check-round {
font-size: 70rpx;
color: #fff;
}
.score-title {
font-size: 34rpx;
font-family: OPPOSANS;
font-weight: 500;
color: #fcff00;
}
.model-title {
font-size: 28rpx;
font-weight: 500;
color: #ffffff;
}
.model-bg {
width: 520rpx;
height: 344rpx;
background-size: 100% 100%;
background-image: v-bind(headerBg);
background-repeat: no-repeat;
border-radius: 0 0 10rpx 10rpx;
.title {
font-size: 34rpx;
font-weight: bold;
color: var(--ui-BG-Main-TC);
}
.subtitle {
font-size: 26rpx;
font-weight: 500;
color: #999999;
}
.cancel-btn {
width: 220rpx;
height: 70rpx;
border: 2rpx solid #ff6000;
border-radius: 35rpx;
font-size: 28rpx;
font-weight: 500;
color: #ff6000;
line-height: normal;
margin-right: 10rpx;
}
.confirm-btn {
width: 220rpx;
height: 70rpx;
background: linear-gradient(90deg, #ff6000, #fe832a);
box-shadow: 0 0.2em 0.5em rgba(#ff6000, 0.4);
border-radius: 35rpx;
font-size: 28rpx;
font-weight: 500;
color: #ffffff;
line-height: normal;
}
}
}
//签到说明
.activity-title {
font-size: 32rpx;
font-weight: 500;
color: #333333;
line-height: normal;
}
.activity-des {
font-size: 26rpx;
font-weight: 500;
color: #666666;
line-height: 40rpx;
}
.reward {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEsAAAA4CAYAAAC1+AWFAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA3ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQyIDc5LjE2MDkyNCwgMjAxNy8wNy8xMy0wMTowNjozOSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDowYWFmYjU3Mi03MGJhLTRiNDctOTI2Yi0zOThlZDkzZDkxMDkiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6RUEyNzZEMjZEMDFCMTFFOEIzQzhEMjMxNjI1NENDQjciIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6RUEyNzZEMjVEMDFCMTFFOEIzQzhEMjMxNjI1NENDQjciIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTggKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6ZmRjNTM0MmUtNmFkOC1iMDRhLThjZTEtMjk2YWYzM2FkMmUxIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjBhYWZiNTcyLTcwYmEtNGI0Ny05MjZiLTM5OGVkOTNkOTEwOSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PuGQ9m8AAAf3SURBVHja7Jxbb9xEFMe9Xu81u6VJL0kqSCToZ0CCNyp4CQ0FqVAkQAgeEE98EPgEwBOCSoCEQPTCG30BVSpfAaQ2KQ1tc4Fm77v2cv7jmc3UGdvjsTdrJEY68m527Zn5zZlzszeF8XhspWl37/xhZdwK/Di2jqA9ufqM9ndtKz+tTvIpSYdkl7+u52h8lpMjUFdIzvH3VZIPSM6SrHOAM292DkHJ7Rz/rP4/rGhQuQNm5xxUroAVNm//PhUnQ7JEco9LGlBy+znGhsX1O1vNajYbjwm1F0l+JfmN5BeSlwKfx4KybTtWw6TriXao39xpFga8v98q08tPSN7Dn0yvVSqVrEqlbBUKBQvxX78/sIbDoenlxhzs6ySDPNmsj0k+SguqWq0wUGwV6Yj35XIpTXD7Csk3eTPw76c5GUAARtUqlVTA0C5wOzd7WLQF19JoFEAASFRLCQwa9hXJ2rQj+GdJ3sCcQj4vkrw7TVAyMLTBwMiGHSP5luQLEjfkO0O+ZW+ZGPjnuIE8YQoDEyyVnIkdmmaDQxgOR+QU+mku85DkZe5RE23D19KCguYcBSjhEHxNLStBarZTJBdNtuGcqlNVx6q4CBo1iwavipDjsUk6Rdq+I0u1brZdkKpCrDVMYLlBUMVikQ0mGM4EBydWeiYpiaLfWq1GwEbKUAwQPc+Vz3NTlWgEqHq9xo7BpoKVpwbNDwtNbHto9XpdAjaOXWBtWNhWAOV53lQnBiPdarWs+fnjmRr/MLtVLjvUp2O57tCK2wwJ4qxCyECyhXX//n3r3tZWxp4yel4+pPiJRMHydAbR7XYzm5Trutbu3p41pFjq0aNHmV0XY0ywqJ4JrIoOKEwwq7a9s2M5RYe5/+3tnUwXIQGwSlJYyKfeMgVlkprAFu7t/c3OLZfLLLhst9vGmUEKYG/x+WvBQgT7ZTDfE8ZPB5RuCiO33d095o0cx2HhCeThw+1UAbEusIAXbPL5r8XBWuf5UVP2JNgWGLzrelMBhT72yFaVCFSRBK6+SNux1+sxmSYwaDQ0GfOTPGaTc1gPyw2R3nwdTJoRMiCow4U6HXNQKOJ1uz0KAvssGR4MBuw4Gg2ZHyqX/O0HzRKTwHcQw/mhCwLisj8x9EVH9FetViOhYTurkm+EQZgXlAqahf4wvtFoJGsaAshLJD/IcdYaJ1mSV9uvM/mD6fX6oaAANE6jcO6dOxtsUEVKP+yCzVINaJJDAwckTEAu/mG1cRyNXBZlQ8vanY7lkYa77shaWVmJhYVxiSQ7OB6ARLCKz6HNtVqVaR364+Mocy6vkvwEzUKR/6blF/snoDB4ROyixLu/3wodUKMxp5XeQLs2NzdZtGwXAazABolzZZHH4SGgJMCAjF0CaJClpSWr2Wxqb/NWS+0s5Dq+r2Eu30GePJZNkuehWSsyKFmrsPKYWFwqoZsHQlNWV1etra2/2ORlUGF5XhGLxaFiXK5XsM6cXmZbKEm+iPOD2UewXz+tI013SqRdfTmifwqMYOA3SO7GlTUOJ9AHBjLJwyXYamfOLDO7k+Q88c3lpcVEoCYaqkjThH3USI3AZ8PmLz7klcIJcRhFWRWx91VJtDCiSRPbpcVFZqy9iLxNngBGsUigTLytanyYS/Bavn0cMacjadWQ87knQodr3OoPxEm+i+2xFfG3il/qUAEzqVDimsePP0HG2o2EJbRibq7OPKYJqKBxlz2hvIAHc55UIIQ3vBaMs77nVcK2TBkns5WNAQZNTAoM13Y1qhjCrZuACoYNKlAHc+1OlINzuMi5KIPSK5xkS1wEHgzeQUfDkgLr9roJwHanBgoaFfCALc7hSly6A5V7h2RfqCc6hSofxEDZAOt1+4e2GzTN46/F9hQrr5u0q0AdhEKHzQGCXym22ufzv6abSCNivRzMC4O5ol+udZTA9Ap9AxZrjflWw7VOnzrJPF61WmNwvIkJsLW1K9g/dod4LCDchk5eXhYRe5JKaax6IA7DILDqJvZEBJ0O+br5hXlmxEU7eXKBJt2gnPEf2iIdNhlkEY1Gw6D6Opgshka40jcpK8dWUYXhN2mwEdCWEwsL1rFjTeV1EFosLp5ixv3BgwcMmsndueXlZV9L9UridqoafFwIYNKQ0509+3TU40WThpxtdXWFwzKrwWdxtykBrPFkb2dRdweApK1erxvDCltoP2IPv8eQGJbwiOK+4YxuCabS/jDNQgkINldDwSNhFVWBIaDJ/YatWlaqn4UWAYbqoTh8FZ8Fxlo0gdUOrg6LgxSxjsruIC5L+VyVUVNBYZmCMkYrMI0KLGrLxOMhzN9RlTqCEhUYjsfjI9MoPyAeKNMl1bgVzzmg6P+diWbd5BXUS3IFVbFV8XzWXBiwqGg+ac0+rESs6bGxU1I9nxVn4G9FnSylR1e13ElIpK0DzBQUb0hh3iS5nkZ7s3i0+zqHZdR0csmUoJBevJ0WVCaweLuYZjBRwFKC8jioH7OYZCawSLsGJLg5i/tswWwX79fp8wKE27cbKmDI/YRDwBHvFaBw7px0vUJEvxesHD7aLaBd5QPc4H+6jff87+I7yFnOq4DB7eMuTLvdYbfuFWEAzjnPryE3Zb9pzIPSUUzxtzvLJH9a4b+hqfPJvKB5zRsccidlv+awZvyzX11gOqDMVvU/9LPf0C15FKBmarOmACw3oPICSwb2OQlu4+Cxv8/yBCoPNitsAcdWDv9Vwb8CDACdCFE+P8dk8gAAAABJRU5ErkJggg==');
width: 75rpx;
height: 56rpx;
}
.rewardTxt {
width: 74rpx;
height: 32rpx;
background-color: #f4b409;
border-radius: 16rpx;
font-size: 20rpx;
color: var(--ui-BG-Main-TC);
line-height: 32rpx;
text-align: center;
padding: 2rpx;
}
.venus {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA3ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQyIDc5LjE2MDkyNCwgMjAxNy8wNy8xMy0wMTowNjozOSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDowYWFmYjU3Mi03MGJhLTRiNDctOTI2Yi0zOThlZDkzZDkxMDkiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6RDQ3N0E3RkJEMDFCMTFFODhGODVBOTc1NEM5Nzg2NzkiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6RDQ3N0E3RkFEMDFCMTFFODhGODVBOTc1NEM5Nzg2NzkiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTggKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6ZmRjNTM0MmUtNmFkOC1iMDRhLThjZTEtMjk2YWYzM2FkMmUxIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjBhYWZiNTcyLTcwYmEtNGI0Ny05MjZiLTM5OGVkOTNkOTEwOSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PnZClGIAAAT7SURBVHja7JtLcxtFEMdnn1rJUhIrTiS7KLsK/EXwNck3gCMcuUFx4QvACW48buHGMZxJPkiowpjC2LElcKzXSqul/6OdoOB9zezsCCimqmvXtdqd+U33dPf0rq2Tn54zg81KjrGuB75x8FbuddsQWIvkS5IxySA5b5no2DUE94TkKPk7IHmf5JDkUQJdW7MNw623o+Ra698KmAdnDNLeIJwRSHvDcLVDVnYynU771fnLl9eFcLZts+VymQf5iJ6pzfFYJeKgT/IZyTskXdWOPM9jjYbPLMticRyz2Sxk8/lc9XFDksckH1IcDKtq8FOSD6rMIuCCoPHXrBIk/qYDC0MlyO1kTBOSj6uuwXerwPn+63DrrdFo8OsV2ns6nEy3Chwg8lpFyK4OwNrgNGpSvxfFoDzP5etJR8PzsiYETmk+X5BjmpkBrHPGU109TeKqv5X3rT3QQ3ObaPDGRjIZXWZpol+b/cebUUA4iuHw938UoNbk9+zsjP16eqoV4JfjH1uqgLjxe10DiaKIDYZDNqfU7OrqSifjkzxIuwDubV2juLi8ZK7j8oT74uJSJ+BRHqQtC6cS/7A9wtrDvb7v84A9Go2UMyQZSFsWrmz6td4GgyF38a7r8lgGefHiQjnjkYG064ZDmjWktecRnEOCDa9DpjqdTrnUDSk2vJXgsHGdTKa0t5vx/V0Yhvy4WMx5hdf3VqYJDQqTxW+QdmECPM8h8flvPPRFR/QXBEEuKEw9Yz/5AyoDtBkei5zriyw4pGVFmoOHPD7+mQ/ccR1mWzZpyuIacx2Hgzl0FJkIjjBTHBeLiO6LuDZH4zFbRkt63oLt7+8XAmJcIhFP0eTn2C9CgzBTrPjUp7XbW6VSJGjx5OSEBhsz2wGkxc0R967LuukuSWKaFEwMnXJQSL/fZ51Op/QSuL5OdVjY7beFBq2sAlHZ/A8aOTg4YKenv/EBr8Nl5ZUO6jPJRGCg0dJie/d3WbPZlMpPMwpZsN0Y2sOVb7PcOzou22CGe3u7fB3J3Cd+udvvScG9soT0Kt13tAZj4UVRwHmWtZClcj+azX6vxx0GN8ECUFyHjnsEp+KlM8b3TBTKBCDqkA/SIFV20jCbO3duk8OIcgHF7G9ttbinVYFLcTBgeAAP+vc4mAkJVywLibARpZvOjWWA36rApYSI1+DSMhltkJPpRGIyJrXAZeWiWiCnk9kNU4RGl8m5MN1VLFzwWKobLm83kQtZbnMb8lgYJ2aIYH//3g73lEHQ5ECAXYUSu7QWZeCKqmoCcqSyPkQgd8lHbne3uSMRbWenSwNtU476BxtT9oJQOZ3OKKloq6SmmXBlyoZKb3nG4wnXyt1ul9261UkN9ggjvd497mDOz885KGN3pfvKg1OuixY15JCHh2/ymFjUms2AMqD9BFB/qwUQg5ZtrVY9b7H/LxtmZSCbaCr9KgGmpEeG6qpzM2tQBHudb5eKNLfKiUMzgAIyL6uRreHklB9qX4MDlQfLpHUV4AY6AB+rzl4ZyIqa+0aHiX6UlDTwMcK2CqQoEGmEE5+RfFK4N636vWjKh0Cp5ceS38k8JXko8yHQ7W7PXKBPBvYwGegNF4/q12g05mV7HXClqgs1ffEr+/LmaTIx0nCb+uI3U5M64Tadi5aBrBXORLKdB1k7nKndhID8GqUaErze/coEXJ1OJm9CY2bw3wr+FGAAoa6PIUihovYAAAAASUVORK5CYII=');
background-repeat: no-repeat;
background-size: 100% 100%;
width: 56rpx;
height: 56rpx;
margin: 10rpx auto;
}
.venusSelect {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADgAAAA4CAYAAACohjseAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA3ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQyIDc5LjE2MDkyNCwgMjAxNy8wNy8xMy0wMTowNjozOSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDowYWFmYjU3Mi03MGJhLTRiNDctOTI2Yi0zOThlZDkzZDkxMDkiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6QzkwRkI4NEFEMDFCMTFFODhDNDdBMDVGOTBBN0U2NTQiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6QzkwRkI4NDlEMDFCMTFFODhDNDdBMDVGOTBBN0U2NTQiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTggKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6ZmRjNTM0MmUtNmFkOC1iMDRhLThjZTEtMjk2YWYzM2FkMmUxIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjBhYWZiNTcyLTcwYmEtNGI0Ny05MjZiLTM5OGVkOTNkOTEwOSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PkX00M0AAAfVSURBVHjaxFpdcBNVFD67STbppn9poU0LtSBYykBlgKrjqKOtihbq8ICKILyJwxM+6fjCk2/6pC+O4hs+6fgAbWFw1OID+ICMAwwKlRmKLW3Tpvlrskk3m13Pvdm0NNn//HA73+ymd3Pv+e4595xzT5bJ3DgJNWyMelUqNaD36a8N+9kaEeMR3yAERES952sxsbtya2lIbgQxqH72IT5EbEW8pZKuHkGlugyLyT3aBtW+qpJkKb/qgEeMIAYNnhlUn+Edz2NuokqtNVdTTbqVyhO0Q67qJMt2MnXbTq/cp+9+ZkyOYYFxN4EiLaF5SbokccyKkWRS1z4we4ZDfIE4hmhxvJLNu8HTtg85ekGRRcjOXwIp9pfT4aKIs4iP+f4zYrl78HPEqbLMJNAPXHBI/SQjSTd+PoD3LpCi15wMGVBlSiM+NfSihJ8Jjlt4Rheu5n7wtL+B93IJPO37aH8Z45+ohBdtKUtz7a+jJLK+/dN+BTX5p5MpWh6HF6XNE9iLwr9mSG6VZP65bPR6NVI1BwQZF3BtA+Bq2oG3Pt3HFCVnfUHaX6XQHCeXgVz8Nojz4+SDzTgo2yfIBV9G89utzi5DtRvDcnQ+JSciyd+rH+hdjb22tFOp5mreCUrocg0CPet5LATJvHbldWSiGllIzZpdeR2ZqFPtZTNJSIQeQGv3DucEbcrLmkRSXvP/cs4RZm6Ow/T1McffpyiSJTZ+jDfOZFDlOuARo5p9aKJ2IYkpCN+9AmLsIcSm/3Y0BkWpPCPRX9/nDVI1BTTAI0YRA9r99gWbv3MF90MGvG4WQrfHnRMslWcQMRL55SivbaJk064FjxhFDGj0gad5u22zkrMZiExcBQ7JcW4GlhfuQ3L+gSMTzc9fItcgYiTy8xHeTIOGmnMHtoEn2G971cP3MAUTk+B2MeBBkh4kGbp92ZEGyfxEDj1NLl56j9fbg4U9N6C179zNPZhK7cV7yRZkSYTI3atIjgUXywCLINf03L8gRGZtj0dA5CDyaMg5SPZk+OLhFZLMwtg7hTLDKGJAM09s6QGuY6+upxLTKcgkFmE5FUdFIYQEiHgv4VURl4BjgZon0SA9EeKKi1kZliU8Nnn84OabwIPw+huB8zdR+OqbwdcYMAwB4ux1yEYmtLp+I5WBdft/EApx8Cs9cu6mblyxXXTl9FpOFGDyjzGQMwlwIQlUEDAMagqvhBTVHl4ZplDezpsq+UdOTkMuIcByfBYElCWHmsgpLHQ/NwTe+gaTBH0XWkgGpPgDrfLHl4gTTOj8IVLdTqkF2aIslwF+2zCeGDjzIJ5OwtSVc6Cko9QMCUmWyZMiZJn8cI8E7Lwm11wJOTypBPuHoCG4yVrgz2VBmFBj69pGTvv1hVSN0XSxHE8LRUbaW9G01wdPvHgQQtcugJwMU5LFpIrWDjXMUGIsMNRR5DwcBPcMQV1L0NKchYHIkU2WkiVrTvgT6XEjyN/TY08R5KyAaWc6n3tagMvjhuCzQ+jlOmylVPRZjw/an9kPdYF1lucjIPLJmERoyP9j+8GflIIXPYW4XOKVJAmWZ27Y8nAMaq5t9yB4WjeumJ4hOaIErx/W978JXH2TbY9K5ZNKMhzC5dSjyTapQ5IyFxozvLJGz9Hp/Am+Y7utH8kan+yDhYVp8lVgdDWX33f+ji3gruNpnLPTxNl/8vKtbeTAeKDj0DmhuGShT3Jxkp4guGCv5cnT0QX0hgq4dOnBSshIxxehUbZJbu4OSJEpbXJvnxf0zoP6JMP/keVGTfZYEiCDQlttmXjE1hlTnJ3A+Ketuc53RwSz86AuSXFxitYzueBWUyGWE9E1pqiozoRhXdQJMLAaEyUMMdJyGp2Ux4Lm7iG5h5rkNhweFUpLFtonZANNTmOA3WzmFiGLGY3XlSel0DpOCzRs6gWXj4fkgwnIhKZIcKDhgsTLTCICfMs683gb1tbchiNjgt0TfYFkym7JQkyl8nkoixkMesiGzT1Q19q20t+0dTvwnU/A0iQSDYeoNinBQLOTE/2BjUcvCE5rMoJ2XcSYYBr3FOPmoHHLU+APdtJMpvg7bp8XAr19mKJ1weK9u5CJxUDpMt+HxfJ2HbsoVLzwa1aT4fw8dD3/Au43lv7YYjQF19gAHXv6UYMJa7UepQZFJzMT9fp9lJidorCvgbfkSRXbBB2UDRX5MdREnZYNwZECRcxQXLUnl8s5KPw6MNFsdBFzzdaaE8xGwrUx0eXZORrw3c1NNdEk0ZwUi2OQn7fvZBz9fEZKDjNzFLqn7dYApnVtNtKvecx5oxVfHCsmSt4ts/0rrxiO5NO6jvUWyC0guZgT+SPllu4Jzjr9AT0bjqKWQ1qH0RWQfvKcwzm+s6BB01X6RC1pHIf82w02NRmnsng7WjX28iJqLu5Ec4XXSE5XYg+S91A+UlHS/H2riXfq1n3N8mM2HKOOgutsodkNqZKIMxGQokvFw40jhnFMoZZ70CzyrpLd2S0kb00Oa5KMJDC8LAHL4QEmK4HGKYaSq+/bJFTyZ/GyX+VK3pzUStA1SRJrkTNZrWHG1e8IGuMZt5eqrUH9U8iwUbVci1w1BKnW65RWSVaVnBomAKoI3E9IQEEipX3jap9Q1hxmBElBocp/AmIYcQaRQSQQ36r/E8odvepOxoa5khfRT1pf+8q0/wUYAFU/P0XyeZQPAAAAAElFTkSuQmCC');
}
.num {
font-size: 36rpx;
font-family: 'Guildford Pro';
}
.item {
align-items: center;
justify-content: space-between;
flex-direction: column;
height: 130rpx;
}
.reward {
background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEsAAAA4CAYAAAC1+AWFAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAA3ZpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuNi1jMTQyIDc5LjE2MDkyNCwgMjAxNy8wNy8xMy0wMTowNjozOSAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wTU09Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9tbS8iIHhtbG5zOnN0UmVmPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvc1R5cGUvUmVzb3VyY2VSZWYjIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDowYWFmYjU3Mi03MGJhLTRiNDctOTI2Yi0zOThlZDkzZDkxMDkiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6RUEyNzZEMjZEMDFCMTFFOEIzQzhEMjMxNjI1NENDQjciIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6RUEyNzZEMjVEMDFCMTFFOEIzQzhEMjMxNjI1NENDQjciIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTggKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6ZmRjNTM0MmUtNmFkOC1iMDRhLThjZTEtMjk2YWYzM2FkMmUxIiBzdFJlZjpkb2N1bWVudElEPSJ4bXAuZGlkOjBhYWZiNTcyLTcwYmEtNGI0Ny05MjZiLTM5OGVkOTNkOTEwOSIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PuGQ9m8AAAf3SURBVHja7Jxbb9xEFMe9Xu81u6VJL0kqSCToZ0CCNyp4CQ0FqVAkQAgeEE98EPgEwBOCSoCEQPTCG30BVSpfAaQ2KQ1tc4Fm77v2cv7jmc3UGdvjsTdrJEY68m527Zn5zZlzszeF8XhspWl37/xhZdwK/Di2jqA9ufqM9ndtKz+tTvIpSYdkl7+u52h8lpMjUFdIzvH3VZIPSM6SrHOAM292DkHJ7Rz/rP4/rGhQuQNm5xxUroAVNm//PhUnQ7JEco9LGlBy+znGhsX1O1vNajYbjwm1F0l+JfmN5BeSlwKfx4KybTtWw6TriXao39xpFga8v98q08tPSN7Dn0yvVSqVrEqlbBUKBQvxX78/sIbDoenlxhzs6ySDPNmsj0k+SguqWq0wUGwV6Yj35XIpTXD7Csk3eTPw76c5GUAARtUqlVTA0C5wOzd7WLQF19JoFEAASFRLCQwa9hXJ2rQj+GdJ3sCcQj4vkrw7TVAyMLTBwMiGHSP5luQLEjfkO0O+ZW+ZGPjnuIE8YQoDEyyVnIkdmmaDQxgOR+QU+mku85DkZe5RE23D19KCguYcBSjhEHxNLStBarZTJBdNtuGcqlNVx6q4CBo1iwavipDjsUk6Rdq+I0u1brZdkKpCrDVMYLlBUMVikQ0mGM4EBydWeiYpiaLfWq1GwEbKUAwQPc+Vz3NTlWgEqHq9xo7BpoKVpwbNDwtNbHto9XpdAjaOXWBtWNhWAOV53lQnBiPdarWs+fnjmRr/MLtVLjvUp2O57tCK2wwJ4qxCyECyhXX//n3r3tZWxp4yel4+pPiJRMHydAbR7XYzm5Trutbu3p41pFjq0aNHmV0XY0ywqJ4JrIoOKEwwq7a9s2M5RYe5/+3tnUwXIQGwSlJYyKfeMgVlkprAFu7t/c3OLZfLLLhst9vGmUEKYG/x+WvBQgT7ZTDfE8ZPB5RuCiO33d095o0cx2HhCeThw+1UAbEusIAXbPL5r8XBWuf5UVP2JNgWGLzrelMBhT72yFaVCFSRBK6+SNux1+sxmSYwaDQ0GfOTPGaTc1gPyw2R3nwdTJoRMiCow4U6HXNQKOJ1uz0KAvssGR4MBuw4Gg2ZHyqX/O0HzRKTwHcQw/mhCwLisj8x9EVH9FetViOhYTurkm+EQZgXlAqahf4wvtFoJGsaAshLJD/IcdYaJ1mSV9uvM/mD6fX6oaAANE6jcO6dOxtsUEVKP+yCzVINaJJDAwckTEAu/mG1cRyNXBZlQ8vanY7lkYa77shaWVmJhYVxiSQ7OB6ARLCKz6HNtVqVaR364+Mocy6vkvwEzUKR/6blF/snoDB4ROyixLu/3wodUKMxp5XeQLs2NzdZtGwXAazABolzZZHH4SGgJMCAjF0CaJClpSWr2Wxqb/NWS+0s5Dq+r2Eu30GePJZNkuehWSsyKFmrsPKYWFwqoZsHQlNWV1etra2/2ORlUGF5XhGLxaFiXK5XsM6cXmZbKEm+iPOD2UewXz+tI013SqRdfTmifwqMYOA3SO7GlTUOJ9AHBjLJwyXYamfOLDO7k+Q88c3lpcVEoCYaqkjThH3USI3AZ8PmLz7klcIJcRhFWRWx91VJtDCiSRPbpcVFZqy9iLxNngBGsUigTLytanyYS/Bavn0cMacjadWQ87knQodr3OoPxEm+i+2xFfG3il/qUAEzqVDimsePP0HG2o2EJbRibq7OPKYJqKBxlz2hvIAHc55UIIQ3vBaMs77nVcK2TBkns5WNAQZNTAoM13Y1qhjCrZuACoYNKlAHc+1OlINzuMi5KIPSK5xkS1wEHgzeQUfDkgLr9roJwHanBgoaFfCALc7hSly6A5V7h2RfqCc6hSofxEDZAOt1+4e2GzTN46/F9hQrr5u0q0AdhEKHzQGCXym22ufzv6abSCNivRzMC4O5ol+udZTA9Ap9AxZrjflWw7VOnzrJPF61WmNwvIkJsLW1K9g/dod4LCDchk5eXhYRe5JKaax6IA7DILDqJvZEBJ0O+br5hXlmxEU7eXKBJt2gnPEf2iIdNhlkEY1Gw6D6Opgshka40jcpK8dWUYXhN2mwEdCWEwsL1rFjTeV1EFosLp5ixv3BgwcMmsndueXlZV9L9UridqoafFwIYNKQ0509+3TU40WThpxtdXWFwzKrwWdxtykBrPFkb2dRdweApK1erxvDCltoP2IPv8eQGJbwiOK+4YxuCabS/jDNQgkINldDwSNhFVWBIaDJ/YatWlaqn4UWAYbqoTh8FZ8Fxlo0gdUOrg6LgxSxjsruIC5L+VyVUVNBYZmCMkYrMI0KLGrLxOMhzN9RlTqCEhUYjsfjI9MoPyAeKNMl1bgVzzmg6P+diWbd5BXUS3IFVbFV8XzWXBiwqGg+ac0+rESs6bGxU1I9nxVn4G9FnSylR1e13ElIpK0DzBQUb0hh3iS5nkZ7s3i0+zqHZdR0csmUoJBevJ0WVCaweLuYZjBRwFKC8jioH7OYZCawSLsGJLg5i/tswWwX79fp8wKE27cbKmDI/YRDwBHvFaBw7px0vUJEvxesHD7aLaBd5QPc4H+6jff87+I7yFnOq4DB7eMuTLvdYbfuFWEAzjnPryE3Zb9pzIPSUUzxtzvLJH9a4b+hqfPJvKB5zRsccidlv+awZvyzX11gOqDMVvU/9LPf0C15FKBmarOmACw3oPICSwb2OQlu4+Cxv8/yBCoPNitsAcdWDv9Vwb8CDACdCFE+P8dk8gAAAABJRU5ErkJggg==');
width: 75rpx;
height: 56rpx;
}
.on {
color: #f4b409;
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<view class="content-container">
<span v-for="(part, index) in formattedContent" :key="index"
@click="handleClick(part)"
:class="{'highlight-number': part.isNumber, 'phone-number': part.isPhone}">
{{ part.text }}
</span>
</view>
</template>
<script>
export default {
name: 'HighlightNumber',
props: {
content: {
type: String,
required: true
}
},
computed: {
formattedContent() {
const phoneRegex = /(1[3-9]\d{9})/g;
const numberRegex = /(\d+)/g;
let text = this.content;
let result = [];
let match;
// Step 1: 提取手机号
while ((match = phoneRegex.exec(text)) !== null) {
if (match.index > 0) {
const before = text.slice(0, match.index);
result.push(...this.splitAndPush(before, false, false));
}
result.push({ text: match[0], isNumber: true, isPhone: true });
text = text.slice(match.index + match[0].length);
}
// Step 2: 提取普通数字
while ((match = numberRegex.exec(text)) !== null) {
if (match.index > 0) {
const before = text.slice(0, match.index);
result.push(...this.splitAndPush(before, false, false));
}
result.push({ text: match[0], isNumber: true });
text = text.slice(match.index + match[0].length);
}
// Step 3: 添加剩余文本
if (text.length > 0) {
result.push(...this.splitAndPush(text, false, false));
}
return result;
}
},
methods: {
splitAndPush(str, isNumber = false, isPhone = false) {
return str.split('').map(char => ({ text: char, isNumber, isPhone }));
},
handleClick(part) {
if (part.isPhone) {
this.$emit('phone-click', { phoneNumber: part.text });
} else if (part.isNumber) {
this.$emit('number-click', { number: part.text });
}
}
}
};
</script>
<style scoped>
.highlight-number {
color: #ff5722;
font-weight: bold;
}
.phone-number {
color: #007AFF;
text-decoration: underline;
}
</style>

227
pages/index/category.vue Normal file
View File

@@ -0,0 +1,227 @@
<!-- 商品分类列表 -->
<template>
<s-layout :bgStyle="{ color: '#fff' }" tabbar="/pages/index/category" title="分类">
<view class="s-category">
<view class="three-level-wrap ss-flex ss-col-top">
<!-- 商品分类 -->
<view class="side-menu-wrap" :style="[{ top: Number(statusBarHeight + 88) + 'rpx' }]">
<scroll-view scroll-y :style="[{ height: pageHeight + 'px' }]">
<view
class="menu-item ss-flex"
v-for="(item, index) in state.categoryList"
:key="item.id"
:class="[{ 'menu-item-active': index === state.activeMenu }]"
@tap="onMenu(index)"
>
<view class="menu-title ss-line-1">
{{ item.name }}
</view>
</view>
</scroll-view>
</view>
<!-- 商品分类 -->
<view class="goods-list-box" v-if="state.categoryList?.length">
<scroll-view scroll-y :style="[{ height: pageHeight + 'px' }]">
<image
v-if="state.categoryList[state.activeMenu].picUrl"
class="banner-img"
:src="sheep.$url.cdn(state.categoryList[state.activeMenu].picUrl)"
mode="widthFix"
/>
<first-one v-if="state.style === 'first_one'" :pagination="state.pagination" />
<first-two v-if="state.style === 'first_two'" :pagination="state.pagination" />
<second-one
v-if="state.style === 'second_one'"
:data="state.categoryList"
:activeMenu="state.activeMenu"
/>
<uni-load-more
v-if="
(state.style === 'first_one' || state.style === 'first_two') &&
state.pagination.total > 0
"
:status="state.loadStatus"
:content-text="{
contentdown: '点击查看更多',
}"
@tap="loadMore"
/>
</scroll-view>
</view>
</view>
</view>
</s-layout>
</template>
<script setup>
import secondOne from './components/second-one.vue';
import firstOne from './components/first-one.vue';
import firstTwo from './components/first-two.vue';
import sheep from '@/sheep';
import { onLoad } from '@dcloudio/uni-app';
import { computed, reactive } from 'vue';
import _ from 'lodash-es';
import { handleTree } from '@/sheep/helper/utils';
const state = reactive({
style: 'second_one', // first_one一级 - 样式一), first_two二级 - 样式二), second_one二级
categoryList: [], // 商品分类树
activeMenu: 0, // 选中的一级菜单,在 categoryList 的下标
pagination: {
// 商品分页
list: [], // 商品列表
total: [], // 商品总数
pageNo: 1,
pageSize: 6,
},
loadStatus: '',
});
const { safeArea } = sheep.$platform.device;
const pageHeight = computed(() => safeArea.height - 44 - 50);
const statusBarHeight = sheep.$platform.device.statusBarHeight * 2;
// 加载商品分类
async function getList() {
// API已被移除返回空数据
state.categoryList = [];
}
// 选中菜单
const onMenu = (val) => {
state.activeMenu = val;
if (state.style === 'first_one' || state.style === 'first_two') {
state.pagination.pageNo = 1;
state.pagination.list = [];
state.pagination.total = 0;
getGoodsList();
}
};
// 加载商品列表
async function getGoodsList() {
// 加载列表
state.loadStatus = 'loading';
// API已被移除返回空数据
state.pagination.list = [];
state.pagination.total = 0;
state.loadStatus = 'noMore';
}
// 加载更多商品
function loadMore() {
if (state.loadStatus === 'noMore') {
return;
}
state.pagination.pageNo++;
getGoodsList();
}
onLoad(async (params) => {
await getList();
// 首页点击分类的处理:查找满足条件的分类
const foundCategory = state.categoryList.find((category) => category.id === Number(params.id));
// 如果找到则调用 onMenu 自动勾选相应分类,否则调用 onMenu(0) 勾选第一个分类
onMenu(foundCategory ? state.categoryList.indexOf(foundCategory) : 0);
});
function handleScrollToLower() {
loadMore();
}
</script>
<style lang="scss" scoped>
.s-category {
:deep() {
.side-menu-wrap {
width: 200rpx;
height: 100%;
padding-left: 12rpx;
background-color: #f6f6f6;
position: fixed;
left: 0;
.menu-item {
width: 100%;
height: 88rpx;
position: relative;
transition: all linear 0.2s;
.menu-title {
line-height: 32rpx;
font-size: 30rpx;
font-weight: 400;
color: #333;
margin-left: 28rpx;
position: relative;
z-index: 0;
&::before {
content: '';
width: 64rpx;
height: 12rpx;
background: linear-gradient(
90deg,
var(--ui-BG-Main-gradient),
var(--ui-BG-Main-light)
) !important;
position: absolute;
left: -64rpx;
bottom: 0;
z-index: -1;
transition: all linear 0.2s;
}
}
&.menu-item-active {
background-color: #fff;
border-radius: 20rpx 0 0 20rpx;
&::before {
content: '';
position: absolute;
right: 0;
bottom: -20rpx;
width: 20rpx;
height: 20rpx;
background: radial-gradient(circle at 0 100%, transparent 20rpx, #fff 0);
}
&::after {
content: '';
position: absolute;
top: -20rpx;
right: 0;
width: 20rpx;
height: 20rpx;
background: radial-gradient(circle at 0% 0%, transparent 20rpx, #fff 0);
}
.menu-title {
font-weight: 600;
&::before {
left: 0;
}
}
}
}
}
.goods-list-box {
background-color: #fff;
width: calc(100vw - 200rpx);
padding: 10px;
margin-left: 200rpx;
}
.banner-img {
width: calc(100vw - 130px);
border-radius: 5px;
margin-bottom: 20rpx;
}
}
}
</style>

View File

@@ -0,0 +1,23 @@
<!-- 分类展示first-one 风格 -->
<template>
<view class="ss-flex-col">
<!-- 商品组件已被移除 -->
<view class="empty-message" style="text-align: center; padding: 50px; color: #999;">
暂无商品数据
</view>
</view>
</template>
<script setup>
import sheep from '@/sheep';
const props = defineProps({
pagination: Object,
});
</script>
<style lang="scss" scoped>
.goods-box {
width: 100%;
}
</style>

View File

@@ -0,0 +1,66 @@
<!-- 分类展示first-two 风格 -->
<template>
<view>
<view class="ss-flex flex-wrap">
<view class="goods-box" v-for="item in pagination?.list" :key="item.id">
<view @click="sheep.$router.go('/pages/goods/index', { id: item.id })">
<view class="goods-img">
<image class="goods-img" :src="item.picUrl" mode="aspectFit" />
</view>
<view class="goods-content">
<view class="goods-title ss-line-1 ss-m-b-28">{{ item.name }}</view>
<view class="goods-price">{{ fen2yuan(item.price) }}</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import sheep from '@/sheep';
import { fen2yuan } from '@/sheep/hooks/useGoods';
const props = defineProps({
pagination: Object,
});
</script>
<style lang="scss" scoped>
.goods-box {
width: calc((100% - 20rpx) / 2);
margin-bottom: 20rpx;
.goods-img {
width: 100%;
height: 246rpx;
border-radius: 10rpx 10rpx 0px 0px;
}
.goods-content {
width: 100%;
background: #ffffff;
box-shadow: 0px 0px 20rpx 4rpx rgba(199, 199, 199, 0.22);
padding: 20rpx 0 32rpx 16rpx;
box-sizing: border-box;
border-radius: 0 0 10rpx 10rpx;
.goods-title {
font-size: 26rpx;
font-weight: bold;
color: #333333;
}
.goods-price {
font-size: 24rpx;
font-family: OPPOSANS;
font-weight: 500;
color: #e1212b;
}
}
&:nth-child(2n + 1) {
margin-right: 20rpx;
}
}
</style>

View File

@@ -0,0 +1,80 @@
<!-- 分类展示second-one 风格 -->
<template>
<view>
<!-- 一级分类的名字 -->
<view class="title-box ss-flex ss-col-center ss-row-center ss-p-b-30">
<view class="title-line-left" />
<view class="title-text ss-p-x-20">{{ props.data[activeMenu].name }}</view>
<view class="title-line-right" />
</view>
<!-- 二级分类的名字 -->
<view class="goods-item-box ss-flex ss-flex-wrap ss-p-b-20">
<view
class="goods-item"
v-for="item in props.data[activeMenu].children"
:key="item.id"
@tap="
sheep.$router.go('/pages/goods/list', {
categoryId: item.id,
})
"
>
<image class="goods-img" :src="item.picUrl" mode="aspectFill" />
<view class="ss-p-10">
<view class="goods-title ss-line-1">{{ item.name }}</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import sheep from '@/sheep';
const props = defineProps({
data: {
type: Object,
default: () => ({}),
},
activeMenu: [Number, String],
});
</script>
<style lang="scss" scoped>
.title-box {
.title-line-left,
.title-line-right {
width: 15px;
height: 1px;
background: #d2d2d2;
}
}
.goods-item {
width: calc((100% - 20px) / 3);
margin-right: 10px;
margin-bottom: 10px;
&:nth-of-type(3n) {
margin-right: 0;
}
.goods-img {
width: calc((100vw - 140px) / 3);
height: calc((100vw - 140px) / 3);
}
.goods-title {
font-size: 26rpx;
font-weight: bold;
color: #333333;
line-height: 40rpx;
text-align: center;
}
.goods-price {
color: $red;
line-height: 40rpx;
}
}
</style>

547
pages/index/index.vue Normal file
View File

@@ -0,0 +1,547 @@
<!-- 首页 - 重新设计 -->
<template>
<view class="container">
<s-layout
title="首页"
navbar="custom"
tabbar="/pages/index/index"
onShareAppMessage
>
<!-- 用户信息区域 - 使用 uview-plus Card 组件 -->
<u-card
v-if="isLogin"
:show-head="false"
:show-foot="false"
margin="30rpx"
>
<template #body>
<view class="user-info-section">
<view class="user-avatar">
<s-avatar
:src="userInfo.avatar"
:nickname="userInfo.nickname || userInfo.username"
:size="60"
:bg-color="getAvatarBgColor(userInfo.nickname || userInfo.username)"
@click="handleUserClick"
/>
</view>
<view class="user-details">
<u-text
:text="userInfo.nickname || '用户'"
size="18"
color="#333"
bold
/>
<u-text
:text="userInfo.mobile || '欢迎使用系统'"
size="14"
color="#999"
margin="6rpx 0 0 0"
/>
</view>
<view class="user-actions">
<u-button
text="个人中心"
size="mini"
type="primary"
plain
@click="navigateTo('/pages/index/user')"
margin="0 0 10rpx 0"
/>
<u-button
text="退出登录"
size="mini"
type="error"
plain
@click="handleLogout"
/>
</view>
</view>
</template>
</u-card>
<!-- 未登录提示区域 - 使用 uview-plus Card Button -->
<u-card
v-else
:show-head="false"
:show-foot="false"
margin="30rpx"
>
<template #body>
<view class="login-prompt">
<view class="prompt-content">
<u-text text="欢迎使用" size="20" color="#333" bold />
<u-text
text="请先登录以使用完整功能"
size="14"
color="#999"
margin="10rpx 0 30rpx 0"
/>
<u-button
text="立即登录"
type="primary"
size="normal"
@click="goToLogin"
/>
</view>
</view>
</template>
</u-card>
<!-- 功能模块入口 - 使用 uview-plus Grid 组件 -->
<u-card
v-if="isLogin"
:show-head="false"
:show-foot="false"
margin="30rpx"
>
<template #body>
<view class="function-modules">
<view class="section-title">
<u-text text="功能模块" size="16" color="#333" bold />
</view>
<u-grid :col="3" :border="false">
<u-grid-item
v-for="module in functionModules"
:key="module.id"
@click="handleModuleClick(module)"
>
<view class="module-content">
<view class="module-icon" :style="{ backgroundColor: module.color + '20' }">
<u-icon :name="module.icon" size="24" :color="module.color" />
</view>
<u-text
:text="module.name"
size="12"
color="#666"
margin="10rpx 0 0 0"
/>
</view>
</u-grid-item>
</u-grid>
</view>
</template>
</u-card>
</s-layout>
</view>
</template>
<script setup>
import { onLoad, onPullDownRefresh, onShow } from '@dcloudio/uni-app';
import { computed, ref } from 'vue';
import sheep from '@/sheep';
import { getAvatarBgColor } from '@/sheep/utils/avatar.js';
// 隐藏原生tabBar
uni.hideTabBar({
fail: () => {},
});
// 跳转到登录页
function goToLogin() {
uni.navigateTo({
url: '/pages/login/index'
});
}
const isLogin = computed(() => sheep.$store('user').isLogin);
const userInfo = computed(() => sheep.$store('user').userInfo);
// 用于防止重复验证
let isValidating = false;
// 用户头像点击事件
function handleUserClick() {
if (!isLogin.value) {
goToLogin();
return;
}
uni.navigateTo({
url: '/pages/index/user'
});
}
// 格式化最后登录时间
const formatLastLoginTime = computed(() => {
const now = new Date();
return `${now.getMonth() + 1}${now.getDate()}${now.getHours()}:${now.getMinutes().toString().padStart(2, '0')}`;
});
// 功能模块数据
const functionModules = ref([
{
id: 'profile',
name: '个人资料',
icon: 'account',
color: 'var(--ui-BG-Main)', // 主题色
path: '/pages/index/user'
},
{
id: 'settings',
name: '系统设置',
icon: 'setting',
color: '#666666', // $dark-6
action: 'showSettings'
},
{
id: 'security',
name: '安全中心',
icon: 'lock',
color: '#d10019', // $red
action: 'showSecurity'
},
{
id: 'feedback',
name: '意见反馈',
icon: 'chat',
color: '#8dc63f', // $green
action: 'showFeedback'
}
]);
// 快捷操作数据
const quickActions = ref([
{
id: 'logout',
title: '退出登录',
desc: '安全退出当前账户',
icon: 'logout',
action: 'logout'
}
]);
onLoad((options) => {
console.log('首页加载完成');
checkLoginStatus();
});
onShow(() => {
// 只有在页面从后台返回前台时才需要重新验证登录状态
// 避免首次加载时的重复验证
console.log('页面显示,检查是否需要验证登录状态');
// 延迟一点时间确保不与onLoad的验证冲突
setTimeout(() => {
checkLoginStatus();
}, 100);
});
onPullDownRefresh(async () => {
console.log('下拉刷新');
try {
// 刷新用户信息
if (isLogin.value) {
await sheep.$store('user').getInfo();
console.log('用户信息刷新成功');
}
} catch (error) {
console.log('刷新用户信息失败,可能登录已过期', error);
// 如果刷新失败,可能是登录过期,重新验证登录状态
await checkLoginStatus();
} finally {
setTimeout(() => {
uni.stopPullDownRefresh();
}, 1000);
}
});
// 检查登录状态
async function checkLoginStatus() {
// 防止重复验证
if (isValidating) {
console.log('正在验证中,跳过重复验证');
return;
}
console.log('检查登录状态...');
// 如果本地显示未登录,直接跳转到登录页
if (!isLogin.value) {
console.log('本地状态未登录,跳转到登录页');
setTimeout(() => {
goToLogin();
}, 500);
return;
}
// 如果本地显示已登录需要验证token是否还有效
console.log('本地状态已登录验证token有效性...');
isValidating = true;
try {
// 尝试获取用户信息来验证token是否有效
await sheep.$store('user').getInfo();
console.log('Token有效用户已登录');
} catch (error) {
console.log('Token已失效需要重新登录', error);
// 清除本地登录状态
sheep.$store('user').logout(false);
// 延迟显示登录弹窗,确保页面渲染完成
setTimeout(() => {
uni.showToast({
title: '登录已过期,请重新登录',
icon: 'none',
duration: 2000
});
setTimeout(() => {
goToLogin();
}, 2000);
}, 500);
} finally {
// 验证完成,重置标志
setTimeout(() => {
isValidating = false;
}, 1000);
}
}
// 导航到指定页面
function navigateTo(path) {
uni.navigateTo({
url: path
});
}
// 处理退出登录
function handleLogout() {
uni.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
showCancel: true,
confirmText: '确定',
cancelText: '取消',
success: async (res) => {
if (res.confirm) {
try {
// 显示加载提示
uni.showLoading({
title: '退出中...',
mask: true
});
// 调用退出登录API
await sheep.$store('user').logout(true);
// 隐藏加载提示
uni.hideLoading();
// 显示退出成功提示
uni.showToast({
title: '退出成功',
icon: 'success',
duration: 1500
});
// 延迟跳转到登录页
setTimeout(() => {
goToLogin();
}, 1500);
} catch (error) {
// 隐藏加载提示
uni.hideLoading();
console.error('退出登录失败:', error);
uni.showToast({
title: '退出失败,请重试',
icon: 'none',
duration: 2000
});
}
}
}
});
}
// 处理模块点击
function handleModuleClick(module) {
if (module.path) {
navigateTo(module.path);
} else if (module.action) {
handleAction(module.action);
}
}
// 处理快捷操作点击
function handleActionClick(action) {
handleAction(action.action);
}
// 处理各种操作
function handleAction(action) {
switch (action) {
case 'showSettings':
uni.showToast({
title: '系统设置功能开发中',
icon: 'none'
});
break;
case 'showSecurity':
uni.showToast({
title: '安全中心功能开发中',
icon: 'none'
});
break;
case 'showFeedback':
uni.showToast({
title: '意见反馈功能开发中',
icon: 'none'
});
break;
case 'showAbout':
uni.showModal({
title: '关于应用',
content: '应用版本v1.0.0\n开发者Yudao Team\n更新时间2024年',
showCancel: false
});
break;
case 'scanCode':
uni.scanCode({
success: (res) => {
uni.showModal({
title: '扫描结果',
content: res.result,
showCancel: false
});
},
fail: (err) => {
uni.showToast({
title: '扫描失败',
icon: 'none'
});
}
});
break;
case 'shareApp':
uni.share({
provider: 'weixin',
scene: 'WXSceneSession',
type: 0,
href: '',
title: '推荐一个好用的应用',
summary: '快来试试这个应用吧!',
imageUrl: ''
});
break;
case 'clearCache':
uni.showModal({
title: '清理缓存',
content: '确定要清理应用缓存吗?',
success: (res) => {
if (res.confirm) {
// 这里可以添加清理缓存的逻辑
uni.clearStorageSync();
uni.showToast({
title: '缓存清理完成',
icon: 'success'
});
}
}
});
break;
case 'logout':
handleLogout();
break;
default:
uni.showToast({
title: '功能开发中',
icon: 'none'
});
}
}
</script>
<style lang="scss" scoped>
.container {
min-height: 100vh;
background: #f5f5f5;
}
/* 用户信息区域 */
.user-info-section {
display: flex;
align-items: center;
padding: 20rpx;
}
.user-avatar {
margin-right: 20rpx;
}
.user-details {
flex: 1;
}
.user-actions {
display: flex;
flex-direction: column;
gap: 10rpx;
}
/* 未登录提示区域 */
.login-prompt {
text-align: center;
padding: 40rpx 20rpx;
}
.prompt-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 10rpx;
}
/* 功能模块 */
.function-modules {
padding: 20rpx;
}
.section-title {
margin-bottom: 20rpx;
}
.module-content {
text-align: center;
padding: 20rpx 10rpx;
}
.module-icon {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 10rpx;
}
/* 快捷操作 */
.quick-actions {
padding: 20rpx;
}
.action-icon {
width: 40rpx;
height: 40rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 15rpx;
background: #f0f0f0;
border-radius: 8rpx;
}
/* 系统信息 */
.system-info {
padding: 20rpx;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15rpx 0;
}
</style>

39
pages/index/login.vue Normal file
View File

@@ -0,0 +1,39 @@
<!-- 微信公众号的登录回调页 -->
<template>
<!-- 空登陆页 -->
<view />
</template>
<script setup>
import sheep from '@/sheep';
import { onLoad } from '@dcloudio/uni-app';
onLoad(async (options) => {
// #ifdef H5
// 将 search 参数赋值到 options 中,方便下面解析
new URLSearchParams(location.search).forEach((value, key) => {
options[key] = value;
});
// 执行登录 or 绑定,注意需要 await 绑定
const event = options.event;
const code = options.code;
const state = options.state;
if (event === 'login') { // 场景一:登录
await sheep.$platform.useProvider().login(code, state);
} else if (event === 'bind') { // 场景二:绑定
await sheep.$platform.useProvider().bind(code, state);
}
// 检测 H5 登录回调
let returnUrl = uni.getStorageSync('returnUrl');
if (returnUrl) {
uni.removeStorage({key:'returnUrl'});
location.replace(returnUrl);
} else {
uni.switchTab({
url: '/',
});
}
// #endif
});
</script>

266
pages/index/menu.vue Normal file
View File

@@ -0,0 +1,266 @@
<!-- 菜单页面 -->
<template>
<s-layout
title="菜单"
tabbar="/pages/index/menu"
navbar="custom"
onShareAppMessage
>
<view class="menu-container">
<!-- 轮播图 -->
<view class="swiper-section">
<swiper
class="swiper-container"
:indicator-dots="true"
:autoplay="true"
:interval="3000"
:duration="500"
indicator-color="rgba(255, 255, 255, 0.3)"
indicator-active-color="#fff"
circular
>
<swiper-item v-for="(item, index) in swiperList" :key="index">
<view class="swiper-item">
<image :src="item.image" mode="aspectFill" class="swiper-image" />
</view>
</swiper-item>
</swiper>
</view>
<!-- 工作台 -->
<view class="section">
<view class="section-title">工作台</view>
<view class="grid-container">
<view
class="grid-item"
v-for="(item, index) in quickMenuList"
:key="index"
@click="handleMenuClick(item)"
>
<view class="item-icon" :style="{ background: item.bg }">
<u-icon
:name="item.icon"
size="24"
:color="item.color"
/>
</view>
<view class="item-title">{{ item.title }}</view>
</view>
</view>
</view>
</view>
</s-layout>
</template>
<script>
import sheep from '@/sheep';
export default {
onShow() {
const userStore = sheep.$store('user');
if (!userStore.isLogin) {
sheep.$router.redirect('/pages/login/index');
}
},
data() {
return {
// 轮播图数据
swiperList: [
{
image: '/static/images/swiper/bg1.png',
title: '轮播图1'
},
{
image: '/static/images/swiper/bg2.png',
title: '轮播图2'
},
{
image: '/static/images/swiper/bg3.png',
title: '轮播图3'
}
],
// 工作台功能数据
quickMenuList: [
{
title: 'Demo',
icon: 'file-text',
color: '#fff',
bg: '#3c9cff',
path: '/pages/app/democontract/index'
},
]
};
},
methods: {
// 处理菜单点击
handleMenuClick(item) {
sheep.$router.go(item.path);
}
}
};
</script>
<style lang="scss" scoped>
.menu-container {
min-height: 100vh;
background-color: #f8f9fa;
padding: 20rpx;
}
/* 轮播图样式 */
.swiper-section {
margin-bottom: 30rpx;
.swiper-container {
height: 300rpx;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
.swiper-item {
width: 100%;
height: 100%;
.swiper-image {
width: 100%;
height: 100%;
object-fit: cover;
}
}
}
}
.section {
margin-bottom: 40rpx;
.section-title {
font-size: 32rpx;
font-weight: 600;
color: #303133;
margin-bottom: 30rpx;
padding-left: 10rpx;
}
}
/* 快捷功能网格布局 */
.grid-container {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20rpx;
.grid-item {
background: #fff;
border-radius: 16rpx;
padding: 30rpx 20rpx;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
&:active {
transform: translateY(2rpx);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
}
.item-icon {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
}
.item-title {
font-size: 24rpx;
color: #606266;
text-align: center;
line-height: 1.2;
}
}
}
/* 管理功能列表布局 */
.list-container {
background: #fff;
border-radius: 16rpx;
padding: 0 30rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
.list-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx 0;
border-bottom: 1px solid #f5f5f5;
transition: background-color 0.3s ease;
&:last-child {
border-bottom: none;
}
&:active {
background-color: #f8f9fa;
}
.item-left {
display: flex;
align-items: center;
flex: 1;
.item-icon-small {
width: 64rpx;
height: 64rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
}
.item-info {
flex: 1;
.item-title {
font-size: 28rpx;
color: #303133;
margin-bottom: 8rpx;
font-weight: 500;
}
.item-desc {
font-size: 22rpx;
color: #909399;
line-height: 1.3;
}
}
}
}
}
/* 响应式调整 */
@media screen and (max-width: 750rpx) {
.grid-container {
grid-template-columns: repeat(2, 1fr);
gap: 16rpx;
}
.grid-item {
padding: 24rpx 16rpx;
.item-icon {
width: 64rpx;
height: 64rpx;
}
.item-title {
font-size: 22rpx;
}
}
}
</style>

51
pages/index/page.vue Normal file
View File

@@ -0,0 +1,51 @@
<!-- 自定义页面支持装修 -->
<template>
<s-layout
:title="state.name"
navbar="custom"
:bgStyle="state.page"
:navbarStyle="state.navigationBar"
onShareAppMessage
showLeftButton
>
<s-block v-for="(item, index) in state.components" :key="index" :styles="item.property.style">
<s-block-item :type="item.id" :data="item.property" :styles="item.property.style" />
</s-block>
</s-layout>
</template>
<script setup>
import { reactive } from 'vue';
import { onLoad, onPageScroll } from '@dcloudio/uni-app';
// Diy API removed with promotion module
const state = reactive({
name: '',
components: [],
navigationBar: {},
page: {},
});
onLoad(async (options) => {
let id = options.id
// #ifdef MP
// 小程序预览自定义页面
if (options.scene) {
const sceneParams = decodeURIComponent(options.scene).split('=');
id = sceneParams[1];
}
// #endif
const { code, data } = await DiyApi.getDiyPage(id);
if (code === 0) {
state.name = data.name;
state.components = data.property?.components;
state.navigationBar = data.property?.navigationBar;
state.page = data.property?.page;
}
});
onPageScroll(() => {});
</script>
<style></style>

144
pages/index/search.vue Normal file
View File

@@ -0,0 +1,144 @@
<!-- 搜索界面 -->
<template>
<s-layout :bgStyle="{ color: '#FFF' }" class="set-wrap" title="搜索">
<view class="search-container">
<view class="search-input-wrapper">
<u-search
v-model="searchText"
placeholder="请输入关键字"
:show-action="false"
:focus="true"
shape="square"
@search="onSearch"
@confirm="onSearch"
/>
</view>
<!-- 搜索历史标题 -->
<view class="search-section">
<view class="section-header">
<u-text text="搜索历史" size="16" bold color="#333" />
<u-button
text="清除搜索历史"
size="mini"
type="error"
plain
@click="onDelete"
/>
</view>
<!-- 历史标签 -->
<view class="history-tags" v-if="state.historyList.length">
<u-tag
v-for="(item, index) in state.historyList"
:key="index"
:text="item"
type="info"
plain
size="medium"
closable
@click="onSearch(item)"
@close="removeHistoryItem(index)"
:custom-style="{ margin: '5rpx 10rpx 5rpx 0' }"
/>
</view>
<!-- 无历史提示 -->
<u-empty
v-else
mode="search"
text="暂无搜索历史"
textSize="14"
/>
</view>
</view>
</s-layout>
</template>
<script setup>
import { reactive, ref } from 'vue';
import sheep from '@/sheep';
import { onLoad } from '@dcloudio/uni-app';
const searchText = ref('');
const state = reactive({
historyList: [],
});
// 搜索
function onSearch(keyword = '') {
const searchKeyword = keyword || searchText.value;
if (!searchKeyword) {
return;
}
saveSearchHistory(searchKeyword);
// 前往商品列表(带搜索条件)
sheep.$router.go('/pages/goods/list', { keyword: searchKeyword });
}
// 移除单个历史记录
function removeHistoryItem(index) {
state.historyList.splice(index, 1);
uni.setStorageSync('searchHistory', state.historyList);
}
// 保存搜索历史
function saveSearchHistory(keyword) {
// 如果关键词在搜索历史中,则把此关键词先移除
if (state.historyList.includes(keyword)) {
state.historyList.splice(state.historyList.indexOf(keyword), 1);
}
// 置顶关键词
state.historyList.unshift(keyword);
// 最多保留 10 条记录
if (state.historyList.length >= 10) {
state.historyList.length = 10;
}
uni.setStorageSync('searchHistory', state.historyList);
}
function onDelete() {
uni.$u.modal({
title: '提示',
content: '确认清除搜索历史吗?',
showCancelButton: true,
confirmText: '确定',
cancelText: '取消'
}).then(res => {
if (res) {
state.historyList = [];
uni.removeStorageSync('searchHistory');
}
});
}
onLoad(() => {
state.historyList = uni.getStorageSync('searchHistory') || [];
});
</script>
<style lang="scss" scoped>
.search-container {
padding: 30rpx;
}
.search-input-wrapper {
margin-bottom: 40rpx;
}
.search-section {
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.history-tags {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
}
}
</style>

258
pages/index/user.vue Normal file
View File

@@ -0,0 +1,258 @@
<!-- 个人中心 -->
<template>
<s-layout
title="我的"
tabbar="/pages/index/user"
navbar="custom"
onShareAppMessage
>
<view class="user-container">
<!-- 用户信息卡片 -->
<view class="user-card">
<view class="user-info">
<view class="avatar-section" @click="handleAvatarClick">
<s-avatar
:src="userInfo.avatar"
:nickname="userInfo.nickname || userInfo.username"
:size="80"
:bg-color="getAvatarBgColor(userInfo.nickname || userInfo.username)"
/>
<view class="user-details">
<view class="user-name">{{ userInfo.nickname || userInfo.username || '未登录' }}</view>
<view class="user-desc">{{ userInfo.mobile || '点击登录获取更多功能' }}</view>
</view>
</view>
</view>
</view>
<!-- 功能菜单 -->
<view class="menu-section">
<view class="menu-group">
<view class="group-title">账户管理</view>
<view class="menu-list">
<view class="menu-item" @click="goToUserInfo">
<view class="item-left">
<view class="item-icon" style="background: var(--ui-BG-Main-opacity-1);">
<u-icon name="account" size="20" color="var(--ui-BG-Main)"/>
</view>
<view class="item-title">个人信息</view>
</view>
<u-icon name="arrow-right" size="16" color="#c0c4cc"/>
</view>
<view class="menu-item" @click="goToAbout">
<view class="item-left">
<view class="item-icon" style="background: var(--ui-BG-Main-opacity-1);">
<u-icon name="info-circle" size="20" color="var(--ui-BG-Main)"/>
</view>
<view class="item-title">关于我们</view>
</view>
<u-icon name="arrow-right" size="16" color="#c0c4cc"/>
</view>
</view>
</view>
<!-- 退出登录按钮 -->
<view class="logout-section" v-if="isLogin">
<view class="logout-btn" @click="handleLogout">
退出登录
</view>
</view>
</view>
</view>
</s-layout>
</template>
<script setup>
import { computed } from 'vue';
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app';
import sheep from '@/sheep';
import { getAvatarBgColor } from '@/sheep/utils/avatar.js';
// 用户信息
const userInfo = computed(() => sheep.$store('user').userInfo);
const isLogin = computed(() => sheep.$store('user').isLogin);
// 页面事件
onShow(() => {
// 先检查登录状态,未登录则会自动跳转到登录页
if (!sheep.$store('user').isLogin) {
return;
}
// 已登录时更新用户数据
sheep.$store('user').updateUserData();
});
onPullDownRefresh(() => {
sheep.$store('user').updateUserData();
setTimeout(() => {
uni.stopPullDownRefresh();
}, 1000);
});
// 头像点击事件
function handleAvatarClick() {
// 跳转到个人信息页面编辑头像
uni.navigateTo({
url: '/pages/user/info'
});
}
// 方法
function goToUserInfo() {
uni.navigateTo({
url: '/pages/user/info'
});
}
function goToAbout() {
uni.navigateTo({
url: '/pages/public/about'
});
}
function handleLogout() {
uni.showModal({
title: '提示',
content: '确定要退出登录吗?',
confirmText: '确定',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
sheep.$store('user').logout();
}
}
});
}
</script>
<style lang="scss" scoped>
.user-container {
min-height: 100vh;
background-color: #f8f9fa;
padding: 20rpx;
}
/* 用户信息卡片 */
.user-card {
background: var(--gradient-diagonal-primary, linear-gradient(135deg, #0055A2, rgba(0, 85, 162, 0.6)));
border-radius: 20rpx;
padding: 40rpx 30rpx;
margin-bottom: 30rpx;
box-shadow: 0 8rpx 20rpx rgba(0, 85, 162, 0.3);
.user-info {
display: flex;
align-items: center;
justify-content: space-between;
.avatar-section {
display: flex;
align-items: center;
flex: 1;
.user-details {
margin-left: 20rpx;
.user-name {
font-size: 32rpx;
font-weight: 600;
color: #fff;
margin-bottom: 8rpx;
}
.user-desc {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.8);
}
}
}
.user-actions {
padding: 10rpx;
}
}
}
/* 菜单区域 */
.menu-section {
.menu-group {
margin-bottom: 30rpx;
.group-title {
font-size: 28rpx;
font-weight: 600;
color: #303133;
margin-bottom: 20rpx;
padding-left: 10rpx;
}
.menu-list {
background: #fff;
border-radius: 16rpx;
padding: 0 30rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 30rpx 0;
border-bottom: 1px solid #f5f5f5;
transition: background-color 0.3s ease;
&:last-child {
border-bottom: none;
}
&:active {
background-color: #f8f9fa;
}
.item-left {
display: flex;
align-items: center;
flex: 1;
.item-icon {
width: 64rpx;
height: 64rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
margin-right: 24rpx;
}
.item-title {
font-size: 28rpx;
color: #303133;
font-weight: 500;
}
}
}
}
}
}
/* 退出登录按钮 */
.logout-section {
margin-top: 40rpx;
.logout-btn {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
text-align: center;
font-size: 28rpx;
color: #f56c6c;
font-weight: 500;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
&:active {
background-color: #fef0f0;
transform: translateY(2rpx);
}
}
}
</style>

342
pages/login/index.vue Normal file
View File

@@ -0,0 +1,342 @@
<template>
<s-layout class="login-container" title="登录/注册" :bgStyle="{
background: '#fff'
}">
<view class="login-wrap">
<!-- 1. 统一登录组件 (整合账号密码登录和短信登录) -->
<unified-login
v-if="authType === 'accountLogin' || authType === 'smsLogin'"
:agreeStatus="state.protocol"
@onConfirm="onConfirm"
/>
<!-- 3. 忘记密码 resetPassword-->
<!-- <reset-password v-if="authType === 'resetPassword'" /> -->
<!-- 4. 绑定手机号 changeMobile -->
<change-mobile v-if="authType === 'changeMobile'" />
<!-- 5. 修改密码 changePassword-->
<changePassword v-if="authType === 'changePassword'" />
<!-- 6. 微信小程序授权 -->
<mp-authorization v-if="authType === 'mpAuthorization'" />
<!-- 7. 第三方登录 -->
<view
v-if="['accountLogin', 'smsLogin'].includes(authType)"
class="auto-login-box ss-flex ss-flex-col ss-row-center ss-col-center"
>
<!-- 7.1 微信小程序的快捷登录 -->
<view v-if="sheep.$platform.name === 'WechatMiniProgram'" class="ss-flex register-box">
<view class="register-title">还没有账号?</view>
<button
class="ss-reset-button login-btn"
open-type="getPhoneNumber"
@getphonenumber="getPhoneNumber"
style="color: var(--ui-BG-Main, #0055A2) !important"
>
快捷登录
</button>
<view class="circle" />
</view>
<!-- 7.2 微信的公众号App小程序的登录基于 openid + code -->
<button
v-if="
['WechatOfficialAccount', 'WechatMiniProgram', 'App'].includes(sheep.$platform.name) &&
sheep.$platform.isWechatInstalled
"
@tap="thirdLogin('wechat')"
class="ss-reset-button auto-login-btn"
>
<image
class="auto-login-img"
:src="sheep.$url.static('/static/img/shop/platform/wechat.png')"
/>
</button>
<!-- 7.3 iOS 登录 TODO 芋艿:等后面搞 App 再弄 -->
<button
v-if="sheep.$platform.os === 'ios' && sheep.$platform.name === 'App'"
@tap="thirdLogin('apple')"
class="ss-reset-button auto-login-btn"
>
<image
class="auto-login-img"
:src="sheep.$url.static('/static/img/shop/platform/apple.png')"
/>
</button>
</view>
<!-- 用户协议的勾选 -->
<view
v-if="['accountLogin', 'smsLogin'].includes(authType)"
class="agreement-box ss-flex ss-flex-col ss-col-center"
:class="{ shake: currentProtocol }"
>
<view class="agreement-title ss-m-b-20">
请阅读并同意以下协议:
</view>
<view class="agreement-options-container">
<view class="agreement-option" @tap="onAgree">
<view class="radio-container ss-flex ss-col-center">
<view
class="custom-radio"
:class="{ 'custom-radio-checked': state.protocol === true }"
>
<view v-if="state.protocol === true" class="radio-dot"></view>
</view>
<view class="agreement-text ss-flex ss-col-center ss-m-l-8">
我已阅读并同意遵守
<view class="tcp-text" @tap.stop="onProtocol('用户协议')"> 《用户协议》 </view>
<view class="agreement-text">与</view>
<view class="tcp-text" @tap.stop="onProtocol('隐私协议')"> 《隐私协议》 </view>
</view>
</view>
</view>
</view>
</view>
<view class="safe-box" />
</view>
</s-layout>
</template>
<script setup>
import { computed, reactive, ref } from 'vue';
import sheep from '@/sheep';
import unifiedLogin from '@/sheep/components/s-auth-modal/components/unified-login.vue';
import changeMobile from '@/sheep/components/s-auth-modal/components/change-mobile.vue';
import changePassword from '@/sheep/components/s-auth-modal/components/change-password.vue';
import mpAuthorization from '@/sheep/components/s-auth-modal/components/mp-authorization.vue';
import { onLoad } from '@dcloudio/uni-app';
import { navigateAfterLogin } from '@/sheep/helper/login-redirect';
const authType = ref('accountLogin');
onLoad((options) => {
if (options.authType) {
authType.value = options.authType;
}
});
const state = reactive({
protocol: false, // false 表示未勾选true 表示已同意
});
const currentProtocol = ref(false);
// 同意协议
function onAgree() {
state.protocol = !state.protocol;
uni.showToast({
title: state.protocol ? '已勾选协议' : '已取消勾选',
icon: state.protocol ? 'success' : 'none',
duration: 1000
});
}
// 查看协议
function onProtocol(title) {
sheep.$router.go('/pages/public/richtext', {
title,
});
}
// 点击登录 / 注册事件
function onConfirm(e) {
currentProtocol.value = e;
setTimeout(() => {
currentProtocol.value = false;
}, 1000);
}
// 第三方授权登陆微信小程序、Apple
const thirdLogin = async (provider) => {
if (state.protocol !== true) {
currentProtocol.value = true;
setTimeout(() => {
currentProtocol.value = false;
}, 1000);
sheep.$helper.toast('请先勾选协议');
return;
}
const loginRes = await sheep.$platform.useProvider(provider).login();
if (loginRes) {
const userInfo = await sheep.$store('user').getInfo();
// 如果用户已经有头像和昵称,不需要再次授权
if (userInfo.avatar && userInfo.nickname) {
// 登录成功后跳转到首页
navigateAfterLogin();
return;
}
// 触发小程序授权信息弹框
// #ifdef MP-WEIXIN
authType.value = 'mpAuthorization';
// #endif
}
};
// 微信小程序的“手机号快速验证”https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/getPhoneNumber.html
const getPhoneNumber = async (e) => {
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
sheep.$helper.toast('快捷登录失败');
return;
}
let result = await sheep.$platform.useProvider().mobileLogin(e.detail);
if (result) {
// 登录成功后跳转到首页
navigateAfterLogin();
}
};
</script>
<style lang="scss" scoped>
@import '@/sheep/components/s-auth-modal/index.scss';
.login-container {
.login-wrap {
padding-top: 100rpx;
}
}
.shake {
animation: shake 0.05s linear 4 alternate;
}
@keyframes shake {
from {
transform: translateX(-10rpx);
}
to {
transform: translateX(10rpx);
}
}
.register-box {
position: relative;
justify-content: center;
.register-btn {
color: #999999;
font-size: 30rpx;
font-weight: 500;
}
.register-title {
color: #999999;
font-size: 30rpx;
font-weight: 400;
margin-right: 24rpx;
}
.or-title {
margin: 0 16rpx;
color: #999999;
font-size: 30rpx;
font-weight: 400;
}
.login-btn {
color: var(--ui-BG-Main, #0055A2) !important;
font-size: 30rpx;
font-weight: 500;
}
.circle {
position: absolute;
right: 0rpx;
top: 18rpx;
width: 8rpx;
height: 8rpx;
border-radius: 8rpx;
background: var(--ui-BG-Main, #0055A2) !important;
}
}
.safe-box {
height: calc(constant(safe-area-inset-bottom) / 5 * 3);
height: calc(env(safe-area-inset-bottom) / 5 * 3);
}
.tcp-text {
color: var(--ui-BG-Main, #0055A2) !important;
}
.agreement-text {
color: $dark-9;
}
.agreement-title {
font-size: 28rpx;
color: $dark-9;
text-align: left;
width: 100%;
padding-left: 60rpx;
position: relative;
}
.protocol-status {
font-size: 24rpx;
font-weight: bold;
margin-top: 8rpx;
padding: 4rpx 12rpx;
border-radius: 16rpx;
display: inline-block;
&.protocol-agreed {
color: #52c41a;
background-color: rgba(82, 196, 26, 0.1);
}
&.protocol-refused {
color: #ff4d4f;
background-color: rgba(255, 77, 79, 0.1);
}
}
.agreement-options-container {
width: 100%;
padding-left: 100rpx;
}
.agreement-option {
width: 100%;
display: flex;
justify-content: flex-start;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
opacity: 0.8;
}
}
.radio-container {
display: flex;
align-items: center;
width: 100%;
}
/* 自定义radio样式 - 同意 */
.custom-radio {
width: 32rpx;
height: 32rpx;
border: 2rpx solid #d9d9d9;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
flex-shrink: 0;
&.custom-radio-checked {
border-color: var(--ui-BG-Main, #409eff);
background-color: var(--ui-BG-Main, #409eff);
}
}
.radio-dot {
width: 16rpx;
height: 16rpx;
background-color: #fff;
border-radius: 50%;
}
</style>

540
pages/public/about.vue Normal file
View File

@@ -0,0 +1,540 @@
<template>
<s-layout :bgStyle="{ color: '#f5f7fa' }" class="about-page" title="关于我们">
<u-card :show-head="false" margin="20rpx" padding="40rpx" border-radius="24rpx">
<template #body>
<view class="hero-content">
<u-avatar
v-if="appInfo.logo"
:src="sheep.$url.cdn(appInfo.logo)"
size="120"
bg-color="transparent"
class="hero-logo"
/>
<u-text
:text="displayName"
type="primary"
size="40rpx"
bold
color="#fff"
class="hero-name"
/>
<u-text
:text="`${displayName} 致力于用开源的数字化能力,帮助企业快速构建电商、营销与业务协同平台。`"
size="28rpx"
color="rgba(255,255,255,0.9)"
class="hero-slogan"
/>
<view class="hero-tags">
<u-tag
v-for="tag in heroTags"
:key="tag"
:text="tag"
bg-color="rgba(255,255,255,0.16)"
color="#fff"
size="mini"
shape="circle"
plain
/>
</view>
</view>
</template>
</u-card>
<u-row gutter="16" class="metrics-row">
<u-col span="4" v-for="metric in metrics" :key="metric.title">
<u-card :show-head="false" margin="0" padding="32rpx 24rpx" border-radius="20rpx">
<template #body>
<view class="metric-content">
<u-text
:text="metric.value"
size="48rpx"
bold
type="primary"
class="metric-value"
/>
<u-text
:text="metric.title"
size="26rpx"
bold
color="#303133"
class="metric-title"
/>
<u-text
:text="metric.desc"
size="22rpx"
color="#909399"
class="metric-desc"
/>
</view>
</template>
</u-card>
</u-col>
</u-row>
<u-card :show-head="false" margin="20rpx" padding="32rpx 28rpx" border-radius="24rpx">
<template #body>
<u-text text="我们提供什么" size="32rpx" bold color="#303133" class="section-title" />
<u-text text="围绕企业全链路经营的核心能力" size="24rpx" color="#909399" class="section-subtitle" />
<view class="capability-grid">
<u-card
v-for="item in capabilities"
:key="item.title"
:show-head="false"
margin="0 0 24rpx 0"
padding="28rpx"
border-radius="20rpx"
bg-color="linear-gradient(135deg, rgba(245, 247, 250, 1), rgba(245, 247, 250, 0.6))"
>
<template #body>
<u-text :text="item.title" size="30rpx" bold color="#2c3e50" class="capability-title" />
<u-text :text="item.desc" size="24rpx" color="#606266" class="capability-desc" />
<view class="capability-tags">
<u-tag
v-for="tag in item.tags"
:key="tag"
:text="tag"
bg-color="rgba(0, 85, 162, 0.08)"
color="var(--ui-BG-Main)"
size="mini"
shape="circle"
/>
</view>
</template>
</u-card>
</view>
</template>
</u-card>
<u-card :show-head="false" margin="20rpx" padding="32rpx 28rpx" border-radius="24rpx">
<template #body>
<u-text text="我们的理念" size="32rpx" bold color="#303133" class="section-title" />
<view class="value-list">
<u-card
v-for="item in values"
:key="item.title"
:show-head="false"
margin="0 0 24rpx 0"
padding="24rpx 26rpx"
border-radius="20rpx"
bg-color="rgba(0, 85, 162, 0.05)"
>
<template #body>
<u-text :text="item.title" size="28rpx" bold color="#2c3e50" class="value-title" />
<u-text :text="item.desc" size="24rpx" color="#606266" class="value-desc" />
</template>
</u-card>
</view>
</template>
</u-card>
<u-card :show-head="false" margin="20rpx" padding="32rpx 28rpx" border-radius="24rpx">
<template #body>
<u-text text="发展里程碑" size="32rpx" bold color="#303133" class="section-title" />
<view class="timeline">
<view
class="timeline-item"
v-for="(item, index) in milestones"
:key="item.year"
>
<view class="timeline-dot"></view>
<view class="timeline-content">
<u-text :text="item.year" size="28rpx" bold color="#2c3e50" class="timeline-year" />
<u-text :text="item.title" size="26rpx" bold color="#303133" class="timeline-title" />
<u-text :text="item.desc" size="24rpx" color="#606266" class="timeline-desc" />
</view>
</view>
</view>
</template>
</u-card>
<u-card :show-head="false" margin="20rpx" padding="32rpx 28rpx" border-radius="24rpx">
<template #body>
<u-text text="联系与合作" size="32rpx" bold color="#303133" class="section-title" />
<u-text text="欢迎与我们探讨更多共赢的可能" size="24rpx" color="#909399" class="section-subtitle" />
<view class="contact-list">
<u-cell
v-for="item in contacts"
:key="item.label"
:title="item.label"
:label="item.value"
:is-link="true"
arrow-direction="right"
bg-color="rgba(245, 247, 250, 0.8)"
title-style="font-size: 26rpx; font-weight: 600; color: #2c3e50;"
label-style="font-size: 24rpx; color: #606266; word-break: break-all; line-height: 34rpx;"
@click="handleContact(item)"
class="contact-cell"
/>
</view>
</template>
</u-card>
<u-text
text="移动商城 始终坚持开源共享,与开发者和企业伙伴一起打造可持续的数字化生态。"
size="24rpx"
color="#909399"
align="center"
class="footer-hint"
/>
</s-layout>
</template>
<script setup>
import { computed } from 'vue';
import sheep from '@/sheep';
const appInfo = computed(() => sheep.$store('app').info || {});
const displayName = computed(() => appInfo.value.name || '移动商城');
const heroTags = [
'100% 开源可商用',
'支持多端统一交付',
'沉淀企业级最佳实践'
];
const metrics = [
{
title: '开源历程',
value: '7+',
desc: '年持续迭代与社区共建'
},
{
title: '行业方案',
value: '20+',
desc: '覆盖零售、制造、政企等场景'
},
{
title: '交付效率',
value: '30%',
desc: '平均缩短客户上线周期'
}
];
const capabilities = [
{
title: '产品矩阵',
desc: '以电商中台为核心,延展直播带货、会员营销、全渠道订单与履约管理。',
tags: ['商城交易', '营销裂变', '数据运营']
},
{
title: '技术架构',
desc: '基于 Spring Cloud Alibaba + Vue3 + UniApp支持多租户、微服务与多端同源。',
tags: ['微服务', '多端一致', '低代码装修']
},
{
title: '服务体系',
desc: '提供从咨询、定制、交付到长期运营的全生命周期陪伴式服务。',
tags: ['数字化顾问', '驻场交付', '持续运营']
}
];
const values = [
{
title: '使命',
desc: '让每一家企业都能以更低成本、更快速度完成业务数字化升级。'
},
{
title: '愿景',
desc: '打造开放、可信赖、可持续的企业级数字商业生态。'
},
{
title: '价值观',
desc: '客户成功、极致体验、持续创新、坦诚协作。'
}
];
const milestones = [
{
year: '2018',
title: '开源起航',
desc: '发布首个电商项目原型,与社区开发者共同启动开源计划。'
},
{
year: '2020',
title: '企业级升级',
desc: '引入多租户、权限与大促能力,满足成长型企业核心诉求。'
},
{
year: '2022',
title: '多端一体',
desc: '统一 H5、App 与小程序技术栈,实现一次开发,多端交付。'
},
{
year: '2024',
title: '智能加速',
desc: '引入智能客服、营销推荐等 AI 能力,提升运营效率。'
},
{
year: '2025',
title: '生态共建',
desc: '联合生态伙伴共建行业方案,形成开放合作的生态体系。'
}
];
const contacts = [
{
label: '商务合作',
value: 'business@iocoder.cn',
type: 'email'
},
{
label: '客服热线',
value: '400-860-888',
type: 'phone',
actionValue: '400860888'
},
{
label: '开源仓库',
value: 'https://github.com/YunaiV/ruoyi-vue-pro',
type: 'link'
},
{
label: '文档中心',
value: 'https://doc.iocoder.cn',
type: 'link'
},
{
label: '办公地址',
value: '浙江省杭州市滨江区江南大道 777 号数字产业园',
type: 'text'
}
];
function handleContact(item) {
if (item.type === 'phone') {
const phoneNumber = item.actionValue || item.value.replace(/[^0-9]/g, '');
if (!phoneNumber) {
return;
}
uni.makePhoneCall({
phoneNumber,
fail: () => {
uni.setClipboardData({
data: item.value,
success: () => {
uni.showToast({ title: '号码已复制', icon: 'none' });
}
});
}
});
return;
}
if (item.type === 'link') {
sheep.$router.go('/pages/public/webview', {
title: item.label,
url: encodeURIComponent(item.value)
});
uni.setClipboardData({
data: item.value,
success: () => {
uni.showToast({ title: '链接已复制', icon: 'none' });
}
});
return;
}
if (item.type === 'text') {
uni.setClipboardData({
data: item.value,
success: () => {
uni.showToast({ title: '地址已复制', icon: 'none' });
}
});
return;
}
uni.setClipboardData({
data: item.value,
success: () => {
uni.showToast({ title: '信息已复制', icon: 'none' });
}
});
}
</script>
<style lang="scss" scoped>
.about-page {
min-height: 100vh;
padding: 0;
background: #f5f7fa;
box-sizing: border-box;
}
// Hero card 渐变背景
:deep(.u-card) {
&:first-child {
background: linear-gradient(135deg, rgba(0, 85, 162, 0.95), rgba(0, 85, 162, 0.68));
box-shadow: 0 16rpx 36rpx rgba(0, 85, 162, 0.25);
color: #fff;
}
}
.hero-content {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
.hero-logo {
margin-bottom: 24rpx;
border-radius: 24rpx;
background: rgba(255, 255, 255, 0.15);
padding: 16rpx;
}
.hero-name {
margin-bottom: 16rpx;
}
.hero-slogan {
margin-bottom: 28rpx;
line-height: 42rpx;
}
.hero-tags {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
}
.metrics-row {
margin: 20rpx;
}
.metric-content {
display: flex;
flex-direction: column;
align-items: flex-start;
text-align: left;
.metric-value {
margin-bottom: 8rpx;
}
.metric-title {
margin-bottom: 6rpx;
}
.metric-desc {
line-height: 32rpx;
}
}
.section-title {
margin-bottom: 12rpx;
}
.section-subtitle {
margin-bottom: 24rpx;
}
.capability-grid {
display: flex;
flex-direction: column;
}
.capability-title {
margin-bottom: 12rpx;
}
.capability-desc {
margin-bottom: 16rpx;
line-height: 38rpx;
}
.capability-tags {
display: flex;
flex-wrap: wrap;
gap: 14rpx;
}
.value-list {
display: flex;
flex-direction: column;
}
.value-title {
margin-bottom: 12rpx;
}
.value-desc {
line-height: 36rpx;
}
.timeline {
position: relative;
margin-left: 20rpx;
padding-left: 20rpx;
border-left: 2rpx solid rgba(0, 85, 162, 0.2);
display: flex;
flex-direction: column;
gap: 32rpx;
.timeline-item {
position: relative;
.timeline-dot {
position: absolute;
left: -31rpx;
top: 6rpx;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: var(--ui-BG-Main);
box-shadow: 0 0 0 6rpx rgba(0, 85, 162, 0.15);
}
.timeline-content {
padding-left: 12rpx;
.timeline-year {
margin-bottom: 6rpx;
}
.timeline-title {
margin-bottom: 6rpx;
}
.timeline-desc {
line-height: 36rpx;
}
}
}
}
.contact-list {
display: flex;
flex-direction: column;
gap: 20rpx;
}
.contact-cell {
border-radius: 18rpx;
overflow: hidden;
}
.footer-hint {
padding: 24rpx 36rpx 80rpx;
line-height: 36rpx;
}
@media (min-width: 750px) {
.metrics-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.capability-grid,
.value-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24rpx;
}
}
</style>

60
pages/public/error.vue Normal file
View File

@@ -0,0 +1,60 @@
<!-- 错误界面 -->
<template>
<view class="error-page">
<s-empty
v-if="errCode === 'NetworkError'"
icon="/static/internet-empty.png"
text="网络连接失败"
showAction
actionText="重新连接"
@clickAction="onReconnect"
buttonColor="#ff3000"
/>
<s-empty
v-else-if="errCode === 'TemplateError'"
icon="/static/internet-empty.png"
text="未找到模板,请前往后台启用对应模板"
showAction
actionText="重新加载"
@clickAction="onReconnect"
buttonColor="#ff3000"
/>
<s-empty
v-else-if="errCode !== ''"
icon="/static/internet-empty.png"
:text="errMsg"
showAction
actionText="重新加载"
@clickAction="onReconnect"
buttonColor="#ff3000"
/>
</view>
</template>
<script setup>
import { onLoad } from '@dcloudio/uni-app';
import { ref } from 'vue';
import { ShoproInit } from '@/sheep';
const errCode = ref('');
const errMsg = ref('');
onLoad((options) => {
errCode.value = options.errCode;
errMsg.value = options.errMsg;
});
// 重新连接
async function onReconnect() {
uni.reLaunch({
url: '/pages/index/menu',
});
await ShoproInit();
}
</script>
<style lang="scss" scoped>
.error-page {
width: 100%;
}
</style>

15
pages/public/faq.vue Normal file
View File

@@ -0,0 +1,15 @@
<!-- 页面已移除 -->
<template>
<s-layout :bgStyle="{ color: '#FFF' }" class="set-wrap" title="页面已下线">
<s-empty text="该页面已下线" icon="/static/internet-empty.png" />
</s-layout>
</template>
<script setup>
</script>
<style lang="scss" scoped>
.set-wrap {
min-height: 100vh;
}
</style>

45
pages/public/richtext.vue Normal file
View File

@@ -0,0 +1,45 @@
<!-- 文章展示 -->
<template>
<s-layout :bgStyle="{ color: '#FFF' }" :title="state.title" class="set-wrap">
<view class="ss-p-30 richtext"><mp-html :content="state.content"></mp-html></view>
</s-layout>
</template>
<script setup>
import { onLoad } from '@dcloudio/uni-app';
import { reactive } from 'vue';
// Article API removed with promotion module
const state = reactive({
title: '',
content: '',
});
async function getRichTextContent(id, title) {
// Article API has been removed as it was part of promotion module
state.title = title || '内容';
state.content = '暂无内容';
}
onLoad((options) => {
if (options.title) {
state.title = options.title;
uni.setNavigationBarTitle({
title: state.title,
});
}
getRichTextContent(options.id, options.title);
});
</script>
<style lang="scss" scoped>
.set-title {
margin: 0 30rpx;
}
:deep() {
image {
display: block;
}
}
</style>

236
pages/public/setting.vue Normal file
View File

@@ -0,0 +1,236 @@
<template>
<s-layout :bgStyle="{ color: '#fff' }" class="set-wrap" title="系统设置">
<view class="header-box ss-flex-col ss-row-center ss-col-center">
<image
class="logo-img ss-m-b-46"
:src="sheep.$url.cdn(appInfo.logo)"
mode="aspectFit"
></image>
<view class="name ss-m-b-24">{{ appInfo.name }}</view>
</view>
<view class="container-list">
<uni-list :border="false">
<uni-list-item
title="当前版本"
:rightText="appInfo.version"
showArrow
clickable
:border="false"
class="list-border"
@tap="onCheckUpdate"
/>
<uni-list-item
title="本地缓存"
:rightText="storageSize"
showArrow
:border="false"
class="list-border"
/>
<uni-list-item
title="关于我们"
showArrow
clickable
:border="false"
class="list-border"
@tap="
sheep.$router.go('/pages/public/richtext', {
title: '关于我们'
})
"
/>
<!-- 为了过审 只有 iOS-App 有注销账号功能 -->
<uni-list-item
v-if="isLogin && sheep.$platform.os === 'ios' && sheep.$platform.name === 'App'"
title="注销账号"
rightText=""
showArrow
clickable
:border="false"
class="list-border"
@click="onLogoff"
/>
</uni-list>
</view>
<view class="set-footer ss-flex-col ss-row-center ss-col-center">
<view class="agreement-box ss-flex ss-col-center ss-m-b-40">
<view class="ss-flex ss-col-center ss-m-b-10">
<view
class="tcp-text"
@tap="
sheep.$router.go('/pages/public/richtext', {
title: '用户协议'
})
"
>
用户协议
</view>
<view class="agreement-text"></view>
<view
class="tcp-text"
@tap="
sheep.$router.go('/pages/public/richtext', {
title: '隐私协议'
})
"
>
隐私协议
</view>
</view>
</view>
<view class="copyright-text ss-m-b-10">{{ appInfo.copyright }}</view>
<view class="copyright-text">{{ appInfo.copytime }}</view>
</view>
<su-fixed bottom placeholder>
<view class="ss-p-x-20 ss-p-b-40">
<button
class="loginout-btn ss-reset-button ui-BG-Main ui-Shadow-Main"
@tap="onLogout"
v-if="isLogin"
>
退出登录
</button>
</view>
</su-fixed>
</s-layout>
</template>
<script setup>
import sheep from '@/sheep';
import { computed, reactive } from 'vue';
import AuthUtil from '@/sheep/api/system/auth';
const appInfo = computed(() => sheep.$store('app').info);
const isLogin = computed(() => sheep.$store('user').isLogin);
const storageSize = uni.getStorageInfoSync().currentSize + 'Kb';
const state = reactive({
showModal: false,
});
function onCheckUpdate() {
sheep.$platform.checkUpdate();
// 小程序初始化时已检查更新
// H5实时更新无需检查
// App 1.跳转应用市场更新 2.手动热更新 3.整包更新
}
// 注销账号
function onLogoff() {
uni.showModal({
title: '提示',
content: '确认注销账号?',
success: async function (res) {
if (!res.confirm) {
return;
}
const { code } = await AuthUtil.logout();
if (code !== 0) {
return;
}
sheep.$store('user').logout();
sheep.$router.go('/pages/index/user');
},
});
}
// 退出账号
function onLogout() {
uni.showModal({
title: '提示',
content: '确认退出账号?',
success: async function (res) {
if (!res.confirm) {
return;
}
const { code } = await AuthUtil.logout();
if (code !== 0) {
return;
}
sheep.$store('user').logout();
sheep.$router.go('/pages/index/user');
},
});
}
</script>
<style lang="scss" scoped>
.container-list {
width: 100%;
}
.set-title {
margin: 0 30rpx;
}
.header-box {
padding: 100rpx 0;
.logo-img {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
}
.name {
font-size: 42rpx;
font-weight: 400;
color: $dark-3;
}
.version {
font-size: 32rpx;
font-weight: 500;
line-height: 32rpx;
color: $gray-b;
}
}
.set-footer {
margin: 100rpx 0 0 0;
.copyright-text {
font-size: 22rpx;
font-weight: 500;
color: $gray-c;
line-height: 30rpx;
}
.agreement-box {
font-size: 26rpx;
font-weight: 500;
.tcp-text {
color: var(--ui-BG-Main);
}
.agreement-text {
color: $dark-9;
}
}
}
.loginout-btn {
width: 100%;
height: 80rpx;
border-radius: 40rpx;
font-size: 30rpx;
}
.list-border {
font-size: 28rpx;
font-weight: 400;
color: #333333;
border-bottom: 2rpx solid #eeeeee;
}
:deep(.uni-list-item__content-title) {
font-size: 28rpx;
font-weight: 500;
color: #333;
}
:deep(.uni-list-item__extra-text) {
color: #bbbbbb;
font-size: 28rpx;
}
</style>

18
pages/public/webview.vue Normal file
View File

@@ -0,0 +1,18 @@
<!-- 网页加载 -->
<template>
<view>
<web-view :src="url" />
</view>
</template>
<script setup>
import { onLoad } from '@dcloudio/uni-app';
import { ref } from 'vue';
const url = ref('');
onLoad((options) => {
url.value = decodeURIComponent(options.url);
});
</script>
<style lang="scss" scoped></style>

410
pages/user/info.vue Normal file
View File

@@ -0,0 +1,410 @@
<!-- 用户信息 -->
<template>
<s-layout title="用户信息" class="set-userinfo-wrap">
<uni-forms
:model="state.model"
:rules="state.rules"
labelPosition="left"
border
class="form-box"
>
<!-- 头像 -->
<view class="ss-flex ss-row-center ss-col-center ss-p-t-60 ss-p-b-0 bg-white">
<view class="header-box-content">
<s-avatar
:src="state.model?.avatar"
:nickname="state.model?.nickname || state.model?.username"
:size="160"
:editable="true"
:bg-color="getAvatarBgColor(state.model?.nickname || state.model?.username)"
@click="onChangeAvatar"
/>
</view>
</view>
<view class="bg-white ss-p-x-30">
<!-- 昵称 + 性别 -->
<uni-forms-item name="nickname" label="昵称">
<uni-easyinput
v-model="state.model.nickname"
type="nickname"
placeholder="设置昵称"
:inputBorder="false"
:placeholderStyle="placeholderStyle"
/>
</uni-forms-item>
<uni-forms-item name="sex" label="性别">
<view class="ss-flex ss-col-center ss-h-100">
<radio-group @change="onChangeGender" class="ss-flex ss-col-center">
<label class="radio" v-for="item in sexRadioMap" :key="item.value">
<view class="ss-flex ss-col-center ss-m-r-32">
<radio
:value="item.value"
color="var(--ui-BG-Main)"
style="transform: scale(0.8)"
:checked="parseInt(item.value) === state.model?.sex"
/>
<view class="gender-name">{{ item.name }}</view>
</view>
</label>
</radio-group>
</view>
</uni-forms-item>
<uni-forms-item name="mobile" label="手机号" @tap="onChangeMobile">
<uni-easyinput
v-model="userInfo.mobile"
placeholder="请绑定手机号"
:inputBorder="false"
disabled
:styles="{ disableColor: '#fff' }"
:placeholderStyle="placeholderStyle"
:clearable="false"
>
<template v-slot:right>
<view class="ss-flex ss-col-center">
<su-radio v-if="userInfo.verification?.mobile" :modelValue="true" />
<button v-else class="ss-reset-button ss-flex ss-col-center ss-row-center">
<text class="_icon-forward" style="color: #bbbbbb; font-size: 26rpx"></text>
</button>
</view>
</template>
</uni-easyinput>
</uni-forms-item>
</view>
</uni-forms>
<!-- 当前社交平台的绑定关系只处理 wechat 微信场景 -->
<view v-if="sheep.$platform.name !== 'H5'">
<view class="title-box ss-p-l-30">第三方账号绑定</view>
<view class="account-list ss-flex ss-row-between">
<view v-if="'WechatOfficialAccount' === sheep.$platform.name" class="ss-flex ss-col-center">
<image
class="list-img"
:src="sheep.$url.static('/static/img/shop/platform/WechatOfficialAccount.png')"
/>
<text class="list-name">微信公众号</text>
</view>
<view v-if="'WechatMiniProgram' === sheep.$platform.name" class="ss-flex ss-col-center">
<image
class="list-img"
:src="sheep.$url.static('/static/img/shop/platform/WechatMiniProgram.png')"
/>
<text class="list-name">微信小程序</text>
</view>
<view v-if="'App' === sheep.$platform.name" class="ss-flex ss-col-center">
<image
class="list-img"
:src="sheep.$url.static('/static/img/shop/platform/wechat.png')"
/>
<text class="list-name">微信开放平台</text>
</view>
<view class="ss-flex ss-col-center">
<view class="info ss-flex ss-col-center" v-if="state.thirdInfo">
<image class="avatar ss-m-r-20" :src="sheep.$url.cdn(state.thirdInfo.avatar)" />
<text class="name">{{ state.thirdInfo.nickname }}</text>
</view>
<view class="bind-box ss-m-l-20">
<button
v-if="state.thirdInfo.openid"
class="ss-reset-button relieve-btn"
@tap="unBindThirdOauth"
>
解绑
</button>
<button v-else class="ss-reset-button bind-btn" @tap="bindThirdOauth">绑定</button>
</view>
</view>
</view>
</view>
<su-fixed bottom placeholder bg="none">
<view class="footer-box ss-p-20">
<button class="ss-rest-button logout-btn ui-Shadow-Main" @tap="onSubmit">保存</button>
</view>
</su-fixed>
</s-layout>
</template>
<script setup>
import { computed, reactive, onBeforeMount } from 'vue';
import sheep from '@/sheep';
import { clone } from 'lodash-es';
import UserApi from '@/sheep/api/system/user';
import { getAvatarBgColor } from '@/sheep/utils/avatar.js';
import {
chooseAndUploadFile,
uploadFilesFromPath,
} from '@/sheep/components/s-uploader/choose-and-upload-file';
const state = reactive({
model: {}, // 个人信息
rules: {},
thirdInfo: {}, // 社交用户的信息
});
const placeholderStyle = 'color:#BBBBBB;font-size:28rpx;line-height:normal';
const sexRadioMap = [
{
name: '男',
value: '1',
},
{
name: '女',
value: '2',
},
];
const userInfo = computed(() => sheep.$store('user').userInfo);
// 选择性别
function onChangeGender(e) {
state.model.sex = e.detail.value;
}
// 修改手机号
const onChangeMobile = () => {
uni.navigateTo({
url: '/pages/login/index?authType=changeMobile'
});
};
// 选择微信的头像,进行上传
async function onChooseAvatar(e) {
debugger;
const tempUrl = e.detail.avatarUrl || '';
if (!tempUrl) return;
const files = await uploadFilesFromPath(tempUrl);
if (files.length > 0) {
state.model.avatar = files[0].url;
}
}
// 手动选择头像,进行上传
async function onChangeAvatar() {
const files = await chooseAndUploadFile({ type: 'image' });
if (files.length > 0) {
state.model.avatar = files[0].url;
}
}
// 绑定第三方账号
async function bindThirdOauth() {
let result = await sheep.$platform.useProvider('wechat').bind();
if (result) {
await getUserInfo();
}
}
// 解绑第三方账号
function unBindThirdOauth() {
uni.showModal({
title: '解绑提醒',
content: '解绑后您将无法通过微信登录此账号',
cancelText: '再想想',
confirmText: '确定',
success: async function (res) {
if (!res.confirm) {
return;
}
const result = await sheep.$platform.useProvider('wechat').unbind(state.thirdInfo.openid);
if (result) {
await getUserInfo();
}
},
});
}
// 保存信息
async function onSubmit() {
const { code } = await UserApi.updateUser({
avatar: state.model.avatar,
nickname: state.model.nickname,
sex: state.model.sex,
});
if (code === 0) {
await getUserInfo();
}
}
// 获得用户信息
const getUserInfo = async () => {
// 个人信息
const userInfo = await sheep.$store('user').getInfo();
state.model = clone(userInfo);
// 获得社交用户的信息
if (sheep.$platform.name !== 'H5') {
const result = await sheep.$platform.useProvider('wechat').getInfo();
state.thirdInfo = result || {};
}
};
onBeforeMount(() => {
getUserInfo();
});
</script>
<style lang="scss" scoped>
:deep() {
.uni-file-picker {
border-radius: 50%;
}
.uni-file-picker__container {
margin: -14rpx -12rpx;
}
.file-picker__progress {
height: 0 !important;
}
.uni-list-item__content-title {
font-size: 28rpx !important;
color: #333333 !important;
line-height: normal !important;
}
.uni-icons {
font-size: 40rpx !important;
}
.is-disabled {
color: #333333;
}
}
:deep(.disabled) {
opacity: 1;
}
.gender-name {
font-size: 28rpx;
font-weight: 500;
line-height: normal;
color: #333333;
}
.title-box {
font-size: 28rpx;
font-weight: 500;
color: #666666;
line-height: 100rpx;
}
.logout-btn {
width: 710rpx;
height: 80rpx;
background: var(--gradient-horizontal-primary, linear-gradient(90deg, #0055A2, rgba(0, 85, 162, 0.6)));
border-radius: 40rpx;
font-size: 30rpx;
font-weight: 500;
color: $white;
transition: all 0.3s ease;
&:active {
background: linear-gradient(90deg, #003F73, rgba(0, 63, 115, 0.6));
transform: scale(0.98);
}
}
.radio-dark {
filter: grayscale(100%);
filter: gray;
opacity: 0.4;
}
.content-img {
border-radius: 50%;
}
.header-box-content {
position: relative;
width: 160rpx;
height: 160rpx;
overflow: hidden;
border-radius: 50%;
}
.avatar-action {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 0;
z-index: 1;
width: 160rpx;
height: 46rpx;
background: rgba(#000000, 0.3);
.avatar-action-btn {
width: 160rpx;
height: 46rpx;
font-weight: 500;
font-size: 24rpx;
color: #ffffff;
}
}
// 绑定项
.account-list {
background-color: $white;
height: 100rpx;
padding: 0 20rpx;
.list-img {
width: 40rpx;
height: 40rpx;
margin-right: 10rpx;
}
.list-name {
font-size: 28rpx;
color: #333333;
}
.info {
.avatar {
width: 38rpx;
height: 38rpx;
border-radius: 50%;
overflow: hidden;
}
.name {
font-size: 28rpx;
font-weight: 400;
color: $dark-9;
}
}
.bind-box {
width: 100rpx;
height: 50rpx;
line-height: normal;
display: flex;
justify-content: center;
align-items: center;
font-size: 24rpx;
.bind-btn {
width: 100%;
height: 100%;
border-radius: 25rpx;
background: #f4f4f4;
color: #999999;
}
.relieve-btn {
width: 100%;
height: 100%;
border-radius: 25rpx;
background: var(--ui-BG-Main-opacity-1);
color: var(--ui-BG-Main);
}
}
}
image {
width: 100%;
height: 100%;
}
</style>

50
sheep/api/democontract.js Normal file
View File

@@ -0,0 +1,50 @@
import request from '@/sheep/request';
const DemoContractApi = {
// 查询示例合同分页
getDemoContractPage: (params) => {
return request({
url: '/demo-contract/page',
method: 'GET',
params,
});
},
// 查询示例合同详情
getDemoContract: (id) => {
return request({
url: '/demo-contract/get',
method: 'GET',
params: { id },
});
},
// 新增示例合同
createDemoContract: (data) => {
return request({
url: '/demo-contract/create',
method: 'POST',
data,
});
},
// 修改示例合同
updateDemoContract: (data) => {
return request({
url: '/demo-contract/update',
method: 'PUT',
data,
});
},
// 删除示例合同
deleteDemoContract: (id) => {
return request({
url: '/demo-contract/delete',
method: 'DELETE',
params: { id },
});
},
};
export default DemoContractApi;

11
sheep/api/index.js Normal file
View File

@@ -0,0 +1,11 @@
// 目的解决微信小程序的「代码质量」在「JS 文件」提示:主包内,不应该存在主包未使用的 JS 文件
const files = import.meta.glob('./*/*.js', { eager: true });
let api = {};
Object.keys(files).forEach((key) => {
api = {
...api,
[key.replace(/(.*\/)*([^.]+).*/gi, '$2')]: files[key].default,
};
});
export default api;

View File

@@ -0,0 +1,50 @@
import request from '@/sheep/request';
const DemoContractApi = {
// 查询示例合同分页
getDemoContractPage: (params) => {
return request({
url: '/template/demo-contract/page',
method: 'GET',
params,
});
},
// 查询示例合同详情
getDemoContract: (id) => {
return request({
url: '/template/demo-contract/get',
method: 'GET',
params: { id },
});
},
// 新增示例合同
createDemoContract: (data) => {
return request({
url: '/template/demo-contract/create',
method: 'POST',
data,
});
},
// 修改示例合同
updateDemoContract: (data) => {
return request({
url: '/template/demo-contract/update',
method: 'PUT',
data,
});
},
// 删除示例合同
deleteDemoContract: (id) => {
return request({
url: '/template/demo-contract/delete',
method: 'DELETE',
params: { id },
});
},
};
export default DemoContractApi;

67
sheep/api/infra/file.js Normal file
View File

@@ -0,0 +1,67 @@
import { baseUrl, apiPath, tenantId } from '@/sheep/config';
import request, { getAccessToken } from '@/sheep/request';
const FileApi = {
// 上传文件
uploadFile: (file, directory = '') => {
uni.showLoading({
title: '上传中',
});
return new Promise((resolve, reject) => {
uni.uploadFile({
url: baseUrl + apiPath + '/infra/file/upload',
filePath: file,
name: 'file',
header: {
Accept: '*/*',
'tenant-id': tenantId,
Authorization: 'Bearer ' + getAccessToken(),
},
formData: {
directory,
},
success: (uploadFileRes) => {
let result = JSON.parse(uploadFileRes.data);
if (result.error === 1) {
uni.showToast({
icon: 'none',
title: result.msg,
});
} else {
return resolve(result);
}
},
fail: (error) => {
console.log('上传失败:', error);
return resolve(false);
},
complete: () => {
uni.hideLoading();
},
});
});
},
// 获取文件预签名地址
getFilePresignedUrl: (name, directory) => {
return request({
url: '/infra/file/presigned-url',
method: 'GET',
params: {
name,
directory,
},
});
},
// 创建文件
createFile: (data) => {
return request({
url: '/infra/file/create', // 请求的 URL
method: 'POST', // 请求方法
data: data, // 要发送的数据
});
},
};
export default FileApi;

17
sheep/api/infra/tenant.js Normal file
View File

@@ -0,0 +1,17 @@
import request from '@/sheep/request';
/**
* 通过网站域名获取租户信息
* @param {string} website - 网站域名
* @returns {Promise<Object>} 租户信息
*/
export function getTenantByWebsite(website) {
return request({
url: '/system/tenant/get-by-website',
method: 'GET',
params: { website },
custom: {
isToken: false, // 避免登录情况下,跨租户访问被拦截
},
});
}

View File

@@ -0,0 +1,5 @@
const AddressApi = {
// API methods have been removed as they are not needed
};
export default AddressApi;

132
sheep/api/member/auth.js Normal file
View File

@@ -0,0 +1,132 @@
import request from '@/sheep/request';
const AuthUtil = {
// 使用手机 + 密码登录
login: (data) => {
return request({
url: '/member/auth/login',
method: 'POST',
data,
custom: {
showSuccess: true,
loadingMsg: '登录中',
successMsg: '登录成功',
},
});
},
// 使用手机 + 验证码登录
smsLogin: (data) => {
return request({
url: '/member/auth/sms-login',
method: 'POST',
data,
custom: {
showSuccess: true,
loadingMsg: '登录中',
successMsg: '登录成功',
},
});
},
// 发送手机验证码
sendSmsCode: (mobile, scene) => {
return request({
url: '/member/auth/send-sms-code',
method: 'POST',
data: {
mobile,
scene,
},
custom: {
loadingMsg: '发送中',
showSuccess: true,
successMsg: '发送成功',
},
});
},
// 登出系统
logout: () => {
return request({
url: '/member/auth/logout',
method: 'POST',
});
},
// 刷新令牌
refreshToken: (refreshToken) => {
return request({
url: '/member/auth/refresh-token',
method: 'POST',
params: {
refreshToken,
},
custom: {
showLoading: false, // 不用加载中
showError: false, // 不展示错误提示
},
});
},
// 社交授权的跳转
socialAuthRedirect: (type, redirectUri) => {
return request({
url: '/member/auth/social-auth-redirect',
method: 'GET',
params: {
type,
redirectUri,
},
custom: {
showSuccess: true,
loadingMsg: '登陆中',
},
});
},
// 社交快捷登录
socialLogin: (type, code, state) => {
return request({
url: '/member/auth/social-login',
method: 'POST',
data: {
type,
code,
state,
},
custom: {
showSuccess: true,
loadingMsg: '登陆中',
},
});
},
// 微信小程序的一键登录
weixinMiniAppLogin: (phoneCode, loginCode, state) => {
return request({
url: '/member/auth/weixin-mini-app-login',
method: 'POST',
data: {
phoneCode,
loginCode,
state,
},
custom: {
showSuccess: true,
loadingMsg: '登陆中',
successMsg: '登录成功',
},
});
},
// 创建微信 JS SDK 初始化所需的签名
createWeixinMpJsapiSignature: (url) => {
return request({
url: '/member/auth/create-weixin-jsapi-signature',
method: 'POST',
params: {
url,
},
custom: {
showError: false,
showLoading: false,
},
});
},
//
};
export default AuthUtil;

View File

@@ -0,0 +1,37 @@
import request from '@/sheep/request';
const SignInApi = {
// 获得签到规则列表
getSignInConfigList: () => {
return request({
url: '/member/sign-in/config/list',
method: 'GET',
});
},
// 获得个人签到统计
getSignInRecordSummary: () => {
return request({
url: '/member/sign-in/record/get-summary',
method: 'GET',
});
},
// 签到
createSignInRecord: () => {
return request({
url: '/member/sign-in/record/create',
method: 'POST',
});
},
// 获得签到记录分页
getSignRecordPage: (params) => {
const queryString = Object.keys(params)
.map((key) => encodeURIComponent(key) + '=' + params[key])
.join('&');
return request({
url: `/member/sign-in/record/page?${queryString}`,
method: 'GET',
});
},
};
export default SignInApi;

View File

@@ -0,0 +1,76 @@
import request from '@/sheep/request';
const SocialApi = {
// 获得社交用户
getSocialUser: (type) => {
return request({
url: '/member/social-user/get',
method: 'GET',
params: {
type
},
custom: {
showLoading: false,
},
});
},
// 社交绑定
socialBind: (type, code, state) => {
return request({
url: '/member/social-user/bind',
method: 'POST',
data: {
type,
code,
state
},
custom: {
custom: {
showSuccess: true,
loadingMsg: '绑定中',
successMsg: '绑定成功',
},
},
});
},
// 社交绑定
socialUnbind: (type, openid) => {
return request({
url: '/member/social-user/unbind',
method: 'DELETE',
data: {
type,
openid
},
custom: {
showLoading: false,
loadingMsg: '解除绑定',
successMsg: '解绑成功',
},
});
},
// 获取订阅消息模板列表
getSubscribeTemplateList: () =>
request({
url: '/member/social-user/get-subscribe-template-list',
method: 'GET',
custom: {
showError: false,
showLoading: false,
},
}),
// 获取微信小程序码
getWxaQrcode: async (path, query) => {
return await request({
url: '/member/social-user/wxa-qrcode',
method: 'POST',
data: {
scene: query,
path,
checkPath: false, // TODO 开发环境暂不检查 path 是否存在
},
});
},
};
export default SocialApi;

85
sheep/api/member/user.js Normal file
View File

@@ -0,0 +1,85 @@
import request from '@/sheep/request';
const UserApi = {
// 获得基本信息
getUserInfo: () => {
return request({
url: '/member/user/get',
method: 'GET',
custom: {
showLoading: false,
auth: true,
},
});
},
// 修改基本信息
updateUser: (data) => {
return request({
url: '/member/user/update',
method: 'PUT',
data,
custom: {
auth: true,
showSuccess: true,
successMsg: '更新成功'
},
});
},
// 修改用户手机
updateUserMobile: (data) => {
return request({
url: '/member/user/update-mobile',
method: 'PUT',
data,
custom: {
loadingMsg: '验证中',
showSuccess: true,
successMsg: '修改成功'
},
});
},
// 基于微信小程序的授权码,修改用户手机
updateUserMobileByWeixin: (code) => {
return request({
url: '/member/user/update-mobile-by-weixin',
method: 'PUT',
data: {
code
},
custom: {
showSuccess: true,
loadingMsg: '获取中',
successMsg: '修改成功'
},
});
},
// 修改密码
updateUserPassword: (data) => {
return request({
url: '/member/user/update-password',
method: 'PUT',
data,
custom: {
loadingMsg: '验证中',
showSuccess: true,
successMsg: '修改成功'
},
});
},
// 重置密码
resetUserPassword: (data) => {
return request({
url: '/member/user/reset-password',
method: 'PUT',
data,
custom: {
loadingMsg: '验证中',
showSuccess: true,
successMsg: '修改成功'
}
});
},
};
export default UserApi;

View File

@@ -0,0 +1,21 @@
import request from '@/sheep/request';
// TODO 芋艿:【直播】小程序直播还不支持
export default {
//小程序直播
mplive: {
getRoomList: (ids) =>
request({
url: 'app/mplive/getRoomList',
method: 'GET',
params: {
ids: ids.join(','),
},
}),
getMpLink: () =>
request({
url: 'app/mplive/getMpLink',
method: 'GET',
}),
},
};

View File

@@ -0,0 +1,18 @@
import request from '@/sheep/request';
export default {
// 苹果相关
apple: {
// 第三方登录
login: (data) =>
request({
url: 'third/apple/login',
method: 'POST',
data,
custom: {
showSuccess: true,
loadingMsg: '登陆中',
},
}),
},
};

13
sheep/api/system/area.js Normal file
View File

@@ -0,0 +1,13 @@
import request from '@/sheep/request';
const AreaApi = {
// 获得地区树
getAreaTree: () => {
return request({
url: '/system/area/tree',
method: 'GET'
});
},
};
export default AreaApi;

166
sheep/api/system/auth.js Normal file
View File

@@ -0,0 +1,166 @@
import request from '@/sheep/request';
const AuthUtil = {
// 使用用户名 + 密码登录
login: (data) => {
return request({
url: '/system/auth/login',
method: 'POST',
data,
custom: {
showSuccess: true,
loadingMsg: '登录中',
successMsg: '登录成功',
},
});
},
// 使用手机 + 验证码登录
smsLogin: (data) => {
return request({
url: '/system/auth/sms-login',
method: 'POST',
data,
custom: {
showSuccess: true,
loadingMsg: '登录中',
successMsg: '登录成功',
},
});
},
// 发送手机验证码
sendSmsCode: (mobile, scene) => {
return request({
url: '/system/auth/send-sms-code',
method: 'POST',
data: {
mobile,
scene,
},
custom: {
loadingMsg: '发送中',
showSuccess: true,
successMsg: '发送成功',
},
});
},
// 登出系统
logout: () => {
return request({
url: '/system/auth/logout',
method: 'POST',
});
},
// 刷新令牌
refreshToken: (refreshToken) => {
return request({
url: '/system/auth/refresh-token',
method: 'POST',
params: {
refreshToken,
},
custom: {
showLoading: false, // 不用加载中
showError: false, // 不展示错误提示
},
});
},
// 社交授权的跳转
socialAuthRedirect: (type, redirectUri) => {
return request({
url: '/system/auth/social-auth-redirect',
method: 'GET',
params: {
type,
redirectUri,
},
custom: {
showSuccess: true,
loadingMsg: '登陆中',
},
});
},
// 社交快捷登录
socialLogin: (type, code, state) => {
return request({
url: '/system/auth/social-login',
method: 'POST',
data: {
type,
code,
state,
},
custom: {
showSuccess: true,
loadingMsg: '登陆中',
},
});
},
// 微信小程序的一键登录
weixinMiniAppLogin: (phoneCode, loginCode, state) => {
return request({
url: '/system/auth/weixin-mini-app-login',
method: 'POST',
data: {
phoneCode,
loginCode,
state,
},
custom: {
showSuccess: true,
loadingMsg: '登陆中',
successMsg: '登录成功',
},
});
},
// 创建微信 JS SDK 初始化所需的签名
createWeixinMpJsapiSignature: (url) => {
return request({
url: '/system/auth/create-weixin-jsapi-signature',
method: 'POST',
params: {
url,
},
custom: {
showError: false,
showLoading: false,
},
});
},
// 获取用户权限信息
getInfo: () => {
return request({
url: '/system/auth/get-permission-info',
method: 'GET',
custom: {
showError: false,
showLoading: false,
},
});
},
// 获取验证图片以及 token
getCaptchaCode: (data) => {
return request({
url: '/system/captcha/get',
method: 'POST',
data,
custom: {
showError: false,
showLoading: false,
},
});
},
// 滑动或者点选验证
verifyCaptcha: (data) => {
return request({
url: '/system/captcha/check',
method: 'POST',
data,
custom: {
showError: false,
showLoading: false,
},
});
}
};
export default AuthUtil;

16
sheep/api/system/dict.js Normal file
View File

@@ -0,0 +1,16 @@
import request from '@/sheep/request';
const DictApi = {
// 根据字典类型查询字典数据信息
getDictDataListByType: (type) => {
return request({
url: `/system/dict-data/type`,
method: 'GET',
params: {
type,
},
});
},
};
export default DictApi;

View File

@@ -0,0 +1,76 @@
import request from '@/sheep/request';
const SocialApi = {
// 获得社交用户
getSocialUser: (type) => {
return request({
url: '/system/social-user/get',
method: 'GET',
params: {
type
},
custom: {
showLoading: false,
},
});
},
// 社交绑定
socialBind: (type, code, state) => {
return request({
url: '/system/social-user/bind',
method: 'POST',
data: {
type,
code,
state
},
custom: {
custom: {
showSuccess: true,
loadingMsg: '绑定中',
successMsg: '绑定成功',
},
},
});
},
// 社交解绑
socialUnbind: (type, openid) => {
return request({
url: '/system/social-user/unbind',
method: 'DELETE',
data: {
type,
openid
},
custom: {
showLoading: false,
loadingMsg: '解除绑定',
successMsg: '解绑成功',
},
});
},
// 获取订阅消息模板列表
getSubscribeTemplateList: () =>
request({
url: '/system/social-user/get-subscribe-template-list',
method: 'GET',
custom: {
showError: false,
showLoading: false,
},
}),
// 获取微信小程序码
getWxaQrcode: async (path, query) => {
return await request({
url: '/system/social-user/wxa-qrcode',
method: 'POST',
data: {
scene: query,
path,
checkPath: false, // TODO 开发环境暂不检查 path 是否存在
},
});
},
};
export default SocialApi;

97
sheep/api/system/user.js Normal file
View File

@@ -0,0 +1,97 @@
import request from '@/sheep/request';
const UserApi = {
// 获得基本信息
getUserInfo: () => {
return request({
url: '/system/user/profile/get',
method: 'GET',
custom: {
showLoading: false,
auth: true,
},
});
},
// 修改基本信息
updateUser: (data) => {
return request({
url: '/system/user/profile/update',
method: 'PUT',
data,
custom: {
auth: true,
showSuccess: true,
successMsg: '更新成功'
},
});
},
// 修改用户手机
updateUserMobile: (data) => {
return request({
url: '/system/user/profile/update-mobile',
method: 'PUT',
data,
custom: {
loadingMsg: '验证中',
showSuccess: true,
successMsg: '修改成功'
},
});
},
// 基于微信小程序的授权码,修改用户手机
updateUserMobileByWeixin: (code) => {
return request({
url: '/system/user/profile/update-mobile-by-weixin',
method: 'PUT',
data: {
code
},
custom: {
showSuccess: true,
loadingMsg: '获取中',
successMsg: '修改成功'
},
});
},
// 修改密码
updateUserPassword: (data) => {
return request({
url: '/system/user/profile/update-password',
method: 'PUT',
data,
custom: {
loadingMsg: '验证中',
showSuccess: true,
successMsg: '修改成功'
},
});
},
// 重置密码
resetUserPassword: (data) => {
return request({
url: '/system/auth/reset-password',
method: 'POST',
data,
custom: {
loadingMsg: '验证中',
showSuccess: true,
successMsg: '修改成功'
}
});
},
// 上传用户头像
uploadAvatar: (data) => {
return request({
url: '/system/user/profile/update-avatar',
method: 'PUT',
data,
custom: {
loadingMsg: '上传中',
showSuccess: true,
successMsg: '上传成功'
},
});
}
};
export default UserApi;

View File

@@ -0,0 +1,233 @@
<template>
<view v-if="dialogStore.show" class="company-dept-dialog">
<view class="company-dept-dialog__mask" @tap="handleCancel" />
<view class="company-dept-dialog__panel">
<view class="company-dept-dialog__header">
<text class="company-dept-dialog__title">{{ dialogStore.title }}</text>
</view>
<view class="company-dept-dialog__body">
<view class="company-dept-dialog__field">
<text class="company-dept-dialog__label">公司</text>
<picker
mode="selector"
:range="companyOptions"
range-key="companyName"
:value="companyIndex"
@change="onCompanyChange"
>
<view class="company-dept-dialog__picker">
<text class="company-dept-dialog__picker-text">
{{ currentCompanyName }}
</text>
<text class="company-dept-dialog__arrow"></text>
</view>
</picker>
</view>
<view class="company-dept-dialog__field">
<text class="company-dept-dialog__label">部门</text>
<picker
mode="selector"
:range="deptOptions"
range-key="deptName"
:value="deptIndex"
@change="onDeptChange"
>
<view class="company-dept-dialog__picker">
<text class="company-dept-dialog__picker-text">
{{ currentDeptName }}
</text>
<text class="company-dept-dialog__arrow"></text>
</view>
</picker>
</view>
</view>
<view class="company-dept-dialog__footer">
<button class="company-dept-dialog__btn company-dept-dialog__btn--cancel" @tap="handleCancel">
取消
</button>
<button
class="company-dept-dialog__btn company-dept-dialog__btn--confirm"
:class="{ 'company-dept-dialog__btn--disabled': !isConfirmEnabled }"
:disabled="!isConfirmEnabled"
@tap="handleConfirm"
>
确定
</button>
</view>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue';
import sheep from '@/sheep';
const dialogStore = sheep.$store('company-dept');
const companyOptions = computed(() => dialogStore.companyList || []);
const deptOptions = computed(() => dialogStore.getDeptsByCompanyId(dialogStore.selectedCompanyId));
const companyIndex = computed(() => {
const index = companyOptions.value.findIndex((item) => item.companyId === dialogStore.selectedCompanyId);
return index >= 0 ? index : 0;
});
const deptIndex = computed(() => {
const index = deptOptions.value.findIndex((item) => item.deptId === dialogStore.selectedDeptId);
return index >= 0 ? index : 0;
});
const currentCompanyName = computed(() => {
if (!companyOptions.value.length) return '暂无公司可选';
const company = companyOptions.value.find((item) => item.companyId === dialogStore.selectedCompanyId);
return company?.companyName || '请选择公司';
});
const currentDeptName = computed(() => {
if (!deptOptions.value.length) return '暂无部门可选';
const dept = deptOptions.value.find((item) => item.deptId === dialogStore.selectedDeptId);
return dept?.deptName || '请选择部门';
});
const isConfirmEnabled = computed(() => !!dialogStore.selectedCompanyId && !!dialogStore.selectedDeptId);
const onCompanyChange = (event) => {
const index = event?.detail?.value;
if (index === undefined) return;
const company = companyOptions.value[index];
if (company) {
dialogStore.setSelectedCompany(company.companyId);
}
};
const onDeptChange = (event) => {
const index = event?.detail?.value;
if (index === undefined) return;
const dept = deptOptions.value[index];
if (dept) {
dialogStore.setSelectedDept(dept.deptId);
}
};
const handleCancel = () => {
dialogStore.cancel();
};
const handleConfirm = () => {
if (!isConfirmEnabled.value) return;
dialogStore.confirm();
};
</script>
<style scoped lang="scss">
.company-dept-dialog {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
&__mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.45);
}
&__panel {
position: relative;
width: 620rpx;
background: #ffffff;
border-radius: 24rpx;
padding: 48rpx 40rpx 32rpx;
box-shadow: 0 20rpx 60rpx rgba(0, 0, 0, 0.18);
}
&__header {
margin-bottom: 40rpx;
}
&__title {
display: block;
font-size: 32rpx;
font-weight: 600;
color: #1f1f1f;
text-align: center;
line-height: 1.4;
}
&__body {
display: flex;
flex-direction: column;
gap: 32rpx;
}
&__field {
display: flex;
flex-direction: column;
gap: 16rpx;
}
&__label {
font-size: 28rpx;
color: #606266;
}
&__picker {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 28rpx;
border-radius: 16rpx;
background: #f7f8fa;
border: 1rpx solid #e4e7ed;
}
&__picker-text {
font-size: 28rpx;
color: #303133;
}
&__arrow {
font-size: 24rpx;
color: #909399;
}
&__footer {
display: flex;
justify-content: space-between;
gap: 24rpx;
margin-top: 48rpx;
}
&__btn {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
font-size: 30rpx;
font-weight: 500;
}
&__btn--cancel {
color: #606266;
background: #f5f7fa;
border: 1rpx solid transparent;
}
&__btn--confirm {
color: #ffffff;
background: linear-gradient(90deg, #2f6bff 0%, #3c8bff 100%);
border: 1rpx solid transparent;
}
&__btn--disabled {
opacity: 0.5;
}
}
</style>

View File

@@ -0,0 +1,145 @@
<!-- 绑定/更换手机号 changeMobile -->
<template>
<view>
<!-- 标题栏 -->
<view class="head-box ss-m-b-60">
<view class="head-title ss-m-b-20">
{{ userInfo.mobile ? '更换手机号' : '绑定手机号' }}
</view>
<view class="head-subtitle">为了您的账号安全请使用本人手机号码</view>
</view>
<!-- 表单项 -->
<uni-forms
ref="changeMobileRef"
v-model="state.model"c
:rules="state.rules"
validateTrigger="bind"
labelWidth="140"
labelAlign="center"
>
<uni-forms-item name="mobile" label="手机号">
<uni-easyinput
placeholder="请输入手机号"
v-model="state.model.mobile"
:inputBorder="false"
type="number"
>
<template v-slot:right>
<button
class="ss-reset-button code-btn-start"
:disabled="state.isMobileEnd"
:class="{ 'code-btn-end': state.isMobileEnd }"
@tap="getSmsCode('changeMobile', state.model.mobile)"
>
{{ getSmsTimer('changeMobile') }}
</button>
</template>
</uni-easyinput>
</uni-forms-item>
<uni-forms-item name="code" label="验证码">
<uni-easyinput
placeholder="请输入验证码"
v-model="state.model.code"
:inputBorder="false"
type="number"
maxlength="4"
>
<template v-slot:right>
<button class="ss-reset-button login-btn-start" @tap="changeMobileSubmit">
确认
</button>
</template>
</uni-easyinput>
</uni-forms-item>
</uni-forms>
<!-- 微信独有:读取手机号 -->
<button
v-if="'WechatMiniProgram' === sheep.$platform.name"
class="ss-reset-button type-btn"
open-type="getPhoneNumber"
@getphonenumber="getPhoneNumber"
>
使用微信手机号
</button>
</view>
</template>
<script setup>
import { computed, ref, reactive, unref } from 'vue';
import sheep from '@/sheep';
import { code, mobile } from '@/sheep/validate/form';
import { getSmsCode, getSmsTimer } from '@/sheep/hooks/useModal';
import UserApi from '@/sheep/api/system/user';
const changeMobileRef = ref(null);
const userInfo = computed(() => sheep.$store('user').userInfo);
// 数据
const state = reactive({
isMobileEnd: false, // 手机号输入完毕
model: {
mobile: '', // 手机号
code: '', // 验证码
},
rules: {
code,
mobile,
},
});
// 绑定手机号
async function changeMobileSubmit() {
const validate = await unref(changeMobileRef)
.validate()
.catch((error) => {
console.log('error: ', error);
});
if (!validate) {
return;
}
// 提交更新请求
const { code } = await UserApi.updateUserMobile(state.model);
if (code !== 0) {
return;
}
sheep.$store('user').getInfo();
// 绑定成功后返回上一页或首页
setTimeout(() => {
if (getCurrentPages().length > 1) {
uni.navigateBack();
} else {
uni.reLaunch({
url: '/pages/index/menu'
});
}
}, 500);
}
// 使用微信手机号
async function getPhoneNumber(e) {
if (e.detail.errMsg !== 'getPhoneNumber:ok') {
return;
}
const result = await sheep.$platform.useProvider().bindUserPhoneNumber(e.detail);
if (result) {
sheep.$store('user').getInfo();
// 绑定成功后返回上一页或首页
setTimeout(() => {
if (getCurrentPages().length > 1) {
uni.navigateBack();
} else {
uni.reLaunch({
url: '/pages/index/menu'
});
}
}, 500);
}
}
</script>
<style lang="scss" scoped>
@import '../index.scss';
</style>

View File

@@ -0,0 +1,125 @@
<!-- 修改密码登录时 -->
<template>
<view>
<!-- 标题栏 -->
<view class="head-box ss-m-b-60">
<view class="head-title ss-m-b-20">修改密码</view>
<view class="head-subtitle">如密码丢失或未设置,请点击忘记密码重新设置</view>
</view>
<!-- 表单项 -->
<uni-forms
ref="changePasswordRef"
v-model="state.model"
:rules="state.rules"
validateTrigger="bind"
labelWidth="140"
labelAlign="center"
>
<uni-forms-item name="code" label="验证码">
<uni-easyinput
placeholder="请输入验证码"
v-model="state.model.code"
type="number"
maxlength="4"
:inputBorder="false"
>
<template v-slot:right>
<button
class="ss-reset-button code-btn code-btn-start"
:disabled="state.isMobileEnd"
:class="{ 'code-btn-end': state.isMobileEnd }"
@tap="getSmsCode('changePassword')"
>
{{ getSmsTimer('resetPassword') }}
</button>
</template>
</uni-easyinput>
</uni-forms-item>
<uni-forms-item name="reNewPassword" label="密码">
<uni-easyinput
type="password"
placeholder="请输入密码"
v-model="state.model.password"
:inputBorder="false"
>
<template v-slot:right>
<button class="ss-reset-button login-btn-start" @tap="changePasswordSubmit">
确认
</button>
</template>
</uni-easyinput>
</uni-forms-item>
</uni-forms>
<button class="ss-reset-button type-btn" @tap="goBack">
取消修改
</button>
</view>
</template>
<script setup>
import { ref, reactive, unref } from 'vue';
import { code, password } from '@/sheep/validate/form';
import { getSmsCode, getSmsTimer } from '@/sheep/hooks/useModal';
import UserApi from '@/sheep/api/system/user';
const changePasswordRef = ref(null);
// 数据
const state = reactive({
model: {
mobile: '', // 手机号
code: '', // 验证码
password: '', // 密码
},
rules: {
code,
password,
},
});
// 返回上一页
function goBack() {
if (getCurrentPages().length > 1) {
uni.navigateBack();
} else {
uni.reLaunch({
url: '/pages/index/menu'
});
}
}
// 更改密码
async function changePasswordSubmit() {
// 参数校验
const validate = await unref(changePasswordRef)
.validate()
.catch((error) => {
console.log('error: ', error);
});
if (!validate) {
return;
}
// 发起请求
const { code } = await UserApi.updateUserPassword(state.model);
if (code !== 0) {
return;
}
// 成功后返回上一页或首页
setTimeout(() => {
if (getCurrentPages().length > 1) {
uni.navigateBack();
} else {
uni.reLaunch({
url: '/pages/index/menu'
});
}
}, 500);
}
</script>
<style lang="scss" scoped>
@import '../index.scss';
</style>

View File

@@ -0,0 +1,160 @@
<!-- 微信授权信息 mpAuthorization -->
<template>
<view>
<!-- 标题栏 -->
<view class="head-box ss-m-b-60 ss-flex-col">
<view class="ss-flex ss-m-b-20">
<view class="head-title ss-m-r-40 head-title-animation">授权信息</view>
</view>
<view class="head-subtitle">完善您的头像昵称手机号</view>
</view>
<!-- 表单项 -->
<uni-forms
ref="accountLoginRef"
v-model="state.model"
:rules="state.rules"
validateTrigger="bind"
labelWidth="140"
labelAlign="center"
>
<!-- 获取头像昵称https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/userProfile.html -->
<uni-forms-item name="avatar" label="头像">
<button
class="ss-reset-button avatar-btn"
open-type="chooseAvatar"
@chooseavatar="onChooseAvatar"
>
<image
class="avatar-img"
:src="sheep.$url.cdn(state.model.avatar)"
mode="aspectFill"
@tap="sheep.$router.go('/pages/user/info')"
/>
<text class="cicon-forward" />
</button>
</uni-forms-item>
<uni-forms-item name="nickname" label="昵称">
<uni-easyinput
type="nickname"
placeholder="请输入昵称"
v-model="state.model.nickname"
:inputBorder="false"
/>
</uni-forms-item>
<view class="foot-box">
<button class="ss-reset-button authorization-btn" @tap="onConfirm"> 确认授权 </button>
</view>
</uni-forms>
</view>
</template>
<script setup>
import { computed, ref, reactive } from 'vue';
import sheep from '@/sheep';
import FileApi from '@/sheep/api/infra/file';
import UserApi from '@/sheep/api/system/user';
const props = defineProps({
agreeStatus: {
type: Boolean,
default: false,
},
});
const userInfo = computed(() => sheep.$store('user').userInfo);
const accountLoginRef = ref(null);
// 数据
const state = reactive({
model: {
nickname: userInfo.value.nickname,
avatar: userInfo.value.avatar,
},
rules: {},
disabledStyle: {
color: '#999',
disableColor: '#fff',
},
});
// 选择头像(来自微信)
function onChooseAvatar(e) {
const tempUrl = e.detail.avatarUrl || '';
uploadAvatar(tempUrl);
}
// 选择头像(来自文件系统)
async function uploadAvatar(tempUrl) {
if (!tempUrl) {
return;
}
let { data } = await FileApi.uploadFile(tempUrl);
state.model.avatar = data;
}
// 确认授权
async function onConfirm() {
const { model } = state;
const { nickname, avatar } = model;
if (!nickname) {
sheep.$helper.toast('请输入昵称');
return;
}
if (!avatar) {
sheep.$helper.toast('请选择头像');
return;
}
// 发起更新
const { code } = await UserApi.updateUser({
avatar: state.model.avatar,
nickname: state.model.nickname,
});
// 更新成功
if (code === 0) {
sheep.$helper.toast('授权成功');
await sheep.$store('user').getInfo();
// 授权成功后返回上一页或首页
setTimeout(() => {
if (getCurrentPages().length > 1) {
uni.navigateBack();
} else {
uni.reLaunch({
url: '/pages/index/menu'
});
}
}, 500);
}
}
</script>
<style lang="scss" scoped>
@import '../index.scss';
.foot-box {
width: 100%;
display: flex;
justify-content: center;
}
.authorization-btn {
width: 686rpx;
height: 80rpx;
background-color: var(--ui-BG-Main);
border-radius: 40rpx;
color: #fff;
}
.avatar-img {
width: 72rpx;
height: 72rpx;
border-radius: 36rpx;
}
.cicon-forward {
font-size: 30rpx;
color: #595959;
}
.avatar-btn {
width: 100%;
justify-content: space-between;
}
</style>

View File

@@ -0,0 +1,126 @@
<!-- 重置密码未登录时 -->
<template>
<view>
<!-- 标题栏 -->
<view class="head-box ss-m-b-60">
<view class="head-title ss-m-b-20">重置密码</view>
<view class="head-subtitle">为了您的账号安全设置密码前请先进行安全验证</view>
</view>
<!-- 表单项 -->
<uni-forms
ref="resetPasswordRef"
v-model="state.model"
:rules="state.rules"
validateTrigger="bind"
labelWidth="140"
labelAlign="center"
>
<uni-forms-item name="mobile" label="手机号">
<uni-easyinput
placeholder="请输入手机号"
v-model="state.model.mobile"
type="number"
:inputBorder="false"
>
<template v-slot:right>
<button
class="ss-reset-button code-btn code-btn-start"
:disabled="state.isMobileEnd"
:class="{ 'code-btn-end': state.isMobileEnd }"
@tap="getSmsCode('resetPassword', state.model.mobile)"
>
{{ getSmsTimer('resetPassword') }}
</button>
</template>
</uni-easyinput>
</uni-forms-item>
<uni-forms-item name="code" label="验证码">
<uni-easyinput
placeholder="请输入验证码"
v-model="state.model.code"
type="number"
maxlength="4"
:inputBorder="false"
/>
</uni-forms-item>
<uni-forms-item name="password" label="密码">
<uni-easyinput
type="password"
placeholder="请输入密码"
v-model="state.model.password"
:inputBorder="false"
>
<template v-slot:right>
<button class="ss-reset-button login-btn-start" @tap="resetPasswordSubmit">
确认
</button>
</template>
</uni-easyinput>
</uni-forms-item>
</uni-forms>
<button v-if="!isLogin" class="ss-reset-button type-btn" @tap="goToLogin">
返回登录
</button>
</view>
</template>
<script setup>
import { computed, ref, reactive, unref } from 'vue';
import sheep from '@/sheep';
import { code, mobile, password } from '@/sheep/validate/form';
import { getSmsCode, getSmsTimer } from '@/sheep/hooks/useModal';
import UserApi from '@/sheep/api/system/user';
// 跳转到登录页
function goToLogin() {
uni.navigateTo({
url: '/pages/login/index'
});
}
const resetPasswordRef = ref(null);
const isLogin = computed(() => sheep.$store('user').isLogin);
// 数据
const state = reactive({
isMobileEnd: false, // 手机号输入完毕
model: {
mobile: '', // 手机号
code: '', // 验证码
password: '', // 密码
},
rules: {
code,
mobile,
password,
},
});
// 重置密码
const resetPasswordSubmit = async () => {
// 参数校验
const validate = await unref(resetPasswordRef)
.validate()
.catch((error) => {
console.log('error: ', error);
});
if (!validate) {
return;
}
// 发起请求
const { code } = await UserApi.resetUserPassword(state.model);
if (code !== 0) {
return;
}
// 成功后,用户重新登录
goToLogin();
};
</script>
<style lang="scss" scoped>
@import '../index.scss';
</style>

View File

@@ -0,0 +1,430 @@
<!-- 统一登录组件 - 整合账号密码登录和短信登录 -->
<template>
<view>
<!-- 标题栏 -->
<view class="head-box ss-m-b-60 ss-flex-col">
<!-- 登录方式切换标签 -->
<view class="ss-flex ss-m-b-20">
<view
class="head-title ss-m-r-40"
:class="{ 'head-title-active': loginType === 'account', 'head-title-animation': loginType === 'account' }"
@tap="switchLoginType('account')"
>
账号登录
</view>
<!-- <view
class="head-title"
:class="{ 'head-title-active': loginType === 'sms', 'head-title-animation': loginType === 'sms' }"
@tap="switchLoginType('sms')"
>
短信登录
</view> -->
</view>
<!-- 副标题 -->
<view class="head-subtitle">
{{ loginType === 'account' ? '如果未设置过密码,请点击忘记密码' : '未注册的手机号,验证后自动注册账号' }}
</view>
</view>
<!-- 账号密码登录表单 -->
<uni-forms
v-if="loginType === 'account'"
ref="accountLoginRef"
v-model="accountState.model"
:rules="accountState.rules"
validateTrigger="bind"
labelWidth="140"
labelAlign="center"
>
<uni-forms-item name="username" label="账号">
<uni-easyinput placeholder="请输入账号" v-model="accountState.model.username" :inputBorder="false">
<template v-slot:right>
<!-- <button class="ss-reset-button forgot-btn" @tap="showAuthModal('resetPassword')">
忘记密码
</button> -->
</template>
</uni-easyinput>
</uni-forms-item>
<uni-forms-item name="password" label="密码">
<uni-easyinput
type="password"
placeholder="请输入密码"
v-model="accountState.model.password"
:inputBorder="false"
>
<template v-slot:right>
<button class="ss-reset-button login-btn-start" @tap="handleAccountLogin">登录</button>
</template>
</uni-easyinput>
</uni-forms-item>
</uni-forms>
<!-- 短信登录表单 -->
<uni-forms
v-if="loginType === 'sms'"
ref="smsLoginRef"
v-model="smsState.model"
:rules="smsState.rules"
validateTrigger="bind"
labelWidth="140"
labelAlign="center"
>
<uni-forms-item name="mobile" label="手机号">
<uni-easyinput
placeholder="请输入手机号"
v-model="smsState.model.mobile"
:inputBorder="false"
type="number"
>
<template v-slot:right>
<button
class="ss-reset-button code-btn code-btn-start"
:disabled="!canSendSms"
:class="{ 'code-btn-end': !canSendSms }"
@tap="checkAgreementAndGetSmsCode"
>
{{ smsTimer }}
</button>
</template>
</uni-easyinput>
</uni-forms-item>
<uni-forms-item name="code" label="验证码">
<uni-easyinput
placeholder="请输入验证码"
v-model="smsState.model.code"
:inputBorder="false"
type="number"
maxlength="4"
>
<template v-slot:right>
<button class="ss-reset-button login-btn-start" @tap="handleSmsLogin">登录</button>
</template>
</uni-easyinput>
</uni-forms-item>
</uni-forms>
<!-- 验证码弹窗 -->
<s-verify
ref="verifyRef"
:captcha-type="captchaType"
mode="pop"
@success="onCaptchaSuccess"
@error="onCaptchaError"
/>
</view>
</template>
<script setup>
import { ref, reactive, unref, computed, onUnmounted } from 'vue';
import sheep from '@/sheep';
import { username, password, mobile, code } from '@/sheep/validate/form';
import { showAuthModal, getSmsCode, useSmsTimer } from '@/sheep/hooks/useModal';
import AuthUtil from '@/sheep/api/system/auth';
import SVerify from '@/sheep/components/s-verify/s-verify.vue';
import { navigateAfterLogin } from '@/sheep/helper/login-redirect';
const accountLoginRef = ref(null);
const smsLoginRef = ref(null);
const verifyRef = ref(null);
const emits = defineEmits(['onConfirm']);
const props = defineProps({
agreeStatus: {
type: [Boolean, null],
default: null,
},
});
// 登录方式account(账号) 或 sms(短信)
const loginType = ref('account');
// 验证码相关
const appStore = sheep.$store('app');
const captchaType = ref('blockPuzzle');
const captchaEnable = computed(() => appStore.captchaEnable !== false);
const pendingLoginData = ref(null);
// 短信验证码计时器
const { timer: smsTimer, cleanup } = useSmsTimer('smsLogin');
// 组件卸载时清理定时器
onUnmounted(() => {
cleanup();
});
// 账号登录数据
const accountState = reactive({
model: {
username: '', // 账号
password: '', // 密码
captchaCode: '', // 图形验证码
captchaVerification: '', // 验证码验证结果
},
rules: {
username,
password,
},
});
// 短信登录数据
const smsState = reactive({
model: {
mobile: '', // 手机号
code: '', // 验证码
captchaVerification: '', // 验证码验证结果
},
rules: {
mobile,
code,
},
});
// 是否可以发送短信验证码
const canSendSms = computed(() => {
return smsTimer.value === '获取验证码' && props.agreeStatus === true;
});
// 切换登录方式
function switchLoginType(type) {
loginType.value = type;
}
// 显示验证码弹窗
function showCaptcha() {
if (verifyRef.value) {
verifyRef.value.show();
}
}
// 验证码验证成功回调
function onCaptchaSuccess(data) {
if (pendingLoginData.value) {
pendingLoginData.value.captchaVerification = data.captchaVerification;
if (loginType.value === 'account') {
accountState.model.captchaVerification = data.captchaVerification;
} else {
smsState.model.captchaVerification = data.captchaVerification;
}
// 执行待定的登录请求
if (loginType.value === 'account') {
performAccountLogin(pendingLoginData.value);
} else {
performSmsLogin(pendingLoginData.value);
}
pendingLoginData.value = null;
}
}
// 验证码验证失败回调
function onCaptchaError(error) {
sheep.$helper.toast(error?.message || '验证码验证失败');
pendingLoginData.value = null;
}
// 处理账号登录点击
async function handleAccountLogin() {
// 表单验证
const validate = await unref(accountLoginRef)
.validate()
.catch((error) => {
console.log('表单验证失败: ', error);
});
if (!validate) return;
accountState.model.captchaVerification = '';
// 检查协议状态
if (props.agreeStatus !== true) {
emits('onConfirm', true);
sheep.$helper.toast('请先勾选协议');
return;
}
// 如果启用验证码,先显示验证码
if (captchaEnable.value) {
pendingLoginData.value = { ...accountState.model };
showCaptcha();
} else {
// 直接登录
performAccountLogin(accountState.model);
}
}
// 执行账号登录
async function performAccountLogin(loginData) {
try {
const { code } = await AuthUtil.login(loginData);
if (code === 0) {
sheep.$helper.toast('登录成功');
navigateAfterLogin();
}
} catch (error) {
sheep.$helper.toast(error.message || '登录失败');
} finally {
accountState.model.captchaVerification = '';
}
}
// 处理短信登录点击
async function handleSmsLogin() {
// 表单验证
const validate = await unref(smsLoginRef)
.validate()
.catch((error) => {
console.log('表单验证失败: ', error);
});
if (!validate) return;
smsState.model.captchaVerification = '';
// 检查协议状态
if (props.agreeStatus !== true) {
emits('onConfirm', true);
sheep.$helper.toast('请先勾选协议');
return;
}
// 如果启用验证码,先显示验证码
if (captchaEnable.value) {
pendingLoginData.value = { ...smsState.model };
showCaptcha();
} else {
// 直接登录
performSmsLogin(smsState.model);
}
}
// 执行短信登录
async function performSmsLogin(loginData) {
try {
const { code } = await AuthUtil.smsLogin(loginData);
if (code === 0) {
sheep.$helper.toast('登录成功');
navigateAfterLogin();
}
} catch (error) {
sheep.$helper.toast(error.message || '登录失败');
} finally {
smsState.model.captchaVerification = '';
}
}
// 检查协议并获取短信验证码
async function checkAgreementAndGetSmsCode() {
// 检查协议状态
if (props.agreeStatus !== true) {
emits('onConfirm', true);
sheep.$helper.toast('请先勾选协议');
return;
}
// 使用现有的getSmsCode函数
getSmsCode('smsLogin', smsState.model.mobile);
}
</script>
<style lang="scss" scoped>
@import '../index.scss';
// 标题样式增强
.head-title {
position: relative;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
color: var(--ui-BG-Main);
}
}
.head-title-active {
color: var(--ui-BG-Main);
font-weight: 600;
&::after {
content: '';
position: absolute;
bottom: -8px;
left: 50%;
transform: translateX(-50%);
width: 20px;
height: 2px;
background: var(--ui-BG-Main);
border-radius: 1px;
}
}
.head-title-animation {
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0.6;
}
to {
opacity: 1;
}
}
// 覆盖登录按钮样式,使用主题色
.login-btn-start {
background: var(--ui-BG-Main) !important;
border: none !important;
&:hover {
background: var(--ui-BG-Main-1) !important;
}
}
// 覆盖验证码按钮样式,使用主题色
.code-btn-start {
border: 2rpx solid var(--ui-BG-Main) !important;
color: var(--ui-BG-Main) !important;
&:hover {
background: var(--ui-BG-Main-tag) !important;
}
}
// 覆盖协议链接样式,使用主题色
:deep(.tcp-text) {
color: var(--ui-BG-Main) !important;
}
// 确保表单输入框焦点状态也使用主题色
:deep(.uni-easyinput__content-input) {
&:focus {
border-color: var(--ui-BG-Main) !important;
}
}
// 确保uni-forms的错误提示等也使用合适的颜色
:deep(.uni-forms-item__error) {
color: #ff4d4f !important;
}
// 按钮悬停状态优化
.login-btn-start {
transition: all 0.3s ease !important;
&:active {
background: var(--ui-BG-Main-1) !important;
transform: scale(0.98);
}
}
.code-btn-start {
transition: all 0.3s ease !important;
&:active {
background: var(--ui-BG-Main-tag) !important;
transform: scale(0.98);
}
}
</style>

View File

@@ -0,0 +1,154 @@
@keyframes title-animation {
0% {
font-size: 32rpx;
}
100% {
font-size: 36rpx;
}
}
.login-wrap {
padding: 50rpx 34rpx;
min-height: 500rpx;
background-color: #fff;
border-radius: 20rpx 20rpx 0 0;
}
.head-box {
.head-title {
min-width: 160rpx;
font-size: 36rpx;
font-weight: bold;
color: #333333;
line-height: 36rpx;
}
.head-title-active {
width: 160rpx;
font-size: 32rpx;
font-weight: 600;
color: #999;
line-height: 36rpx;
}
.head-title-animation {
animation-name: title-animation;
animation-duration: 0.1s;
animation-timing-function: ease-out;
animation-fill-mode: forwards;
}
.head-title-line {
position: relative;
&::before {
content: '';
width: 1rpx;
height: 34rpx;
background-color: #e4e7ed;
position: absolute;
left: -30rpx;
top: 50%;
transform: translateY(-50%);
}
}
.head-subtitle {
font-size: 26rpx;
font-weight: 400;
color: #afb6c0;
text-align: left;
display: flex;
}
}
// .code-btn[disabled] {
// background-color: #fff;
// }
.code-btn-start {
width: 160rpx;
height: 56rpx;
line-height: normal;
border: 2rpx solid var(--ui-BG-Main, #0055A2) !important;
border-radius: 28rpx;
font-size: 26rpx;
font-weight: 400;
color: var(--ui-BG-Main, #0055A2) !important;
opacity: 1;
}
.forgot-btn {
width: 160rpx;
line-height: 56rpx;
font-size: 30rpx;
font-weight: 500;
color: #999;
}
.login-btn-start {
width: 158rpx;
height: 56rpx;
line-height: normal;
background: var(--ui-BG-Main, #0055A2) !important;
border-radius: 28rpx;
font-size: 26rpx;
font-weight: 500;
color: #fff !important;
border: none;
position: relative;
z-index: 1;
}
.type-btn {
padding: 20rpx;
margin: 40rpx auto;
width: 200rpx;
font-size: 30rpx;
font-weight: 500;
color: #999999;
}
.auto-login-box {
width: 100%;
.auto-login-btn {
width: 68rpx;
height: 68rpx;
border-radius: 50%;
margin: 0 30rpx;
}
.auto-login-img {
width: 68rpx;
height: 68rpx;
border-radius: 50%;
}
}
.agreement-box {
margin: 80rpx auto 0;
.protocol-check {
transform: scale(0.7);
}
.agreement-text {
font-size: 26rpx;
font-weight: 500;
color: #999999;
.tcp-text {
color: var(--ui-BG-Main, #0055A2) !important;
}
}
}
// 修改密码
.editPwd-btn-box {
.save-btn {
width: 690rpx;
line-height: 70rpx;
background: var(--ui-BG-Main, #0055A2) !important;
border-radius: 35rpx;
font-size: 28rpx;
font-weight: 500;
color: #ffffff !important;
}
.forgot-btn {
width: 690rpx;
line-height: 70rpx;
font-size: 28rpx;
font-weight: 500;
color: #999999;
}
}

View File

@@ -0,0 +1,203 @@
<!--
默认头像组件
参考 zt-vue-element CropperAvatar 实现
支持默认头像逻辑头像URL优先否则显示用户昵称首字母
-->
<template>
<view class="avatar-container" @click="handleClick">
<!-- 有头像时显示图片 -->
<image
v-if="avatarUrl"
:src="avatarUrl"
:style="avatarStyle"
class="avatar-image"
mode="aspectFill"
@error="handleImageError"
/>
<!-- 无头像时显示首字母头像 -->
<view
v-else
class="avatar-initial"
:style="[avatarStyle, initialStyle]"
>
{{ initialText }}
</view>
<!-- 编辑按钮 -->
<view v-if="editable && !readonly" class="edit-mask">
<text class="edit-icon">+</text>
</view>
</view>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
// 组件属性
const props = defineProps({
// 头像URL
src: {
type: String,
default: ''
},
// 用户昵称,用于生成首字母
nickname: {
type: String,
default: ''
},
// 头像尺寸
size: {
type: [String, Number],
default: 80
},
// 是否可编辑
editable: {
type: Boolean,
default: false
},
// 是否只读
readonly: {
type: Boolean,
default: false
},
// 背景色(用于首字母头像)
bgColor: {
type: String,
default: '#0055A2'
},
// 文字色(用于首字母头像)
textColor: {
type: String,
default: '#ffffff'
},
// 自定义初始字符
initial: {
type: String,
default: ''
}
})
// 事件定义
const emit = defineEmits(['click', 'error'])
// 响应式数据
const avatarUrl = ref(props.src)
const imageError = ref(false)
// 计算属性
const avatarStyle = computed(() => {
const size = typeof props.size === 'number' ? `${props.size}rpx` : props.size
return {
width: size,
height: size,
borderRadius: '50%'
}
})
const initialStyle = computed(() => ({
backgroundColor: props.bgColor,
color: props.textColor,
fontSize: `${Math.floor(props.size * 0.4)}rpx`,
fontWeight: 'bold'
}))
// 生成初始字符逻辑
const initialText = computed(() => {
// 如果提供了自定义初始字符,使用它
if (props.initial) {
return props.initial.charAt(0).toUpperCase()
}
// 如果有昵称,取首字母
if (props.nickname) {
// 处理中文和英文
const firstChar = props.nickname.charAt(0)
// 如果是中文,直接使用
if (/[\u4e00-\u9fa5]/.test(firstChar)) {
return firstChar
}
// 如果是英文,转大写
return firstChar.toUpperCase()
}
// 默认返回用户图标
return '用'
})
// 监听src变化
watch(() => props.src, (newSrc) => {
avatarUrl.value = newSrc
imageError.value = false
}, { immediate: true })
// 方法
const handleClick = () => {
if (!props.readonly) {
emit('click')
}
}
const handleImageError = () => {
imageError.value = true
avatarUrl.value = '' // 清空URL显示首字母头像
emit('error')
}
// 暴露方法给父组件
defineExpose({
refresh: () => {
avatarUrl.value = props.src
imageError.value = false
}
})
</script>
<style lang="scss" scoped>
.avatar-container {
position: relative;
display: inline-block;
overflow: hidden;
&:hover .edit-mask {
opacity: 1;
}
}
.avatar-image {
display: block;
object-fit: cover;
}
.avatar-initial {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--theme-primary, #0055A2);
color: #ffffff;
font-weight: bold;
text-align: center;
user-select: none;
}
.edit-mask {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.edit-icon {
color: #ffffff;
font-size: 48rpx;
font-weight: bold;
}
</style>

View File

@@ -0,0 +1,173 @@
<template>
<view class="time" :style="justifyLeft">
<text class="" v-if="tipText">{{ tipText }}</text>
<text class="styleAll p6" v-if="isDay === true"
:style="{background:bgColor.bgColor,color:bgColor.Color}">{{ day }}{{bgColor.isDay?'天':''}}</text>
<text class="timeTxt" v-if="dayText"
:style="{width:bgColor.timeTxtwidth,color:bgColor.bgColor}">{{ dayText }}</text>
<text class="styleAll" :class='isCol?"timeCol":""'
:style="{background:bgColor.bgColor,color:bgColor.Color,width:bgColor.width}">{{ hour }}</text>
<text class="timeTxt" v-if="hourText" :class='isCol?"whit":""'
:style="{width:bgColor.timeTxtwidth,color:bgColor.bgColor}">{{ hourText }}</text>
<text class="styleAll" :class='isCol?"timeCol":""'
:style="{background:bgColor.bgColor,color:bgColor.Color,width:bgColor.width}">{{ minute }}</text>
<text class="timeTxt" v-if="minuteText" :class='isCol?"whit":""'
:style="{width:bgColor.timeTxtwidth,color:bgColor.bgColor}">{{ minuteText }}</text>
<text class="styleAll" :class='isCol?"timeCol":""'
:style="{background:bgColor.bgColor,color:bgColor.Color,width:bgColor.width}">{{ second }}</text>
<text class="timeTxt" v-if="secondText">{{ secondText }}</text>
</view>
</template>
<script>
export default {
name: "countDown",
props: {
justifyLeft: {
type: String,
default: ""
},
//距离开始提示文字
tipText: {
type: String,
default: "倒计时"
},
dayText: {
type: String,
default: "天"
},
hourText: {
type: String,
default: "时"
},
minuteText: {
type: String,
default: "分"
},
secondText: {
type: String,
default: "秒"
},
datatime: {
type: Number,
default: 0
},
isDay: {
type: Boolean,
default: true
},
isCol: {
type: Boolean,
default: false
},
bgColor: {
type: Object,
default: null
}
},
data: function() {
return {
day: "00",
hour: "00",
minute: "00",
second: "00"
};
},
created: function() {
this.show_time();
},
mounted: function() {},
methods: {
show_time: function() {
let that = this;
function runTime() {
//时间函数
let intDiff = that.datatime - Date.parse(new Date()) / 1000; //获取数据中的时间戳的时间差;
let day = 0,
hour = 0,
minute = 0,
second = 0;
if (intDiff > 0) {
//转换时间
if (that.isDay === true) {
day = Math.floor(intDiff / (60 * 60 * 24));
} else {
day = 0;
}
hour = Math.floor(intDiff / (60 * 60)) - day * 24;
minute = Math.floor(intDiff / 60) - day * 24 * 60 - hour * 60;
second =
Math.floor(intDiff) -
day * 24 * 60 * 60 -
hour * 60 * 60 -
minute * 60;
if (hour <= 9) hour = "0" + hour;
if (minute <= 9) minute = "0" + minute;
if (second <= 9) second = "0" + second;
that.day = day;
that.hour = hour;
that.minute = minute;
that.second = second;
} else {
that.day = "00";
that.hour = "00";
that.minute = "00";
that.second = "00";
}
}
runTime();
setInterval(runTime, 1000);
}
}
};
</script>
<style scoped>
.p6 {
padding: 0 8rpx;
}
.styleAll {
/* color: #fff; */
font-size: 24rpx;
height: 36rpx;
line-height: 36rpx;
border-radius: 6rpx;
text-align: center;
/* padding: 0 6rpx; */
}
.timeTxt {
text-align: center;
/* width: 16rpx; */
height: 36rpx;
line-height: 36rpx;
display: inline-block;
}
.whit {
color: #fff !important;
}
.time {
display: flex;
justify-content: center;
}
.red {
color: #fc4141;
margin: 0 4rpx;
}
.timeCol {
/* width: 40rpx;
height: 40rpx;
line-height: 40rpx;
text-align:center;
border-radius: 6px;
background: #fff;
font-size: 24rpx; */
color: #E93323;
}
</style>

View File

@@ -0,0 +1,93 @@
<template>
<view
class="ss-flex-col ss-col-center ss-row-center empty-box"
:style="[{ paddingTop: paddingTop + 'rpx' }]"
>
<view class=""><image class="empty-icon" :src="icon" mode="widthFix"></image></view>
<view class="empty-text ss-m-t-28 ss-m-b-40">
<text v-if="text !== ''">{{ text }}</text>
</view>
<button class="ss-reset-button empty-btn" v-if="showAction" @tap="clickAction">
{{ actionText }}
</button>
</view>
</template>
<script setup>
import sheep from '@/sheep';
/**
* 容器组件 - 装修组件的样式容器
*/
const props = defineProps({
// 图标
icon: {
type: String,
default: '',
},
// 描述
text: {
type: String,
default: '',
},
// 是否显示button
showAction: {
type: Boolean,
default: false,
},
// button 文字
actionText: {
type: String,
default: '',
},
// 链接
actionUrl: {
type: String,
default: '',
},
// 间距
paddingTop: {
type: String,
default: '260',
},
//主题色
buttonColor: {
type: String,
default: 'var(--ui-BG-Main)',
},
});
const emits = defineEmits(['clickAction']);
function clickAction() {
if (props.actionUrl !== '') {
sheep.$router.go(props.actionUrl);
}
emits('clickAction');
}
</script>
<style lang="scss" scoped>
.empty-box {
width: 100%;
}
.empty-icon {
width: 240rpx;
}
.empty-text {
font-size: 26rpx;
font-weight: 500;
color: #999999;
}
.empty-btn {
width: 320rpx;
height: 70rpx;
border: 2rpx solid v-bind('buttonColor');
border-radius: 35rpx;
font-weight: 500;
color: v-bind('buttonColor');
font-size: 28rpx;
}
</style>

View File

@@ -0,0 +1,273 @@
<template>
<view
class="page-app"
:class="['theme-' + sys?.mode, 'main-' + sys?.theme, 'font-' + sys?.fontSize]"
>
<view class="page-main" :style="[bgMain]">
<!-- &lt;!&ndash; 顶部导航栏-情况1默认通用顶部导航栏 &ndash;&gt;-->
<!-- <su-navbar-->
<!-- v-if="navbar === 'normal'"-->
<!-- :title="title"-->
<!-- statusBar-->
<!-- :color="color"-->
<!-- :tools="tools"-->
<!-- :opacityBgUi="opacityBgUi"-->
<!-- @search="(e) => emits('search', e)"-->
<!-- :defaultSearch="defaultSearch"-->
<!-- />-->
<!-- &lt;!&ndash; 顶部导航栏-情况2装修组件导航栏-标准 &ndash;&gt;-->
<!-- <s-custom-navbar-->
<!-- v-else-if="navbar === 'custom' && navbarMode === 'normal'"-->
<!-- :data="navbarStyle"-->
<!-- :showLeftButton="showLeftButton"-->
<!-- />-->
<view class="page-body" :style="[bgBody]">
<!-- &lt;!&ndash; 顶部导航栏-情况3沉浸式头部 &ndash;&gt;-->
<!-- <su-inner-navbar v-if="navbar === 'inner'" :title="title" />-->
<!-- <view-->
<!-- v-if="navbar === 'inner'"-->
<!-- :style="[{ paddingTop: sheep?.$platform?.navbar + 'px' }]"-->
<!-- ></view>-->
<!-- &lt;!&ndash; 顶部导航栏-情况4装修组件导航栏-沉浸式 &ndash;&gt;-->
<!-- <s-custom-navbar-->
<!-- v-if="navbar === 'custom' && navbarMode === 'inner'"-->
<!-- :data="navbarStyle"-->
<!-- :showLeftButton="showLeftButton"-->
<!-- />-->
<!-- 页面内容插槽 -->
<slot />
<!-- 底部导航 -->
<s-tabbar-uview v-if="tabbar !== ''" :path="tabbar" />
</view>
</view>
<view class="page-modal">
<!-- 全局快捷入口 -->
<!-- <s-menu-tools /> -->
<CompanyDeptDialog />
</view>
</view>
</template>
<script setup>
/**
* 模板组件 - 提供页面公共组件,属性,方法
*/
import { computed, onMounted } from 'vue';
import sheep from '@/sheep';
import { isEmpty } from 'lodash-es';
// #ifdef MP-WEIXIN
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app';
// #endif
// 引入新的基于 uview-plus 的底部导航组件
import STabbarUview from '@/sheep/components/s-tabbar-uview/s-tabbar-uview.vue';
import CompanyDeptDialog from '@/sheep/components/company-dept-dialog/company-dept-dialog.vue';
const props = defineProps({
title: {
type: String,
default: '',
},
navbar: {
type: String,
default: 'normal',
},
opacityBgUi: {
type: String,
default: 'bg-white',
},
color: {
type: String,
default: '',
},
tools: {
type: String,
default: 'title',
},
keyword: {
type: String,
default: '',
},
navbarStyle: {
type: Object,
default: () => ({
styleType: '',
type: '',
color: '',
src: '',
list: [],
alwaysShow: 0,
}),
},
bgStyle: {
type: Object,
default: () => ({
src: '',
color: 'var(--ui-BG-1)',
}),
},
tabbar: {
type: [String, Boolean],
default: '',
},
onShareAppMessage: {
type: [Boolean, Object],
default: true,
},
leftWidth: {
type: [Number, String],
default: 100,
},
rightWidth: {
type: [Number, String],
default: 100,
},
defaultSearch: {
type: String,
default: '',
},
//展示返回按钮
showLeftButton: {
type: Boolean,
default: false,
},
});
const emits = defineEmits(['search']);
const sysStore = sheep.$store('sys');
const userStore = sheep.$store('user');
const appStore = sheep.$store('app');
const modalStore = sheep.$store('modal');
const sys = computed(() => sysStore);
// 导航栏模式(因为有自定义导航栏 需要计算)
const navbarMode = computed(() => {
if (props.navbar === 'normal' || props.navbarStyle.styleType === 'normal') {
return 'normal';
}
return 'inner';
});
// 背景1
const bgMain = computed(() => {
if (navbarMode.value === 'inner') {
return {
background: `${props.bgStyle.backgroundColor || props.bgStyle.color} url(${sheep.$url.cdn(
props.bgStyle.backgroundImage,
)}) no-repeat top center / 100% auto`,
};
}
return {};
});
// 背景2
const bgBody = computed(() => {
if (navbarMode.value === 'normal') {
return {
background: `${props.bgStyle.backgroundColor || props.bgStyle.color} url(${sheep.$url.cdn(
props.bgStyle.backgroundImage,
)}) no-repeat top center / 100% auto`,
};
}
return {};
});
// 分享信息
const shareInfo = computed(() => {
if (props.onShareAppMessage === true) {
return sheep.$platform.share.getShareInfo();
} else {
if (!isEmpty(props.onShareAppMessage)) {
sheep.$platform.share.updateShareInfo(props.onShareAppMessage);
return props.onShareAppMessage;
}
}
return {};
});
// #ifdef MP-WEIXIN
uni.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage', 'shareTimeline'],
});
// 微信小程序分享好友
onShareAppMessage(() => {
return {
title: shareInfo.value.title,
path: shareInfo.value.forward.path,
imageUrl: shareInfo.value.image,
};
});
// 微信小程序分享朋友圈
onShareTimeline(() => {
return {
title: shareInfo.value.title,
query: shareInfo.value.forward.path,
imageUrl: shareInfo.value.image,
};
});
// #endif
// 组件中使用 onMounted 监听页面加载,不是页面组件不使用 onShow
onMounted(()=>{
if (!isEmpty(shareInfo.value)) {
sheep.$platform.share.updateShareInfo(shareInfo.value);
}
})
</script>
<style lang="scss" scoped>
.page-app {
position: relative;
color: var(--ui-TC);
background-color: var(--ui-BG-1) !important;
z-index: 2;
display: flex;
width: 100%;
height: 100vh;
.page-main {
position: absolute;
z-index: 1;
width: 100%;
min-height: 100%;
display: flex;
flex-direction: column;
.page-body {
width: 100%;
position: relative;
z-index: 1;
flex: 1;
}
.page-img {
width: 100vw;
height: 100vh;
position: absolute;
top: 0;
left: 0;
z-index: 0;
}
}
.page-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
pointer-events: none;
> * {
pointer-events: auto;
}
}
}
</style>

View File

@@ -0,0 +1,167 @@
<template>
<view class="tabbar-container" v-if="showTabbar">
<u-tabbar
:value="currentPath"
:fixed="true"
:placeholder="true"
:safeAreaInsetBottom="true"
:border="true"
:activeColor="activeColor"
:inactiveColor="inactiveColor"
:bgColor="bgColor"
@change="handleTabbarChange"
>
<u-tabbar-item
v-for="(item, index) in tabbarList"
:key="index"
:name="item.pagePath"
:text="item.text"
>
<template #active-icon>
<view class="custom-icon active">
<u-icon
:name="item.activeIcon || item.icon"
size="24"
:color="activeColor"
/>
</view>
</template>
<template #inactive-icon>
<view class="custom-icon">
<u-icon
:name="item.icon"
size="24"
:color="inactiveColor"
/>
</view>
</template>
</u-tabbar-item>
</u-tabbar>
</view>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const props = defineProps({
path: {
type: String,
default: ''
}
})
// 底部导航配置
const tabbarList = ref([
{
pagePath: '/pages/index/menu',
text: '菜单',
icon: 'home',
activeIcon: 'home-fill'
},
{
pagePath: '/pages/index/user',
text: '我的',
icon: 'account',
activeIcon: 'account-fill'
}
])
// 颜色配置
const activeColor = '#0055A2'
const inactiveColor = '#999999'
const bgColor = '#ffffff'
// 当前路径
const currentPath = computed(() => {
return props.path || getCurrentPath()
})
// 是否显示tabbar
const showTabbar = computed(() => {
const currentRoute = getCurrentPath()
return tabbarList.value.some(item => item.pagePath === currentRoute)
})
// 获取当前页面路径
const getCurrentPath = () => {
const pages = getCurrentPages()
if (pages.length > 0) {
const currentPage = pages[pages.length - 1]
return '/' + currentPage.route
}
return ''
}
// 处理tabbar切换
const handleTabbarChange = (name) => {
if (name !== currentPath.value) {
uni.switchTab({
url: name,
fail: (error) => {
console.error('switchTab failed:', error)
// 如果switchTab失败使用普通跳转
uni.navigateTo({
url: name,
fail: (navError) => {
console.error('navigateTo failed:', navError)
}
})
}
})
}
}
onMounted(() => {
// 隐藏原生tabBar
uni.hideTabBar({
animation: false
})
})
</script>
<style lang="scss" scoped>
.tabbar-container {
position: relative;
z-index: 999;
}
.custom-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
transition: transform 0.2s ease;
&.active {
transform: scale(1.1);
}
}
:deep(.u-tabbar) {
background-color: #ffffff;
border-top: 1px solid #ebedf0;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
.u-tabbar-item {
padding: 4px 0 8px;
&__text {
font-size: 10px;
margin-top: 4px;
font-weight: 500;
line-height: 1.2;
}
&__icon {
margin-bottom: 0;
}
}
}
// 为不同主题色适配
:deep(.u-tabbar .u-tabbar-item--active .u-tabbar-item__text) {
color: #0055A2;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,304 @@
'use strict';
import FileApi from '@/sheep/api/infra/file';
const ERR_MSG_OK = 'chooseAndUploadFile:ok';
const ERR_MSG_FAIL = 'chooseAndUploadFile:fail';
function chooseImage(opts) {
const {
count,
sizeType = ['original', 'compressed'],
sourceType = ['album', 'camera'],
extension,
} = opts;
return new Promise((resolve, reject) => {
uni.chooseImage({
count,
sizeType,
sourceType,
extension,
success(res) {
resolve(normalizeChooseAndUploadFileRes(res, 'image'));
},
fail(res) {
reject({
errMsg: res.errMsg.replace('chooseImage:fail', ERR_MSG_FAIL),
});
},
});
});
}
function chooseVideo(opts) {
const { camera, compressed, maxDuration, sourceType = ['album', 'camera'], extension } = opts;
return new Promise((resolve, reject) => {
uni.chooseVideo({
camera,
compressed,
maxDuration,
sourceType,
extension,
success(res) {
const { tempFilePath, duration, size, height, width } = res;
resolve(
normalizeChooseAndUploadFileRes(
{
errMsg: 'chooseVideo:ok',
tempFilePaths: [tempFilePath],
tempFiles: [
{
name: (res.tempFile && res.tempFile.name) || '',
path: tempFilePath,
size,
type: (res.tempFile && res.tempFile.type) || '',
width,
height,
duration,
fileType: 'video',
cloudPath: '',
},
],
},
'video',
),
);
},
fail(res) {
reject({
errMsg: res.errMsg.replace('chooseVideo:fail', ERR_MSG_FAIL),
});
},
});
});
}
function chooseAll(opts) {
const { count, extension } = opts;
return new Promise((resolve, reject) => {
let chooseFile = uni.chooseFile;
if (typeof wx !== 'undefined' && typeof wx.chooseMessageFile === 'function') {
chooseFile = wx.chooseMessageFile;
}
if (typeof chooseFile !== 'function') {
return reject({
errMsg: ERR_MSG_FAIL + ' 请指定 type 类型,该平台仅支持选择 image 或 video。',
});
}
chooseFile({
type: 'all',
count,
extension,
success(res) {
resolve(normalizeChooseAndUploadFileRes(res));
},
fail(res) {
reject({
errMsg: res.errMsg.replace('chooseFile:fail', ERR_MSG_FAIL),
});
},
});
});
}
function normalizeChooseAndUploadFileRes(res, fileType) {
res.tempFiles.forEach((item, index) => {
if (!item.name) {
item.name = item.path.substring(item.path.lastIndexOf('/') + 1);
}
if (fileType) {
item.fileType = fileType;
}
item.cloudPath = Date.now() + '_' + index + item.name.substring(item.name.lastIndexOf('.'));
});
if (!res.tempFilePaths) {
res.tempFilePaths = res.tempFiles.map((file) => file.path);
}
return res;
}
async function readFile(uniFile) {
// 微信小程序
if (uni.getFileSystemManager) {
const fs = uni.getFileSystemManager();
return fs.readFileSync(uniFile.path);
}
// H5 等
return uniFile.arrayBuffer();
}
function uploadCloudFiles(files, max = 5, onUploadProgress) {
files = JSON.parse(JSON.stringify(files));
const len = files.length;
let count = 0;
let self = this;
return new Promise((resolve) => {
while (count < max) {
next();
}
function next() {
let cur = count++;
if (cur >= len) {
!files.find((item) => !item.url && !item.errMsg) && resolve(files);
return;
}
const fileItem = files[cur];
const index = self.files.findIndex((v) => v.uuid === fileItem.uuid);
fileItem.url = '';
delete fileItem.errMsg;
uniCloud
.uploadFile({
filePath: fileItem.path,
cloudPath: fileItem.cloudPath,
fileType: fileItem.fileType,
onUploadProgress: (res) => {
res.index = index;
onUploadProgress && onUploadProgress(res);
},
})
.then((res) => {
fileItem.url = res.fileID;
fileItem.index = index;
if (cur < len) {
next();
}
})
.catch((res) => {
fileItem.errMsg = res.errMsg || res.message;
fileItem.index = index;
if (cur < len) {
next();
}
});
}
});
}
function uploadFilesFromPath(path, directory = '') {
// 目的:用于微信小程序,选择图片时,只有 path
return uploadFiles(
Promise.resolve({
tempFiles: [
{
path,
type: 'image/jpeg',
name: path.includes('/') ? path.substring(path.lastIndexOf('/') + 1) : path,
},
],
}),
{
directory,
},
);
}
async function uploadFiles(choosePromise, { onChooseFile, onUploadProgress, directory }) {
// 获取选择的文件
const res = await choosePromise;
// 处理文件选择回调
let files = res.tempFiles || [];
if (onChooseFile) {
const customChooseRes = onChooseFile(res);
if (typeof customChooseRes !== 'undefined') {
files = await Promise.resolve(customChooseRes);
if (typeof files === 'undefined') {
files = res.tempFiles || []; // Fallback
}
}
}
// 如果是前端直连上传
if (UPLOAD_TYPE.CLIENT === import.meta.env.SHOPRO_UPLOAD_TYPE) {
// 为上传创建一组 Promise
const uploadPromises = files.map(async (file) => {
try {
// 1.1 获取文件预签名地址
const { data: presignedInfo } = await FileApi.getFilePresignedUrl(file.name, directory);
// 1.2 获取二进制文件对象
const fileBuffer = await readFile(file);
// 返回上传的 Promise
return new Promise((resolve, reject) => {
// 1.3. 上传文件到 S3
uni.request({
url: presignedInfo.uploadUrl,
method: 'PUT',
header: {
'Content-Type': file.type,
},
data: fileBuffer,
success: (res) => {
// 1.4. 记录文件信息到后端(异步)
createFile(presignedInfo, file);
// 1.5. 重新赋值
file.url = presignedInfo.url;
resolve(file);
},
fail: (err) => {
reject(err);
},
});
});
} catch (error) {
console.error('上传失败:', error);
throw error;
}
});
// 等待所有上传完成
return await Promise.all(uploadPromises); // 返回已上传的文件列表
} else {
// 后端上传
for (let file of files) {
const { data } = await FileApi.uploadFile(file.path, directory);
file.url = data;
}
return files;
}
}
function chooseAndUploadFile(
opts = {
type: 'all',
directory: undefined,
},
) {
if (opts.type === 'image') {
return uploadFiles(chooseImage(opts), opts);
} else if (opts.type === 'video') {
return uploadFiles(chooseVideo(opts), opts);
}
return uploadFiles(chooseAll(opts), opts);
}
/**
* 创建文件信息
* @param vo 文件预签名信息
* @param file 文件
*/
function createFile(vo, file) {
const fileVo = {
configId: vo.configId,
url: vo.url,
path: vo.path,
name: file.name,
type: file.fileType,
size: file.size,
};
FileApi.createFile(fileVo);
return fileVo;
}
/**
* 上传类型
*/
const UPLOAD_TYPE = {
// 客户端直接上传只支持S3服务
CLIENT: 'client',
// 客户端发送到后端上传
SERVER: 'server',
};
export { chooseAndUploadFile, uploadCloudFiles, uploadFilesFromPath };

View File

@@ -0,0 +1,677 @@
<!-- 文件上传基于 upload-file upload-image 实现 -->
<template>
<view class="uni-file-picker">
<view v-if="title" class="uni-file-picker__header">
<text class="file-title">{{ title }}</text>
<text class="file-count">{{ filesList.length }}/{{ limitLength }}</text>
</view>
<view v-if="subtitle" class="file-subtitle">
<view>{{ subtitle }}</view>
</view>
<upload-image
v-if="fileMediatype === 'image' && showType === 'grid'"
:readonly="readonly"
:image-styles="imageStyles"
:files-list="url"
:limit="limitLength"
:disablePreview="disablePreview"
:delIcon="delIcon"
@uploadFiles="uploadFiles"
@choose="choose"
@delFile="delFile"
>
<slot>
<view class="is-add">
<image :src="imgsrc" class="add-icon"></image>
</view>
</slot>
</upload-image>
<upload-file
v-if="fileMediatype !== 'image' || showType !== 'grid'"
:readonly="readonly"
:list-styles="listStyles"
:files-list="filesList"
:showType="showType"
:delIcon="delIcon"
@uploadFiles="uploadFiles"
@choose="choose"
@delFile="delFile"
>
<slot><button type="primary" size="mini">选择文件</button></slot>
</upload-file>
</view>
</template>
<script>
import { chooseAndUploadFile, uploadCloudFiles } from './choose-and-upload-file.js';
import { get_extname, get_files_and_is_max, get_file_data } from './utils.js';
import uploadImage from './upload-image.vue';
import uploadFile from './upload-file.vue';
import sheep from '@/sheep';
import { isEmpty } from 'lodash-es';
let fileInput = null;
/**
* FilePicker 文件选择上传
* @description 文件选择上传组件,可以选择图片、视频等任意文件并上传到当前绑定的服务空间
* @tutorial https://ext.dcloud.net.cn/plugin?id=4079
* @property {Object|Array} value 组件数据,通常用来回显 ,类型由return-type属性决定
* @property {String|Array} url url数据
* @property {Boolean} disabled = [true|false] 组件禁用
* @value true 禁用
* @value false 取消禁用
* @property {Boolean} readonly = [true|false] 组件只读,不可选择,不显示进度,不显示删除按钮
* @value true 只读
* @value false 取消只读
* @property {Boolean} disable-preview = [true|false] 禁用图片预览,仅 mode:grid 时生效
* @value true 禁用图片预览
* @value false 取消禁用图片预览
* @property {Boolean} del-icon = [true|false] 是否显示删除按钮
* @value true 显示删除按钮
* @value false 不显示删除按钮
* @property {Boolean} auto-upload = [true|false] 是否自动上传值为true则只触发@select,可自行上传
* @value true 自动上传
* @value false 取消自动上传
* @property {Number|String} limit 最大选择个数 h5 会自动忽略多选的部分
* @property {String} title 组件标题,右侧显示上传计数
* @property {String} mode = [list|grid] 选择文件后的文件列表样式
* @value list 列表显示
* @value grid 宫格显示
* @property {String} file-mediatype = [image|video|all] 选择文件类型
* @value image 只选择图片
* @value video 只选择视频
* @value all 选择所有文件
* @property {Array} file-extname 选择文件后缀,根据 file-mediatype 属性而不同
* @property {Object} list-style mode:list 时的样式
* @property {Object} image-styles 选择文件后缀,根据 file-mediatype 属性而不同
* @event {Function} select 选择文件后触发
* @event {Function} progress 文件上传时触发
* @event {Function} success 上传成功触发
* @event {Function} fail 上传失败触发
* @event {Function} delete 文件从列表移除时触发
*/
export default {
name: 'sUploader',
components: {
uploadImage,
uploadFile,
},
options: {
virtualHost: true,
},
emits: ['select', 'success', 'fail', 'progress', 'delete', 'update:modelValue', 'update:url'],
props: {
modelValue: {
type: [Array, Object],
default() {
return [];
},
},
url: {
type: [Array, String],
default() {
return [];
},
},
disabled: {
type: Boolean,
default: false,
},
disablePreview: {
type: Boolean,
default: false,
},
delIcon: {
type: Boolean,
default: true,
},
// 自动上传
autoUpload: {
type: Boolean,
default: true,
},
// 最大选择个数 h5只能限制单选或是多选
limit: {
type: [Number, String],
default: 9,
},
// 列表样式 grid | list | list-card
mode: {
type: String,
default: 'grid',
},
// 选择文件类型 image/video/all
fileMediatype: {
type: String,
default: 'image',
},
// 文件类型筛选
fileExtname: {
type: [Array, String],
default() {
return [];
},
},
title: {
type: String,
default: '',
},
listStyles: {
type: Object,
default() {
return {
// 是否显示边框
border: true,
// 是否显示分隔线
dividline: true,
// 线条样式
borderStyle: {},
};
},
},
imageStyles: {
type: Object,
default() {
return {
width: 'auto',
height: 'auto',
};
},
},
readonly: {
type: Boolean,
default: false,
},
sizeType: {
type: Array,
default() {
return ['original', 'compressed'];
},
},
driver: {
type: String,
default: 'local', // local=本地 | oss | unicloud
},
subtitle: {
type: String,
default: '',
},
},
data() {
return {
files: [],
localValue: [],
imgsrc: sheep.$url.static('/static/img/shop/upload-camera.png'),
};
},
watch: {
modelValue: {
handler(newVal, oldVal) {
this.setValue(newVal, oldVal);
},
immediate: true,
},
},
computed: {
returnType() {
if (this.limit > 1) {
return 'array';
}
return 'object';
},
filesList() {
let files = [];
this.files.forEach((v) => {
files.push(v);
});
return files;
},
showType() {
if (this.fileMediatype === 'image') {
return this.mode;
}
return 'list';
},
limitLength() {
if (this.returnType === 'object') {
return 1;
}
if (!this.limit) {
return 1;
}
if (this.limit >= 9) {
return 9;
}
return this.limit;
},
},
created() {
if (this.driver === 'local') {
uniCloud.chooseAndUploadFile = chooseAndUploadFile;
}
this.form = this.getForm('uniForms');
this.formItem = this.getForm('uniFormsItem');
if (this.form && this.formItem) {
if (this.formItem.name) {
this.rename = this.formItem.name;
this.form.inputChildrens.push(this);
}
}
},
methods: {
/**
* 公开用户使用,清空文件
* @param {Object} index
*/
clearFiles(index) {
if (index !== 0 && !index) {
this.files = [];
this.$nextTick(() => {
this.setEmit();
});
} else {
this.files.splice(index, 1);
}
this.$nextTick(() => {
this.setEmit();
});
},
/**
* 公开用户使用,继续上传
*/
upload() {
let files = [];
this.files.forEach((v, index) => {
if (v.status === 'ready' || v.status === 'error') {
files.push(Object.assign({}, v));
}
});
return this.uploadFiles(files);
},
async setValue(newVal, oldVal) {
const newData = async (v) => {
const reg = /cloud:\/\/([\w.]+\/?)\S*/;
let url = '';
if (v.fileID) {
url = v.fileID;
} else {
url = v.url;
}
if (reg.test(url)) {
v.fileID = url;
v.url = await this.getTempFileURL(url);
}
if (v.url) v.path = v.url;
return v;
};
if (this.returnType === 'object') {
if (newVal) {
await newData(newVal);
} else {
newVal = {};
}
} else {
if (!newVal) newVal = [];
for (let i = 0; i < newVal.length; i++) {
let v = newVal[i];
await newData(v);
}
}
this.localValue = newVal;
if (this.form && this.formItem && !this.is_reset) {
this.is_reset = false;
this.formItem.setValue(this.localValue);
}
let filesData = Object.keys(newVal).length > 0 ? newVal : [];
this.files = [].concat(filesData);
},
/**
* 选择文件
*/
choose() {
if (this.disabled) return;
if (
this.files.length >= Number(this.limitLength) &&
this.showType !== 'grid' &&
this.returnType === 'array'
) {
uni.showToast({
title: `您最多选择 ${this.limitLength} 个文件`,
icon: 'none',
});
return;
}
this.chooseFiles();
},
/**
* 选择文件并上传
*/
async chooseFiles() {
const _extname = get_extname(this.fileExtname);
// 获取后缀
await chooseAndUploadFile({
type: this.fileMediatype,
compressed: false,
sizeType: this.sizeType,
// TODO 如果为空video 有问题
extension: _extname.length > 0 ? _extname : undefined,
count: this.limitLength - this.files.length, //默认9
onChooseFile: this.chooseFileCallback,
onUploadProgress: (progressEvent) => {
this.setProgress(progressEvent, progressEvent.index);
},
})
.then((result) => {
this.setSuccessAndError(result);
})
.catch((err) => {
console.log('选择失败', err);
});
},
/**
* 选择文件回调
* @param {Object} res
*/
async chooseFileCallback(res) {
const _extname = get_extname(this.fileExtname);
const is_one =
(Number(this.limitLength) === 1 && this.disablePreview && !this.disabled) ||
this.returnType === 'object';
// 如果这有一个文件 ,需要清空本地缓存数据
if (is_one) {
this.files = [];
}
let { filePaths, files } = get_files_and_is_max(res, _extname);
if (!(_extname && _extname.length > 0)) {
filePaths = res.tempFilePaths;
files = res.tempFiles;
}
let currentData = [];
for (let i = 0; i < files.length; i++) {
if (this.limitLength - this.files.length <= 0) break;
files[i].uuid = Date.now();
let filedata = await get_file_data(files[i], this.fileMediatype);
filedata.progress = 0;
filedata.status = 'ready';
this.files.push(filedata);
currentData.push({
...filedata,
file: files[i],
});
}
this.$emit('select', {
tempFiles: currentData,
tempFilePaths: filePaths,
});
res.tempFiles = files;
// 停止自动上传
if (!this.autoUpload) {
res.tempFiles = [];
}
},
/**
* 批传
* @param {Object} e
*/
uploadFiles(files) {
files = [].concat(files);
return uploadCloudFiles
.call(this, files, 5, (res) => {
this.setProgress(res, res.index, true);
})
.then((result) => {
this.setSuccessAndError(result);
return result;
})
.catch((err) => {
console.log(err);
});
},
/**
* 成功或失败
*/
async setSuccessAndError(res, fn) {
let successData = [];
let errorData = [];
let tempFilePath = [];
let errorTempFilePath = [];
for (let i = 0; i < res.length; i++) {
const item = res[i];
const index = item.uuid ? this.files.findIndex((p) => p.uuid === item.uuid) : item.index;
if (index === -1 || !this.files) break;
if (item.errMsg === 'request:fail') {
this.files[index].url = item.url;
this.files[index].status = 'error';
this.files[index].errMsg = item.errMsg;
// this.files[index].progress = -1
errorData.push(this.files[index]);
errorTempFilePath.push(this.files[index].url);
} else {
this.files[index].errMsg = '';
this.files[index].fileID = item.url;
const reg = /cloud:\/\/([\w.]+\/?)\S*/;
if (reg.test(item.url)) {
this.files[index].url = await this.getTempFileURL(item.url);
} else {
this.files[index].url = item.url;
}
this.files[index].status = 'success';
this.files[index].progress += 1;
successData.push(this.files[index]);
tempFilePath.push(this.files[index].fileID);
}
}
if (successData.length > 0) {
this.setEmit();
// 状态改变返回
this.$emit('success', {
tempFiles: this.backObject(successData),
tempFilePaths: tempFilePath,
});
}
if (errorData.length > 0) {
this.$emit('fail', {
tempFiles: this.backObject(errorData),
tempFilePaths: errorTempFilePath,
});
}
},
/**
* 获取进度
* @param {Object} progressEvent
* @param {Object} index
* @param {Object} type
*/
setProgress(progressEvent, index, type) {
const fileLenth = this.files.length;
const percentNum = (index / fileLenth) * 100;
const percentCompleted = Math.round((progressEvent.loaded * 100) / progressEvent.total);
let idx = index;
if (!type) {
idx = this.files.findIndex((p) => p.uuid === progressEvent.tempFile.uuid);
}
if (idx === -1 || !this.files[idx]) return;
// fix by mehaotian 100 就会消失,-1 是为了让进度条消失
this.files[idx].progress = percentCompleted - 1;
// 上传中
this.$emit('progress', {
index: idx,
progress: parseInt(percentCompleted),
tempFile: this.files[idx],
});
},
/**
* 删除文件
* @param {Object} index
*/
delFile(index) {
if (!isEmpty(this.files)) {
this.$emit('delete', {
tempFile: this.files[index],
tempFilePath: this.files[index].url,
});
this.files.splice(index, 1);
} else {
this.$emit('delete', {
tempFilePath: this.url,
});
}
this.$nextTick(() => {
this.setEmit();
});
},
/**
* 获取文件名和后缀
* @param {Object} name
*/
getFileExt(name) {
const last_len = name.lastIndexOf('.');
const len = name.length;
return {
name: name.substring(0, last_len),
ext: name.substring(last_len + 1, len),
};
},
/**
* 处理返回事件
*/
setEmit() {
let data = [];
let updateUrl = [];
if (this.returnType === 'object') {
data = this.backObject(this.files)[0];
this.localValue = data ? data : null;
updateUrl = data ? data.url : '';
} else {
data = this.backObject(this.files);
if (!this.localValue) {
this.localValue = [];
}
this.localValue = [...data];
if (this.localValue.length > 0) {
this.localValue.forEach((item) => {
updateUrl.push(item.url);
});
}
}
this.$emit('update:modelValue', this.localValue);
this.$emit('update:url', updateUrl);
},
/**
* 处理返回参数
* @param {Object} files
*/
backObject(files) {
let newFilesData = [];
files.forEach((v) => {
newFilesData.push({
extname: v.extname,
fileType: v.fileType,
image: v.image,
name: v.name,
path: v.path,
size: v.size,
fileID: v.fileID,
url: v.url,
});
});
return newFilesData;
},
async getTempFileURL(fileList) {
fileList = {
fileList: [].concat(fileList),
};
const urls = await uniCloud.getTempFileURL(fileList);
return urls.fileList[0].tempFileURL || '';
},
/**
* 获取父元素实例
*/
getForm(name = 'uniForms') {
let parent = this.$parent;
let parentName = parent.$options.name;
while (parentName !== name) {
parent = parent.$parent;
if (!parent) return false;
parentName = parent.$options.name;
}
return parent;
},
},
};
</script>
<style lang="scss" scoped>
.uni-file-picker {
/* #ifndef APP-NVUE */
box-sizing: border-box;
overflow: hidden;
/* width: 100%; */
/* #endif */
/* flex: 1; */
position: relative;
}
.uni-file-picker__header {
padding-top: 5px;
padding-bottom: 10px;
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
justify-content: space-between;
}
.file-title {
font-size: 14px;
color: #333;
}
.file-count {
font-size: 14px;
color: #999;
}
.is-add {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
align-items: center;
justify-content: center;
}
.add-icon {
width: 57rpx;
height: 49rpx;
}
.file-subtitle {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 0;
width: 140rpx;
height: 36rpx;
z-index: 1;
display: flex;
justify-content: center;
color: #fff;
font-weight: 500;
background: rgba(#000, 0.3);
font-size: 24rpx;
}
</style>

View File

@@ -0,0 +1,335 @@
<template>
<view class="uni-file-picker__files">
<view v-if="!readonly" class="files-button" @click="choose">
<slot></slot>
</view>
<!-- :class="{'is-text-box':showType === 'list'}" -->
<view v-if="list.length > 0" class="uni-file-picker__lists is-text-box" :style="borderStyle">
<!-- ,'is-list-card':showType === 'list-card' -->
<view
class="uni-file-picker__lists-box"
v-for="(item, index) in list"
:key="index"
:class="{
'files-border': index !== 0 && styles.dividline,
}"
:style="index !== 0 && styles.dividline && borderLineStyle"
>
<view class="uni-file-picker__item">
<!-- :class="{'is-text-image':showType === 'list'}" -->
<!-- <view class="files__image is-text-image">
<image class="header-image" :src="item.logo" mode="aspectFit"></image>
</view> -->
<view class="files__name">{{ item.name }}</view>
<view v-if="delIcon && !readonly" class="icon-del-box icon-files" @click="delFile(index)">
<view class="icon-del icon-files"></view>
<view class="icon-del rotate"></view>
</view>
</view>
<view
v-if="(item.progress && item.progress !== 100) || item.progress === 0"
class="file-picker__progress"
>
<progress
class="file-picker__progress-item"
:percent="item.progress === -1 ? 0 : item.progress"
stroke-width="4"
:backgroundColor="item.errMsg ? '#ff5a5f' : '#EBEBEB'"
/>
</view>
<view
v-if="item.status === 'error'"
class="file-picker__mask"
@click.stop="uploadFiles(item, index)"
>
点击重试
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'uploadFile',
emits: ['uploadFiles', 'choose', 'delFile'],
props: {
filesList: {
type: Array,
default() {
return [];
},
},
delIcon: {
type: Boolean,
default: true,
},
limit: {
type: [Number, String],
default: 9,
},
showType: {
type: String,
default: '',
},
listStyles: {
type: Object,
default() {
return {
// 是否显示边框
border: true,
// 是否显示分隔线
dividline: true,
// 线条样式
borderStyle: {},
};
},
},
readonly: {
type: Boolean,
default: false,
},
},
computed: {
list() {
let files = [];
this.filesList.forEach((v) => {
files.push(v);
});
return files;
},
styles() {
let styles = {
border: true,
dividline: true,
'border-style': {},
};
return Object.assign(styles, this.listStyles);
},
borderStyle() {
let { borderStyle, border } = this.styles;
let obj = {};
if (!border) {
obj.border = 'none';
} else {
let width = (borderStyle && borderStyle.width) || 1;
width = this.value2px(width);
let radius = (borderStyle && borderStyle.radius) || 5;
radius = this.value2px(radius);
obj = {
'border-width': width,
'border-style': (borderStyle && borderStyle.style) || 'solid',
'border-color': (borderStyle && borderStyle.color) || '#eee',
'border-radius': radius,
};
}
let classles = '';
for (let i in obj) {
classles += `${i}:${obj[i]};`;
}
return classles;
},
borderLineStyle() {
let obj = {};
let { borderStyle } = this.styles;
if (borderStyle && borderStyle.color) {
obj['border-color'] = borderStyle.color;
}
if (borderStyle && borderStyle.width) {
let width = (borderStyle && borderStyle.width) || 1;
let style = (borderStyle && borderStyle.style) || 0;
if (typeof width === 'number') {
width += 'px';
} else {
width = width.indexOf('px') ? width : width + 'px';
}
obj['border-width'] = width;
if (typeof style === 'number') {
style += 'px';
} else {
style = style.indexOf('px') ? style : style + 'px';
}
obj['border-top-style'] = style;
}
let classles = '';
for (let i in obj) {
classles += `${i}:${obj[i]};`;
}
return classles;
},
},
methods: {
uploadFiles(item, index) {
this.$emit('uploadFiles', {
item,
index,
});
},
choose() {
this.$emit('choose');
},
delFile(index) {
this.$emit('delFile', index);
},
value2px(value) {
if (typeof value === 'number') {
value += 'px';
} else {
value = value.indexOf('px') !== -1 ? value : value + 'px';
}
return value;
},
},
};
</script>
<style lang="scss">
.uni-file-picker__files {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
flex-direction: column;
justify-content: flex-start;
}
.files-button {
// border: 1px red solid;
}
.uni-file-picker__lists {
position: relative;
margin-top: 5px;
overflow: hidden;
}
.file-picker__mask {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
justify-content: center;
align-items: center;
position: absolute;
right: 0;
top: 0;
bottom: 0;
left: 0;
color: #fff;
font-size: 14px;
background-color: rgba(0, 0, 0, 0.4);
}
.uni-file-picker__lists-box {
position: relative;
}
.uni-file-picker__item {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
align-items: center;
padding: 8px 10px;
padding-right: 5px;
padding-left: 10px;
}
.files-border {
border-top: 1px #eee solid;
}
.files__name {
flex: 1;
font-size: 14px;
color: #666;
margin-right: 25px;
/* #ifndef APP-NVUE */
word-break: break-all;
word-wrap: break-word;
/* #endif */
}
.icon-files {
/* #ifndef APP-NVUE */
position: static;
background-color: initial;
/* #endif */
}
// .icon-files .icon-del {
// background-color: #333;
// width: 12px;
// height: 1px;
// }
.is-list-card {
border: 1px #eee solid;
margin-bottom: 5px;
border-radius: 5px;
box-shadow: 0 0 2px 0px rgba(0, 0, 0, 0.1);
padding: 5px;
}
.files__image {
width: 40px;
height: 40px;
margin-right: 10px;
}
.header-image {
width: 100%;
height: 100%;
}
.is-text-box {
border: 1px #eee solid;
border-radius: 5px;
}
.is-text-image {
width: 25px;
height: 25px;
margin-left: 5px;
}
.rotate {
position: absolute;
transform: rotate(90deg);
}
.icon-del-box {
/* #ifndef APP-NVUE */
display: flex;
margin: auto 0;
/* #endif */
align-items: center;
justify-content: center;
position: absolute;
top: 0px;
bottom: 0;
right: 5px;
height: 26px;
width: 26px;
// border-radius: 50%;
// background-color: rgba(0, 0, 0, 0.5);
z-index: 2;
transform: rotate(-45deg);
}
.icon-del {
width: 15px;
height: 1px;
background-color: #333;
// border-radius: 1px;
}
/* #ifdef H5 */
@media all and (min-width: 768px) {
.uni-file-picker__files {
max-width: 375px;
}
}
/* #endif */
</style>

View File

@@ -0,0 +1,306 @@
<template>
<view class="uni-file-picker__container">
<view class="file-picker__box" v-for="(url, index) in list" :key="index" :style="boxStyle">
<view class="file-picker__box-content" :style="borderStyle">
<image
class="file-image"
:src="getImageUrl(url)"
mode="aspectFill"
@click.stop="previewImage(url, index)"
></image>
<view v-if="delIcon && !readonly" class="icon-del-box" @click.stop="delFile(index)">
<view class="icon-del"></view>
<view class="icon-del rotate"></view>
</view>
<!-- <view v-if="item.errMsg" class="file-picker__mask" @click.stop="uploadFiles(item, index)">
点击重试
</view> -->
</view>
</view>
<view v-if="list.length < limit && !readonly" class="file-picker__box" :style="boxStyle">
<view class="file-picker__box-content is-add" :style="borderStyle" @click="choose">
<slot>
<view class="icon-add"></view>
<view class="icon-add rotate"></view>
</slot>
</view>
</view>
</view>
</template>
<script>
import sheep from '@/sheep';
export default {
name: 'uploadImage',
emits: ['uploadFiles', 'choose', 'delFile'],
props: {
filesList: {
type: [Array, String],
default() {
return [];
},
},
disabled: {
type: Boolean,
default: false,
},
disablePreview: {
type: Boolean,
default: false,
},
limit: {
type: [Number, String],
default: 9,
},
imageStyles: {
type: Object,
default() {
return {
width: 'auto',
height: 'auto',
border: {},
};
},
},
delIcon: {
type: Boolean,
default: true,
},
readonly: {
type: Boolean,
default: false,
},
},
computed: {
list() {
if (typeof this.filesList === 'string') {
if (this.filesList) {
return [this.filesList];
} else {
return [];
}
}
return this.filesList;
},
styles() {
let styles = {
width: 'auto',
height: 'auto',
border: {},
};
return Object.assign(styles, this.imageStyles);
},
boxStyle() {
const { width = 'auto', height = 'auto' } = this.styles;
let obj = {};
if (height === 'auto') {
if (width !== 'auto') {
obj.height = this.value2px(width);
obj['padding-top'] = 0;
} else {
obj.height = 0;
}
} else {
obj.height = this.value2px(height);
obj['padding-top'] = 0;
}
if (width === 'auto') {
if (height !== 'auto') {
obj.width = this.value2px(height);
} else {
obj.width = '33.3%';
}
} else {
obj.width = this.value2px(width);
}
let classles = '';
for (let i in obj) {
classles += `${i}:${obj[i]};`;
}
return classles;
},
borderStyle() {
let { border } = this.styles;
let obj = {};
const widthDefaultValue = 1;
const radiusDefaultValue = 3;
if (typeof border === 'boolean') {
obj.border = border ? '1px #eee solid' : 'none';
} else {
let width = (border && border.width) || widthDefaultValue;
width = this.value2px(width);
let radius = (border && border.radius) || radiusDefaultValue;
radius = this.value2px(radius);
obj = {
'border-width': width,
'border-style': (border && border.style) || 'solid',
'border-color': (border && border.color) || '#eee',
'border-radius': radius,
};
}
let classles = '';
for (let i in obj) {
classles += `${i}:${obj[i]};`;
}
return classles;
},
},
methods: {
getImageUrl(url) {
if ('blob:http:' === url.substr(0, 10)) {
return url;
} else {
return sheep.$url.cdn(url);
}
},
uploadFiles(item, index) {
this.$emit('uploadFiles', item);
},
choose() {
this.$emit('choose');
},
delFile(index) {
this.$emit('delFile', index);
},
previewImage(img, index) {
let urls = [];
if (Number(this.limit) === 1 && this.disablePreview && !this.disabled) {
this.$emit('choose');
}
if (this.disablePreview) return;
this.list.forEach((i) => {
urls.push(this.getImageUrl(i));
});
uni.previewImage({
urls: urls,
current: index,
});
},
value2px(value) {
if (typeof value === 'number') {
value += 'px';
} else {
if (value.indexOf('%') === -1) {
value = value.indexOf('px') !== -1 ? value : value + 'px';
}
}
return value;
},
},
};
</script>
<style lang="scss">
.uni-file-picker__container {
/* #ifndef APP-NVUE */
display: flex;
box-sizing: border-box;
/* #endif */
flex-wrap: wrap;
margin: -5px;
}
.file-picker__box {
position: relative;
// flex: 0 0 33.3%;
width: 33.3%;
height: 0;
padding-top: 33.33%;
/* #ifndef APP-NVUE */
box-sizing: border-box;
/* #endif */
}
.file-picker__box-content {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
margin: 5px;
border: 1px #eee solid;
border-radius: 5px;
overflow: hidden;
}
.file-picker__progress {
position: absolute;
bottom: 0;
left: 0;
right: 0;
/* border: 1px red solid; */
z-index: 2;
}
.file-picker__progress-item {
width: 100%;
}
.file-picker__mask {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
justify-content: center;
align-items: center;
position: absolute;
right: 0;
top: 0;
bottom: 0;
left: 0;
color: #fff;
font-size: 12px;
background-color: rgba(0, 0, 0, 0.4);
}
.file-image {
width: 100%;
height: 100%;
}
.is-add {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
align-items: center;
justify-content: center;
}
.icon-add {
width: 50px;
height: 5px;
background-color: #f1f1f1;
border-radius: 2px;
}
.rotate {
position: absolute;
transform: rotate(90deg);
}
.icon-del-box {
/* #ifndef APP-NVUE */
display: flex;
/* #endif */
align-items: center;
justify-content: center;
position: absolute;
top: 3px;
right: 3px;
height: 26px;
width: 26px;
border-radius: 50%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 2;
transform: rotate(-45deg);
}
.icon-del {
width: 15px;
height: 2px;
background-color: #fff;
border-radius: 2px;
}
</style>

View File

@@ -0,0 +1,109 @@
/**
* 获取文件名和后缀
* @param {String} name
*/
export const get_file_ext = (name) => {
const last_len = name.lastIndexOf('.');
const len = name.length;
return {
name: name.substring(0, last_len),
ext: name.substring(last_len + 1, len),
};
};
/**
* 获取扩展名
* @param {Array} fileExtname
*/
export const get_extname = (fileExtname) => {
if (!Array.isArray(fileExtname)) {
let extname = fileExtname.replace(/([\[\]])/g, '');
return extname.split(',');
} else {
return fileExtname;
}
};
/**
* 获取文件和检测是否可选
*/
export const get_files_and_is_max = (res, _extname) => {
let filePaths = [];
let files = [];
if (!_extname || _extname.length === 0) {
return {
filePaths,
files,
};
}
res.tempFiles.forEach((v) => {
let fileFullName = get_file_ext(v.name);
const extname = fileFullName.ext.toLowerCase();
if (_extname.indexOf(extname) !== -1) {
files.push(v);
filePaths.push(v.path);
}
});
if (files.length !== res.tempFiles.length) {
uni.showToast({
title: `当前选择了${res.tempFiles.length}个文件 ${
res.tempFiles.length - files.length
} 个文件格式不正确`,
icon: 'none',
duration: 5000,
});
}
return {
filePaths,
files,
};
};
/**
* 获取图片信息
* @param {Object} filepath
*/
export const get_file_info = (filepath) => {
return new Promise((resolve, reject) => {
uni.getImageInfo({
src: filepath,
success(res) {
resolve(res);
},
fail(err) {
reject(err);
},
});
});
};
/**
* 获取封装数据
*/
export const get_file_data = async (files, type = 'image') => {
// 最终需要上传数据库的数据
let fileFullName = get_file_ext(files.name);
const extname = fileFullName.ext.toLowerCase();
let filedata = {
name: files.name,
uuid: files.uuid,
extname: extname || '',
cloudPath: files.cloudPath,
fileType: files.fileType,
url: files.url || files.path,
size: files.size, //单位是字节
image: {},
path: files.path,
video: {},
};
if (type === 'image') {
const imageinfo = await get_file_info(files.path);
delete filedata.video;
filedata.image.width = imageinfo.width;
filedata.image.height = imageinfo.height;
filedata.image.location = imageinfo.path;
} else {
delete filedata.image;
}
return filedata;
};

View File

@@ -0,0 +1,569 @@
<template>
<view>
<view v-if="visible" class="s-verify-mask" @touchmove.stop.prevent>
<view class="s-verify-wrapper" @tap.stop>
<view class="s-verify-header">
<text class="s-verify-title">安全验证</text>
<view class="s-verify-close" @tap="handleClose">×</view>
</view>
<view class="s-verify-body">
<view class="s-verify-image" :style="imageStyle">
<image
v-if="backgroundImage"
class="s-verify-image-bg"
:src="backgroundImage"
mode="aspectFill"
/>
<image
v-if="showBlock"
class="s-verify-image-block"
:style="blockStyle"
:src="blockImage"
mode="widthFix"
/>
<view class="s-verify-refresh" @tap="refreshCaptcha">
<text></text>
</view>
<view v-if="loading" class="s-verify-loading">加载中...</view>
</view>
<view v-if="tipMessage" :class="['s-verify-tip', tipClass]">
{{ tipMessage }}
</view>
<view
class="s-verify-slider"
ref="sliderRef"
@touchstart.stop.prevent="onDragStart"
@touchmove.stop.prevent="onDragMove"
@touchend.stop.prevent="onDragEnd"
@mousedown.stop="onMouseDown"
>
<view class="s-verify-slider-track" />
<view class="s-verify-slider-fill" :style="sliderFillStyle" />
<view
class="s-verify-slider-handle"
:class="{
's-verify-slider-success': success,
's-verify-slider-fail': failAnimate
}"
:style="sliderHandleStyle"
>
<text class="s-verify-handle-icon">{{ success ? '✔' : '' }}</text>
</view>
<text class="s-verify-slider-text">{{ sliderHint }}</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, reactive, ref } from 'vue';
import AuthUtil from '@/sheep/api/system/auth';
import { aesEncrypt } from '@/sheep/components/s-verify/utils/aes';
const props = defineProps({
captchaType: {
type: String,
default: 'blockPuzzle',
},
mode: {
type: String,
default: 'pop',
},
});
const emits = defineEmits(['success', 'error']);
const visible = ref(false);
const loading = ref(false);
const success = ref(false);
const failAnimate = ref(false);
const verifying = ref(false);
const tipMessage = ref('');
const sliderRef = ref(null);
const sliderWidth = 310;
const sliderHeight = 40;
const blockWidth = Math.floor((sliderWidth * 47) / 310);
const imageHeight = 155;
const dragState = reactive({
startClientX: 0,
baseOffset: 0,
isDragging: false,
});
const captchaState = reactive({
originalImageBase64: '',
jigsawImageBase64: '',
token: '',
secretKey: '',
});
const handleOffset = ref(0);
const maxOffset = computed(() => Math.max(sliderWidth - blockWidth, 0));
const showBlock = computed(() => props.captchaType === 'blockPuzzle' && blockImage.value);
const backgroundImage = computed(() =>
captchaState.originalImageBase64
? `data:image/png;base64,${captchaState.originalImageBase64}`
: ''
);
const blockImage = computed(() =>
captchaState.jigsawImageBase64
? `data:image/png;base64,${captchaState.jigsawImageBase64}`
: ''
);
const imageStyle = computed(() => ({
width: `${sliderWidth}px`,
height: `${imageHeight}px`,
}));
const blockStyle = computed(() => ({
width: `${blockWidth}px`,
height: `${imageHeight}px`,
transform: `translateX(${handleOffset.value}px)`,
}));
const sliderHandleStyle = computed(() => ({
width: `${blockWidth}px`,
height: `${sliderHeight}px`,
transform: `translateX(${handleOffset.value}px)`,
}));
const sliderFillStyle = computed(() => ({
width: `${handleOffset.value > 0 ? Math.min(handleOffset.value + blockWidth, sliderWidth) : 0}px`,
}));
const sliderHint = computed(() => {
if (success.value) {
return '验证通过';
}
if (loading.value) {
return '验证码加载中';
}
return '请按住滑块,拖动完成拼图';
});
const tipClass = computed(() => {
if (success.value) return 's-verify-tip-success';
if (failAnimate.value) return 's-verify-tip-error';
return '';
});
let closeTimer = null;
let mouseMoveListener = null;
let mouseUpListener = null;
function show() {
if (visible.value) return;
visible.value = true;
resetState();
nextTick(() => {
loadCaptcha();
});
}
function handleClose() {
visible.value = false;
cleanupMouseListeners();
clearCloseTimer();
}
function refreshCaptcha() {
if (loading.value) return;
resetSlider();
loadCaptcha();
}
function resetSlider() {
handleOffset.value = 0;
dragState.baseOffset = 0;
dragState.startClientX = 0;
dragState.isDragging = false;
success.value = false;
failAnimate.value = false;
tipMessage.value = '';
verifying.value = false;
}
function resetState() {
captchaState.originalImageBase64 = '';
captchaState.jigsawImageBase64 = '';
captchaState.token = '';
captchaState.secretKey = '';
resetSlider();
}
async function loadCaptcha() {
loading.value = true;
try {
const res = await AuthUtil.getCaptchaCode({ captchaType: props.captchaType });
if (res && (res.repCode === '0000' || res.code === 0)) {
const data = res.repData || res.data || {};
captchaState.originalImageBase64 = data.originalImageBase64 || '';
captchaState.jigsawImageBase64 = data.jigsawImageBase64 || '';
captchaState.token = data.token || '';
captchaState.secretKey = data.secretKey || '';
} else {
tipMessage.value = res?.repMsg || res?.msg || '验证码获取失败,请重试';
emits('error', res);
}
} catch (error) {
console.error('captcha load error', error);
tipMessage.value = '验证码加载异常,请稍后再试';
emits('error', error);
} finally {
loading.value = false;
}
}
function onDragStart(event) {
if (loading.value || success.value) return;
dragState.isDragging = true;
dragState.startClientX = getClientX(event);
dragState.baseOffset = handleOffset.value;
failAnimate.value = false;
tipMessage.value = '';
}
function onDragMove(event) {
if (!dragState.isDragging) return;
const currentX = getClientX(event);
const delta = currentX - dragState.startClientX;
let next = dragState.baseOffset + delta;
if (next < 0) next = 0;
if (next > maxOffset.value) next = maxOffset.value;
handleOffset.value = next;
}
function onDragEnd() {
if (!dragState.isDragging) return;
dragState.isDragging = false;
submitVerification();
}
function onMouseDown(event) {
if (event?.button !== 0) return;
if (typeof window !== 'undefined') {
mouseMoveListener = (ev) => {
onDragMove(ev);
ev.preventDefault();
};
mouseUpListener = (ev) => {
cleanupMouseListeners();
onDragEnd(ev);
};
window.addEventListener('mousemove', mouseMoveListener);
window.addEventListener('mouseup', mouseUpListener);
}
onDragStart(event);
}
function cleanupMouseListeners() {
if (typeof window === 'undefined') return;
if (mouseMoveListener) {
window.removeEventListener('mousemove', mouseMoveListener);
mouseMoveListener = null;
}
if (mouseUpListener) {
window.removeEventListener('mouseup', mouseUpListener);
mouseUpListener = null;
}
}
function clearCloseTimer() {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
}
async function submitVerification() {
if (verifying.value) return;
verifying.value = true;
failAnimate.value = false;
const point = {
x: Math.round(handleOffset.value),
y: 5.0,
};
try {
const payload = captchaState.secretKey
? aesEncrypt(JSON.stringify(point), captchaState.secretKey)
: JSON.stringify(point);
const res = await AuthUtil.verifyCaptcha({
captchaType: props.captchaType,
token: captchaState.token,
pointJson: payload,
});
if (res && (res.repCode === '0000' || res.code === 0)) {
success.value = true;
tipMessage.value = '验证成功';
const captchaVerification = captchaState.secretKey
? aesEncrypt(
`${captchaState.token}---${JSON.stringify(point)}`,
captchaState.secretKey,
)
: `${captchaState.token}---${JSON.stringify(point)}`;
emits('success', {
captchaVerification,
token: captchaState.token,
point,
});
clearCloseTimer();
closeTimer = setTimeout(() => {
handleClose();
}, 800);
} else {
handleFail(res?.repMsg || res?.msg || '验证失败,请重试');
}
} catch (error) {
console.error('captcha verify error', error);
handleFail('网络异常,请重试');
} finally {
verifying.value = false;
}
}
function handleFail(message) {
failAnimate.value = true;
tipMessage.value = message;
emits('error', message);
setTimeout(() => {
failAnimate.value = false;
}, 400);
setTimeout(() => {
refreshCaptcha();
}, 500);
}
function getClientX(event) {
if (event?.touches && event.touches.length) {
return event.touches[0].clientX;
}
if (event?.changedTouches && event.changedTouches.length) {
return event.changedTouches[0].clientX;
}
return event?.clientX || 0;
}
onBeforeUnmount(() => {
cleanupMouseListeners();
clearCloseTimer();
});
defineExpose({
show,
close: handleClose,
refresh: refreshCaptcha,
});
</script>
<style lang="scss" scoped>
.s-verify-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.s-verify-wrapper {
width: 340px;
background: #ffffff;
border-radius: 16px;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.18);
overflow: hidden;
}
.s-verify-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid #f2f3f5;
}
.s-verify-title {
font-size: 16px;
font-weight: 600;
color: #1f2d3d;
}
.s-verify-close {
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
border-radius: 50%;
color: #999999;
font-size: 18px;
}
.s-verify-body {
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.s-verify-image {
position: relative;
border-radius: 12px;
overflow: hidden;
background: #f5f5f5;
}
.s-verify-image-bg {
width: 100%;
height: 100%;
display: block;
}
.s-verify-image-block {
position: absolute;
top: 0;
display: block;
}
.s-verify-refresh {
position: absolute;
top: 10px;
right: 10px;
width: 30px;
height: 30px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
color: #ffffff;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
}
.s-verify-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.65);
font-size: 14px;
color: #666666;
}
.s-verify-tip {
width: 100%;
text-align: center;
font-size: 14px;
padding: 6px 0;
border-radius: 6px;
}
.s-verify-tip-success {
color: #52c41a;
background: rgba(82, 196, 26, 0.12);
}
.s-verify-tip-error {
color: #ff4d4f;
background: rgba(255, 77, 79, 0.12);
}
.s-verify-slider {
position: relative;
width: 310px;
height: 40px;
border-radius: 20px;
background: #f2f3f5;
overflow: hidden;
}
.s-verify-slider-track {
position: absolute;
inset: 0;
border-radius: 20px;
background: #f2f3f5;
}
.s-verify-slider-fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: var(--ui-BG-Main, #409eff);
border-radius: 20px;
transition: width 0.05s linear;
}
.s-verify-slider-handle {
position: absolute;
top: 0;
background: #ffffff;
border-radius: 20px;
box-shadow: 0 6px 14px rgba(25, 87, 170, 0.25);
display: flex;
align-items: center;
justify-content: center;
color: #1f2d3d;
transition: transform 0.05s linear;
}
.s-verify-slider-success {
background: #52c41a;
color: #ffffff;
}
.s-verify-slider-fail {
animation: s-verify-shake 0.3s;
}
.s-verify-handle-icon {
font-size: 22px;
font-weight: 600;
}
.s-verify-slider-text {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #8c8c8c;
font-size: 14px;
}
@keyframes s-verify-shake {
0% {
transform: translateX(-4px);
}
25% {
transform: translateX(4px);
}
50% {
transform: translateX(-2px);
}
75% {
transform: translateX(2px);
}
100% {
transform: translateX(0);
}
}
</style>

View File

@@ -0,0 +1,17 @@
import CryptoJS from 'crypto-js';
/**
* 使用 AES-ECB 模式 + PKCS7 填充对字符串进行加密
* @param {string} word - 需要加密的明文
* @param {string} [keyWord='XwKsGlMcdPMEhR1B'] - 加密密钥,默认为 aj-captcha 默认密钥
* @returns {string} - 加密后的密文
*/
export function aesEncrypt(word, keyWord = 'XwKsGlMcdPMEhR1B') {
const key = CryptoJS.enc.Utf8.parse(keyWord);
const srcs = CryptoJS.enc.Utf8.parse(word);
const encrypted = CryptoJS.AES.encrypt(srcs, key, {
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
});
return encrypted.toString();
}

View File

@@ -0,0 +1,227 @@
/**
* 验证码组件工具函数
*/
/**
* 生成随机整数
* @param {number} min 最小值
* @param {number} max 最大值
* @returns {number} 随机整数
*/
export function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
/**
* 生成随机颜色
* @returns {string} 十六进制颜色值
*/
export function randomColor() {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8'];
return colors[Math.floor(Math.random() * colors.length)];
}
/**
* 生成随机字符串
* @param {number} length 字符串长度
* @param {string} chars 字符集
* @returns {string} 随机字符串
*/
export function randomString(length = 4, chars = 'ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678') {
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
/**
* 计算两点距离
* @param {number} x1 点1 x坐标
* @param {number} y1 点1 y坐标
* @param {number} x2 点2 x坐标
* @param {number} y2 点2 y坐标
* @returns {number} 距离
*/
export function getDistance(x1, y1, x2, y2) {
return Math.sqrt(Math.pow(x2 - x1, 2) + Math.pow(y2 - y1, 2));
}
/**
* 判断点是否在矩形区域内
* @param {number} x 点x坐标
* @param {number} y 点y坐标
* @param {Object} rect 矩形对象 {x, y, width, height}
* @returns {boolean} 是否在区域内
*/
export function isPointInRect(x, y, rect) {
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
}
/**
* 生成验证码图片拼图
* @param {CanvasRenderingContext2D} ctx Canvas上下文
* @param {number} x x坐标
* @param {number} y y坐标
* @param {number} r 圆角半径
* @param {number} PI 圆周率
*/
export function drawPath(ctx, x, y, r, PI) {
ctx.beginPath();
ctx.moveTo(x, y);
ctx.arc(x + r / 2, y - r + 2, r, 0, 2 * PI);
ctx.lineTo(x + r, y);
ctx.arc(x + r + r / 2, y + r / 2, r, 1.5 * PI, 0.5 * PI);
ctx.lineTo(x + r, y + r);
ctx.lineTo(x, y + r);
ctx.arc(x + r / 2, y + r + r / 2, r, PI, 0);
ctx.closePath();
}
/**
* 获取图片像素数据
* @param {string} imgSrc 图片源
* @returns {Promise<ImageData>} 图片像素数据
*/
export function getImageData(imgSrc) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = function() {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
resolve(ctx.getImageData(0, 0, canvas.width, canvas.height));
};
img.onerror = reject;
img.crossOrigin = 'anonymous';
img.src = imgSrc;
});
}
/**
* 创建干扰线
* @param {CanvasRenderingContext2D} ctx Canvas上下文
* @param {number} width 画布宽度
* @param {number} height 画布高度
* @param {number} lineCount 干扰线数量
*/
export function drawInterferenceLines(ctx, width, height, lineCount = 8) {
for (let i = 0; i < lineCount; i++) {
ctx.strokeStyle = randomColor();
ctx.lineWidth = randomInt(1, 3);
ctx.beginPath();
ctx.moveTo(randomInt(0, width), randomInt(0, height));
ctx.lineTo(randomInt(0, width), randomInt(0, height));
ctx.stroke();
}
}
/**
* 创建干扰点
* @param {CanvasRenderingContext2D} ctx Canvas上下文
* @param {number} width 画布宽度
* @param {number} height 画布高度
* @param {number} pointCount 干扰点数量
*/
export function drawInterferencePoints(ctx, width, height, pointCount = 100) {
for (let i = 0; i < pointCount; i++) {
ctx.fillStyle = randomColor();
ctx.beginPath();
ctx.arc(randomInt(0, width), randomInt(0, height), randomInt(1, 2), 0, 2 * Math.PI);
ctx.fill();
}
}
/**
* 节流函数
* @param {Function} func 要执行的函数
* @param {number} delay 延迟时间
* @returns {Function} 节流后的函数
*/
export function throttle(func, delay) {
let timer = null;
return function(...args) {
if (!timer) {
timer = setTimeout(() => {
func.apply(this, args);
timer = null;
}, delay);
}
};
}
/**
* 防抖函数
* @param {Function} func 要执行的函数
* @param {number} delay 延迟时间
* @returns {Function} 防抖后的函数
*/
export function debounce(func, delay) {
let timer = null;
return function(...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
func.apply(this, args);
}, delay);
};
}
/**
* 重置画布
* @param {CanvasRenderingContext2D} ctx Canvas上下文
* @param {number} width 画布宽度
* @param {number} height 画布高度
*/
export function resetCanvas(ctx, width, height) {
ctx.clearRect(0, 0, width, height);
}
/**
* 获取元素的绝对位置
* @param {Element} element DOM元素
* @returns {Object} {left, top}
*/
export function getElementPosition(element) {
let left = 0;
let top = 0;
while (element) {
left += element.offsetLeft;
top += element.offsetTop;
element = element.offsetParent;
}
return { left, top };
}
/**
* 移动端触摸事件坐标获取
* @param {Event} e 事件对象
* @returns {Object} {x, y}
*/
export function getTouchPosition(e) {
const touch = e.touches && e.touches.length > 0 ? e.touches[0] : e.changedTouches[0];
return {
x: touch.clientX,
y: touch.clientY
};
}
/**
* 获取鼠标或触摸位置
* @param {Event} e 事件对象
* @returns {Object} {x, y}
*/
export function getEventPosition(e) {
if (e.touches) {
return getTouchPosition(e);
}
return {
x: e.clientX,
y: e.clientY
};
}
export { aesEncrypt } from './aes';

3
sheep/config/captcha.js Normal file
View File

@@ -0,0 +1,3 @@
export default {
captchaEnable: true, // 是否开启验证码
}

31
sheep/config/index.js Normal file
View File

@@ -0,0 +1,31 @@
import packageInfo from '@/package.json';
const { version } = packageInfo;
// 开发环境配置
export let baseUrl;
if (process.env.NODE_ENV === 'development') {
baseUrl = import.meta.env.SHOPRO_DEV_BASE_URL;
} else {
baseUrl = import.meta.env.SHOPRO_BASE_URL;
}
if (typeof baseUrl === 'undefined') {
console.error('请检查.env配置文件是否存在');
} else {
console.log(`[移动端 ${version}] https://doc.iocoder.cn`);
}
export const apiPath = import.meta.env.SHOPRO_API_PATH;
export const staticUrl = import.meta.env.SHOPRO_STATIC_URL;
export const tenantId = import.meta.env.SHOPRO_TENANT_ID;
export const websocketPath = import.meta.env.SHOPRO_WEBSOCKET_PATH;
export const h5Url = import.meta.env.SHOPRO_H5_URL;
export default {
baseUrl,
apiPath,
staticUrl,
tenantId,
websocketPath,
h5Url,
};

35
sheep/config/theme.js Normal file
View File

@@ -0,0 +1,35 @@
// 主题配置文件
export const themeConfig = {
// 主题色配置 - 统一使用 #0055A2
primary: {
main: '#0055A2',
light: '#337AB7',
dark: '#003F73',
gradient: 'rgba(0, 85, 162, 0.6)', // 渐变结束色
},
// 渐变配置
gradients: {
// 水平渐变 (90度)
horizontal: 'linear-gradient(90deg, #0055A2, rgba(0, 85, 162, 0.6))',
// 垂直渐变 (180度)
vertical: 'linear-gradient(180deg, #0055A2 0%, rgba(0, 85, 162, 0.6) 100%)',
// 对角渐变
diagonal: 'linear-gradient(135deg, #0055A2, rgba(0, 85, 162, 0.6))',
},
// CSS 变量映射
getCSSVars() {
return {
'--theme-primary': this.primary.main,
'--theme-primary-light': this.primary.light,
'--theme-primary-dark': this.primary.dark,
'--theme-primary-gradient': this.primary.gradient,
'--gradient-horizontal-primary': this.gradients.horizontal,
'--gradient-vertical-primary': this.gradients.vertical,
'--gradient-diagonal-primary': this.gradients.diagonal,
};
}
};
export default themeConfig;

20
sheep/config/zIndex.js Normal file
View File

@@ -0,0 +1,20 @@
// uniapp在H5中各API的z-index值如下
/**
* actionsheet: 999
* modal: 999
* navigate: 998
* tabbar: 998
* toast: 999
*/
export default {
toast: 10090,
noNetwork: 10080,
popup: 10075, // popup包含popupactionsheetkeyboardpicker的值
mask: 10070,
navbar: 980,
topTips: 975,
sticky: 970,
indexListSticky: 965,
popover: 960,
};

46
sheep/helper/const.js Normal file
View File

@@ -0,0 +1,46 @@
// ========== COMMON - 公共模块 ==========
/**
* 与后端Terminal枚举一一对应
*/
export const TerminalEnum = {
UNKNOWN: 0, // 未知, 目的:在无法解析到 terminal 时,使用它
WECHAT_MINI_PROGRAM: 10, //微信小程序
WECHAT_WAP: 11, // 微信公众号
H5: 20, // H5 网页
APP: 31, // 手机 App
};
/**
* 分享页面枚举
*/
export const SharePageEnum = {
HOME: {
value: 'home',
page: '/pages/index/index'
},
GOODS: {
value: 'goods',
page: '/pages/index/index'
}
};
/**
* 将 uni-app 提供的平台转换为后端所需的 terminal值
*
* @return 终端
*/
export const getTerminal = () => {
const platformType = uni.getAppBaseInfo().uniPlatform;
// 与后端terminal枚举一一对应
switch (platformType) {
case 'app':
return TerminalEnum.APP;
case 'web':
return TerminalEnum.H5;
case 'mp-weixin':
return TerminalEnum.WECHAT_MINI_PROGRAM;
default:
return TerminalEnum.UNKNOWN;
}
};

168
sheep/helper/digit.js Normal file
View File

@@ -0,0 +1,168 @@
let _boundaryCheckingState = true; // 是否进行越界检查的全局开关
/**
* 把错误的数据转正
* @private
* @example strip(0.09999999999999998)=0.1
*/
function strip(num, precision = 15) {
return +parseFloat(Number(num).toPrecision(precision));
}
/**
* Return digits length of a number
* @private
* @param {*number} num Input number
*/
function digitLength(num) {
// Get digit length of e
const eSplit = num.toString().split(/[eE]/);
const len = (eSplit[0].split('.')[1] || '').length - +(eSplit[1] || 0);
return len > 0 ? len : 0;
}
/**
* 把小数转成整数,如果是小数则放大成整数
* @private
* @param {*number} num 输入数
*/
function float2Fixed(num) {
if (num.toString().indexOf('e') === -1) {
return Number(num.toString().replace('.', ''));
}
const dLen = digitLength(num);
return dLen > 0 ? strip(Number(num) * Math.pow(10, dLen)) : Number(num);
}
/**
* 检测数字是否越界,如果越界给出提示
* @private
* @param {*number} num 输入数
*/
function checkBoundary(num) {
if (_boundaryCheckingState) {
if (num > Number.MAX_SAFE_INTEGER || num < Number.MIN_SAFE_INTEGER) {
console.warn(`${num} 超出了精度限制,结果可能不正确`);
}
}
}
/**
* 把递归操作扁平迭代化
* @param {number[]} arr 要操作的数字数组
* @param {function} operation 迭代操作
* @private
*/
function iteratorOperation(arr, operation) {
const [num1, num2, ...others] = arr;
let res = operation(num1, num2);
others.forEach((num) => {
res = operation(res, num);
});
return res;
}
/**
* 高精度乘法
* @export
*/
export function times(...nums) {
if (nums.length > 2) {
return iteratorOperation(nums, times);
}
const [num1, num2] = nums;
const num1Changed = float2Fixed(num1);
const num2Changed = float2Fixed(num2);
const baseNum = digitLength(num1) + digitLength(num2);
const leftValue = num1Changed * num2Changed;
checkBoundary(leftValue);
return leftValue / Math.pow(10, baseNum);
}
/**
* 高精度加法
* @export
*/
export function plus(...nums) {
if (nums.length > 2) {
return iteratorOperation(nums, plus);
}
const [num1, num2] = nums;
// 取最大的小数位
const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2)));
// 把小数都转为整数然后再计算
return (times(num1, baseNum) + times(num2, baseNum)) / baseNum;
}
/**
* 高精度减法
* @export
*/
export function minus(...nums) {
if (nums.length > 2) {
return iteratorOperation(nums, minus);
}
const [num1, num2] = nums;
const baseNum = Math.pow(10, Math.max(digitLength(num1), digitLength(num2)));
return (times(num1, baseNum) - times(num2, baseNum)) / baseNum;
}
/**
* 高精度除法
* @export
*/
export function divide(...nums) {
if (nums.length > 2) {
return iteratorOperation(nums, divide);
}
const [num1, num2] = nums;
const num1Changed = float2Fixed(num1);
const num2Changed = float2Fixed(num2);
checkBoundary(num1Changed);
checkBoundary(num2Changed);
// 重要这里必须用strip进行修正
return times(
num1Changed / num2Changed,
strip(Math.pow(10, digitLength(num2) - digitLength(num1))),
);
}
/**
* 四舍五入
* @export
*/
export function round(num, ratio) {
const base = Math.pow(10, ratio);
let result = divide(Math.round(Math.abs(times(num, base))), base);
if (num < 0 && result !== 0) {
result = times(result, -1);
}
// 位数不足则补0
return result;
}
/**
* 是否进行边界检查,默认开启
* @param flag 标记开关true 为开启false 为关闭,默认为 true
* @export
*/
export function enableBoundaryChecking(flag = true) {
_boundaryCheckingState = flag;
}
export default {
times,
plus,
minus,
divide,
round,
enableBoundaryChecking,
};

703
sheep/helper/index.js Normal file
View File

@@ -0,0 +1,703 @@
import test from './test.js';
import { round } from './digit.js';
/**
* @description 如果value小于min取min如果value大于max取max
* @param {number} min
* @param {number} max
* @param {number} value
*/
function range(min = 0, max = 0, value = 0) {
return Math.max(min, Math.min(max, Number(value)));
}
/**
* @description 用于获取用户传递值的px值 如果用户传递了"xxpx"或者"xxrpx",取出其数值部分,如果是"xxxrpx"还需要用过uni.upx2px进行转换
* @param {number|string} value 用户传递值的px值
* @param {boolean} unit
* @returns {number|string}
*/
export function getPx(value, unit = false) {
if (test.number(value)) {
return unit ? `${value}px` : Number(value);
}
// 如果带有rpx先取出其数值部分再转为px值
if (/(rpx|upx)$/.test(value)) {
return unit ? `${uni.upx2px(parseInt(value))}px` : Number(uni.upx2px(parseInt(value)));
}
return unit ? `${parseInt(value)}px` : parseInt(value);
}
/**
* @description 进行延时,以达到可以简写代码的目的
* @param {number} value 堵塞时间 单位ms 毫秒
* @returns {Promise} 返回promise
*/
export function sleep(value = 30) {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, value);
});
}
/**
* @description 运行期判断平台
* @returns {string} 返回所在平台(小写)
* @link 运行期判断平台 https://uniapp.dcloud.io/frame?id=判断平台
*/
export function os() {
return uni.getDeviceInfo().platform.toLowerCase();
}
/**
* @description 取一个区间数
* @param {Number} min 最小值
* @param {Number} max 最大值
*/
function random(min, max) {
if (min >= 0 && max > 0 && max >= min) {
const gab = max - min + 1;
return Math.floor(Math.random() * gab + min);
}
return 0;
}
/**
* @param {Number} len uuid的长度
* @param {Boolean} firstU 将返回的首字母置为"u"
* @param {Nubmer} radix 生成uuid的基数(意味着返回的字符串都是这个基数),2-二进制,8-八进制,10-十进制,16-十六进制
*/
export function guid(len = 32, firstU = true, radix = null) {
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('');
const uuid = [];
radix = radix || chars.length;
if (len) {
// 如果指定uuid长度,只是取随机的字符,0|x为位运算,能去掉x的小数位,返回整数位
for (let i = 0; i < len; i++) uuid[i] = chars[0 | (Math.random() * radix)];
} else {
let r;
// rfc4122标准要求返回的uuid中,某些位为固定的字符
uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-';
uuid[14] = '4';
for (let i = 0; i < 36; i++) {
if (!uuid[i]) {
r = 0 | (Math.random() * 16);
uuid[i] = chars[i == 19 ? (r & 0x3) | 0x8 : r];
}
}
}
// 移除第一个字符,并用u替代,因为第一个字符为数值时,该guuid不能用作id或者class
if (firstU) {
uuid.shift();
return `u${uuid.join('')}`;
}
return uuid.join('');
}
/**
* @description 获取父组件的参数因为支付宝小程序不支持provide/inject的写法
this.$parent在非H5中可以准确获取到父组件但是在H5中需要多次this.$parent.$parent.xxx
这里默认值等于undefined有它的含义因为最顶层元素(组件)的$parent就是undefined意味着不传name
值(默认为undefined),就是查找最顶层的$parent
* @param {string|undefined} name 父组件的参数名
*/
export function $parent(name = undefined) {
let parent = this.$parent;
// 通过while历遍这里主要是为了H5需要多层解析的问题
while (parent) {
// 父组件
if (parent.$options && parent.$options.name !== name) {
// 如果组件的name不相等继续上一级寻找
parent = parent.$parent;
} else {
return parent;
}
}
return false;
}
/**
* @description 样式转换
* 对象转字符串,或者字符串转对象
* @param {object | string} customStyle 需要转换的目标
* @param {String} target 转换的目的object-转为对象string-转为字符串
* @returns {object|string}
*/
export function addStyle(customStyle, target = 'object') {
// 字符串转字符串,对象转对象情形,直接返回
if (
test.empty(customStyle) ||
(typeof customStyle === 'object' && target === 'object') ||
(target === 'string' && typeof customStyle === 'string')
) {
return customStyle;
}
// 字符串转对象
if (target === 'object') {
// 去除字符串样式中的两端空格(中间的空格不能去掉比如padding: 20px 0如果去掉了就错了),空格是无用的
customStyle = trim(customStyle);
// 根据";"将字符串转为数组形式
const styleArray = customStyle.split(';');
const style = {};
// 历遍数组,拼接成对象
for (let i = 0; i < styleArray.length; i++) {
// 'font-size:20px;color:red;',如此最后字符串有";"的话会导致styleArray最后一个元素为空字符串这里需要过滤
if (styleArray[i]) {
const item = styleArray[i].split(':');
style[trim(item[0])] = trim(item[1]);
}
}
return style;
}
// 这里为对象转字符串形式
let string = '';
for (const i in customStyle) {
// 驼峰转为中划线的形式否则css内联样式无法识别驼峰样式属性名
const key = i.replace(/([A-Z])/g, '-$1').toLowerCase();
string += `${key}:${customStyle[i]};`;
}
// 去除两端空格
return trim(string);
}
/**
* @description 添加单位如果有rpxupx%px等单位结尾或者值为auto直接返回否则加上px单位结尾
* @param {string|number} value 需要添加单位的值
* @param {string} unit 添加的单位名 比如px
*/
export function addUnit(value = 'auto', unit = 'px') {
value = String(value);
return test.number(value) ? `${value}${unit}` : value;
}
/**
* @description 深度克隆
* @param {object} obj 需要深度克隆的对象
* @returns {*} 克隆后的对象或者原值(不是对象)
*/
function deepClone(obj) {
// 对常见的“非”值,直接返回原来值
if ([null, undefined, NaN, false].includes(obj)) return obj;
if (typeof obj !== 'object' && typeof obj !== 'function') {
// 原始类型直接返回
return obj;
}
const o = test.array(obj) ? [] : {};
for (const i in obj) {
if (obj.hasOwnProperty(i)) {
o[i] = typeof obj[i] === 'object' ? deepClone(obj[i]) : obj[i];
}
}
return o;
}
/**
* @description JS对象深度合并
* @param {object} target 需要拷贝的对象
* @param {object} source 拷贝的来源对象
* @returns {object|boolean} 深度合并后的对象或者false入参有不是对象
*/
export function deepMerge(target = {}, source = {}) {
target = deepClone(target);
if (typeof target !== 'object' || typeof source !== 'object') return false;
for (const prop in source) {
if (!source.hasOwnProperty(prop)) continue;
if (prop in target) {
if (typeof target[prop] !== 'object') {
target[prop] = source[prop];
} else if (typeof source[prop] !== 'object') {
target[prop] = source[prop];
} else if (target[prop].concat && source[prop].concat) {
target[prop] = target[prop].concat(source[prop]);
} else {
target[prop] = deepMerge(target[prop], source[prop]);
}
} else {
target[prop] = source[prop];
}
}
return target;
}
/**
* @description error提示
* @param {*} err 错误内容
*/
function error(err) {
// 开发环境才提示,生产环境不会提示
if (process.env.NODE_ENV === 'development') {
console.error(`SheepJS:${err}`);
}
}
/**
* @description 打乱数组
* @param {array} array 需要打乱的数组
* @returns {array} 打乱后的数组
*/
function randomArray(array = []) {
// 原理是sort排序,Math.random()产生0<= x < 1之间的数,会导致x-0.05大于或者小于0
return array.sort(() => Math.random() - 0.5);
}
// padStart 的 polyfill因为某些机型或情况还无法支持es7的padStart比如电脑版的微信小程序
// 所以这里做一个兼容polyfill的兼容处理
if (!String.prototype.padStart) {
// 为了方便表示这里 fillString 用了ES6 的默认参数,不影响理解
String.prototype.padStart = function (maxLength, fillString = ' ') {
if (Object.prototype.toString.call(fillString) !== '[object String]') {
throw new TypeError('fillString must be String');
}
const str = this;
// 返回 String(str) 这里是为了使返回的值是字符串字面量,在控制台中更符合直觉
if (str.length >= maxLength) return String(str);
const fillLength = maxLength - str.length;
let times = Math.ceil(fillLength / fillString.length);
while ((times >>= 1)) {
fillString += fillString;
if (times === 1) {
fillString += fillString;
}
}
return fillString.slice(0, fillLength) + str;
};
}
/**
* @description 格式化时间
* @param {String|Number} dateTime 需要格式化的时间戳
* @param {String} fmt 格式化规则 yyyy:mm:dd|yyyy:mm|yyyy年mm月dd日|yyyy年mm月dd日 hh时MM分等,可自定义组合 默认yyyy-mm-dd
* @returns {string} 返回格式化后的字符串
*/
function timeFormat(dateTime = null, formatStr = 'yyyy-mm-dd') {
let date;
// 若传入时间为假值,则取当前时间
if (!dateTime) {
date = new Date();
}
// 若为unix秒时间戳则转为毫秒时间戳逻辑有点奇怪但不敢改以保证历史兼容
else if (/^\d{10}$/.test(dateTime?.toString().trim())) {
date = new Date(dateTime * 1000);
}
// 若用户传入字符串格式时间戳new Date无法解析需做兼容
else if (typeof dateTime === 'string' && /^\d+$/.test(dateTime.trim())) {
date = new Date(Number(dateTime));
}
// 其他都认为符合 RFC 2822 规范
else {
// 处理平台性差异在Safari/Webkit中new Date仅支持/作为分割符的字符串时间
date = new Date(typeof dateTime === 'string' ? dateTime.replace(/-/g, '/') : dateTime);
}
const timeSource = {
y: date.getFullYear().toString(), // 年
m: (date.getMonth() + 1).toString().padStart(2, '0'), // 月
d: date.getDate().toString().padStart(2, '0'), // 日
h: date.getHours().toString().padStart(2, '0'), // 时
M: date.getMinutes().toString().padStart(2, '0'), // 分
s: date.getSeconds().toString().padStart(2, '0'), // 秒
// 有其他格式化字符需求可以继续添加,必须转化成字符串
};
for (const key in timeSource) {
const [ret] = new RegExp(`${key}+`).exec(formatStr) || [];
if (ret) {
// 年可能只需展示两位
const beginIndex = key === 'y' && ret.length === 2 ? 2 : 0;
formatStr = formatStr.replace(ret, timeSource[key].slice(beginIndex));
}
}
return formatStr;
}
/**
* @description 时间戳转为多久之前
* @param {String|Number} timestamp 时间戳
* @param {String|Boolean} format
* 格式化规则如果为时间格式字符串,超出一定时间范围,返回固定的时间格式;
* 如果为布尔值false无论什么时间都返回多久以前的格式
* @returns {string} 转化后的内容
*/
function timeFrom(timestamp = null, format = 'yyyy-mm-dd') {
if (timestamp == null) timestamp = Number(new Date());
timestamp = parseInt(timestamp);
// 判断用户输入的时间戳是秒还是毫秒,一般前端js获取的时间戳是毫秒(13位),后端传过来的为秒(10位)
if (timestamp.toString().length == 10) timestamp *= 1000;
let timer = new Date().getTime() - timestamp;
timer = parseInt(timer / 1000);
// 如果小于5分钟,则返回"刚刚",其他以此类推
let tips = '';
switch (true) {
case timer < 300:
tips = '刚刚';
break;
case timer >= 300 && timer < 3600:
tips = `${parseInt(timer / 60)}分钟前`;
break;
case timer >= 3600 && timer < 86400:
tips = `${parseInt(timer / 3600)}小时前`;
break;
case timer >= 86400 && timer < 2592000:
tips = `${parseInt(timer / 86400)}天前`;
break;
default:
// 如果format为false则无论什么时间戳都显示xx之前
if (format === false) {
if (timer >= 2592000 && timer < 365 * 86400) {
tips = `${parseInt(timer / (86400 * 30))}个月前`;
} else {
tips = `${parseInt(timer / (86400 * 365))}年前`;
}
} else {
tips = timeFormat(timestamp, format);
}
}
return tips;
}
/**
* @description 去除空格
* @param String str 需要去除空格的字符串
* @param String pos both(左右)|left|right|all 默认both
*/
function trim(str, pos = 'both') {
str = String(str);
if (pos == 'both') {
return str.replace(/^\s+|\s+$/g, '');
}
if (pos == 'left') {
return str.replace(/^\s*/, '');
}
if (pos == 'right') {
return str.replace(/(\s*$)/g, '');
}
if (pos == 'all') {
return str.replace(/\s+/g, '');
}
return str;
}
/**
* @description 对象转url参数
* @param {object} data,对象
* @param {Boolean} isPrefix,是否自动加上"?"
* @param {string} arrayFormat 规则 indices|brackets|repeat|comma
*/
function queryParams(data = {}, isPrefix = true, arrayFormat = 'brackets') {
const prefix = isPrefix ? '?' : '';
const _result = [];
if (['indices', 'brackets', 'repeat', 'comma'].indexOf(arrayFormat) == -1)
arrayFormat = 'brackets';
for (const key in data) {
const value = data[key];
// 去掉为空的参数
if (['', undefined, null].indexOf(value) >= 0) {
continue;
}
// 如果值为数组,另行处理
if (value.constructor === Array) {
// e.g. {ids: [1, 2, 3]}
switch (arrayFormat) {
case 'indices':
// 结果: ids[0]=1&ids[1]=2&ids[2]=3
for (let i = 0; i < value.length; i++) {
_result.push(`${key}[${i}]=${value[i]}`);
}
break;
case 'brackets':
// 结果: ids[]=1&ids[]=2&ids[]=3
value.forEach((_value) => {
_result.push(`${key}[]=${_value}`);
});
break;
case 'repeat':
// 结果: ids=1&ids=2&ids=3
value.forEach((_value) => {
_result.push(`${key}=${_value}`);
});
break;
case 'comma':
// 结果: ids=1,2,3
let commaStr = '';
value.forEach((_value) => {
commaStr += (commaStr ? ',' : '') + _value;
});
_result.push(`${key}=${commaStr}`);
break;
default:
value.forEach((_value) => {
_result.push(`${key}[]=${_value}`);
});
}
} else {
_result.push(`${key}=${value}`);
}
}
return _result.length ? prefix + _result.join('&') : '';
}
/**
* 显示消息提示框
* @param {String} title 提示的内容,长度与 icon 取值有关。
* @param {Number} duration 提示的延迟时间单位毫秒默认2000
*/
function toast(title, duration = 2000) {
uni.showToast({
title: String(title),
icon: 'none',
duration,
});
}
/**
* @description 根据主题type值,获取对应的图标
* @param {String} type 主题名称,primary|info|error|warning|success
* @param {boolean} fill 是否使用fill填充实体的图标
*/
function type2icon(type = 'success', fill = false) {
// 如果非预置值,默认为success
if (['primary', 'info', 'error', 'warning', 'success'].indexOf(type) == -1) type = 'success';
let iconName = '';
// 目前(2019-12-12),info和primary使用同一个图标
switch (type) {
case 'primary':
iconName = 'info-circle';
break;
case 'info':
iconName = 'info-circle';
break;
case 'error':
iconName = 'close-circle';
break;
case 'warning':
iconName = 'error-circle';
break;
case 'success':
iconName = 'checkmark-circle';
break;
default:
iconName = 'checkmark-circle';
}
// 是否是实体类型,加上-fill,在icon组件库中,实体的类名是后面加-fill的
if (fill) iconName += '-fill';
return iconName;
}
/**
* @description 数字格式化
* @param {number|string} number 要格式化的数字
* @param {number} decimals 保留几位小数
* @param {string} decimalPoint 小数点符号
* @param {string} thousandsSeparator 千分位符号
* @returns {string} 格式化后的数字
*/
function priceFormat(number, decimals = 0, decimalPoint = '.', thousandsSeparator = ',') {
number = `${number}`.replace(/[^0-9+-Ee.]/g, '');
const n = !isFinite(+number) ? 0 : +number;
const prec = !isFinite(+decimals) ? 0 : Math.abs(decimals);
const sep = typeof thousandsSeparator === 'undefined' ? ',' : thousandsSeparator;
const dec = typeof decimalPoint === 'undefined' ? '.' : decimalPoint;
let s = '';
s = (prec ? round(n, prec) + '' : `${Math.round(n)}`).split('.');
const re = /(-?\d+)(\d{3})/;
while (re.test(s[0])) {
s[0] = s[0].replace(re, `$1${sep}$2`);
}
if ((s[1] || '').length < prec) {
s[1] = s[1] || '';
s[1] += new Array(prec - s[1].length + 1).join('0');
}
return s.join(dec);
}
/**
* @description 获取duration值
* 如果带有ms或者s直接返回如果大于一定值认为是ms单位小于一定值认为是s单位
* 比如以30位阈值那么300大于30可以理解为用户想要的是300ms而不是想花300s去执行一个动画
* @param {String|number} value 比如: "1s"|"100ms"|1|100
* @param {boolean} unit 提示: 如果是false 默认返回number
* @return {string|number}
*/
function getDuration(value, unit = true) {
const valueNum = parseInt(value);
if (unit) {
if (/s$/.test(value)) return value;
return value > 30 ? `${value}ms` : `${value}s`;
}
if (/ms$/.test(value)) return valueNum;
if (/s$/.test(value)) return valueNum > 30 ? valueNum : valueNum * 1000;
return valueNum;
}
/**
* @description 日期的月或日补零操作
* @param {String} value 需要补零的值
*/
function padZero(value) {
return `00${value}`.slice(-2);
}
/**
* @description 获取某个对象下的属性,用于通过类似'a.b.c'的形式去获取一个对象的的属性的形式
* @param {object} obj 对象
* @param {string} key 需要获取的属性字段
* @returns {*}
*/
function getProperty(obj, key) {
if (!obj) {
return;
}
if (typeof key !== 'string' || key === '') {
return '';
}
if (key.indexOf('.') !== -1) {
const keys = key.split('.');
let firstObj = obj[keys[0]] || {};
for (let i = 1; i < keys.length; i++) {
if (firstObj) {
firstObj = firstObj[keys[i]];
}
}
return firstObj;
}
return obj[key];
}
/**
* @description 设置对象的属性值,如果'a.b.c'的形式进行设置
* @param {object} obj 对象
* @param {string} key 需要设置的属性
* @param {string} value 设置的值
*/
function setProperty(obj, key, value) {
if (!obj) {
return;
}
// 递归赋值
const inFn = function (_obj, keys, v) {
// 最后一个属性key
if (keys.length === 1) {
_obj[keys[0]] = v;
return;
}
// 0~length-1个key
while (keys.length > 1) {
const k = keys[0];
if (!_obj[k] || typeof _obj[k] !== 'object') {
_obj[k] = {};
}
const key = keys.shift();
// 自调用判断是否存在属性,不存在则自动创建对象
inFn(_obj[k], keys, v);
}
};
if (typeof key !== 'string' || key === '') {
} else if (key.indexOf('.') !== -1) {
// 支持多层级赋值操作
const keys = key.split('.');
inFn(obj, keys, value);
} else {
obj[key] = value;
}
}
/**
* @description 获取当前页面路径
*/
function page() {
const pages = getCurrentPages();
// 某些特殊情况下(比如页面进行redirectTo时的一些时机)pages可能为空数组
return `/${pages[pages.length - 1]?.route || ''}`;
}
/**
* @description 获取当前路由栈实例数组
*/
function pages() {
const pages = getCurrentPages();
return pages;
}
/**
* 获取H5-真实根地址 兼容hash+history模式
*/
export function getRootUrl() {
let url = '';
// #ifdef H5
url = location.origin;
// + location.pathname;
if (location.hash !== '') {
url += '#/';
} else {
url += '/';
}
// #endif
return url;
}
/**
* copyText 多端复制文本
*/
export function copyText(text) {
// #ifndef H5
uni.setClipboardData({
data: text,
success: function () {
toast('复制成功!');
},
fail: function () {
toast('复制失败!');
},
});
// #endif
// #ifdef H5
var createInput = document.createElement('textarea');
createInput.value = text;
document.body.appendChild(createInput);
createInput.select();
document.execCommand('Copy');
createInput.className = 'createInput';
createInput.style.display = 'none';
toast('复制成功');
// #endif
}
export default {
range,
getPx,
sleep,
os,
random,
guid,
$parent,
addStyle,
addUnit,
deepClone,
deepMerge,
error,
randomArray,
timeFormat,
timeFrom,
trim,
queryParams,
toast,
type2icon,
priceFormat,
getDuration,
padZero,
getProperty,
setProperty,
page,
pages,
test,
getRootUrl,
copyText,
};

View File

@@ -0,0 +1,13 @@
const HOME_MENU_URL = '/pages/index/menu';
export function navigateAfterLogin({ delay = 500 } = {}) {
setTimeout(() => {
uni.reLaunch({
url: HOME_MENU_URL,
});
}, delay);
}
export default {
navigateAfterLogin,
};

285
sheep/helper/test.js Normal file
View File

@@ -0,0 +1,285 @@
/**
* 验证电子邮箱格式
*/
function email(value) {
return /^\w+((-\w+)|(\.\w+))*\@[A-Za-z0-9]+((\.|-)[A-Za-z0-9]+)*\.[A-Za-z0-9]+$/.test(value);
}
/**
* 验证手机格式
*/
function mobile(value) {
return /^1[23456789]\d{9}$/.test(value);
}
/**
* 验证URL格式
*/
function url(value) {
return /^((https|http|ftp|rtsp|mms):\/\/)(([0-9a-zA-Z_!~*'().&=+$%-]+: )?[0-9a-zA-Z_!~*'().&=+$%-]+@)?(([0-9]{1,3}.){3}[0-9]{1,3}|([0-9a-zA-Z_!~*'()-]+.)*([0-9a-zA-Z][0-9a-zA-Z-]{0,61})?[0-9a-zA-Z].[a-zA-Z]{2,6})(:[0-9]{1,4})?((\/?)|(\/[0-9a-zA-Z_!~*'().;?:@&=+$,%#-]+)+\/?)$/.test(
value,
);
}
/**
* 验证日期格式
*/
function date(value) {
if (!value) return false;
// 判断是否数值或者字符串数值(意味着为时间戳)转为数值否则new Date无法识别字符串时间戳
if (number(value)) value = +value;
return !/Invalid|NaN/.test(new Date(value).toString());
}
/**
* 验证ISO类型的日期格式
*/
function dateISO(value) {
return /^\d{4}[\/\-](0?[1-9]|1[012])[\/\-](0?[1-9]|[12][0-9]|3[01])$/.test(value);
}
/**
* 验证十进制数字
*/
function number(value) {
return /^[\+-]?(\d+\.?\d*|\.\d+|\d\.\d+e\+\d+)$/.test(value);
}
/**
* 验证字符串
*/
function string(value) {
return typeof value === 'string';
}
/**
* 验证整数
*/
function digits(value) {
return /^\d+$/.test(value);
}
/**
* 验证身份证号码
*/
function idCard(value) {
return /^[1-9]\d{5}[1-9]\d{3}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}([0-9]|X)$/.test(value);
}
/**
* 是否车牌号
*/
function carNo(value) {
// 新能源车牌
const xreg =
/^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}(([0-9]{5}[DF]$)|([DF][A-HJ-NP-Z0-9][0-9]{4}$))/;
// 旧车牌
const creg =
/^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领A-Z]{1}[A-Z]{1}[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9挂学警港澳]{1}$/;
if (value.length === 7) {
return creg.test(value);
}
if (value.length === 8) {
return xreg.test(value);
}
return false;
}
/**
* 金额,只允许2位小数
*/
function amount(value) {
// 金额,只允许保留两位小数
return /^[1-9]\d*(,\d{3})*(\.\d{1,2})?$|^0\.\d{1,2}$/.test(value);
}
/**
* 中文
*/
function chinese(value) {
const reg = /^[\u4e00-\u9fa5]+$/gi;
return reg.test(value);
}
/**
* 只能输入字母
*/
function letter(value) {
return /^[a-zA-Z]*$/.test(value);
}
/**
* 只能是字母或者数字
*/
function enOrNum(value) {
// 英文或者数字
const reg = /^[0-9a-zA-Z]*$/g;
return reg.test(value);
}
/**
* 验证是否包含某个值
*/
function contains(value, param) {
return value.indexOf(param) >= 0;
}
/**
* 验证一个值范围[min, max]
*/
function range(value, param) {
return value >= param[0] && value <= param[1];
}
/**
* 验证一个长度范围[min, max]
*/
function rangeLength(value, param) {
return value.length >= param[0] && value.length <= param[1];
}
/**
* 是否固定电话
*/
function landline(value) {
const reg = /^\d{3,4}-\d{7,8}(-\d{3,4})?$/;
return reg.test(value);
}
/**
* 判断是否为空
*/
function empty(value) {
switch (typeof value) {
case 'undefined':
return true;
case 'string':
if (value.replace(/(^[ \t\n\r]*)|([ \t\n\r]*$)/g, '').length == 0) return true;
break;
case 'boolean':
if (!value) return true;
break;
case 'number':
if (value === 0 || isNaN(value)) return true;
break;
case 'object':
if (value === null || value.length === 0) return true;
for (const i in value) {
return false;
}
return true;
}
return false;
}
/**
* 是否json字符串
*/
function jsonString(value) {
if (typeof value === 'string') {
try {
const obj = JSON.parse(value);
if (typeof obj === 'object' && obj) {
return true;
}
return false;
} catch (e) {
return false;
}
}
return false;
}
/**
* 是否数组
*/
function array(value) {
if (typeof Array.isArray === 'function') {
return Array.isArray(value);
}
return Object.prototype.toString.call(value) === '[object Array]';
}
/**
* 是否对象
*/
function object(value) {
return Object.prototype.toString.call(value) === '[object Object]';
}
/**
* 是否短信验证码
*/
function code(value, len = 6) {
return new RegExp(`^\\d{${len}}$`).test(value);
}
/**
* 是否函数方法
* @param {Object} value
*/
function func(value) {
return typeof value === 'function';
}
/**
* 是否promise对象
* @param {Object} value
*/
function promise(value) {
return object(value) && func(value.then) && func(value.catch);
}
/** 是否图片格式
* @param {Object} value
*/
function image(value) {
const newValue = value.split('?')[0];
const IMAGE_REGEXP = /\.(jpeg|jpg|gif|png|svg|webp|jfif|bmp|dpg)/i;
return IMAGE_REGEXP.test(newValue);
}
/**
* 是否视频格式
* @param {Object} value
*/
function video(value) {
const VIDEO_REGEXP = /\.(mp4|mpg|mpeg|dat|asf|avi|rm|rmvb|mov|wmv|flv|mkv|m3u8)/i;
return VIDEO_REGEXP.test(value);
}
/**
* 是否为正则对象
* @param {Object}
* @return {Boolean}
*/
function regExp(o) {
return o && Object.prototype.toString.call(o) === '[object RegExp]';
}
export default {
email,
mobile,
url,
date,
dateISO,
number,
digits,
idCard,
carNo,
amount,
chinese,
letter,
enOrNum,
contains,
range,
rangeLength,
empty,
isEmpty: empty,
isNumber: number,
jsonString,
landline,
object,
array,
code,
};

125
sheep/helper/theme.js Normal file
View File

@@ -0,0 +1,125 @@
// 主题工具函数
import { themeConfig } from '@/sheep/config/theme';
/**
* 获取主题色
* @param {string} type - 主题色类型 'main' | 'light' | 'dark'
* @returns {string} 颜色值
*/
export function getThemeColor(type = 'main') {
return themeConfig.primary[type] || themeConfig.primary.main;
}
/**
* 获取主题色透明度版本
* @param {number} opacity - 透明度 1-5
* @returns {string} rgba颜色值
*/
export function getThemeColorWithOpacity(opacity = 1) {
return themeConfig.primary.opacity[opacity] || themeConfig.primary.opacity[1];
}
/**
* 获取语义化颜色
* @param {string} type - 颜色类型 'success' | 'warning' | 'danger' | 'info'
* @returns {string} 颜色值
*/
export function getSemanticColor(type) {
return themeConfig.colors[type] || themeConfig.colors.info;
}
/**
* 获取文本颜色
* @param {string} type - 文本类型 'primary' | 'regular' | 'secondary' | 'placeholder'
* @returns {string} 颜色值
*/
export function getTextColor(type = 'primary') {
return themeConfig.colors.text[type] || themeConfig.colors.text.primary;
}
/**
* 获取边框颜色
* @param {string} type - 边框类型 'base' | 'light' | 'lighter' | 'extra_light'
* @returns {string} 颜色值
*/
export function getBorderColor(type = 'base') {
return themeConfig.colors.border[type] || themeConfig.colors.border.base;
}
/**
* 获取背景颜色
* @param {string} type - 背景类型 'base' | 'page' | 'card'
* @returns {string} 颜色值
*/
export function getBackgroundColor(type = 'base') {
return themeConfig.colors.background[type] || themeConfig.colors.background.base;
}
/**
* 设置主题色
* @param {string} color - 新的主题色
*/
export function setThemeColor(color) {
// 更新主题配置
themeConfig.primary.main = color;
// 生成相关颜色
themeConfig.primary.light = lightenColor(color, 20);
themeConfig.primary.dark = darkenColor(color, 20);
// 生成透明度颜色
const rgb = hexToRgb(color);
for (let i = 1; i <= 5; i++) {
themeConfig.primary.opacity[i] = `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.${i})`;
}
}
/**
* 十六进制颜色转RGB
*/
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
/**
* 颜色变亮
*/
function lightenColor(hex, percent) {
const rgb = hexToRgb(hex);
if (!rgb) return hex;
const factor = percent / 100;
rgb.r = Math.round(rgb.r + (255 - rgb.r) * factor);
rgb.g = Math.round(rgb.g + (255 - rgb.g) * factor);
rgb.b = Math.round(rgb.b + (255 - rgb.b) * factor);
return `#${rgb.r.toString(16).padStart(2, '0')}${rgb.g.toString(16).padStart(2, '0')}${rgb.b.toString(16).padStart(2, '0')}`;
}
/**
* 颜色变暗
*/
function darkenColor(hex, percent) {
const rgb = hexToRgb(hex);
if (!rgb) return hex;
const factor = percent / 100;
rgb.r = Math.round(rgb.r * (1 - factor));
rgb.g = Math.round(rgb.g * (1 - factor));
rgb.b = Math.round(rgb.b * (1 - factor));
return `#${rgb.r.toString(16).padStart(2, '0')}${rgb.g.toString(16).padStart(2, '0')}${rgb.b.toString(16).padStart(2, '0')}`;
}
export default {
getThemeColor,
getThemeColorWithOpacity,
getSemanticColor,
getTextColor,
getBorderColor,
getBackgroundColor,
setThemeColor
};

31
sheep/helper/throttle.js Normal file
View File

@@ -0,0 +1,31 @@
let timer;
let flag;
/**
* 节流原理:在一定时间内,只能触发一次
*
* @param {Function} func 要执行的回调函数
* @param {Number} wait 延时的时间
* @param {Boolean} immediate 是否立即执行
* @return null
*/
function throttle(func, wait = 500, immediate = true) {
if (immediate) {
if (!flag) {
flag = true;
// 如果是立即执行则在wait毫秒内开始时执行
typeof func === 'function' && func();
timer = setTimeout(() => {
flag = false;
}, wait);
} else {
}
} else if (!flag) {
flag = true;
// 如果是非立即执行则在wait毫秒内的结束处执行
timer = setTimeout(() => {
flag = false;
typeof func === 'function' && func();
}, wait);
}
}
export default throttle;

67
sheep/helper/tools.js Normal file
View File

@@ -0,0 +1,67 @@
import router from '@/sheep/router';
export default {
/**
* 打电话
* @param {String<Number>} phoneNumber - 数字字符串
*/
callPhone(phoneNumber = '') {
let num = phoneNumber.toString();
uni.makePhoneCall({
phoneNumber: num,
fail(err) {
console.log('makePhoneCall出错', err);
},
});
},
/**
* 微信头像
* @param {String} url -图片地址
*/
checkMPUrl(url) {
// #ifdef MP
if (
url.substring(0, 4) === 'http' &&
url.substring(0, 5) !== 'https' &&
url.substring(0, 12) !== 'http://store' &&
url.substring(0, 10) !== 'http://tmp' &&
url.substring(0, 10) !== 'http://usr'
) {
url = 'https' + url.substring(4, url.length);
}
// #endif
return url;
},
/**
* getUuid 生成唯一id
*/
getUuid(len = 32, firstU = true, radix = null) {
const chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'.split('');
const uuid = [];
radix = radix || chars.length;
if (len) {
// 如果指定uuid长度,只是取随机的字符,0|x为位运算,能去掉x的小数位,返回整数位
for (let i = 0; i < len; i++) uuid[i] = chars[0 | (Math.random() * radix)];
} else {
let r;
// rfc4122标准要求返回的uuid中,某些位为固定的字符
uuid[8] = uuid[13] = uuid[18] = uuid[23] = '-';
uuid[14] = '4';
for (let i = 0; i < 36; i++) {
if (!uuid[i]) {
r = 0 | (Math.random() * 16);
uuid[i] = chars[i == 19 ? (r & 0x3) | 0x8 : r];
}
}
}
// 移除第一个字符,并用u替代,因为第一个字符为数值时,该guuid不能用作id或者class
if (firstU) {
uuid.shift();
return `u${uuid.join('')}`;
}
return uuid.join('');
},
};

336
sheep/helper/utils.js Normal file
View File

@@ -0,0 +1,336 @@
export function isArray(value) {
if (typeof Array.isArray === 'function') {
return Array.isArray(value);
} else {
return Object.prototype.toString.call(value) === '[object Array]';
}
}
export function isObject(value) {
return Object.prototype.toString.call(value) === '[object Object]';
}
export function isNumber(value) {
return !isNaN(Number(value));
}
export function isFunction(value) {
return typeof value == 'function';
}
export function isString(value) {
return typeof value == 'string';
}
export function isEmpty(value) {
if (value === '' || value === undefined || value === null) {
return true;
}
if (isArray(value)) {
return value.length === 0;
}
if (isObject(value)) {
return Object.keys(value).length === 0;
}
return false;
}
export function isBoolean(value) {
return typeof value === 'boolean';
}
export function last(data) {
if (isArray(data) || isString(data)) {
return data[data.length - 1];
}
}
export function cloneDeep(obj) {
const d = isArray(obj) ? [...obj] : {};
if (isObject(obj)) {
for (const key in obj) {
if (obj[key]) {
if (obj[key] && typeof obj[key] === 'object') {
d[key] = cloneDeep(obj[key]);
} else {
d[key] = obj[key];
}
}
}
}
return d;
}
export function clone(obj) {
return Object.create(Object.getPrototypeOf(obj), Object.getOwnPropertyDescriptors(obj));
}
export function deepMerge(a, b) {
let k;
for (k in b) {
a[k] = a[k] && a[k].toString() === '[object Object]' ? deepMerge(a[k], b[k]) : (a[k] = b[k]);
}
return a;
}
export function contains(parent, node) {
while (node && (node = node.parentNode)) if (node === parent) return true;
return false;
}
export function orderBy(list, key) {
return list.sort((a, b) => a[key] - b[key]);
}
export function deepTree(list) {
const newList = [];
const map = {};
list.forEach((e) => (map[e.id] = e));
list.forEach((e) => {
const parent = map[e.parentId];
if (parent) {
(parent.children || (parent.children = [])).push(e);
} else {
newList.push(e);
}
});
const fn = (list) => {
list.map((e) => {
if (e.children instanceof Array) {
e.children = orderBy(e.children, 'orderNum');
fn(e.children);
}
});
};
fn(newList);
return orderBy(newList, 'orderNum');
}
export function revDeepTree(list = []) {
const d = [];
let id = 0;
const deep = (list, parentId) => {
list.forEach((e) => {
if (!e.id) {
e.id = id++;
}
e.parentId = parentId;
d.push(e);
if (e.children && isArray(e.children)) {
deep(e.children, e.id);
}
});
};
deep(list || [], null);
return d;
}
export function basename(path) {
let index = path.lastIndexOf('/');
index = index > -1 ? index : path.lastIndexOf('\\');
if (index < 0) {
return path;
}
return path.substring(index + 1);
}
export function isWxBrowser() {
const ua = navigator.userAgent.toLowerCase();
if (ua.match(/MicroMessenger/i) == 'micromessenger') {
return true;
} else {
return false;
}
}
/**
* @description 如果value小于min取min如果value大于max取max
* @param {number} min
* @param {number} max
* @param {number} value
*/
export function range(min = 0, max = 0, value = 0) {
return Math.max(min, Math.min(max, Number(value)));
}
import dayjs from 'dayjs';
/**
* 将一个整数转换为分数保留两位小数
* @param {number | string | undefined} num 整数
* @return {number} 分数
*/
export const formatToFraction = (num) => {
if (typeof num === 'undefined') return 0;
const parsedNumber = typeof num === 'string' ? parseFloat(num) : num;
return parseFloat((parsedNumber / 100).toFixed(2));
};
/**
* 将一个数转换为 1.00 这样
* 数据呈现的时候使用
*
* @param {number | string | undefined} num 整数
* @return {string} 分数
*/
export const floatToFixed2 = (num) => {
let str = '0.00';
if (typeof num === 'undefined') {
return str;
}
const f = formatToFraction(num);
const decimalPart = f.toString().split('.')[1];
const len = decimalPart ? decimalPart.length : 0;
switch (len) {
case 0:
str = f.toString() + '.00';
break;
case 1:
str = f.toString() + '.0';
break;
case 2:
str = f.toString();
break;
}
return str;
};
/**
* 时间日期转换
* @param {dayjs.ConfigType} date 当前时间new Date() 格式
* @param {string} format 需要转换的时间格式字符串
* @description format 字符串随意,如 `YYYY-mm、YYYY-mm-dd`
* @description format 季度:"YYYY-mm-dd HH:MM:SS QQQQ"
* @description format 星期:"YYYY-mm-dd HH:MM:SS WWW"
* @description format 几周:"YYYY-mm-dd HH:MM:SS ZZZ"
* @description format 季度 + 星期 + 几周:"YYYY-mm-dd HH:MM:SS WWW QQQQ ZZZ"
* @returns {string} 返回拼接后的时间字符串
*/
export function formatDate(date, format = 'YYYY-MM-DD HH:mm:ss') {
// 日期不存在,则返回空
if (!date) {
return '';
}
// 日期存在,则进行格式化
if (format === undefined) {
format = 'YYYY-MM-DD HH:mm:ss';
}
return dayjs(date).format(format);
}
/**
* 构造树型结构数据
*
* @param {*} data 数据源
* @param {*} id id字段 默认 'id'
* @param {*} parentId 父节点字段 默认 'parentId'
* @param {*} children 孩子节点字段 默认 'children'
* @param {*} rootId 根Id 默认 0
*/
export function handleTree(
data,
id = 'id',
parentId = 'parentId',
children = 'children',
rootId = 0,
) {
// 对源数据深度克隆
const cloneData = JSON.parse(JSON.stringify(data));
// 循环所有项
const treeData = cloneData.filter((father) => {
let branchArr = cloneData.filter((child) => {
//返回每一项的子级数组
return father[id] === child[parentId];
});
branchArr.length > 0 ? (father.children = branchArr) : '';
//返回第一层
return father[parentId] === rootId;
});
return treeData !== '' ? treeData : data;
}
/**
* 重置分页对象
*
* @param pagination 分页对象
*/
export function resetPagination(pagination) {
pagination.list = [];
pagination.total = 0;
pagination.pageNo = 1;
}
/**
* 将值复制到目标对象且以目标对象属性为准target: {a:1} source:{a:2,b:3} 结果为:{a:2}
* @param target 目标对象
* @param source 源对象
*/
export const copyValueToTarget = (target, source) => {
const newObj = Object.assign({}, target, source);
// 删除多余属性
Object.keys(newObj).forEach((key) => {
// 如果不是target中的属性则删除
if (Object.keys(target).indexOf(key) === -1) {
delete newObj[key];
}
});
// 更新目标对象值
Object.assign(target, newObj);
};
/**
* 解析 JSON 字符串
*
* @param str
*/
export function jsonParse(str) {
try {
return JSON.parse(str);
} catch (e) {
console.warn(`str[${str}] 不是一个 JSON 字符串`);
return str;
}
}
/**
* 获得当前周的开始和结束时间
*/
export function getWeekTimes() {
const today = new Date();
const dayOfWeek = today.getDay();
return [
new Date(today.getFullYear(), today.getMonth(), today.getDate() - dayOfWeek, 0, 0, 0),
new Date(today.getFullYear(), today.getMonth(), today.getDate() + (6 - dayOfWeek), 23, 59, 59),
];
}
/**
* 获得当前月的开始和结束时间
*/
export function getMonthTimes() {
const today = new Date();
const year = today.getFullYear();
const month = today.getMonth();
const startDate = new Date(year, month, 1, 0, 0, 0);
const nextMonth = new Date(year, month + 1, 1);
const endDate = new Date(nextMonth.getTime() - 1);
return [startDate, endDate];
}

166
sheep/hooks/useModal.js Normal file
View File

@@ -0,0 +1,166 @@
import $store from '@/sheep/store';
import $helper from '@/sheep/helper';
import dayjs from 'dayjs';
import { ref } from 'vue';
import test from '@/sheep/helper/test.js';
import AuthUtil from '@/sheep/api/system/auth';
import sheep from '@/sheep';
// 打开授权弹框
export function showAuthModal(type = 'accountLogin') {
sheep.$router.go('/pages/login/index', { authType: type });
}
// 关闭授权弹框
export function closeAuthModal() {
sheep.$router.back();
}
// 打开分享弹框
export function showShareModal() {
$store('modal').$patch((state) => {
state.share = true;
});
}
// 关闭分享弹框
export function closeShareModal() {
$store('modal').$patch((state) => {
state.share = false;
});
}
// 打开快捷菜单
export function showMenuTools() {
$store('modal').$patch((state) => {
state.menu = true;
});
}
// 关闭快捷菜单
export function closeMenuTools() {
$store('modal').$patch((state) => {
state.menu = false;
});
}
// 发送短信验证码 60秒
export function getSmsCode(event, mobile) {
const modalStore = $store('modal');
const lastSendTimer = modalStore.lastTimer[event];
if (typeof lastSendTimer === 'undefined') {
$helper.toast('短信发送事件错误');
return;
}
const duration = dayjs().unix() - lastSendTimer;
const canSend = duration >= 60;
if (!canSend) {
$helper.toast('请稍后再试');
return;
}
// 只有 mobile 非空时才校验。因为部分场景(修改密码),不需要输入手机
if (mobile && !test.mobile(mobile)) {
$helper.toast('手机号码格式不正确');
return;
}
// 发送验证码 + 更新上次发送验证码时间
let scene = -1;
switch (event) {
case 'resetPassword':
scene = 4;
break;
case 'changePassword':
scene = 3;
break;
case 'changeMobile':
scene = 2;
break;
case 'smsLogin':
scene = 1;
break;
}
AuthUtil.sendSmsCode(mobile, scene).then((res) => {
if (res.code === 0) {
modalStore.$patch((state) => {
state.lastTimer[event] = dayjs().unix();
});
} else {
$helper.toast(res.msg || '验证码发送失败,请稍后重试');
}
}).catch((error) => {
$helper.toast('验证码发送失败,请稍后重试');
});
}
// 获取短信验证码倒计时 -- 60秒
export function getSmsTimer(event, mobile = '') {
const modalStore = $store('modal');
const lastSendTimer = modalStore.lastTimer[event];
if (typeof lastSendTimer === 'undefined') {
return '获取验证码';
}
const currentTime = dayjs().unix();
const duration = currentTime - lastSendTimer;
const canSend = duration >= 60;
if (canSend) {
return '获取验证码';
}
const remainingTime = 60 - duration;
return `${remainingTime}`;
}
// 创建响应式的短信验证码计时器
export function useSmsTimer(event) {
const modalStore = $store('modal');
const timer = ref('获取验证码');
const updateTimer = () => {
const lastSendTimer = modalStore.lastTimer[event];
if (typeof lastSendTimer === 'undefined') {
timer.value = '获取验证码';
return;
}
const currentTime = dayjs().unix();
const duration = currentTime - lastSendTimer;
const canSend = duration >= 60;
if (canSend) {
timer.value = '获取验证码';
} else {
const remainingTime = 60 - duration;
timer.value = `${remainingTime}`;
}
};
// 初始化
updateTimer();
// 每秒更新一次
const intervalId = setInterval(updateTimer, 1000);
// 清理定时器的函数
const cleanup = () => {
clearInterval(intervalId);
};
return { timer, cleanup };
}
// 记录广告弹框历史
export function saveAdvHistory(adv) {
const modal = $store('modal');
modal.$patch((state) => {
if (!state.advHistory.includes(adv.imgUrl)) {
state.advHistory.push(adv.imgUrl);
}
});
}

52
sheep/index.js Normal file
View File

@@ -0,0 +1,52 @@
import $api from '@/sheep/api';
import $url from '@/sheep/url';
import $router from '@/sheep/router';
import $platform from '@/sheep/platform';
import $helper from '@/sheep/helper';
import zIndex from '@/sheep/config/zIndex.js';
import $store from '@/sheep/store';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import duration from 'dayjs/plugin/duration';
import 'dayjs/locale/zh-cn';
dayjs.locale('zh-cn');
dayjs.extend(relativeTime);
dayjs.extend(duration);
const sheep = {
$api,
$store,
$url,
$router,
$platform,
$helper,
$zIndex: zIndex,
};
// 加载Shopro底层依赖
export async function ShoproInit() {
// 应用初始化
await $store('app').init();
// 平台初始化加载(各平台provider提供不同的加载流程)
$platform.load();
if (process.env.NODE_ENV === 'development') {
ShoproDebug();
}
}
// 开发模式
function ShoproDebug() {
// 开发环境引入vconsole调试
// #ifdef H5
// import("vconsole").then(vconsole => {
// new vconsole.default();
// });
// #endif
// 同步前端页面到后端
// console.log(ROUTES)
}
export default sheep;

View File

@@ -0,0 +1,32 @@
const fs = require('fs');
const manifestPath = process.env.UNI_INPUT_DIR + '/manifest.json';
let Manifest = fs.readFileSync(manifestPath, {
encoding: 'utf-8'
});
function mpliveMainfestPlugin(isOpen) {
if (process.env.UNI_PLATFORM !== 'mp-weixin') return;
const manifestData = JSON.parse(Manifest)
if (isOpen === '0') {
delete manifestData['mp-weixin'].plugins['live-player-plugin'];
}
if (isOpen === '1') {
manifestData['mp-weixin'].plugins['live-player-plugin'] = {
"version": "1.3.5",
"provider": "wx2b03c6e691cd7370"
}
}
Manifest = JSON.stringify(manifestData, null, 2)
fs.writeFileSync(manifestPath, Manifest, {
"flag": "w"
})
}
export default mpliveMainfestPlugin

244
sheep/libs/permission.js Normal file
View File

@@ -0,0 +1,244 @@
/// null = 未请求1 = 已允许0 = 拒绝|受限, 2 = 系统未开启
var isIOS;
function album() {
var result = 0;
var PHPhotoLibrary = plus.ios.import('PHPhotoLibrary');
var authStatus = PHPhotoLibrary.authorizationStatus();
if (authStatus === 0) {
result = null;
} else if (authStatus == 3) {
result = 1;
} else {
result = 0;
}
plus.ios.deleteObject(PHPhotoLibrary);
return result;
}
function camera() {
var result = 0;
var AVCaptureDevice = plus.ios.import('AVCaptureDevice');
var authStatus = AVCaptureDevice.authorizationStatusForMediaType('vide');
if (authStatus === 0) {
result = null;
} else if (authStatus == 3) {
result = 1;
} else {
result = 0;
}
plus.ios.deleteObject(AVCaptureDevice);
return result;
}
function location() {
var result = 0;
var cllocationManger = plus.ios.import('CLLocationManager');
var enable = cllocationManger.locationServicesEnabled();
var status = cllocationManger.authorizationStatus();
if (!enable) {
result = 2;
} else if (status === 0) {
result = null;
} else if (status === 3 || status === 4) {
result = 1;
} else {
result = 0;
}
plus.ios.deleteObject(cllocationManger);
return result;
}
function push() {
var result = 0;
var UIApplication = plus.ios.import('UIApplication');
var app = UIApplication.sharedApplication();
var enabledTypes = 0;
if (app.currentUserNotificationSettings) {
var settings = app.currentUserNotificationSettings();
enabledTypes = settings.plusGetAttribute('types');
if (enabledTypes == 0) {
result = 0;
console.log('推送权限没有开启');
} else {
result = 1;
console.log('已经开启推送功能!');
}
plus.ios.deleteObject(settings);
} else {
enabledTypes = app.enabledRemoteNotificationTypes();
if (enabledTypes == 0) {
result = 3;
console.log('推送权限没有开启!');
} else {
result = 4;
console.log('已经开启推送功能!');
}
}
plus.ios.deleteObject(app);
plus.ios.deleteObject(UIApplication);
return result;
}
function contact() {
var result = 0;
var CNContactStore = plus.ios.import('CNContactStore');
var cnAuthStatus = CNContactStore.authorizationStatusForEntityType(0);
if (cnAuthStatus === 0) {
result = null;
} else if (cnAuthStatus == 3) {
result = 1;
} else {
result = 0;
}
plus.ios.deleteObject(CNContactStore);
return result;
}
function record() {
var result = null;
var avaudiosession = plus.ios.import('AVAudioSession');
var avaudio = avaudiosession.sharedInstance();
var status = avaudio.recordPermission();
console.log('permissionStatus:' + status);
if (status === 1970168948) {
result = null;
} else if (status === 1735552628) {
result = 1;
} else {
result = 0;
}
plus.ios.deleteObject(avaudiosession);
return result;
}
function calendar() {
var result = null;
var EKEventStore = plus.ios.import('EKEventStore');
var ekAuthStatus = EKEventStore.authorizationStatusForEntityType(0);
if (ekAuthStatus == 3) {
result = 1;
console.log('日历权限已经开启');
} else {
console.log('日历权限没有开启');
}
plus.ios.deleteObject(EKEventStore);
return result;
}
function memo() {
var result = null;
var EKEventStore = plus.ios.import('EKEventStore');
var ekAuthStatus = EKEventStore.authorizationStatusForEntityType(1);
if (ekAuthStatus == 3) {
result = 1;
console.log('备忘录权限已经开启');
} else {
console.log('备忘录权限没有开启');
}
plus.ios.deleteObject(EKEventStore);
return result;
}
function requestIOS(permissionID) {
return new Promise((resolve, reject) => {
switch (permissionID) {
case 'push':
resolve(push());
break;
case 'location':
resolve(location());
break;
case 'record':
resolve(record());
break;
case 'camera':
resolve(camera());
break;
case 'album':
resolve(album());
break;
case 'contact':
resolve(contact());
break;
case 'calendar':
resolve(calendar());
break;
case 'memo':
resolve(memo());
break;
default:
resolve(0);
break;
}
});
}
function requestAndroid(permissionID) {
return new Promise((resolve, reject) => {
plus.android.requestPermissions(
[permissionID],
function (resultObj) {
var result = 0;
for (var i = 0; i < resultObj.granted.length; i++) {
var grantedPermission = resultObj.granted[i];
console.log('已获取的权限:' + grantedPermission);
result = 1;
}
for (var i = 0; i < resultObj.deniedPresent.length; i++) {
var deniedPresentPermission = resultObj.deniedPresent[i];
console.log('拒绝本次申请的权限:' + deniedPresentPermission);
result = 0;
}
for (var i = 0; i < resultObj.deniedAlways.length; i++) {
var deniedAlwaysPermission = resultObj.deniedAlways[i];
console.log('永久拒绝申请的权限:' + deniedAlwaysPermission);
result = -1;
}
resolve(result);
},
function (error) {
console.log('result error: ' + error.message);
resolve({
code: error.code,
message: error.message,
});
},
);
});
}
function gotoAppPermissionSetting() {
if (permission.isIOS) {
var UIApplication = plus.ios.import('UIApplication');
var application2 = UIApplication.sharedApplication();
var NSURL2 = plus.ios.import('NSURL');
var setting2 = NSURL2.URLWithString('app-settings:');
application2.openURL(setting2);
plus.ios.deleteObject(setting2);
plus.ios.deleteObject(NSURL2);
plus.ios.deleteObject(application2);
} else {
var Intent = plus.android.importClass('android.content.Intent');
var Settings = plus.android.importClass('android.provider.Settings');
var Uri = plus.android.importClass('android.net.Uri');
var mainActivity = plus.android.runtimeMainActivity();
var intent = new Intent();
intent.setAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
var uri = Uri.fromParts('package', mainActivity.getPackageName(), null);
intent.setData(uri);
mainActivity.startActivity(intent);
}
}
const permission = {
get isIOS() {
return typeof isIOS === 'boolean' ? isIOS : (isIOS = uni.getDeviceInfo().platform === 'ios');
},
requestIOS: requestIOS,
requestAndroid: requestAndroid,
gotoAppSetting: gotoAppPermissionSetting,
};
export default permission;

193
sheep/libs/sdk-h5-weixin.js Normal file
View File

@@ -0,0 +1,193 @@
/**
* 本模块封装微信浏览器下的一些方法。
* 更多微信网页开发sdk方法,详见:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html
* 有 the permission value is offline verifying 报错请参考 @see https://segmentfault.com/a/1190000042289419 解决
*/
import jweixin from 'weixin-js-sdk';
import $helper from '@/sheep/helper';
import AuthUtil from '@/sheep/api/system/auth';
let configSuccess = false;
export default {
// 判断是否在微信中
isWechat() {
const ua = window.navigator.userAgent.toLowerCase();
// noinspection EqualityComparisonWithCoercionJS
return ua.match(/micromessenger/i) == 'micromessenger';
},
isReady(api) {
jweixin.ready(api);
},
// 初始化 JSSDK
async init(callback) {
if (!this.isWechat()) {
$helper.toast('请使用微信网页浏览器打开');
return;
}
// 调用后端接口,获得 JSSDK 初始化所需的签名
const url = location.origin;
const { code, data } = await AuthUtil.createWeixinMpJsapiSignature(url);
if (code === 0) {
jweixin.config({
debug: false,
appId: data.appId,
timestamp: data.timestamp,
nonceStr: data.nonceStr,
signature: data.signature,
jsApiList: [
'chooseWXPay',
'openLocation',
'getLocation',
'updateAppMessageShareData',
'updateTimelineShareData',
'scanQRCode',
], // TODO 芋艿:后续可以设置更多权限;
openTagList: data.openTagList,
});
} else {
console.log('请求 JSSDK 配置失败,错误码:', code);
}
// 监听结果
configSuccess = true;
jweixin.error((err) => {
configSuccess = false;
console.error('微信 JSSDK 初始化失败', err);
$helper.toast('微信JSSDK:' + err.errMsg);
});
jweixin.ready(() => {
if (configSuccess) {
console.log('微信 JSSDK 初始化成功');
}
});
// 回调
if (callback) {
callback(data);
}
},
//在需要定位页面调用 TODO 芋艿:未测试
getLocation(callback) {
this.isReady(() => {
jweixin.getLocation({
type: 'gcj02', // 默认为wgs84的gps坐标如果要返回直接给openLocation用的火星坐标可传入'gcj02'
success: function (res) {
callback(res);
},
fail: function (res) {
console.log('%c微信H5sdk,getLocation失败', 'color:green;background:yellow');
},
});
});
},
// 获取微信收货地址
openAddress(callback) {
this.isReady(() => {
jweixin.openAddress({
success: function (res) {
callback.success && callback.success(res);
},
fail: function (err) {
callback.error && callback.error(err);
console.log('%c微信H5sdk,openAddress失败', 'color:green;background:yellow');
},
complete: function (res) {},
});
});
},
// 微信扫码 TODO 芋艿:未测试
scanQRCode(callback) {
this.isReady(() => {
jweixin.scanQRCode({
needResult: 1, // 默认为0扫描结果由微信处理1则直接返回扫描结果
scanType: ['qrCode', 'barCode'], // 可以指定扫二维码还是一维码,默认二者都有
success: function (res) {
callback(res);
},
fail: function (res) {
console.log('%c微信H5sdk,scanQRCode失败', 'color:green;background:yellow');
},
});
});
},
// 更新微信分享信息
updateShareInfo(data, callback = null) {
this.isReady(() => {
const shareData = {
title: data.title,
desc: data.desc,
link: data.link,
imgUrl: data.image,
success: function (res) {
if (callback) {
callback(res);
}
// 分享后的一些操作,比如分享统计等等
},
cancel: function (res) {},
};
// 新版 分享聊天api
jweixin.updateAppMessageShareData(shareData);
// 新版 分享到朋友圈api
jweixin.updateTimelineShareData(shareData);
});
},
// 打开坐标位置 TODO 芋艿:未测试
openLocation(data, callback) {
this.isReady(() => {
jweixin.openLocation({
...data,
success: function (res) {
console.log(res);
},
});
});
},
// 选择图片 TODO 芋艿:未测试
chooseImage(callback) {
this.isReady(() => {
jweixin.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album'],
success: function (rs) {
callback(rs);
},
});
});
},
// 微信支付
wxpay(data, callback) {
this.isReady(() => {
jweixin.chooseWXPay({
timestamp: data.timeStamp, // 支付签名时间戳注意微信jssdk中的所有使用timestamp字段均为小写。但最新版的支付后台生成签名使用的timeStamp字段名需大写其中的S字符
nonceStr: data.nonceStr, // 支付签名随机串,不长于 32 位
package: data.packageValue, // 统一支付接口返回的prepay_id参数值提交格式如prepay_id=\*\*\*
signType: data.signType, // 签名方式,默认为'SHA1',使用新版支付需传入'MD5'
paySign: data.paySign, // 支付签名
success: function (res) {
callback.success && callback.success(res);
},
fail: function (err) {
callback.fail && callback.fail(err);
},
cancel: function (err) {
callback.cancel && callback.cancel(err);
},
});
});
},
};

168
sheep/platform/index.js Normal file
View File

@@ -0,0 +1,168 @@
/**
* Shopro 第三方平台功能聚合
* @version 1.0.3
* @author lidongtony
* @param {String} name - 厂商+平台名称
* @param {String} provider - 厂商
* @param {String} platform - 平台名称
* @param {String} os - 系统型号
* @param {Object} device - 设备信息
*/
import { isEmpty } from 'lodash-es';
// #ifdef H5
import { isWxBrowser } from '@/sheep/helper/utils';
// #endif
import wechat from './provider/wechat/index.js';
import apple from './provider/apple';
import share from './share';
const device = uni.getWindowInfo();
const os = uni.getDeviceInfo().platform;
let name = '';
let provider = '';
let platform = '';
let isWechatInstalled = true;
// #ifdef H5
if (isWxBrowser()) {
name = 'WechatOfficialAccount';
provider = 'wechat';
platform = 'officialAccount';
} else {
name = 'H5';
platform = 'h5';
}
// #endif
// #ifdef APP-PLUS
name = 'App';
platform = 'openPlatform';
// 检查微信客户端是否安装否则AppleStore会因此拒绝上架
if (os === 'ios') {
isWechatInstalled = plus.ios.import('WXApi').isWXAppInstalled();
}
// #endif
// #ifdef MP-WEIXIN
name = 'WechatMiniProgram';
platform = 'miniProgram';
provider = 'wechat';
// #endif
if (isEmpty(name)) {
uni.showToast({
title: '暂不支持该平台',
icon: 'none',
});
}
// 加载当前平台前置行为
const load = () => {
if (provider === 'wechat') {
wechat.load();
}
};
// 使用厂商独占sdk name = 'wechat' | 'alipay' | 'apple'
const useProvider = (_provider = '') => {
if (_provider === '') _provider = provider;
if (_provider === 'wechat') return wechat;
if (_provider === 'apple') return apple;
};
/**
* 检查更新 (只检查小程序和App)
* @param {Boolean} silence - 静默检查
*/
const checkUpdate = (silence = false) => {
let canUpdate;
// #ifdef MP-WEIXIN
useProvider().checkUpdate(silence);
// #endif
// #ifdef APP-PLUS
// TODO: 热更新
// #endif
};
/**
* 检查网络
* @param {Boolean} silence - 静默检查
*/
async function checkNetwork() {
const networkStatus = await uni.getNetworkType();
if (networkStatus.networkType == 'none') {
return Promise.resolve(false);
}
return Promise.resolve(true);
}
// 获取小程序胶囊信息
const getCapsule = () => {
// #ifdef MP
let capsule = uni.getMenuButtonBoundingClientRect();
if (!capsule) {
capsule = {
bottom: 56,
height: 32,
left: 278,
right: 365,
top: 24,
width: 87,
};
}
return capsule;
// #endif
// #ifndef MP
return {
bottom: 56,
height: 32,
left: 278,
right: 365,
top: 24,
width: 87,
};
// #endif
};
const capsule = getCapsule();
// 标题栏高度
const getNavBar = () => {
return device.statusBarHeight + 44;
};
const navbar = getNavBar();
function getLandingPage() {
let page = '';
// #ifdef H5
page = location.href.split('?')[0];
// #endif
return page;
}
// 设置ios+公众号网页落地页 解决微信sdk签名问题
const landingPage = getLandingPage();
const _platform = {
name,
device,
os,
provider,
platform,
useProvider,
checkUpdate,
checkNetwork,
share,
load,
capsule,
navbar,
landingPage,
isWechatInstalled,
};
export default _platform;

View File

@@ -0,0 +1,36 @@
// import third from '@/sheep/api/third';
// TODO 芋艿:等后面搞 App 再弄
const login = () => {
return new Promise(async (resolve, reject) => {
const loginRes = await uni.login({
provider: 'apple',
success: () => {
uni.getUserInfo({
provider: 'apple',
success: async (res) => {
if (res.errMsg === 'getUserInfo:ok') {
const payload = res.userInfo;
const { error } = await third.apple.login({
payload,
shareInfo: uni.getStorageSync('shareLog') || {},
});
if (error === 0) {
resolve(true);
} else {
resolve(false);
}
}
},
});
},
fail: (err) => {
resolve(false);
},
});
});
};
export default {
login,
};

View File

@@ -0,0 +1,9 @@
// #ifdef APP-PLUS
import service from './app';
// #endif
let apple = {};
if (typeof service !== 'undefined') {
apple = service;
}
export default apple;

View File

@@ -0,0 +1,15 @@
// #ifdef H5
import service from './officialAccount';
// #endif
// #ifdef MP-WEIXIN
import service from './miniProgram';
// #endif
// #ifdef APP-PLUS
import service from './openPlatform';
// #endif
const wechat = service;
export default wechat;

View File

@@ -0,0 +1,241 @@
import AuthUtil from '@/sheep/api/system/auth';
import SocialApi from '@/sheep/api/system/social';
import UserApi from '@/sheep/api/system/user';
import sheep from '@/sheep';
const socialType = 34; // 社交类型 - 微信小程序
let subscribeEventList = [];
// 加载微信小程序
function load() {
checkUpdate();
getSubscribeTemplate();
}
// 微信小程序静默授权登陆
const login = async () => {
return new Promise(async (resolve, reject) => {
// 1. 获得微信 code
const codeResult = await uni.login();
if (codeResult.errMsg !== 'login:ok') {
return resolve(false);
}
// 2. 社交登录
const loginResult = await AuthUtil.socialLogin(socialType, codeResult.code, 'default');
if (loginResult.code === 0) {
setOpenid(loginResult.data.openid);
return resolve(true);
} else {
return resolve(false);
}
});
};
// 微信小程序手机号授权登陆
const mobileLogin = async (e) => {
return new Promise(async (resolve, reject) => {
if (e.errMsg !== 'getPhoneNumber:ok') {
return resolve(false);
}
// 1. 获得微信 code
const codeResult = await uni.login();
if (codeResult.errMsg !== 'login:ok') {
return resolve(false);
}
// 2. 一键登录
const loginResult = await AuthUtil.weixinMiniAppLogin(e.code, codeResult.code, 'default');
if (loginResult.code === 0) {
setOpenid(loginResult.data.openid);
return resolve(true);
} else {
return resolve(false);
}
});
};
// 微信小程序绑定
const bind = () => {
return new Promise(async (resolve, reject) => {
// 1. 获得微信 code
const codeResult = await uni.login();
if (codeResult.errMsg !== 'login:ok') {
return resolve(false);
}
// 2. 绑定账号
const bindResult = await SocialApi.socialBind(socialType, codeResult.code, 'default');
if (bindResult.code === 0) {
setOpenid(bindResult.data);
return resolve(true);
} else {
return resolve(false);
}
});
};
// 微信小程序解除绑定
const unbind = async (openid) => {
const { code } = await SocialApi.socialUnbind(socialType, openid);
return code === 0;
};
// 绑定用户手机号
const bindUserPhoneNumber = (e) => {
return new Promise(async (resolve, reject) => {
const { code } = await UserApi.updateUserMobileByWeixin(e.code);
if (code === 0) {
resolve(true);
}
resolve(false);
});
};
// 设置 openid 到本地存储,目前只有 pay 支付时会使用
function setOpenid(openid) {
uni.setStorageSync('openid', openid);
}
// 获得 openid
async function getOpenid(force = false) {
let openid = uni.getStorageSync('openid');
if (!openid && force) {
const info = await getInfo();
if (info && info.openid) {
openid = info.openid;
setOpenid(openid);
}
}
return openid;
}
// 获得社交信息
async function getInfo() {
const { code, data } = await SocialApi.getSocialUser(socialType);
if (code !== 0) {
return undefined;
}
return data;
}
// ========== 非登录相关的逻辑 ==========
// 小程序更新
const checkUpdate = async (silence = true) => {
if (uni.canIUse('getUpdateManager')) {
const updateManager = uni.getUpdateManager();
updateManager.onCheckForUpdate(function (res) {
// 请求完新版本信息的回调
if (res.hasUpdate) {
updateManager.onUpdateReady(function () {
uni.showModal({
title: '更新提示',
content: '新版本已经准备好,是否重启应用?',
success: function (res) {
if (res.confirm) {
// 新的版本已经下载好,调用 applyUpdate 应用新版本并重启
updateManager.applyUpdate();
}
},
});
});
updateManager.onUpdateFailed(function () {
// 新的版本下载失败
// uni.showModal({
// title: '已经有新版本了哟~',
// content: '新版本已经上线啦,请您删除当前小程序,重新搜索打开~',
// });
});
} else {
if (!silence) {
uni.showModal({
title: '当前为最新版本',
showCancel: false,
});
}
}
});
}
};
// 获取订阅消息模板
async function getSubscribeTemplate() {
const { code, data } = await SocialApi.getSubscribeTemplateList();
if (code === 0) {
subscribeEventList = data;
}
}
// 订阅消息
function subscribeMessage(event, callback = undefined) {
let tmplIds = [];
if (typeof event === 'string') {
const temp = subscribeEventList.find((item) => item.title.includes(event));
if (temp) {
tmplIds.push(temp.id);
}
}
if (typeof event === 'object') {
event.forEach((e) => {
const temp = subscribeEventList.find((item) => item.title.includes(e));
if (temp) {
tmplIds.push(temp.id);
}
});
}
if (tmplIds.length === 0) return;
uni.requestSubscribeMessage({
tmplIds,
success: () => {
// 不管是拒绝还是同意都触发
callback && callback();
},
fail: (err) => {
console.log(err);
},
});
}
// 商家转账用户确认模式下,拉起页面请求用户确认收款 Transfer
function requestMerchantTransfer(mchId, packageInfo, successCallback, failCallback) {
if (!wx.canIUse('requestMerchantTransfer')) {
wx.showModal({
content: '你的微信版本过低,请更新至最新版本。',
showCancel: false,
});
return;
}
wx.requestMerchantTransfer({
mchId: mchId,
appId: wx.getAccountInfoSync().miniProgram.appId,
package: packageInfo,
success: (res) => {
// res.err_msg 将在页面展示成功后返回应用时返回 ok并不代表付款成功
console.log('success:', res);
successCallback && successCallback(res);
},
fail: (res) => {
console.log('fail:', res);
sheep.$helper.toast(res.errMsg);
failCallback && failCallback(res);
},
});
}
export default {
load,
login,
bind,
unbind,
bindUserPhoneNumber,
mobileLogin,
getInfo,
getOpenid,
subscribeMessage,
checkUpdate,
requestMerchantTransfer,
};

View File

@@ -0,0 +1,105 @@
import $wxsdk from '@/sheep/libs/sdk-h5-weixin';
import { getRootUrl } from '@/sheep/helper';
import AuthUtil from '@/sheep/api/system/auth';
import SocialApi from '@/sheep/api/system/social';
const socialType = 31; // 社交类型 - 微信公众号
// 加载微信公众号JSSDK
async function load() {
$wxsdk.init();
}
// 微信公众号登陆
async function login(code = '', state = '') {
// 情况一:没有 code 时,去获取 code
if (!code) {
const loginUrl = await getLoginUrl();
if (loginUrl) {
uni.setStorageSync('returnUrl', location.href);
window.location = loginUrl;
}
// 情况二:有 code 时,使用 code 去自动登录
} else {
// 解密 code 发起登陆
const loginResult = await AuthUtil.socialLogin(socialType, code, state);
if (loginResult.code === 0) {
setOpenid(loginResult.data.openid);
return loginResult;
}
}
return false;
}
// 微信公众号绑定
async function bind(code = '', state = '') {
// 情况一:没有 code 时,去获取 code
if (code === '') {
const loginUrl = await getLoginUrl('bind');
if (loginUrl) {
uni.setStorageSync('returnUrl', location.href);
window.location = loginUrl;
}
} else {
// 情况二:有 code 时,使用 code 去自动绑定
const loginResult = await SocialApi.socialBind(socialType, code, state);
if (loginResult.code === 0) {
setOpenid(loginResult.data);
return loginResult;
}
}
return false;
}
// 微信公众号解除绑定
const unbind = async (openid) => {
const { code } = await SocialApi.socialUnbind(socialType, openid);
return code === 0;
};
// 获取公众号登陆地址
async function getLoginUrl(event = 'login') {
const page = getRootUrl() + 'pages/index/login' + '?event=' + event; // event 目的,区分是 login 还是 bind
const { code, data } = await AuthUtil.socialAuthRedirect(socialType, page);
if (code !== 0) {
return undefined;
}
return data;
}
// 设置 openid 到本地存储,目前只有 pay 支付时会使用
function setOpenid(openid) {
uni.setStorageSync('openid', openid);
}
// 获得 openid
async function getOpenid(force = false) {
let openid = uni.getStorageSync('openid');
if (!openid && force) {
const info = await getInfo();
if (info && info.openid) {
openid = info.openid;
setOpenid(openid);
}
}
return openid;
}
// 获得社交信息
async function getInfo() {
const { code, data } = await SocialApi.getSocialUser(socialType);
if (code !== 0) {
return undefined;
}
return data;
}
export default {
load,
login,
bind,
unbind,
getInfo,
getOpenid,
jsWxSdk: $wxsdk,
};

View File

@@ -0,0 +1,64 @@
// 登录
import third from '@/sheep/api/migration/third';
import SocialApi from '@/sheep/api/system/social';
import $share from '@/sheep/platform/share';
// TODO 芋艿:等后面搞 App 再弄
const socialType = 32; // 社交类型 - 微信开放平台
const load = async () => {};
// 微信开放平台移动应用授权登陆
const login = () => {
return new Promise(async (resolve, reject) => {
const loginRes = await uni.login({
provider: 'weixin',
onlyAuthorize: true,
});
debugger
if (loginRes.errMsg == 'login:ok') {
// TODO third.wechat.login 函数未实现
const res = await third.wechat.login({
platform: 'openPlatform',
shareInfo: uni.getStorageSync('shareLog') || {},
payload: encodeURIComponent(
JSON.stringify({
code: loginRes.code,
}),
),
});
if (res.error === 0) {
$share.bindBrokerageUser()
resolve(true);
}
} else {
uni.showToast({
icon: 'none',
title: loginRes.errMsg,
});
}
resolve(false);
});
};
// 微信 App 解除绑定
const unbind = async (openid) => {
const { code } = await SocialApi.socialUnbind(socialType, openid);
return code === 0;
};
// 获得社交信息
async function getInfo() {
const { code, data } = await SocialApi.getSocialUser(socialType);
if (code !== 0) {
return undefined;
}
return data;
}
export default {
load,
login,
getInfo
};

Some files were not shown because too many files have changed in this diff Show More