This commit is contained in:
lixin 2025-01-09 16:16:11 +08:00
commit d2f735d2f6
442 changed files with 35859 additions and 0 deletions

31
.gitignore vendored Normal file
View File

@ -0,0 +1,31 @@
.DS_Store
node_modules/
unpackage/
dist/
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.project
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw*
yarn.lock
package-lock.json
pnpm-lock.yaml
types/auto-imports.d.ts
types/components.d.ts

36
.hbuilderx/launch.json Normal file
View File

@ -0,0 +1,36 @@
{
// launch.json configurations app-plus/h5/mp-weixin/mp-baidu/mp-alipay/mp-qq/mp-toutiao/mp-360/
// launchtypelocalremote, localremote
"version" : "0.0",
"configurations" : [
{
"app-plus" : {
"launchtype" : "local"
},
"default" : {
"launchtype" : "local"
},
"h5" : {
"launchtype" : "local"
},
"mp-alipay" : {
"launchtype" : "local"
},
"mp-toutiao" : {
"launchtype" : "local"
},
"mp-weixin" : {
"launchtype" : "local"
},
"type" : "uniCloud"
},
{
"playground" : "standard",
"type" : "uni-app:app-ios"
},
{
"playground" : "standard",
"type" : "uni-app:app-android"
}
]
}

8
.hintrc Normal file
View File

@ -0,0 +1,8 @@
{
"extends": [
"development"
],
"hints": {
"typescript-config/is-valid": "off"
}
}

8
.prettierrc Normal file
View File

@ -0,0 +1,8 @@
{
"tabWidth": 4,
"useTabs": true,
"semi": true,
"jsxBracketSameLine": true,
"singleQuote": false,
"printWidth": 100
}

32
App.vue Normal file
View File

@ -0,0 +1,32 @@
<script setup lang="ts">
import { useStore } from "/@/cool";
import { onLaunch, onShow, onHide } from "@dcloudio/uni-app";
onLaunch(() => {
console.log("App Launch");
const { dict, user } = useStore();
//
dict.refresh();
if (user.token) {
//
user.get();
}
});
onShow(() => {
console.log("App Show");
});
onHide(() => {
console.log("App Hide");
});
</script>
<style lang="scss">
@import "/static/css/iconfont/index.scss";
@import "/$/cool-ui/index.scss";
@import "/@/static/css/index.scss";
</style>

67
README.md Normal file
View File

@ -0,0 +1,67 @@
# COOL-UNI
让你不用想太多就能开发完功能7.0 携带 vue3、vite、ts、pinia 等众多新特性细节曝光!![文档地址](https://cool-js.com/uni/introduce.html)
## 更快
- 启动快:基于 `vite`,快速的冷启动,不需要等待打包,即时的热模块更新,真正的按需编译。
- 开发快:`eps` 模式下,无须手动添加接口请求方法。
## 更强
内置请求、路由、文件上传、组件通信、缓存等方法及 ui 库和 hooks
```html
<script lang="ts" setup>
import { useCool } from "/@/cool";
import { useUi } from "/$/cool-ui";
const { service, router, mitt, storage, upload } = useCool();
const ui = useUi();
// 请求
service.test.page().then((res) => {
consoe.log(res);
});
// 跳转
router.push({
path: "/pages/goods/info",
// 方式1
query: {
id: 1,
},
// 方式2
params: {
id: 2,
},
});
// ui全局事件
ui.showLoading();
ui.showToast();
// 通信
mitt.emit("refresh", { page: 1 });
mitt.on("refresh", (params) => {});
// 储存
storage.set("token", "a123huis");
// 文件上传
uni.chooseImage({
count: 1,
sourceType: ["album", "camera"],
success(res) {
upload(res.tempFiles[0]).then((url) => {
console.log(url);
});
},
});
</script>
```
## 更细
全面的代码描述

3
androidPrivacy.json Normal file
View File

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

1687
build/cool/eps.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

1
build/cool/eps.json Normal file
View File

@ -0,0 +1 @@
[{"prefix":"/app/app/complain","name":"AppComplainEntity","api":[{"method":"post","path":"/submit"},{"method":"post","path":"/page"},{"method":"get","path":"/info"}]},{"prefix":"/app/app/feedback","name":"AppFeedbackEntity","api":[{"method":"post","path":"/submit"},{"method":"post","path":"/page"},{"method":"get","path":"/info"}]},{"prefix":"/app/app/goods","name":"AppGoodsEntity","api":[{"method":"post","path":"/list"}]},{"prefix":"/app/app/version","name":"AppVersionEntity","api":[{"method":"get","path":"/check"}]},{"prefix":"/app/base/comm","name":"","api":[{"method":"get","path":"/uploadMode"},{"method":"post","path":"/upload"},{"method":"get","path":"/param"},{"method":"get","path":"/eps"}]},{"prefix":"/app/cs/msg","name":"CsMsgEntity","api":[{"method":"get","path":"/unreadCount"},{"method":"post","path":"/read"},{"method":"post","path":"/page"}]},{"prefix":"/app/cs/session","name":"","api":[{"method":"get","path":"/detail"},{"method":"post","path":"/create"}]},{"prefix":"/app/dict/info","name":"","api":[{"method":"post","path":"/data"}]},{"prefix":"/app/goods/comment","name":"GoodsCommentEntity","api":[{"method":"post","path":"/submit"},{"method":"post","path":"/page"}]},{"prefix":"/app/goods/info","name":"GoodsInfoEntity","api":[{"method":"post","path":"/page"},{"method":"get","path":"/info"}]},{"prefix":"/app/goods/searchKeyword","name":"GoodsSearchKeywordEntity","api":[{"method":"post","path":"/delete"},{"method":"post","path":"/update"},{"method":"get","path":"/info"},{"method":"post","path":"/list"},{"method":"post","path":"/page"},{"method":"post","path":"/add"}]},{"prefix":"/app/goods/spec","name":"GoodsSpecEntity","api":[{"method":"post","path":"/delete"},{"method":"post","path":"/update"},{"method":"get","path":"/info"},{"method":"post","path":"/list"},{"method":"post","path":"/page"},{"method":"post","path":"/add"}]},{"prefix":"/app/goods/type","name":"GoodsTypeEntity","api":[{"method":"post","path":"/list"}]},{"prefix":"/app/info/banner","name":"InfoBannerEntity","api":[{"method":"post","path":"/list"}]},{"prefix":"/app/market/coupon/info","name":"MarketCouponInfoEntity","api":[{"method":"post","path":"/page"}]},{"prefix":"/app/market/coupon/user","name":"MarketCouponUserEntity","api":[{"method":"post","path":"/receive"},{"method":"post","path":"/delete"},{"method":"post","path":"/update"},{"method":"get","path":"/info"},{"method":"post","path":"/list"},{"method":"post","path":"/page"},{"method":"post","path":"/add"}]},{"prefix":"/app/order/info","name":"OrderInfoEntity","api":[{"method":"get","path":"/logistics"},{"method":"get","path":"/userCount"},{"method":"get","path":"/confirm"},{"method":"post","path":"/create"},{"method":"post","path":"/cancel"},{"method":"post","path":"/refund"},{"method":"post","path":"/update"},{"method":"get","path":"/info"},{"method":"post","path":"/page"}]},{"prefix":"/app/order/pay","name":"","api":[{"method":"post","path":"/wxMiniPay"},{"method":"post","path":"/wxNotify"},{"method":"post","path":"/wxAppPay"},{"method":"post","path":"/wxMpPay"}]},{"prefix":"/app/user/address","name":"UserAddressEntity","api":[{"method":"get","path":"/default"},{"method":"post","path":"/delete"},{"method":"post","path":"/update"},{"method":"get","path":"/info"},{"method":"post","path":"/list"},{"method":"post","path":"/page"},{"method":"post","path":"/add"}]},{"prefix":"/app/user/comm","name":"","api":[{"method":"post","path":"/wxMpConfig"}]},{"prefix":"/app/user/info","name":"UserInfoEntity","api":[{"method":"post","path":"/updatePassword"},{"method":"post","path":"/updatePerson"},{"method":"post","path":"/bindPhone"},{"method":"post","path":"/miniPhone"},{"method":"get","path":"/person"},{"method":"post","path":"/logoff"}]},{"prefix":"/app/user/login","name":"","api":[{"method":"post","path":"/refreshToken"},{"method":"post","path":"/password"},{"method":"get","path":"/captcha"},{"method":"post","path":"/smsCode"},{"method":"post","path":"/wxApp"},{"method":"post","path":"/phone"},{"method":"post","path":"/mini"},{"method":"post","path":"/mp"}]},{"prefix":"/","name":"","api":[{"method":"get","path":"/"}]},{"prefix":"/app/test","name":"TestEntity","api":[{"path":"/page"},{"path":"/list"},{"path":"/info"},{"path":"/delete"},{"path":"/update"},{"path":"/add"}]}]

View File

@ -0,0 +1,71 @@
<template>
<view
class="address-item"
:style="{
backgroundColor,
}"
>
<view class="address-item__inner">
<template v-if="item">
<cl-text block color="info" :size="24"
>{{ item?.province }}{{ item?.city }}{{ item?.district }}</cl-text
>
<cl-text block bold :size="30" :line-height="1.2" :margin="[14, 0, 14, 0]">{{
item?.address
}}</cl-text>
<cl-row type="flex">
<cl-text>{{ item?.contact }}</cl-text>
<cl-text type="phone" :margin="[0, 20, 0, 100]" :value="item?.phone" />
<cl-tag size="small" type="primary" round v-if="item?.isDefault"
>默认地址</cl-tag
>
</cl-row>
</template>
<template v-else>
<cl-text block bold :size="30" :margin="[6, 0, 14, 0]">选择地址</cl-text>
<cl-text color="info" :size="24">设置默认地址后优先使用</cl-text>
</template>
</view>
<view class="address-item__icon">
<slot name="icon"> </slot>
</view>
</view>
</template>
<script lang="ts" setup>
import type { PropType } from "vue";
const props = defineProps({
item: Object as PropType<Eps.UserAddressEntity>,
backgroundColor: {
type: String,
default: "#ffffff",
},
});
</script>
<style lang="scss" scoped>
.address-item {
display: flex;
box-sizing: border-box;
width: 100%;
border-radius: 24rpx;
padding: 24rpx;
&__inner {
flex: 1;
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
height: initial;
font-size: 32rpx;
margin-left: 24rpx;
}
}
</style>

View File

@ -0,0 +1,150 @@
<template>
<view class="select-address" @tap="open()">
<address-item :item="data || address.info">
<template #icon>
<text class="cl-icon-arrow-right" v-if="!disabled"></text>
</template>
</address-item>
</view>
<cl-popup
v-model="visible"
direction="bottom"
:border-radius="[32, 32, 0, 0]"
:padding="0"
show-close-btn
>
<view class="select-address__popup">
<cl-text :size="30" block bold :margin="[30, 0, 30, 30]">选择地址</cl-text>
<cl-loading-mask :loading="loading">
<scroll-view scroll-y class="scroller">
<view class="list">
<view
class="item"
v-for="item in list"
:key="item.id"
:class="{
'is-active': item.id == address.info?.id,
}"
@tap="select(item)"
>
<address-item :item="item" background-color="#f7f7f7">
<template #icon>
<text class="cl-icon-check-border"></text>
</template>
</address-item>
</view>
</view>
<cl-empty icon="address" :fixed="false" text="暂无地址" v-if="isEmpty(list)" />
</scroll-view>
</cl-loading-mask>
<cl-footer :fixed="false" :vt="[visible]">
<cl-button custom type="primary" @tap="router.push('/pages/user/address-edit')"
>添加收货地址</cl-button
>
</cl-footer>
</view>
</cl-popup>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { useAddress } from "/@/hooks";
import { useCool } from "/@/cool";
import { onShow } from "@dcloudio/uni-app";
import AddressItem from "./item.vue";
import { isEmpty } from "lodash-es";
const props = defineProps({
disabled: Boolean,
data: Object,
});
const { service, router } = useCool();
const address = useAddress();
const visible = ref(false);
const loading = ref(false);
//
const list = ref<Eps.UserAddressEntity[]>([]);
//
async function refresh() {
loading.value = true;
await service.user.address.list().then((res) => {
list.value = res;
});
loading.value = false;
}
function select(item: Eps.UserAddressEntity) {
address.set(item);
close();
}
function open() {
if (!props.disabled) {
visible.value = true;
}
}
function close() {
visible.value = false;
}
onShow(() => {
refresh();
address.getDefault();
});
defineExpose({
open,
close,
});
</script>
<style lang="scss" scoped>
.select-address {
margin-bottom: 24rpx;
&__popup {
.scroller {
height: 50vh;
.list {
padding: 0 32rpx;
.item {
border: 2rpx solid #f7f7f7;
border-radius: 24rpx;
margin-bottom: 24rpx;
.cl-icon-check-border {
display: none;
color: $cl-color-primary;
font-size: 46rpx;
}
&.is-active {
border-color: $cl-color-primary;
.cl-icon-check-border {
display: block;
}
}
&:last-child {
margin-bottom: 0;
}
}
}
}
}
}
</style>

58
components/agree-btn.vue Normal file
View File

@ -0,0 +1,58 @@
<template>
<cl-checkbox :size="34" v-model="agree" round>
<view class="agree-btn">
已阅读并同意
<text @tap.stop="toDoc('用户协议', 'userAgreement')">用户协议</text>
<text @tap.stop="toDoc('隐私政策', 'privacyPolicy')">隐私政策</text>
</view>
</cl-checkbox>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useCool } from "/@/cool";
import { useUi } from "/$/cool-ui";
const { router } = useCool();
const ui = useUi();
const agree = ref(false);
function toDoc(title: string, key: string) {
router.push({
path: "/pages/user/doc",
query: {
title,
key,
},
});
}
function check() {
if (!agree.value) {
ui.showToast("请先勾选同意后再进行登录");
}
return agree.value;
}
defineExpose({
check,
});
</script>
<style lang="scss" scoped>
.agree-btn {
display: flex;
align-items: center;
justify-content: center;
color: #999999;
letter-spacing: 1rpx;
text {
color: $cl-color-primary;
padding: 0 10rpx;
}
}
</style>

166
components/coupon/get.vue Normal file
View File

@ -0,0 +1,166 @@
<template>
<view class="coupon-get__btns">
<cl-skeleton
v-for="item in list"
:key="item.id"
:margin="[0, 10, 0, 0]"
:height="46"
:loading-style="{
width: '150rpx',
}"
:loading="loading"
>
<view class="item" @tap="open">
<text class="shop-icon-coupon"></text>
<text>{{ item.text }}</text>
</view>
</cl-skeleton>
</view>
<cl-popup
v-model="visible"
direction="bottom"
:border-radius="[32, 32, 0, 0]"
:padding="0"
title="领取优惠券"
show-close-btn
>
<view class="coupon-get">
<scroll-view class="scroller" scroll-y>
<view class="list">
<coupon-item :item="item" v-for="item in list" :key="item.id">
<template #btn>
<cl-button
type="primary"
:height="46"
:width="116"
:font-size="22"
:disabled="!!item.userId"
size="small"
:margin="[0, 0, 0, 20]"
@tap="toGet(item)"
>
{{ item.userId ? "已领取" : "立即领取" }}
</cl-button>
</template>
</coupon-item>
</view>
<cl-empty :fixed="false" :margin="[-40, 0, 0, 0]" v-if="isEmpty(list)" />
</scroll-view>
</view>
</cl-popup>
</template>
<script lang="ts" setup>
import { onMounted, ref } from "vue";
import { useCool } from "/@/cool";
import { useUi } from "/$/cool-ui";
import CouponItem from "./item/index.vue";
import { isEmpty } from "lodash-es";
const emit = defineEmits(["success"]);
const { service } = useCool();
const ui = useUi();
//
const visible = ref(false);
//
const loading = ref(false);
//
const list = ref<CouponInfo[]>([{}, {}]);
//
async function refresh() {
await service.market.coupon.info.page({ page: 1, size: 100 }).then((res) => {
list.value = res.list.map((e) => {
switch (e.type) {
case 0:
e.text = `${e.condition?.fullAmount}${Math.floor(e.amount!)}`;
break;
default:
e.text = "";
break;
}
return e;
});
});
}
//
function open() {
visible.value = true;
refresh();
}
//
function close() {
visible.value = false;
}
//
async function toGet(item: CouponInfo) {
item.userId = true;
service.market.coupon.user
.receive({
couponId: item.id,
})
.then(() => {
ui.showToast("领取成功");
})
.catch((err) => {
ui.showToast(err.message);
item.userId = null;
});
}
onMounted(async () => {
loading.value = true;
await refresh();
loading.value = false;
});
defineExpose({
open,
close,
});
</script>
<style lang="scss" scoped>
.coupon-get {
&__btns {
display: flex;
flex-wrap: wrap;
.item {
display: inline-flex;
align-items: center;
padding: 0 6rpx;
height: 100%;
border: 2rpx solid $cl-color-primary;
box-sizing: border-box;
border-radius: 8rpx;
font-size: 22rpx;
line-height: 1;
.shop-icon-coupon {
font-size: 36rpx;
margin-right: 2rpx;
}
}
}
.scroller {
height: 60vh;
.list {
padding: 0 32rpx;
}
}
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@ -0,0 +1,168 @@
<template>
<view
class="coupon-item"
:class="{
'is-disabled': info.useStatus == 1 || disabled,
}"
>
<image :src="Bg" class="bg" />
<view class="a">
<text class="amount">{{ info.amount }}</text>
<text class="doc">{{ doc }}</text>
</view>
<view class="b">
<view class="top">
<view class="text">
<cl-text :ellipsis="1" bold :size="30" :margin="[0, 0, 10, 0]">{{
info.title
}}</cl-text>
<cl-text :ellipsis="1" color="info" :size="24">{{ info.description }}</cl-text>
</view>
<slot name="btn"></slot>
</view>
<view class="bottom">
<text class="time">有效期{{ info.time }}</text>
</view>
</view>
<view class="status" v-if="info.useStatus == 1">已使用</view>
<view class="status" v-if="checked">使用中</view>
</view>
</template>
<script lang="ts" setup>
import { computed, type PropType } from "vue";
import Bg from "./bg.png";
import dayjs from "dayjs";
const props = defineProps({
item: {
type: Object as PropType<CouponInfo>,
default: () => ({}),
},
checked: Boolean,
disabled: Boolean,
});
//
const info = computed(() => {
return {
...props.item,
amount: Math.floor(props.item.amount!),
useStatus: props.item.useStatus,
time:
dayjs(props.item.startTime).format("YYYY-MM-DD") +
" ~ " +
dayjs(props.item.endTime).format("YYYY-MM-DD"),
};
});
// 使
const doc = computed(() => {
const { type, condition } = props.item || {};
switch (type) {
case 0:
return `${condition.fullAmount}可用`;
}
});
</script>
<style lang="scss" scoped>
.coupon-item {
display: flex;
height: 200rpx;
position: relative;
margin-bottom: 24rpx;
overflow: hidden;
.bg {
height: 100%;
width: 100%;
position: absolute;
}
.status {
transform: rotate(45deg);
height: 46rpx;
width: 200rpx;
background-color: #2b2e3d;
position: absolute;
right: -60rpx;
top: 20rpx;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 22rpx;
line-height: 46rpx;
}
.a {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
color: #fff;
height: 100%;
width: 172rpx;
margin-left: 10rpx;
padding: 0 20rpx;
box-sizing: border-box;
.amount {
font-size: 50rpx;
font-weight: 500;
margin-bottom: 10rpx;
line-height: 1;
&::before {
content: "¥";
font-size: 38rpx;
position: relative;
top: -1rpx;
}
}
.doc {
font-size: 22rpx;
color: #ddd;
}
}
.b {
display: flex;
flex-direction: column;
flex: 1;
position: relative;
padding: 0 20rpx;
.top {
display: flex;
align-items: center;
flex: 1;
}
.bottom {
display: flex;
align-items: center;
border-top: 1rpx solid #ddd;
height: 54rpx;
.time {
font-size: 22rpx;
color: #999;
}
}
}
&.is-disabled {
opacity: 0.4;
}
}
</style>

View File

@ -0,0 +1,134 @@
<template>
<view class="coupon-select__inner" @tap="open">
<template v-if="checked">
<cl-text :size="28" color="error">-</cl-text>
<cl-text :size="28" type="price" color="error" :value="checked.amount" />
</template>
<cl-text color="info" v-else>选择优惠券</cl-text>
</view>
<cl-popup
v-model="visible"
direction="bottom"
:border-radius="[32, 32, 0, 0]"
:padding="0"
title="选择优惠券"
show-close-btn
>
<view class="coupon-select">
<scroll-view class="scroller" scroll-y>
<view class="list">
<view class="item" v-for="item in checkList" :key="item.id" @tap="select(item)">
<coupon-item
:item="item"
:checked="checked?.id == item.id"
:disabled="item.disabled"
/>
</view>
</view>
<cl-empty :fixed="false" :margin="[-40, 0, 0, 0]" v-if="isEmpty(list)" />
</scroll-view>
</view>
</cl-popup>
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import { useCool } from "/@/cool";
import { isEmpty } from "lodash-es";
import CouponItem from "./item/index.vue";
import { useUi } from "/$/cool-ui";
const props = defineProps({
totalAmount: {
type: Number,
default: 0,
},
});
const emit = defineEmits(["success"]);
const { service } = useCool();
const ui = useUi();
//
const visible = ref(false);
//
const checked = ref<CouponInfo>();
//
const list = ref<CouponInfo[]>();
//
const checkList = computed(() => {
return list.value?.map((e) => {
let disabled = true;
switch (e.type) {
case 0:
if (props.totalAmount >= e.condition?.fullAmount!) {
disabled = false;
}
break;
}
return {
...e,
disabled,
};
});
});
//
function open() {
visible.value = true;
// 使
service.market.coupon.user.page({ page: 1, size: 100, status: 0 }).then((res) => {
list.value = res.list;
});
}
//
function close() {
visible.value = false;
}
//
function select(item: CouponInfo) {
if (item.disabled) {
ui.showToast("优惠券不可用");
} else {
if (checked.value?.id == item.id) {
checked.value = undefined;
} else {
checked.value = item;
}
close();
}
}
defineExpose({
open,
close,
checked,
});
</script>
<style lang="scss" scoped>
.coupon-select {
height: 60vh;
.scroller {
height: 100%;
.list {
padding: 0 32rpx;
}
}
}
</style>

View File

@ -0,0 +1,96 @@
<template>
<cl-image
:src="resizeImage(current, 300)"
:radius="radius"
:size="size"
:background-color="backgroundColor"
@tap="toGoods"
>
<view class="zoom" @tap.stop="preview()" v-if="zoom">
<text class="shop-icon-zoom"></text>
</view>
</cl-image>
</template>
<script lang="ts" setup>
import { isEmpty } from "lodash-es";
import { computed, type PropType } from "vue";
import { useCool, useUpload } from "/@/cool";
const props = defineProps({
item: {
type: Object as PropType<Eps.GoodsInfoEntity>,
default: () => ({}),
},
spec: {
type: Object as PropType<Eps.GoodsSpecEntity>,
default: () => ({}),
},
size: [Number, String, Array],
radius: {
type: [Number, String],
default: 24,
},
backgroundColor: String,
link: {
type: Boolean,
default: true,
},
zoom: Boolean,
});
const { router } = useCool();
const { resizeImage } = useUpload();
const urls = computed(() => {
if (props.spec) {
const arr = props.spec.images || [];
if (!isEmpty(arr)) {
return arr;
}
}
return [props.item.mainPic];
});
const current = computed(() => urls.value[0]);
//
function toGoods() {
if (props.link) {
router.push({
path: "/pages/goods/detail",
query: {
id: props.item.id,
specId: props.spec?.id,
},
});
}
}
//
function preview() {
uni.previewImage({
urls: urls.value,
current: current.value,
});
}
</script>
<style lang="scss" scoped>
.zoom {
display: inline-flex;
align-items: center;
justify-content: center;
position: absolute;
right: 8rpx;
top: 8rpx;
height: 30rpx;
width: 30rpx;
background-color: rgba(60, 60, 60, 0.7);
color: #fff;
font-size: 18rpx;
border-radius: 100%;
}
</style>

202
components/goods/group.vue Normal file
View File

@ -0,0 +1,202 @@
<template>
<view
class="goods-group"
:class="{
'is-border': border,
}"
>
<view class="goods" v-for="goods in list" :key="goods.id">
<!-- 名称 -->
<view
class="name"
@tap="
router.push({
path: '/pages/goods/detail',
query: {
id: goods.id,
},
})
"
>
<cl-text :ellipsis="1" :value="goods.name" bold />
<cl-icon name="arrow-right" :size="20" :margin="[0, 0, 0, 6]" />
</view>
<!-- 规格 -->
<view class="specs">
<view class="item" v-for="item in goods.children" :key="item.id">
<view class="checkbox" v-if="showCheckbox">
<cl-checkbox round v-model="item.checked" />
</view>
<goods-cover :size="140" :item="item.goodsInfo" :spec="item.spec" />
<view class="det">
<cl-row>
<view class="spec" @tap="onSpec(item)">
<cl-text :size="24" :ellipsis="1">{{ item.spec?.name }}</cl-text>
<cl-icon
name="arrow-bottom"
:size="20"
:margin="[0, 0, 0, 10]"
v-if="specEdit"
/>
</view>
</cl-row>
<view class="flex1" />
<cl-row type="flex" justify="space-between">
<cl-text
type="price"
color="error"
:size="36"
:value="item.spec?.price"
/>
<slot name="count" :item="item">
<cl-text color="info" v-if="readonly">x{{ item.count }}</cl-text>
<cl-input-number
v-model="item.count"
:min="1"
:max="item.spec?.stock"
v-else
/>
</slot>
</cl-row>
</view>
</view>
</view>
<slot name="goods" :goods="goods"></slot>
</view>
</view>
</template>
<script lang="ts" setup>
import { computed, type PropType } from "vue";
import { useCool } from "/@/cool";
import GoodsCover from "/@/components/goods/cover.vue";
interface Item {
id: number;
name: string;
children: OrderGoods[];
[key: string]: any;
}
const props = defineProps({
//
data: {
type: Array as PropType<OrderGoods[]>,
default: () => [],
},
//
specEdit: Boolean,
//
showCheckbox: Boolean,
//
border: Boolean,
//
readonly: Boolean,
});
const emit = defineEmits(["spec"]);
const { router } = useCool();
const list = computed(() => {
const arr: Item[] = [];
props.data
.filter((e) => !!e)
.forEach((e) => {
const d = arr.find((a) => a.id == e.goodsInfo.id);
if (d) {
d.children?.push(e);
} else {
arr.push({
id: e.goodsInfo.id!,
name: e.goodsInfo.title!,
children: [e],
});
}
});
return arr;
});
function onSpec(item: OrderGoods) {
if (props.specEdit) {
emit("spec", item);
}
}
defineExpose({
list,
});
</script>
<style lang="scss" scoped>
.goods-group {
.goods {
background-color: #ffffff;
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 24rpx;
.name {
display: flex;
align-items: center;
margin-bottom: 24rpx;
}
.specs {
.item {
display: flex;
margin-bottom: 24rpx;
.checkbox {
display: flex;
align-items: center;
width: 34rpx;
margin-right: 24rpx;
.cl-checkbox {
position: absolute;
}
}
.det {
display: flex;
flex-direction: column;
margin-left: 24rpx;
padding: 10rpx 0;
flex: 1;
.spec {
display: inline-flex;
align-items: center;
height: 46rpx;
padding: 0 16rpx;
border-radius: 10rpx;
background-color: $cl-color-bg;
}
}
&:last-child {
margin-bottom: 0;
}
}
}
}
&.is-border {
.goods {
border: $cl-border-width solid $cl-border-color;
}
}
}
</style>

68
components/goods/item.vue Normal file
View File

@ -0,0 +1,68 @@
<template>
<view class="goods-item" @tap="toDetail">
<cl-image
mode="aspectFill"
background-color="#fff"
:size="[350, '100%']"
:radius="[16, 16, 0, 0]"
:src="resizeImage(item.mainPic!, 360)"
/>
<view class="goods-item__det">
<cl-text
:value="item.title"
block
:margin="[0, 0, 16, 0]"
:ellipsis="2"
:line-height="1.4"
/>
<cl-row>
<cl-text type="price" color="#333" :size="34" :value="item.price" bold />
<cl-text
color="info"
:size="22"
:margin="[0, 0, 0, 10]"
:value="`${item.sold || 0}件已售`"
/>
</cl-row>
</view>
</view>
</template>
<script lang="ts" setup>
import type { PropType } from "vue";
import { useCool, useUpload } from "/@/cool";
const props = defineProps({
item: {
type: Object as PropType<Eps.GoodsInfoEntity>,
default: () => ({}),
},
});
const { router } = useCool();
const { resizeImage } = useUpload();
function toDetail() {
router.push({
path: "/pages/goods/detail",
query: {
id: props.item.id,
},
});
}
</script>
<style lang="scss" scoped>
.goods-item {
position: relative;
background-color: #ffffff;
border-radius: 16rpx;
margin-bottom: 20rpx;
.goods-item__det {
padding: 20rpx;
}
}
</style>

393
components/goods/spec.vue Normal file
View File

@ -0,0 +1,393 @@
<template>
<cl-popup
v-model="spec.visible"
direction="bottom"
:border-radius="[32, 32, 0, 0]"
:padding="0"
show-close-btn
@open="onOpen"
>
<view class="goods-spec">
<view class="goods">
<goods-cover :item="spec.goods" :spec="spec.info" :link="false" :size="160" />
<view class="det">
<cl-row :margin="[0, 0, 16, 0]">
<cl-text
:value="spec.info?.price || spec.goods?.price"
type="price"
color="error"
:size="40"
bold
/>
<cl-text
color="info"
:size="24"
:margin="[0, 0, 0, 30]"
:value="`库存:${stock}`"
v-if="spec.info"
/>
</cl-row>
<cl-text
:ellipsis="2"
:line-height="1.2"
:color="spec.info ? 'primary' : 'info'"
:value="spec.text"
/>
</view>
</view>
<cl-row type="flex" justify="space-between" :margin="[0, 24, 16, 24]">
<cl-text bold>规格分类{{ list.length + 1 }}</cl-text>
<cl-icon
:size="40"
:class-name="`${isGrid ? 'shop-icon-list' : 'shop-icon-list2'}`"
@tap="isGrid = !isGrid"
/>
</cl-row>
<!-- 规格列表 -->
<cl-loading-mask :loading="loading">
<scroll-view scroll-y class="scroller">
<view
class="list"
:class="{
'is-grid': isGrid,
}"
>
<cl-row :gutter="20">
<cl-col
v-for="(item, label) in list"
:key="label"
:span="isGrid ? 8 : 24"
>
<view
class="item"
:class="{
'is-active': spec.info?.id == item.id,
'is-disabled': !item.stock,
}"
@tap="select(item)"
>
<view class="cover">
<goods-cover
:item="spec.goods"
:spec="item"
:link="false"
:radius="isGrid ? 24 : 12"
:zoom="isGrid"
/>
</view>
<view class="name">
<text>{{ item.name }}</text>
</view>
</view>
</cl-col>
</cl-row>
<cl-list-item
label="数量"
:arrow-icon="false"
:margin="[0, -10, 0, -10]"
v-if="spec.info"
>
<cl-input-number v-model="spec.num" :min="1" :max="stock" />
</cl-list-item>
</view>
</scroll-view>
</cl-loading-mask>
<!-- 底部按钮 -->
<cl-footer :fixed="false" :vt="[spec.action]">
<!-- 加入购物车 -->
<template v-if="spec.action == 'spCart'">
<cl-button custom type="primary" :disabled="disabled" @tap="toAdd"
>确定</cl-button
>
</template>
<!-- 选规格的时候 -->
<template v-else-if="spec.action == 'select'">
<cl-button custom :disabled="disabled" @tap="toAdd">加入购物车</cl-button>
<cl-button custom type="primary" :disabled="disabled" @tap="toBuy"
>立即购买</cl-button
>
</template>
<!-- 立即购买 -->
<template v-else-if="spec.action == 'buy'">
<cl-button custom type="primary" :disabled="disabled" @tap="toBuy"
>立即购买</cl-button
>
</template>
<!-- 编辑 -->
<template v-else-if="spec.action == 'edit'">
<cl-button custom type="primary" :disabled="disabled" @tap="toEdit"
>确定</cl-button
>
</template>
</cl-footer>
</view>
</cl-popup>
</template>
<script lang="ts" setup>
import { isEmpty } from "lodash-es";
import { computed, nextTick, onUnmounted, ref } from "vue";
import { useCool } from "/@/cool";
import { useSpec } from "/@/hooks";
import { useShoppingCart } from "/@/hooks";
import { useUi } from "/$/cool-ui";
import GoodsCover from "/@/components/goods/cover.vue";
const { service, router } = useCool();
const spec = useSpec();
const spCart = useShoppingCart();
const ui = useUi();
//
const isGrid = ref(true);
//
const loading = ref(false);
//
const list = ref<Eps.GoodsSpecEntity[]>([]);
//
const stock = computed(() => {
const a = spec.info?.stock || 0;
const b = spCart.list.find((e) => e.spec?.id == spec.info?.id)?.count || 0;
if (spec.action == "edit") {
return a;
}
let d = a - b;
if (d < 0) {
d = 0;
}
return d;
});
//
const disabled = computed(() => {
return !stock.value;
});
//
async function refresh(params?: any) {
loading.value = true;
await service.goods.spec
.list({
goodsId: spec.goods?.id,
order: "sortNum",
sort: "desc",
...params,
})
.then((res) => {
list.value = res.map((e) => {
let arr = e.images || [];
if (isEmpty(arr)) {
arr = [spec.goods?.mainPic];
}
return {
...e,
cover: arr[0],
};
});
//
spec.set(list.value.find((e) => e.id == spec.specId));
});
loading.value = false;
}
//
function onOpen() {
refresh();
}
//
function select(item: Eps.GoodsSpecEntity) {
if (spec.info?.id == item.id) {
spec.set();
} else {
spec.set(item);
}
nextTick(() => {
spec.setNum(1);
});
}
//
function toBuy() {
spec.close();
router.push({
path: "/pages/order/submit",
params: {
buyList: [
{
spec: spec.info!,
goodsInfo: spec.goods!,
count: spec.num,
},
],
},
});
}
//
function toEdit() {
spec.emit("edit");
spec.close();
}
//
function toAdd() {
spCart.add({
count: spec.num,
spec: spec.info!,
goodsInfo: spec.goods!,
});
ui.showToast("添加购物车成功");
spec.close();
}
onUnmounted(() => {
spec.clear();
spec.close();
});
defineExpose({
refresh,
});
</script>
<style lang="scss" scoped>
.goods-spec {
.goods {
display: flex;
padding: 24rpx;
.det {
flex: 1;
padding: 24rpx 24rpx 0 24rpx;
}
}
.scroller {
height: 50vh;
.list {
padding: 0 24rpx;
.item {
display: flex;
align-items: center;
margin-bottom: 16rpx;
border-radius: 12rpx;
border: 2rpx solid #eeeeee;
padding: 12rpx;
.cover {
height: 80rpx;
width: 80rpx;
flex-shrink: 0;
position: relative;
}
.name {
display: flex;
align-items: center;
padding: 0 16rpx;
font-size: 24rpx;
line-height: 1.4;
flex: 1;
text {
word-break: break-all;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
}
&.is-active {
border-color: $cl-color-primary;
}
&.is-disabled {
.cover {
&::after {
display: flex;
align-items: center;
justify-content: center;
height: 100rpx;
width: 100rpx;
content: "无货";
color: #fff;
background-color: rgba(50, 50, 50, 0.6);
border-radius: 100%;
position: absolute;
left: calc(50% - 50rpx);
top: calc(50% - 50rpx);
font-size: 26rpx;
border: 1rpx solid currentColor;
box-sizing: border-box;
transform: scale(0.6);
}
}
.name {
color: $cl-color-disabled;
}
}
}
&.is-grid {
.item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
border-radius: 28rpx;
padding: 0;
margin-bottom: 24rpx;
.cover {
height: 216rpx;
width: 100%;
&::after {
transform: scale(1);
}
}
.name {
flex: none;
height: 70rpx;
line-height: 1.2;
}
}
}
}
}
}
</style>

229
components/sms-btn.vue Normal file
View File

@ -0,0 +1,229 @@
<template>
<view class="sms-btn">
<slot :disabled="isDisabled" :countdown="countdown" :btnText="btnText">
<cl-button
:border="false"
background-color="transparent"
color="#FE6B03"
:height="height"
:font-size="fontSize"
fill
:size="size"
:disabled="isDisabled"
@tap="open"
>
{{ btnText }}
</cl-button>
</slot>
<cl-popup
v-model="captcha.visible"
:ref="setRefs('popup')"
:padding="40"
border-radius="24rpx"
>
<cl-loading-mask :loading="captcha.loading">
<view class="sms-popup">
<view class="head">
<cl-text bold :size="28" value="获取短信验证码"></cl-text>
<cl-icon :size="32" name="close" @tap="close"></cl-icon>
</view>
<view class="row">
<cl-input
type="number"
v-model="form.code"
placeholder="验证码"
:maxlength="4"
:height="70"
:clearable="false"
:focus="refs.popup?.isFocus"
:border="false"
background-color="#f7f7f7"
@confirm="send"
/>
<image :src="captcha.img" mode="aspectFit" @tap="getCaptcha" />
</view>
<cl-button
type="primary"
fill
:disabled="!form.code"
:loading="captcha.sending"
:height="70"
@tap="send"
>
发送短信
</cl-button>
</view>
</cl-loading-mask>
</cl-popup>
</view>
</template>
<script lang="ts" setup>
import { computed, type PropType, reactive, ref } from "vue";
import { useCool } from "../cool";
import { useUi } from "/$/cool-ui";
const props = defineProps({
phone: String,
type: String,
height: Number,
fontSize: Number,
size: String as PropType<"large" | "default" | "small">,
border: {
type: Boolean,
default: true,
},
plain: Boolean,
});
const emit = defineEmits(["success"]);
const { service, refs, setRefs } = useCool();
const ui = useUi();
//
const captcha = reactive({
visible: false,
loading: false,
sending: false,
img: "",
});
//
const countdown = ref(0);
//
const isDisabled = computed(() => countdown.value > 0 || !props.phone);
//
const btnText = computed(() =>
countdown.value > 0 ? `${countdown.value}s后重新获取` : "获取验证码",
);
//
const form = reactive({
code: "",
captchaId: "",
});
//
function startCountdown() {
countdown.value = 60;
function fn() {
countdown.value--;
if (countdown.value < 1) {
clearInterval(timer);
}
}
const timer = setInterval(fn, 1000);
fn();
}
//
async function send() {
if (form.code) {
captcha.sending = true;
await service.user.login
.smsCode({
phone: props.phone,
...form,
})
.then(() => {
ui.showToast("短信已发送,请查收");
startCountdown();
close();
emit("success");
})
.catch((err) => {
ui.showToast(err.message);
getCaptcha();
});
captcha.sending = false;
} else {
ui.showToast("请填写验证码");
}
}
//
async function getCaptcha() {
clear();
captcha.loading = true;
await service.user.login
.captcha({ type: "png", color: "#000000", phone: props.phone })
.then((res) => {
form.captchaId = res.captchaId;
captcha.img = res.data;
})
.catch((err) => {
ui.showToast(err.message);
});
captcha.loading = false;
}
//
function open() {
if (props.phone) {
if (/^(?:(?:\+|00)86)?1[3-9]\d{9}$/.test(props.phone)) {
captcha.visible = true;
getCaptcha();
} else {
ui.showToast("请填写正确的手机号格式");
}
}
}
//
function close() {
captcha.visible = false;
clear();
}
//
function clear() {
form.code = "";
form.captchaId = "";
}
defineExpose({
open,
send,
getCaptcha,
startCountdown,
});
</script>
<style lang="scss" scoped>
.sms-popup {
width: 400rpx;
.head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 30rpx;
}
.row {
display: flex;
align-items: center;
margin-bottom: 30rpx;
image {
height: 70rpx;
width: 200rpx;
flex-shrink: 0;
}
}
}
</style>

17
config/dev.ts Normal file
View File

@ -0,0 +1,17 @@
import { proxy } from "./proxy";
export default {
// 根地址
host: proxy["/dev/"].target,
// 请求地址
get baseUrl() {
// #ifdef H5
return "/dev";
// #endif
// #ifndef H5
return this.host + "";
// #endif
},
};

40
config/index.ts Normal file
View File

@ -0,0 +1,40 @@
import dev from "./dev";
import prod from "./prod";
// 是否开发模式
export const isDev = import.meta.env.MODE === "development";
// 配置
export const config = {
// 应用信息
app: {
// 应用名称
name: "酷卖",
// 应用描述
desc: "能用钱解决的事,就不要客气",
// 页面配置
pages: {
login: "/pages/user/login",
},
wx: {
debug: false,
},
},
// 调试
test: {
token: "",
mock: false,
eps: true,
},
// 忽略
ignore: {
token: [],
},
// 当前环境
...(isDev ? dev : prod),
};
export * from "./proxy";

18
config/prod.ts Normal file
View File

@ -0,0 +1,18 @@
import { proxy } from "./proxy";
export default {
// 根地址
host: proxy["/prod/"].target,
// 请求地址
get baseUrl() {
// #ifdef H5
return "/api";
// #endif
// #ifndef H5
return "https://cool-mall-dev.cool-js.cloud";
// return this.host + "/api";
// #endif
},
};

13
config/proxy.ts Normal file
View File

@ -0,0 +1,13 @@
export const proxy = {
"/dev/": {
target: "http://127.0.0.1:8001",
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/dev/, ""),
},
"/prod/": {
target: "https://cool-mall-dev.cool-js.cloud",
changeOrigin: true,
rewrite: (path: string) => path.replace(/^\/prod/, ""),
},
};

138
cool/bootstrap/eps.ts Normal file
View File

@ -0,0 +1,138 @@
import { merge } from "lodash-es";
import { BaseService, service } from "../service";
import { path2Obj } from "../utils";
import { config, isDev } from "/@/config";
import { eps } from "virtual:eps";
// 读取本地所有 service
const files = import.meta.glob("/service/**/*", {
eager: true,
});
// 数据集合
const services: any[] = [];
// 取值
for (const i in files) {
try {
// @ts-ignore
services.push(new files[i].default());
} catch (e) {
console.error(`[service] ${i} error: `, e);
}
}
// 更新事件
function onUpdate() {
// 设置 request 方法
function set(d: any) {
if (d.namespace) {
const a: any = new BaseService(d.namespace);
for (const i in d) {
const { path, method = "get" } = d[i];
if (path) {
a.request = a.request;
a[i] = function (data?: any) {
return this.request({
url: path,
method,
[method.toLocaleLowerCase() == "post" ? "data" : "params"]: data,
});
};
}
}
for (const i in a) {
d[i] = a[i];
}
} else {
for (const i in d) {
set(d[i]);
}
}
}
// 遍历每一个方法
set(eps.service);
// 合并 eps
merge(service, eps.service);
// 合并[local]
merge(
service,
path2Obj(
services.map((e) => {
return {
path: (e.namespace || "").replace("app/", ""),
value: e,
};
}),
),
);
// 提示
if (isDev) {
console.log("[cool-eps] updated");
}
}
export function createEps() {
// 更新 eps
onUpdate();
// #ifdef H5
// 开发环境下,生成本地 service 的类型描述文件
if (isDev && config.test.eps) {
const list = services.map((s) => {
const api = Array.from(
new Set([
...Object.getOwnPropertyNames(s.constructor.prototype),
"page",
"list",
"info",
"delete",
"update",
"add",
]),
)
.filter((e) => !["constructor", "namespace"].includes(e))
.map((e) => {
return {
path: `/${e}`,
};
});
return {
api,
module: s.namespace.split("/")[0],
name: s.constructor.name + "Entity",
prefix: `/app/${s.namespace}`,
};
});
service.request({
url: "/__cool_eps",
method: "POST",
proxy: false,
data: {
list,
},
});
}
// #endif
}
// 监听 vite 触发事件
if (import.meta.hot) {
import.meta.hot.on("eps-update", ({ service }) => {
if (service) {
eps.service = service;
}
onUpdate();
});
}

15
cool/bootstrap/index.ts Normal file
View File

@ -0,0 +1,15 @@
import { createPinia } from "pinia";
import { createEps } from "./eps";
import { createModules } from "./modules";
import { type App } from "vue";
export async function bootstrap(app: App) {
// 状态共享存储
app.use(createPinia());
// 创建 EPS
createEps();
// 创建 uni_modules
createModules();
}

38
cool/bootstrap/modules.ts Normal file
View File

@ -0,0 +1,38 @@
import { keys, orderBy } from "lodash-es";
import { module } from "../module";
export async function createModules() {
// 加载 uni_modules 插件
const files: any = import.meta.glob("/uni_modules/cool-*/config.ts", {
eager: true,
});
const modules = orderBy(
keys(files).map((k) => {
const [, , name] = k.split("/");
return {
name,
value: files[k]?.default,
};
}),
"order",
"desc",
);
for (let i in modules) {
const { name, value } = modules[i];
const data = value ? value() : undefined;
// 添加模块
module.add({
name,
...data,
});
// 触发加载事件
if (data) {
await data.onLoad?.(data.options);
}
}
}

28
cool/hooks/app.ts Normal file
View File

@ -0,0 +1,28 @@
import { reactive, ref } from "vue";
import { storage } from "../utils";
import { config } from "/@/config";
import { defineStore } from "pinia";
// 主题
export const useTheme = defineStore("theme", () => {
const name = ref(storage.get("theme") || "default");
function set(value: string) {
name.value = value;
storage.set("theme", value);
}
return {
name,
set,
};
});
export function useApp() {
const info = reactive(config.app);
return {
info,
theme: useTheme(),
};
}

11
cool/hooks/comm.ts Normal file
View File

@ -0,0 +1,11 @@
import { reactive } from "vue";
export function useRefs() {
const refs = reactive<{ [key: string]: any }>({});
function setRefs(name: string) {
return (el: any) => {
refs[name] = el;
};
}
return { refs, setRefs };
}

23
cool/hooks/hmr.ts Normal file
View File

@ -0,0 +1,23 @@
// 解决热更新后失效问题;
const data = import.meta.hot?.data.getData?.() || {};
if (import.meta.hot) {
import.meta.hot.data.getData = () => {
return data;
};
}
export const hmr = {
data,
setData(key: string, value: any) {
data[key] = value;
},
getData(key: string, defaultValue?: any) {
if (defaultValue !== undefined && !data[key]) {
this.setData(key, defaultValue);
}
return data[key];
}
};

21
cool/hooks/index.ts Normal file
View File

@ -0,0 +1,21 @@
import { router } from "../router";
import { service } from "../service";
import { upload } from "../upload";
import { storage } from "../utils";
import { useRefs } from "./comm";
export function useCool() {
return {
router,
service,
upload,
storage,
...useRefs(),
};
}
export * from "./app";
export * from "./comm";
export * from "./hmr";
export * from "./pager";
export * from "./wx";

168
cool/hooks/pager.ts Normal file
View File

@ -0,0 +1,168 @@
import { computed, getCurrentInstance, onUnmounted, reactive } from "vue";
import { onPullDownRefresh, onReachBottom, onUnload } from "@dcloudio/uni-app";
import { useUi } from "/$/cool-ui";
interface Res {
list: any[];
pagination: {
total: number;
page: number;
size: number;
[key: string]: any;
};
[key: string]: any;
}
export function usePager<T = any>() {
const { proxy }: any = getCurrentInstance();
const ui = useUi();
// 分页信息
const pager = reactive({
params: {},
pagination: {
page: 1,
size: 20,
total: 0,
},
list: [] as T[],
loading: false,
finished: false,
});
// 事件
const events: any = {};
// 列表
const list = computed(() => pager.list);
// 刷新
async function refresh(params?: any) {
if (pager.loading) {
return false;
}
if (proxy.refresh) {
await proxy.refresh(params);
} else if (proxy.$.exposed.refresh) {
await proxy.$.exposed.refresh(params);
} else {
console.log("use defineExpose({ refresh })");
}
}
// 数据
function onData(cb: (list: T[]) => void) {
events.onData = cb;
}
// 刷新
function onRefresh(params: any = {}, options?: { clear?: boolean; loading?: boolean }) {
const { clear, loading = true } = options || {};
// 是否清空
if (clear) {
if (params.page == 1) {
pager.list = [];
pager.finished = false;
}
}
// 合并请求参数
Object.assign(pager.params, params);
const data = {
...pager.pagination,
...pager.params,
total: undefined,
};
// 是否显示加载动画
if (data.page == 1 && loading) {
ui.showLoading();
}
pager.loading = true;
// 完成
function done() {
ui.hideLoading();
pager.loading = false;
}
return {
data,
done,
next: (req: Promise<Res>) => {
return new Promise((resolve, reject) => {
req.then((res: Res) => {
// 设置列表数据
if (data.page == 1) {
pager.list = res.list;
} else {
pager.list.push(...res.list);
}
// 追加事件
if (events.onData) {
events.onData(res.list);
}
// 是否加载完成
pager.finished = pager.list.length === res.pagination.total;
// 分页信息
pager.pagination = res.pagination;
done();
resolve(res);
}).catch((err) => {
done();
ui.showToast(err.message);
reject(err);
});
});
},
};
}
// 关闭
function close() {
isReg = false;
ui.hideLoading();
}
// 是否注册,避免在组件中重复注入事件问题
let isReg = true;
// 上拉加载
onReachBottom(() => {
if (isReg) {
if (!pager.finished) {
refresh({ page: pager.pagination.page + 1 });
}
}
});
// 下拉刷新
onPullDownRefresh(async () => {
if (isReg) {
await refresh({ page: 1 });
uni.stopPullDownRefresh();
}
});
// 组件销毁
onUnmounted(close);
// 离开页面
onUnload(close);
return {
pager,
list,
onData,
onRefresh,
onPullDownRefresh,
onReachBottom,
};
}

288
cool/hooks/wx.ts Normal file
View File

@ -0,0 +1,288 @@
import { ref } from "vue";
import { onReady, onShow } from "@dcloudio/uni-app";
import { config } from "/@/config";
import { getUrlParam, storage } from "../utils";
import { service } from "../service";
// #ifdef H5
import wx from "weixin-js-sdk";
// #endif
export function useWx() {
const { platform } = uni.getSystemInfoSync();
// 授权码
const code = ref("");
// 获取授权码
async function getCode() {
return new Promise((resolve) => {
// #ifdef MP-WEIXIN
uni.login({
provider: "weixin",
success: (res) => {
code.value = res.code;
resolve(res.code);
},
});
// #endif
});
}
// 是否微信浏览器
function isWxBrowser() {
// #ifdef H5
const ua: any = window.navigator.userAgent.toLowerCase();
if (ua.match(/MicroMessenger/i) == "micromessenger") {
return true;
} else {
return false;
}
// #endif
// #ifndef H5
return false;
// #endif
}
// 是否安装了微信
function hasApp() {
// #ifdef APP
return plus.runtime.isApplicationExist({ pname: "com.tencent.mm", action: "weixin://" });
// #endif
// #ifndef APP
return true;
// #endif
}
// 下载微信
function downloadApp() {
// #ifdef APP
if (platform == "android") {
const Uri: any = plus.android.importClass("android.net.Uri");
const uri: any = Uri.parse("market://details?id=" + "com.tencent.mm");
const Intent: any = plus.android.importClass("android.content.Intent");
const intent: any = new Intent(Intent.ACTION_VIEW, uri);
const main: any = plus.android.runtimeMainActivity();
main.startActivity(intent);
} else {
plus.runtime.openURL(
"itms-apps://" + "itunes.apple.com/cn/app/wechat/id414478124?mt=8",
);
}
// #endif
}
// 微信公众号配置
const mpConfig = {
appId: "",
};
// 获取微信公众号配置
function getMpConfig() {
// #ifdef H5
if (isWxBrowser()) {
service.user.comm
.wxMpConfig({
url: `${location.origin}${location.pathname}`,
})
.then((res) => {
wx.config({
debug: config.app.wx.debug,
jsApiList: ["chooseWXPay"],
...res,
});
Object.assign(mpConfig, res);
});
}
// #endif
}
// 微信公众号授权
function mpAuth() {
const redirect_uri = encodeURIComponent(
`${location.origin}${location.pathname}#/pages/user/login`,
);
const response_type = "code";
const scope = "snsapi_userinfo";
const state = "STATE";
const url = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${mpConfig.appId}&redirect_uri=${redirect_uri}&response_type=${response_type}&scope=${scope}&state=${state}#wechat_redirect`;
location.href = url;
}
// 微信公众号登录
async function mpLogin() {
return new Promise((resolve) => {
const code = getUrlParam("code");
const mpCode = storage.get("mpCode");
let url = window.location.href;
url = url.replace(/(\?[^#]*)#/, "#");
window.history.replaceState({}, "", url);
if (code != mpCode) {
storage.set("mpCode", code);
resolve(code);
} else {
resolve(null);
}
});
}
// 微信公众号支付
async function mpPay(params: wx.IchooseWXPay & { timeStamp: number }): Promise<void> {
return new Promise((resolve, reject) => {
if (!isWxBrowser()) {
return reject({
message: "请在微信浏览器中打开",
});
}
wx.chooseWXPay({
...params,
timestamp: params.timeStamp,
success() {
resolve();
},
complete(e: { errMsg: string }) {
switch (e.errMsg) {
case "chooseWXPay:cancel":
reject({ message: "已取消支付" });
break;
default:
reject({ message: "支付失败" });
}
},
});
});
}
// 微信app登录
function appLogin(): Promise<string> {
let all: any;
let Service: any;
return new Promise((resolve, reject) => {
plus.oauth.getServices((Services: any) => {
all = Services;
Object.keys(all).some((key) => {
if (all[key].id == "weixin") {
Service = all[key];
}
});
Service.authorize(resolve, reject);
}, reject);
});
}
// 微信app支付
function appPay(orderInfo: {
appid: string;
noncestr: string;
package: string;
partnerid: string;
prepayid: string;
timestamp: string;
sign: string;
[key: string]: any;
}): Promise<void> {
return new Promise((resolve, reject) => {
uni.requestPayment({
provider: "wxpay",
orderInfo,
success() {
resolve();
},
fail() {
reject({ message: "已取消支付" });
},
});
});
}
// 微信小程序登录
async function miniLogin(): Promise<{ code: string; [key: string]: any }> {
return new Promise((resolve, reject) => {
// 兼容 Mac
const k = platform === "mac" ? "getUserInfo" : "getUserProfile";
uni[k]({
lang: "zh_CN",
desc: "授权信息仅用于用户登录",
success({ iv, encryptedData, signature, rawData }) {
function next() {
resolve({
iv,
encryptedData,
signature,
rawData,
code: code.value,
});
}
// 检查登录状态是否过期
uni.checkSession({
success() {
next();
},
fail() {
getCode().then(next);
},
});
},
fail(err) {
console.error(err);
getCode();
reject({
message: "登录授权失败",
});
},
});
});
}
// 微信小程序支付
function miniPay(params: any): Promise<void> {
return new Promise((resolve, reject) => {
uni.requestPayment({
provider: "wxpay",
...params,
success() {
resolve();
},
fail() {
reject({ message: "已取消支付" });
},
});
});
}
onShow(() => {
getCode();
});
onReady(() => {
getMpConfig();
});
return {
code,
getCode,
isWxBrowser,
hasApp,
downloadApp,
mpConfig,
mpAuth,
mpLogin,
mpPay,
miniLogin,
miniPay,
appLogin,
appPay,
};
}

9
cool/index.ts Normal file
View File

@ -0,0 +1,9 @@
export * from "./hooks";
export * from "./router";
export * from "./store";
export * from "./upload";
export * from "./service";
export * from "./module";
export * from "../config";
export type * from "./types";
export { storage } from "./utils";

21
cool/module/index.ts Normal file
View File

@ -0,0 +1,21 @@
import type { Module } from "../types";
import { hmr } from "../hooks";
// 模块列表
const list: Module[] = hmr.getData("modules", []);
// 模块对象
const module = {
list,
get(name: string): Module {
return this.list.find((e) => e.name == name)!;
},
config(name: string) {
return this.get(name).options || {};
},
add(data: Module) {
this.list.push(data);
},
};
export { module };

325
cool/router/index.ts Normal file
View File

@ -0,0 +1,325 @@
import { last } from "lodash-es";
import { ctx } from "virtual:ctx";
import { storage } from "../utils";
import { config } from "../../config";
type PushOptions =
| string
| {
path: string;
mode?: "navigateTo" | "redirectTo" | "reLaunch" | "switchTab" | "preloadPage";
events?: {
[key: string]: (data: any) => void;
};
query?: {
[key: string]: any;
};
params?: {
[key: string]: any;
};
isGuard?: boolean;
[key: string]: any;
};
type Tabs = {
text?: string;
pagePath: string;
iconPath?: string;
selectedIconPath?: string;
[key: string]: any;
}[];
// 路由列表
const routes = [...ctx.pages];
// 子包
if (ctx.subPackages) {
ctx.subPackages.forEach((a: { pages: any[]; root: string }) => {
a.pages.forEach((b) => {
routes.push({
...b,
path: a.root + "/" + b.path,
});
});
});
}
// 钩子函数
const fn: { [key: string]: (...args: any[]) => any } = {};
// 路由
const router = {
// 底部导航
get tabs(): Tabs {
if (ctx.tabBar) {
return ctx.tabBar.list || [];
} else {
return [];
}
},
// 全局样式配置
globalStyle: ctx.globalStyle,
// 路由列表
routes,
// 地址栏参数
get query() {
const info = this.info();
return {
...info?.query,
};
},
// 临时参数
get params() {
return storage.get("router-params") || {};
},
// 页面地址
get pages() {
return {
home: "/" + (ctx.tabBar ? this.tabs[0].pagePath : ctx.pages[0].path),
...config.app.pages,
};
},
// 当前页
currentPage(): { [key: string]: any } {
return last(getCurrentPages())!;
},
// 当前页
get path() {
return router.info()?.path;
},
// 当前路由信息
info() {
const page = last(getCurrentPages());
if (page) {
const { route, $page, $vm, $getAppWebview }: any = page;
const q: any = {};
try {
$page?.fullPath
.split("?")[1]
.split("&")
.forEach((e: string) => {
const [k, v] = e.split("=");
q[k] = decodeURIComponent(v);
});
} catch (e) {}
// 页面配置
const style = this.routes.find((e) => e.path == route)?.style;
let d = {
$vm,
$getAppWebview,
path: `/${route}`,
fullPath: $page?.fullPath,
query: q || {},
isTab: this.isTab(route),
style,
isCustomNavbar: style?.navigationStyle == "custom",
};
return d;
} else {
return null;
}
},
// 跳转
push(options: PushOptions) {
if (typeof options == "string") {
options = {
path: options,
mode: "navigateTo",
};
}
let {
path,
mode = "navigateTo",
animationType,
animationDuration,
events,
success,
fail,
complete,
query,
params,
isGuard = true,
} = options || {};
if (query) {
let arr = [];
for (let i in query) {
if (query[i] !== undefined) {
arr.push(`${i}=${query[i]}`);
}
}
path += "?" + arr.join("&");
}
if (params) {
storage.set("router-params", params);
}
let data = {
url: path,
animationType,
animationDuration,
events,
success,
fail,
complete,
};
if (this.isTab(path)) {
mode = "switchTab";
}
const next = () => {
switch (mode) {
case "navigateTo":
uni.navigateTo(data);
break;
case "redirectTo":
uni.redirectTo(data);
break;
case "reLaunch":
uni.reLaunch(data);
break;
case "switchTab":
uni.switchTab(data);
break;
case "preloadPage":
uni.preloadPage(data);
break;
}
};
if (fn.beforeEach && isGuard) {
fn.beforeEach({ path: options.path, query }, next, (options: PushOptions) => {
this.push(options);
});
} else {
next();
}
},
// 后退
back(options?: UniApp.NavigateBackOptions) {
if (this.isFirstPage()) {
this.home();
} else {
uni.navigateBack(options || {});
}
},
// 执行当前页面的某个方法
callMethod(name: string, data?: any) {
const { $vm } = this.info()!;
if ($vm) {
if ($vm.$.exposed?.[name]) {
return $vm.$.exposed[name](data);
}
}
},
// 页面栈长度是否只有1
isFirstPage() {
return getCurrentPages().length == 1;
},
// 是否当前页
isCurrentPage(path: string) {
return this.info()?.path == path;
},
// 回到首页
home() {
this.push(this.pages.home);
},
// tabbar
switchTab(name: string) {
let item = this.tabs.find((e) => e.pagePath.includes(name));
if (item) {
this.push({
path: `/${item.pagePath}`,
mode: "switchTab",
});
} else {
console.error("不存在Tab页", name);
}
},
// 是否是Tab页
isTab(path: string) {
return !!this.tabs.find((e) => path == `/${e.pagePath}`);
},
// 去登陆
login(options?: { reLaunch: boolean }) {
const { reLaunch = false } = options || {};
this.push({
path: this.pages.login,
mode: reLaunch ? "reLaunch" : "navigateTo",
isGuard: false,
});
},
// 登录成功后操作
nextLogin(type?: string) {
const pages = getCurrentPages();
const index = pages.findIndex((e) => this.pages.login.includes(e.route!));
if (index <= 0) {
this.home();
} else {
router.back({
delta: pages.length - index,
});
}
// 登录方式
storage.set("loginType", type);
// 登录回调
if (fn.afterLogin) {
fn.afterLogin();
}
// 事件
uni.$emit("afterLogin", { type });
},
// 跳转前钩子
beforeEach(callback: (to: any, next: () => void) => void) {
fn.beforeEach = callback;
},
// 登录后回调
afterLogin(callback: () => void) {
fn.afterLogin = callback;
},
};
export { router };

123
cool/service/base.ts Normal file
View File

@ -0,0 +1,123 @@
// @ts-nocheck
import { has } from "lodash-es";
import { isDev, config } from "../../config";
import request from "./request";
export function Service(
value:
| {
namespace?: string;
url?: string;
mock?: boolean;
}
| string
) {
return function (target: any) {
// 命名
if (typeof value == "string") {
target.prototype.namespace = value;
}
// 复杂项
if (has(value, "namespace")) {
target.prototype.namespace = value.namespace;
target.prototype.mock = value.mock;
if (value.url) {
target.prototype.url = value.url;
}
}
};
}
export class BaseService {
constructor(
options = {} as {
namespace?: string;
}
) {
if (options?.namespace) {
this.namespace = options.namespace;
}
}
request(options: any = {}) {
if (!options.params) options.params = {};
let ns = "";
// 是否 mock 模式
if (this.mock || config.test.mock) {
// 测试
} else {
if (isDev) {
ns = this.proxy || config.baseUrl;
} else {
ns = this.proxy ? this.url : config.baseUrl;
}
}
// 拼接前缀
if (this.namespace) {
ns += "/" + this.namespace;
}
// 处理地址
if (options.proxy === undefined || options.proxy) {
options.url = ns + options.url;
}
// 处理参数
options.data =
options.method?.toLocaleUpperCase() == "POST" ? options.data : options.params;
return request(options);
}
list(data: any) {
return this.request({
url: "/list",
method: "POST",
data,
});
}
page(data: any) {
return this.request({
url: "/page",
method: "POST",
data,
});
}
info(params: any) {
return this.request({
url: "/info",
params,
});
}
update(data: any) {
return this.request({
url: "/update",
method: "POST",
data,
});
}
delete(data: any) {
return this.request({
url: "/delete",
method: "POST",
data,
});
}
add(data: any) {
return this.request({
url: "/add",
method: "POST",
data,
});
}
}

9
cool/service/index.ts Normal file
View File

@ -0,0 +1,9 @@
import { BaseService } from "./base";
// service 数据集合
// @ts-ignore
export const service: Eps.Service = {
request: new BaseService().request,
};
export * from "./base";

132
cool/service/request.ts Normal file
View File

@ -0,0 +1,132 @@
import { useStore } from "../store";
import { router } from "../router";
import { isDev, config } from "../../config";
import { storage } from "../utils";
// 请求队列
let requests: any[] = [];
// Token 是否刷新中
let isRefreshing = false;
export default function request(options: any) {
// 缓存信息
const { user } = useStore();
// 标识
let Authorization = user.token || "";
// 忽略标识
config.ignore.token.forEach((e) => {
if (options.url.includes(e)) {
Authorization = "";
}
});
if (isDev) {
console.log(`[${options.method || "GET"}] ${options.url}`);
}
return new Promise(async (resolve, reject) => {
// 继续请求
function next() {
uni.request({
...options,
header: {
Authorization,
...options.header,
},
success(res) {
const { code, data, message } = res.data as {
code: number;
message: string;
data: any;
};
// 无权限
if (res.statusCode === 401) {
if (router.info()?.path == router.pages.login) {
return reject({ message });
} else {
user.logout();
}
}
// 服务异常
if (res.statusCode === 502) {
return reject({
message: "服务异常",
});
}
// 未找到
if (res.statusCode === 404) {
return reject({
message: `[404] ${options.url}`,
});
}
// 成功
if (res.statusCode === 200) {
switch (code) {
case 1000:
resolve(data);
break;
default:
reject({ message, code });
}
} else {
reject({ message: "服务异常" });
}
},
fail(err) {
reject({ message: err.errMsg });
},
});
}
// 刷新token处理
if (!options.url.includes("refreshToken")) {
if (Authorization) {
// 判断 token 是否过期
if (storage.isExpired("token")) {
// 判断 refreshToken 是否过期
if (storage.isExpired("refreshToken")) {
// 退出登录
return user.logout();
}
// 是否在刷新中
if (!isRefreshing) {
isRefreshing = true;
user.refreshToken()
.then((token) => {
requests.forEach((cb) => cb(token));
requests = [];
isRefreshing = false;
})
.catch((err) => {
user.logout();
reject(err);
});
}
return new Promise((resolve) => {
// 继续请求
requests.push((token: string) => {
// 重新设置 token
Authorization = token;
next();
resolve();
});
});
}
}
}
next();
});
}

22
cool/service/sign.ts Normal file
View File

@ -0,0 +1,22 @@
import md5 from "md5";
function useSign(params: any) {
const timestamp = new Date().getTime();
let arr = [`timestamp=${timestamp}`];
for (const i in params) {
arr.push(`${i}=${decodeURIComponent(params[i])}`);
}
arr.sort();
const sign = md5(arr.join("&"));
return {
timestamp,
sign,
};
}
export { useSign };

66
cool/store/dict.ts Normal file
View File

@ -0,0 +1,66 @@
import { defineStore } from "pinia";
import { computed, reactive, toRaw } from "vue";
import { deepTree } from "../utils";
import { service } from "../service";
import { isDev } from "/@/config";
import { isString } from "lodash-es";
import type { Dict } from "../types";
const useDictStore = defineStore("dict", () => {
// 对象数据
const data = reactive<Dict.Data>({});
function get(name: string) {
return computed(() => data[name]).value || [];
}
// 获取名称
function getLabel(name: string | any[], value: any): string {
const arr: any[] = String(value)?.split(",") || [];
return arr
.map((e) => {
return (isString(name) ? get(name) : name).find((a) => a.value == e)?.label;
})
.filter(Boolean)
.join(",");
}
// 刷新
async function refresh(types?: string[]) {
return service.dict.info
.data({
types,
})
.then((res: Dict.Data) => {
const d: any = {};
for (const [i, arr] of Object.entries(res)) {
arr.forEach((e) => {
e.label = e.name;
e.value = e.value !== undefined ? e.value : e.id;
});
d[i] = deepTree(arr, "desc");
}
Object.assign(data, d);
if (isDev) {
console.log("字典数据:");
console.log(toRaw(data));
}
return data;
});
}
return {
data,
get,
getLabel,
refresh,
};
});
export { useDictStore };

9
cool/store/index.ts Normal file
View File

@ -0,0 +1,9 @@
import { useUserStore } from "./user";
import { useDictStore } from "./dict";
export function useStore() {
return {
user: useUserStore(),
dict: useDictStore(),
};
}

94
cool/store/user.ts Normal file
View File

@ -0,0 +1,94 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { deepMerge, storage } from "../utils";
import { router } from "../router";
import { service } from "../service";
import type { User } from "../types";
// 本地缓存
const data = storage.info();
const useUserStore = defineStore("user", function () {
// 标识
const token = ref(data.token || "");
// 设置标识
function setToken(data: User.Token) {
token.value = data.token;
// 访问
storage.set("token", data.token, data.expire - 5);
// 刷新
storage.set("refreshToken", data.refreshToken, data.refreshExpire - 5);
}
// 刷新标识
async function refreshToken() {
return service.user.login
.refreshToken({
refreshToken: storage.get("refreshToken"),
})
.then((res) => {
setToken(res);
return res.token;
});
}
// 用户信息
const info = ref<User.Info | undefined>(data.userInfo);
// 设置用户信息
function set(value: User.Info) {
info.value = value;
storage.set("userInfo", value);
}
// 更新用户信息
async function update(data: User.Info & { [key: string]: any }) {
set(deepMerge(info.value, data));
return service.user.info.updatePerson(data);
}
// 清除用户
function clear() {
storage.remove("userInfo");
storage.remove("token");
storage.remove("refreshToken");
token.value = "";
info.value = undefined;
}
// 退出
function logout() {
clear();
router.login({ reLaunch: true });
}
// 获取用户信息
async function get() {
return service.user.info
.person()
.then((res) => {
if (res) {
set(res);
}
return res;
})
.catch(() => {
logout();
});
}
return {
token,
setToken,
refreshToken,
info,
get,
set,
update,
logout,
};
});
export { useUserStore };

43
cool/types/index.d.ts vendored Normal file
View File

@ -0,0 +1,43 @@
export declare interface ModuleConfig {
name?: string;
description?: string;
order?: number;
demo?: { label: string; path: string };
options?: {
[key: string]: any;
};
onLoad?(options?: any): any;
}
export declare interface Module extends ModuleConfig {
name: string;
options: {
[key: string]: any;
};
[key: string]: any;
}
export namespace User {
interface Token {
token: string;
expire: number;
refreshToken: string;
refreshExpire: number;
}
interface Info extends Eps.UserInfoEntity {}
}
export namespace Dict {
interface Item {
id: string;
label: string;
value: any;
children?: Item[];
[key: string]: any;
}
interface Data {
[key: string]: Item[];
}
}

48
cool/upload/comm.ts Normal file
View File

@ -0,0 +1,48 @@
import { isArray, has } from "lodash-es";
function parse(rules: string[], { url, size }: any) {
if (!url) {
return "";
}
let h = 0;
let w = 0;
if (isArray(size)) {
h = size[0];
w = size[1];
} else if (has(size, "h")) {
h = size.h;
w = size.w;
if (size.m) {
rules.push(`m_${size.m}`);
}
} else {
h = w = size;
}
url += url.includes("?") ? "&" : "?";
if (h) {
rules.push(`h_${h}`);
}
if (w) {
rules.push(`w_${w}`);
}
return `${url}${rules.join(",")}`;
}
type Size = number | number[] | { h?: number; w?: number; m?: string };
function videoPoster(url: string, size: Size) {
return parse(["x-oss-process=video/snapshot,t_1000,f_jpg,m_fast"], { url, size });
}
function resizeImage(url: string, size: Size) {
return parse(["x-oss-process=image/resize"], { url, size });
}
export { videoPoster, resizeImage };

141
cool/upload/index.ts Normal file
View File

@ -0,0 +1,141 @@
import dayjs from "dayjs";
import { config } from "../../config";
import { service } from "../service";
import { basename, pathJoin, uuid } from "../utils";
import { useStore } from "../store";
import { videoPoster, resizeImage } from "./comm";
declare interface UploadCallback {
onProgressUpdate?(options: UniApp.OnProgressUpdateResult): void;
onTask?(task: UniApp.UploadTask): void;
}
export async function upload(file: any, cb?: UploadCallback): Promise<string> {
const { onProgressUpdate, onTask } = cb || {};
// 获取上传模式
const { mode, type } = await service.base.comm.uploadMode();
// 用户缓存
const { user } = useStore();
// 本地上传
const isLocal = mode == "local";
// 文件名
const fileName = uuid() + "_" + (file.name || basename(file.path));
// Key
const key = isLocal ? fileName : pathJoin("app", dayjs().format("YYYY-MM-DD"), fileName);
// 多种上传请求
return new Promise((resolve, reject) => {
// 上传文件
function next({ host, preview, data }: { host: string; preview?: string; data?: any }) {
// 签名数据
const fd = {
...data,
key,
};
// 上传
const task = uni.uploadFile({
url: host,
filePath: file.path,
name: "file",
header: isLocal
? {
Authorization: user.token,
}
: {},
formData: fd,
success(res) {
if (isLocal) {
const { code, data, message } = JSON.parse(res.data);
if (code == 1000) {
resolve(data);
} else {
reject(message);
}
} else {
resolve(pathJoin(preview || host, fd.key));
}
},
fail(err) {
reject(err);
},
});
if (onTask) {
onTask(task);
}
if (onProgressUpdate) {
task.onProgressUpdate(onProgressUpdate);
}
}
if (isLocal) {
next({
host: config.baseUrl + "/app/base/comm/upload",
});
} else {
service.base.comm
.upload(
type == "aws"
? {
key,
}
: {}
)
.then((res) => {
switch (type) {
// 腾讯
case "cos":
next({
host: res.url,
data: res.credentials,
});
break;
// 阿里
case "oss":
next({
host: res.host,
data: {
OSSAccessKeyId: res.OSSAccessKeyId,
policy: res.policy,
signature: res.signature,
},
});
break;
// 七牛
case "qiniu":
next({
host: res.uploadUrl,
preview: res.publicDomain,
data: {
token: res.token,
},
});
break;
// aws
case "aws":
next({
host: res.url,
data: res.fields,
});
break;
}
})
.catch(reject);
}
});
}
export function useUpload() {
return {
upload,
videoPoster,
resizeImage,
};
}

603
cool/utils/canvas.ts Normal file
View File

@ -0,0 +1,603 @@
import { getCurrentInstance } from "vue";
import { isEmpty, isString, cloneDeep, isObject } from "lodash-es";
// 渲染参数
declare interface RenderOptions {
x: number;
y: number;
height?: number;
width?: number;
[key: string]: any;
}
// 文本渲染参数
declare interface TextRenderOptions extends RenderOptions {
text: string;
color?: string;
fontSize?: number;
textAlign?: "left" | "right" | "center";
overflow?: "ellipsis";
lineClamp?: number;
letterSpace?: number;
lineHeight?: number;
}
// 图片渲染参数
declare interface ImageRenderOptions extends RenderOptions {
url: string;
mode?: "aspectFill" | "aspectFit";
radius?: number;
}
// 块渲染参数
declare interface DivRenderOptions extends RenderOptions {
radius?: number;
backgroundColor?: string;
border?: {
width: number;
color: string;
};
}
// 导出图片参数
declare interface CreateImageOptins {
x?: number;
y?: number;
width?: number;
height?: number;
destWidth?: number;
destHeight?: number;
fileType?: "jpg" | "png";
quality?: number;
}
class Canvas {
ctx: any;
canvasId: any;
scope: any;
renderQuene: any;
imageQueue: any;
constructor(canvasId: string) {
// 绘图上下文
this.ctx = null;
// canvas id
this.canvasId = canvasId;
// 当前页面作用域
const { proxy }: any = getCurrentInstance();
this.scope = proxy;
// 渲染队列
this.renderQuene = [];
// 图片队列
this.imageQueue = [];
// 创建画布
this.create();
}
// 创建画布
create() {
this.ctx = uni.createCanvasContext(this.canvasId, this.scope);
return this;
}
// 块
div(options: DivRenderOptions) {
let render = () => {
this.divRender(options);
};
this.renderQuene.push(render);
return this;
}
// 文本
text(options: TextRenderOptions) {
let render = () => {
this.textRender(options);
};
this.renderQuene.push(render);
return this;
}
// 图片
image(options: ImageRenderOptions) {
let render = () => {
this.imageRender(options);
};
this.imageQueue.push(options);
this.renderQuene.push(render);
return this;
}
// 绘画
draw(save = false) {
return new Promise((resolve) => {
let next = () => {
this.render();
this.ctx.draw(save, () => {
resolve(true);
});
};
if (!isEmpty(this.imageQueue)) {
this.preLoadImage().then(next);
} else {
next();
}
});
}
// 生成图片
createImage(options?: CreateImageOptins): Promise<string> {
return new Promise((resolve, reject) => {
let data = {
canvasId: this.canvasId,
...options,
success: (res: any) => {
// #ifdef MP-ALIPAY
resolve(res.apFilePath);
// #endif
// #ifndef MP-ALIPAY
resolve(res.tempFilePath);
// #endif
},
fail: reject,
};
// #ifdef MP-ALIPAY
this.ctx.toTempFilePath(data);
// #endif
// #ifndef MP-ALIPAY
uni.canvasToTempFilePath(data, this.scope);
// #endif
});
}
// 保存图片
saveImage(options?: CreateImageOptins) {
uni.showLoading({
title: "图片下载中...",
});
this.createImage(options).then((path: any) => {
return new Promise((resolve) => {
uni.hideLoading();
uni.saveImageToPhotosAlbum({
filePath: path,
success: () => {
uni.showToast({
title: "保存图片成功",
});
resolve(path);
},
fail: (err) => {
// #ifdef MP-ALIPAY
uni.showToast({
title: "保存图片成功",
});
// #endif
// #ifndef MP-ALIPAY
uni.showToast({
title: "保存图片失败",
icon: "none",
});
// #endif
},
});
});
});
}
// 预览图片
previewImage(options?: CreateImageOptins) {
this.createImage(options).then((url: string | any) => {
uni.previewImage({
urls: [url],
});
});
}
// 下载图片
downLoadImage(item: any) {
return new Promise((resolve, reject) => {
if (!item.url) {
return reject("url 不能为空");
}
// 处理base64
// #ifdef MP
if (item.url.indexOf("data:image") >= 0) {
let extName = item.url.match(/data\:\S+\/(\S+);/);
if (extName) {
extName = extName[1];
}
const fs = uni.getFileSystemManager();
const fileName = Date.now() + "." + extName;
// @ts-ignore
const filePath = wx.env.USER_DATA_PATH + "/" + fileName;
return fs.writeFile({
filePath,
data: item.url.replace(/^data:\S+\/\S+;base64,/, ""),
encoding: "base64",
success: () => {
item.url = filePath;
resolve(filePath);
},
});
}
// #endif
// 是否网络图片
const isHttp = item.url.includes("http");
uni.getImageInfo({
src: item.url,
success: (result) => {
item.sheight = result.height;
item.swidth = result.width;
if (isHttp) {
item.url = result.path;
}
resolve(item.url);
},
fail: (err) => {
console.log(err, item.url);
reject(err);
},
});
return 1;
});
}
// 预加载图片
async preLoadImage() {
await Promise.all(this.imageQueue.map(this.downLoadImage));
}
// 设置背景颜色
setBackground(options: any) {
if (!options) return null;
let backgroundColor;
if (!isString(options)) {
backgroundColor = options;
}
if (isString(options.backgroundColor)) {
backgroundColor = options.backgroundColor;
}
if (isObject(options.backgroundColor)) {
let { startX, startY, endX, endY, gradient } = options.backgroundColor;
const rgb = this.ctx.createLinearGradient(startX, startY, endX, endY);
for (let i = 0, l = gradient.length; i < l; i++) {
rgb.addColorStop(gradient[i].step, gradient[i].color);
}
backgroundColor = rgb;
}
this.ctx.setFillStyle(backgroundColor);
return this;
}
// 设置边框
setBorder(options: any) {
if (!options.border) return this;
let { x, y, width: w, height: h, border, radius: r } = options;
if (border.width) {
this.ctx.setLineWidth(border.width);
}
if (border.color) {
this.ctx.setStrokeStyle(border.color);
}
// 偏移距离
let p = border.width / 2;
// 是否有圆角
if (r) {
this.drawRadiusRoute(x - p, y - p, w + 2 * p, h + 2 * p, r + p);
this.ctx.stroke();
} else {
this.ctx.strokeRect(x - p, y - p, w + 2 * p, h + 2 * p);
}
return this;
}
// 设置缩放,旋转
setTransform(options: any) {
if (options.scale) {
}
if (options.rotate) {
}
}
// 带有圆角的路径绘制
drawRadiusRoute(x: number, y: number, w: number, h: number, r: number) {
this.ctx.beginPath();
this.ctx.moveTo(x + r, y, y);
this.ctx.lineTo(x + w - r, y);
this.ctx.arc(x + w - r, y + r, r, 1.5 * Math.PI, 0);
this.ctx.lineTo(x + w, y + h - r);
this.ctx.arc(x + w - r, y + h - r, r, 0, 0.5 * Math.PI);
this.ctx.lineTo(x + r, y + h);
this.ctx.arc(x + r, y + h - r, r, 0.5 * Math.PI, Math.PI);
this.ctx.lineTo(x, y + r);
this.ctx.arc(x + r, y + r, r, Math.PI, 1.5 * Math.PI);
this.ctx.closePath();
}
// 裁剪图片
cropImage(
mode: "aspectFill" | "aspectFit",
width: number,
height: number,
sWidth: number,
sHeight: number,
x: number,
y: number
) {
let cx, cy, cw, ch, sx, sy, sw, sh;
switch (mode) {
case "aspectFill":
if (width <= height) {
let p = width / sWidth;
cw = width;
ch = sHeight * p;
cx = 0;
cy = (height - ch) / 2;
} else {
let p = height / sHeight;
cw = sWidth * p;
ch = height;
cx = (width - cw) / 2;
cy = 0;
}
break;
case "aspectFit":
if (width <= height) {
let p = height / sHeight;
sw = width / p;
sh = sHeight;
sx = x + (sWidth - sw) / 2;
sy = y;
} else {
let p = width / sWidth;
sw = sWidth;
sh = height / p;
sx = x;
sy = y + (sHeight - sh) / 2;
}
break;
}
return { cx, cy, cw, ch, sx, sy, sw, sh };
}
// 获取文本内容
getTextRows({
text,
fontSize = 14,
width = 100,
lineClamp = 1,
overflow,
letterSpace = 0,
}: any) {
let arr: any[] = [[]];
let a = 0;
for (let i = 0; i < text.length; i++) {
let b = this.getFontPx(text[i], { fontSize, letterSpace });
if (a + b > width) {
a = b;
arr.push(text[i]);
} else {
// 最后一行且设置超出省略号
if (
overflow == "ellipsis" &&
arr.length == lineClamp &&
a + 3 * this.getFontPx(".", { fontSize, letterSpace }) > width - 5
) {
arr[arr.length - 1] += "...";
break;
} else {
a += b;
arr[arr.length - 1] += text[i];
}
}
}
return arr;
}
// 获取单个字体像素大小
getFontPx(text: string, { fontSize = 14, letterSpace }: any) {
if (!text) {
return fontSize / 2 + fontSize / 14 + letterSpace;
}
let ch = text.charCodeAt(0);
if ((ch >= 0x0001 && ch <= 0x007e) || (0xff60 <= ch && ch <= 0xff9f)) {
return fontSize / 2 + fontSize / 14 + letterSpace;
} else {
return fontSize + letterSpace;
}
}
// 渲染块
divRender(options: DivRenderOptions) {
this.ctx.save();
this.setBackground(options);
this.setBorder(options);
this.setTransform(options);
// 区分是否有圆角采用不同模式渲染
if (options.radius) {
let { x, y } = options;
let w = options.width || 0;
let h = options.height || 0;
let r = options.radius || 0;
// 画路径
this.drawRadiusRoute(x, y, w, h, r);
// 填充
this.ctx.fill();
} else {
this.ctx.fillRect(options.x, options.y, options.width, options.height);
}
this.ctx.restore();
}
// 渲染文本
textRender(options: TextRenderOptions) {
let {
fontSize = 14,
textAlign,
width,
color = "#000000",
x,
y,
letterSpace,
lineHeight = 14,
} = options || {};
this.ctx.save();
// 设置字体大小
this.ctx.setFontSize(fontSize);
// 设置字体颜色
this.ctx.setFillStyle(color);
// 获取文本内容
let rows = this.getTextRows(options);
// 获取文本行高
let lh = lineHeight - fontSize;
// 左偏移
let offsetLeft = 0;
// 字体对齐
if (textAlign && width) {
this.ctx.textAlign = textAlign;
switch (textAlign) {
case "left":
break;
case "center":
offsetLeft = width / 2;
break;
case "right":
offsetLeft = width;
break;
}
}
// 逐行写入
for (let i = 0; i < rows.length; i++) {
let d = offsetLeft;
if (letterSpace) {
for (let j = 0; j < rows[i].length; j++) {
// 写入文字
this.ctx.fillText(rows[i][j], x + d, (i + 1) * fontSize + y + lh * i);
// 设置偏移
d += this.getFontPx(rows[i][j], options);
}
} else {
// 写入文字
this.ctx.fillText(rows[i], x + offsetLeft, (i + 1) * fontSize + y + lh * i);
}
}
this.ctx.restore();
}
// 渲染图片
imageRender(options: ImageRenderOptions) {
this.ctx.save();
if (options.radius) {
// 画路径
this.drawRadiusRoute(
options.x,
options.y,
options.width || options.swidth,
options.height || options.sHeight,
options.radius
);
// 填充
this.ctx.fill();
// 裁剪
this.ctx.clip();
}
let temp = cloneDeep(this.imageQueue[0]);
if (options.mode) {
let { cx, cy, cw, ch, sx, sy, sw, sh } = this.cropImage(
options.mode,
temp.swidth,
temp.sheight,
temp.width,
temp.height,
temp.x,
temp.y
);
switch (options.mode) {
case "aspectFit":
this.ctx.drawImage(temp.url, sx, sy, sw, sh);
break;
case "aspectFill":
this.ctx.drawImage(
temp.url,
cx,
cy,
cw,
ch,
temp.x,
temp.y,
temp.width,
temp.height
);
break;
}
} else {
this.ctx.drawImage(
temp.url,
temp.x,
temp.y,
temp.width || temp.swidth,
temp.height || temp.sheight
);
}
this.imageQueue.shift();
this.ctx.restore();
}
// 渲染全部
render() {
this.renderQuene.forEach((ele: any) => {
ele();
});
}
}
export { Canvas };

163
cool/utils/comm.ts Normal file
View File

@ -0,0 +1,163 @@
import { orderBy } from "lodash-es";
export const { platform } = uni.getSystemInfoSync();
// 是否安卓
export const isAndroid = platform == "android";
// 是否苹果
export const isIos = platform == "ios";
// 是否小数
export function isDecimal(value: any): boolean {
return String(value).length - String(value).indexOf(".") + 1 > 0;
}
// 首字母大写
export function firstUpperCase(value: string): string {
return value.replace(/\b(\w)(\w*)/g, function ($0, $1, $2) {
return $1.toUpperCase() + $2;
});
}
// 深度合并
export function deepMerge(a: any, b: any) {
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 getUrlParam(name: string): string | null {
const reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
const r = window.location.search.substr(1).match(reg);
if (r != null) return decodeURIComponent(r[2]);
return null;
}
// 列表转树形
export function deepTree(list: any[], sort?: "desc" | "asc"): any[] {
const newList: any[] = [];
const map: any = {};
orderBy(list, "orderNum", sort)
.map((e) => {
map[e.id] = e;
return e;
})
.forEach((e) => {
const parent = map[e.parentId];
if (parent) {
(parent.children || (parent.children = [])).push(e);
} else {
newList.push(e);
}
});
return newList;
}
// 路径转对象
export function path2Obj(list: any[]) {
const data: any = {};
list.forEach(({ path, value }) => {
const arr: string[] = path.split("/");
const parents = arr.slice(0, arr.length - 1);
const name = basename(path).replace(".ts", "");
let curr = data;
parents.forEach((k) => {
if (!curr[k]) {
curr[k] = {};
}
curr = curr[k];
});
curr[name] = value;
});
return data;
}
// 路径拼接
export function pathJoin(...parts: string[]): string {
if (parts.length === 0) {
return "";
}
const firstPart = parts[0];
let isAbsolute = false;
// 检查第一个部分是否以 "http" 开头,以确定路径类型(绝对还是相对)
if (firstPart.startsWith("http")) {
isAbsolute = true;
}
// 标准化路径,去除任何开头或结尾的斜杠
const normalizedParts = parts.map((part) => part.replace(/(^\/+|\/+$)/g, ""));
if (isAbsolute) {
// 如果是绝对路径,使用斜杠连接部分
return normalizedParts.join("/");
} else {
// 如果是相对路径,使用平台特定的分隔符连接部分
return normalizedParts.join("/");
}
}
// 文件名
export function filename(path: string): string {
return basename(path.substring(0, path.lastIndexOf(".")));
}
// 路径名称
export function basename(path: string): string {
let index = path.lastIndexOf("/");
index = index > -1 ? index : path.lastIndexOf("\\");
if (index < 0) {
return path;
}
return path.substring(index + 1);
}
// 文件扩展名
export function extname(path: string): string {
return path.substring((path || "").lastIndexOf(".") + 1);
}
// 横杠转驼峰
export function toCamel(str: string): string {
return str.replace(/([^-])(?:-+([^-]))/g, function ($0, $1, $2) {
return $1 + $2.toUpperCase();
});
}
// uuid
export function uuid(): string {
const s: any[] = [];
const hexDigits = "0123456789abcdef";
for (let i = 0; i < 36; i++) {
s[i] = hexDigits.substr(Math.floor(Math.random() * 0x10), 1);
}
s[14] = "4";
s[19] = hexDigits.substr((s[19] & 0x3) | 0x8, 1);
s[8] = s[13] = s[18] = s[23] = "-";
return s.join("");
}
// 延迟
export function sleep(duration: number) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(true);
}, duration);
});
}

4
cool/utils/index.ts Normal file
View File

@ -0,0 +1,4 @@
export * from "./comm";
export * from "./ui";
export * from "./canvas";
export * from "./storage";

75
cool/utils/storage.ts Normal file
View File

@ -0,0 +1,75 @@
export const storage = {
// 后缀标识
suffix: "_deadtime",
/**
*
* @param {*} key
*/
get(key: string): any {
return uni.getStorageSync(key);
},
/**
*
*/
info() {
const { keys } = uni.getStorageInfoSync();
const d: any = {};
keys.forEach((e: string) => {
d[e] = uni.getStorageSync(e);
});
return d;
},
/**
*
* @param {*} key
* @param {*} value
* @param {*} expires
*/
set(key: string, value: any, expires?: number): void {
uni.setStorageSync(key, value);
if (expires) {
uni.setStorageSync(
`${key}${this.suffix}`,
Date.parse(String(new Date())) + expires * 1000
);
}
},
/**
*
* @param {*} key
*/
isExpired(key: string): boolean {
return uni.getStorageSync(`${key}${this.suffix}`) - Date.parse(String(new Date())) <= 0;
},
/**
*
* @param {*} key
*/
remove(key: string) {
return uni.removeStorageSync(key);
},
/**
*
*/
clear() {
uni.clearStorageSync();
},
/**
*
*/
once(key: string) {
const value = this.get(key);
this.remove(key);
return value;
},
};

78
cool/utils/ui.ts Normal file
View File

@ -0,0 +1,78 @@
import { isArray, isEmpty, isNumber } from "lodash-es";
import { computed, getCurrentInstance, nextTick, ref } from "vue";
// 获取父组件
export function getParent(name: string, k1: string[], k2?: string[]) {
const { proxy }: any = getCurrentInstance();
const d = ref();
let n = 10;
const next = () => {
let parent = proxy.$parent;
while (parent) {
if (parent.$options.name !== name) {
parent = parent.$parent;
} else {
if (isArray(k2)) {
nextTick(() => {
const child: any = {};
(k2 || []).map((key: string) => {
if (proxy[key]) {
child[key] = proxy[key];
}
});
if (!parent.__children) {
parent.__children = [];
}
if (!isEmpty(child)) {
parent.__children.push(child);
}
});
}
return (k1 || []).reduce((res: any, key: string) => {
res[key] = parent[key];
return res;
}, {});
}
}
// if (!d.value && n-- > 0) {
// setTimeout(() => {
// d.value = next();
// }, 50);
// }
return parent || d.value;
};
return computed(() => next());
}
// 获取元素位置信息
export async function getRect(selector: string): Promise<any> {
return new Promise((resolve) => {
uni.createSelectorQuery()
.select(selector)
.boundingClientRect((res) => {
resolve(res);
})
.exec();
});
}
// 解析rpx
export function parseRpx(val: any): string {
return isArray(val) ? val.map(parseRpx).join(" ") : isNumber(val) ? `${val}rpx` : val;
}
// px 转 rpx
export function px2Rpx(px: number) {
return px / (uni.upx2px(100) / 100);
}

29
hooks/index.ts Normal file
View File

@ -0,0 +1,29 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { service } from "/@/cool";
export const useAddress = defineStore("address", () => {
const info = ref<Eps.UserAddressEntity>();
function set(data: Eps.UserAddressEntity) {
info.value = data;
}
function getDefault() {
if (!info.value || info.value?.isDefault) {
service.user.address.default().then((res) => {
info.value = res;
});
}
}
return {
info,
set,
getDefault,
};
});
export * from "./shopping-cart";
export * from "./spec";
export * from "./order";

47
hooks/order.ts Normal file
View File

@ -0,0 +1,47 @@
import { service, useWx } from "/@/cool";
export function useOrder() {
const wx = useWx();
const payTypes = [
{
label: "微信支付",
value: 1,
key: "wxpay",
icon: "/static/icon/wxpay.png",
},
// {
// label: "支付宝支付",
// value: 2,
// key: "alipay",
// icon: "/static/icon/alipay.png",
// },
];
async function toPay(orderId: number, type = "wxpay") {
// #ifdef MP-WEIXIN
return service.order.pay.wxMiniPay({ orderId }).then((res) => {
return wx.miniPay(res.data);
});
// #endif
// #ifdef H5
if (wx.isWxBrowser()) {
return service.order.pay.wxMpPay({ orderId }).then((res) => {
return wx.mpPay(res.data);
});
}
// #endif
// #ifdef APP
return service.order.pay.wxAppPay({ orderId }).then((res) => {
return wx.appPay(res.data);
});
// #endif
}
return {
toPay,
payTypes,
};
}

70
hooks/shopping-cart.ts Normal file
View File

@ -0,0 +1,70 @@
import { defineStore } from "pinia";
import { computed, ref, watch } from "vue";
import { storage } from "/@/cool";
import { uuid } from "/@/cool/utils";
// 购物车
export const useShoppingCart = defineStore("shopping-cart", () => {
const list = ref<OrderGoods[]>(storage.get("shopping-cart.list") || []);
// 购物车数量
const num = computed(() => {
return list.value.length;
});
// 数量+1
function add(data: OrderGoods) {
const d = list.value.find((e) => e.spec?.id == data.spec?.id);
if (d) {
// 判定库存
d.count += data.count || 1;
if (d.count > data.spec.stock!) {
d.count = data.spec.stock || 1;
}
} else {
list.value.push({
...data,
id: uuid(),
});
}
}
// 删除规格
function del(id: string) {
const i = list.value.findIndex((e) => e.id == id);
if (i >= 0) {
list.value.splice(i, 1);
}
}
// 删除规格根据 specId
function delBySpecId(id: number) {
const i = list.value.findIndex((e) => e.spec?.id == id);
if (i >= 0) {
list.value.splice(i, 1);
}
}
// 监听更新
watch(
list,
(val) => {
storage.set("shopping-cart.list", val);
},
{
deep: true,
},
);
return {
list,
num,
add,
del,
delBySpecId,
};
});

115
hooks/spec.ts Normal file
View File

@ -0,0 +1,115 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
type Action = "select" | "spCart" | "buy" | "edit";
export const useSpec = defineStore("goods.spec", () => {
const visible = ref(false);
// 打开类型
const action = ref<Action>("select");
// 商品信息
const goods = ref<Eps.GoodsInfoEntity>();
// 已选规格信息
const info = ref<Eps.GoodsSpecEntity>();
// 已选数量
const num = ref(1);
// 指定规格id
const specId = ref();
// 回调函数
let cb: ((action: Action) => void) | undefined;
// 选中文案
const text = computed(() => {
return info.value ? `已选:${info.value.name}` : "选择规格";
});
// 打开弹窗
function open(options: {
action: Action;
goods: Eps.GoodsInfoEntity;
spec?: Eps.GoodsSpecEntity;
specId?: number;
count?: number;
item?: OrderGoods;
callback?: typeof cb;
}) {
if (!options.goods) {
return false;
}
visible.value = true;
action.value = options.action || "select";
goods.value = options.goods;
specId.value = options.specId || options.spec?.id || specId.value;
if (options.spec) {
info.value = options.spec;
}
cb = options.callback;
if (options.action == "edit") {
num.value = options.count || 0;
}
}
// 关闭弹窗
function close() {
visible.value = false;
}
// 设置选中规格
function set(data?: Eps.GoodsSpecEntity) {
info.value = data;
setId(data?.id!);
}
// 设置规格id
function setId(id: number) {
specId.value = id;
}
// 设置数量
function setNum(val?: number) {
num.value = val || 0;
}
// 回调
function emit(action: Action) {
if (cb) {
cb(action);
}
}
// 清空选项
function clear() {
num.value = 1;
info.value = undefined;
goods.value = undefined;
specId.value = undefined;
cb = undefined;
}
return {
visible,
action,
goods,
info,
emit,
num,
setNum,
specId,
text,
open,
close,
set,
setId,
clear,
};
});

14
index.html Normal file
View File

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

18
main.js Normal file
View File

@ -0,0 +1,18 @@
import { createSSRApp } from "vue";
import { bootstrap } from "/@/cool/bootstrap";
import App from "./App.vue";
import "./router";
export function createApp() {
const app = createSSRApp(App);
// 启动
bootstrap(app);
// 隐藏底部导航栏
uni.hideTabBar();
return {
app,
};
}

174
manifest.json Normal file
View File

@ -0,0 +1,174 @@
{
"name" : "酷卖",
"appid" : "__UNI__75A96B5",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : 100,
"transformPx" : false,
"app-plus" : {
"usingComponents" : true,
"splashscreen" : {
"alwaysShowBeforeRender" : true,
"waiting" : true,
"autoclose" : true,
"delay" : 0
},
"modules" : {
"VideoPlayer" : {},
"Share" : {},
"Payment" : {},
"OAuth" : {},
"Geolocation" : {},
"Camera" : {}
},
"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.ACCESS_NETWORK_STATE\"/>",
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
"<uses-permission android:name=\"android.permission.CAPTURE_VIDEO_OUTPUT\"/>",
"<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.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_LOGS\"/>",
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
"<uses-permission android:name=\"android.permission.RECORD_AUDIO\"/>",
"<uses-permission android:name=\"android.permission.STATUS_BAR\"/>",
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
"<uses-permission android:name=\"android.permission.WRITE_EXTERNAL_STORAGE\"/>",
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>"
],
"abiFilters" : [ "armeabi-v7a", "arm64-v8a", "x86" ]
},
"ios" : {
"capabilities" : {
"entitlements" : {
"com.apple.developer.associated-domains" : []
}
},
"idfa" : true,
"privacyDescription" : {
"NSUserTrackingUsageDescription" : "请放心,开启权限不会获取您在其他站点的隐私信息,该权限仅用于标识设备并保障服务安全与提示浏览体验"
}
},
"sdkConfigs" : {
"payment" : {
"alipay" : {
"__platform__" : [ "ios", "android" ]
},
"weixin" : {
"__platform__" : [ "ios", "android" ],
"appid" : "wx348f72db1512fa2e",
"UniversalLinks" : ""
}
},
"ad" : {},
"share" : {
"weixin" : {
"appid" : "wx348f72db1512fa2e",
"UniversalLinks" : ""
}
},
"oauth" : {
"weixin" : {
"appid" : "wx348f72db1512fa2e",
"appsecret" : "test",
"UniversalLinks" : ""
},
"apple" : {}
},
"geolocation" : {
"system" : {
"__platform__" : [ "ios", "android" ]
}
},
"push" : {
"unipush" : {}
},
"maps" : {}
},
"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"
}
}
},
"splashscreen" : {
"useOriginalMsgbox" : true
}
},
"safearea" : {
"bottom" : {
"offset" : "none"
}
},
"uniStatistics" : {
"enable" : true
}
},
"quickapp" : {},
"mp-weixin" : {
"appid" : "wxdebc4de0b5584ca4",
"setting" : {
"urlCheck" : false,
"es6" : true
},
"usingComponents" : true
},
"mp-alipay" : {
"usingComponents" : true
},
"mp-baidu" : {
"usingComponents" : true
},
"mp-toutiao" : {
"usingComponents" : true
},
"uniStatistics" : {
"enable" : false
},
"vueVersion" : "3",
"h5" : {
"router" : {
"base" : "./",
"mode" : "hash"
},
"devServer" : {
"https" : false
}
}
}

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "cool-uni",
"version": "7.3.0",
"license": "MIT",
"dependencies": {
"@dcloudio/uni-app": "3.0.0-3081220230817001",
"@hyoga/uni-socket.io": "^3.0.4",
"dayjs": "^1.11.10",
"js-pinyin": "^0.2.5",
"lodash-es": "^4.17.21",
"md5": "^2.3.0",
"pinia": "^2.1.7",
"vue": "^3.4.21",
"weixin-js-sdk": "^1.6.5"
},
"engines": {
"node": ">= 16"
},
"devDependencies": {
"@cool-vue/vite-plugin": "7.1.3",
"@dcloudio/types": "^3.4.8",
"@types/lodash-es": "^4.17.12",
"@types/md5": "^2.3.2",
"@types/node": "^20.11.26",
"@vue/tsconfig": "^0.5.1",
"typescript": "^5.2.2",
"vite": "^5.1.6"
}
}

268
pages.json Normal file
View File

@ -0,0 +1,268 @@
{
"pages": [
{
"path": "pages/index/home",
"style": {
"enablePullDownRefresh": true
}
},
{
"path": "pages/index/category",
"style": {
"disableScroll": true,
"navigationBarTitleText": "分类"
}
},
{
"path": "pages/index/shopping-cart",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "购物车"
}
},
{
"path": "pages/index/my",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "我的",
"navigationBarTextStyle": "white"
}
}
],
"subPackages": [
{
"root": "uni_modules/cool-app/pages",
"pages": [
{
"path": "version/demo",
"style": {
"navigationBarTitleText": "版本升级"
}
},
{
"path": "complain/detail",
"style": {
"navigationBarTitleText": "投诉详情"
}
},
{
"path": "complain/list",
"style": {
"navigationBarTitleText": "投诉列表"
}
},
{
"path": "complain/submit",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "feedback/detail",
"style": {
"navigationBarTitleText": "反馈详情"
}
},
{
"path": "feedback/list",
"style": {
"navigationBarTitleText": "反馈列表"
}
},
{
"path": "feedback/submit",
"style": {
"navigationStyle": "custom"
}
}
],
"isTemp": true
},
{
"root": "uni_modules/cool-cs/pages",
"pages": [
{
"path": "chat",
"style": {
"navigationBarTitleText": "客服聊天"
}
}
],
"isTemp": true
},
{
"root": "pages/market",
"pages": [
{
"path": "coupon",
"style": {
"navigationBarTitleText": "我的优惠券",
"enablePullDownRefresh": true
}
}
]
},
{
"root": "pages/order",
"pages": [
{
"path": "refund",
"style": {
"navigationBarTitleText": "退款申请"
}
},
{
"path": "comment",
"style": {
"navigationBarTitleText": "商品评价"
}
},
{
"path": "logistics",
"style": {
"navigationBarTitleText": "物流详情",
"enablePullDownRefresh": true
}
},
{
"path": "list",
"style": {
"navigationBarTitleText": "订单列表",
"enablePullDownRefresh": true
}
},
{
"path": "detail",
"style": {
"navigationBarTitleText": "订单详情",
"enablePullDownRefresh": true,
"navigationStyle": "custom"
}
},
{
"path": "submit",
"style": {
"navigationBarTitleText": "订单提交"
}
}
]
},
{
"root": "pages/goods",
"pages": [
{
"path": "search",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "list",
"style": {
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "detail",
"style": {
"navigationStyle": "custom",
"enablePullDownRefresh": true
}
},
{
"path": "comment",
"style": {
"navigationBarTitleText": "商品评价"
}
}
]
},
{
"root": "pages/user",
"pages": [
{
"path": "address-list",
"style": {
"navigationBarTitleText": "收货地址",
"enablePullDownRefresh": true
}
},
{
"path": "address-edit",
"style": {
"navigationBarTitleText": ""
}
},
{
"path": "doc",
"style": {
"navigationBarTitleText": ""
}
},
{
"path": "set",
"style": {
"navigationBarTitleText": "设置"
}
},
{
"path": "edit",
"style": {
"navigationBarTitleText": "编辑"
}
},
{
"path": "login",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "captcha",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "about",
"style": {
"navigationBarTitleText": ""
}
}
]
}
],
"globalStyle": {
"navigationBarTextStyle": "black",
"navigationBarTitleText": "酷卖",
"navigationBarBackgroundColor": "#ffffff",
"backgroundColor": "#f6f7fa"
},
"tabBar": {
"backgroundColor": "#ffffff",
"borderStyle": "white",
"list": [
{
"pagePath": "pages/index/home",
"iconPath": "static/icon/tabbar/home.png",
"selectedIconPath": "static/icon/tabbar/home2.png"
},
{
"pagePath": "pages/index/category",
"iconPath": "static/icon/tabbar/category.png",
"selectedIconPath": "static/icon/tabbar/category2.png"
},
{
"pagePath": "pages/index/shopping-cart",
"iconPath": "static/icon/tabbar/shopping-cart.png",
"selectedIconPath": "static/icon/tabbar/shopping-cart2.png"
},
{
"pagePath": "pages/index/my",
"iconPath": "static/icon/tabbar/my.png",
"selectedIconPath": "static/icon/tabbar/my2.png"
}
]
}
}

57
pages/goods/comment.vue Normal file
View File

@ -0,0 +1,57 @@
<template>
<cl-page>
<view class="page">
<view class="list">
<view class="item" v-for="item in list">
<comment-item :item="item" />
</view>
<cl-empty text="暂无评论" icon="message" v-if="isEmpty(list)" />
</view>
</view>
</cl-page>
</template>
<script lang="ts" setup>
import { onMounted } from "vue";
import { useCool, usePager } from "/@/cool";
import { isEmpty } from "lodash-es";
import CommentItem from "./components/comment-item.vue";
const { service, router } = useCool();
const { list, onRefresh } = usePager<Eps.GoodsCommentEntity>();
function refresh(params?: any) {
const { next, data } = onRefresh(params);
next(service.goods.comment.page(data));
}
onMounted(() => {
refresh({
goodsId: router.query.goodsId,
});
});
defineExpose({
refresh,
});
</script>
<style lang="scss" scoped>
.page {
padding: 24rpx;
.list {
.item {
background-color: #fff;
padding: 24rpx;
border-radius: 24rpx;
margin-bottom: 24rpx;
&:last-child {
margin-bottom: 0;
}
}
}
}
</style>

View File

@ -0,0 +1,81 @@
<template>
<view class="comment-item">
<view class="inner">
<view class="user">
<cl-avatar :size="70" :src="item.avatarUrl" />
<cl-row :margin="[4, 0, 0, 20]">
<cl-text :size="24" block :margin="[0, 0, 4, 0]">{{ item.nickName }}</cl-text>
<cl-rate disabled :size="24" v-model="item.starCount" />
</cl-row>
</view>
<cl-text
:ellipsis="demo ? 2 : undefined"
block
:line-height="1.5"
:margin="[20, 0, 0, 0]"
>{{ item.content }}</cl-text
>
</view>
<view class="pics" v-if="!isEmpty(item.pics)">
<cl-image
:size="120"
:src="resizeImage(item.pics[0], 120)"
:preview-list="item.pics"
:radius="12"
/>
<text class="num" v-if="1 || item.pics.length > 1">+{{ item.pics.length }}</text>
</view>
</view>
</template>
<script lang="ts" setup>
import { type PropType } from "vue";
import { isEmpty } from "lodash-es";
import { useUpload } from "/@/cool";
defineProps({
item: {
type: Object as PropType<Eps.GoodsCommentEntity>,
default: () => ({}),
},
demo: Boolean,
});
const { resizeImage } = useUpload();
</script>
<style lang="scss" scoped>
.comment-item {
display: flex;
.inner {
flex: 1;
.user {
display: flex;
align-items: center;
}
}
.pics {
position: relative;
margin-left: 24rpx;
.num {
position: absolute;
top: 84rpx;
right: 10rpx;
font-size: 20rpx;
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
padding: 4rpx;
border-radius: 6rpx;
letter-spacing: 1rpx;
line-height: 1;
}
}
}
</style>

View File

@ -0,0 +1,350 @@
<template>
<view class="filter-bar">
<cl-filter-bar v-model="flt.form" @change="flt.onChange">
<cl-filter-item label="综合" prop="score" type="switch" />
<cl-filter-item label="销量" prop="sold" type="order" />
<cl-filter-item label="价格" prop="price" type="order" />
<view class="cl-filter-item" @tap="adv.open">
<text>筛选</text>
<cl-icon class-name="shop-icon-filter" :size="36" />
</view>
</cl-filter-bar>
</view>
<!-- 侧边筛选 -->
<cl-popup v-model="adv.visible" direction="right" :size="600" :padding="0">
<view class="filter-adv">
<!-- 筛选内容 -->
<scroll-view scroll-y class="filter-adv__container">
<view class="filter-adv__list">
<!-- 价格 -->
<view class="filter-adv__item">
<text class="label">价格区间</text>
<cl-row type="flex" justify="space-between">
<cl-input
v-model="adv.form.minPrice"
placeholder="最低价"
type="number"
round
align="center"
@confirm="toSearch"
/>
<cl-icon name="minus" :margin="[0, 20, 0, 20]" />
<cl-input
v-model="adv.form.maxPrice"
placeholder="最高价"
type="number"
round
align="center"
@confirm="toSearch"
/>
</cl-row>
</view>
<!-- 分类 -->
<view class="filter-adv__item">
<cl-loading-mask :loading="type.loading">
<view class="category">
<!-- 一级 -->
<template v-for="m1 in type.list">
<view class="m1" :key="m1.id" v-if="m1.children">
<text class="label">
{{ m1.name }}
</text>
<view class="m1-list">
<!-- 二级 -->
<view
class="m2"
v-for="m2 in m1.children"
:key="m2.id"
:class="{
'is-active': adv.form.typeId.includes(m2.id!),
}"
@tap="type.select(m2)"
>
{{ m2.name }}
</view>
</view>
</view>
</template>
</view>
</cl-loading-mask>
</view>
</view>
</scroll-view>
<!-- 操作按钮 -->
<view class="filter-adv__footer">
<view class="inner">
<button class="reset" @tap="adv.reset">重置</button>
<button class="confirm" @tap="adv.confirm">确定</button>
</view>
</view>
</view>
</cl-popup>
</template>
<script lang="ts" setup>
import { cloneDeep, isEmpty } from "lodash-es";
import { useCool } from "/@/cool";
import { deepTree } from "/@/cool/utils";
import { onMounted, reactive } from "vue";
interface Type extends Eps.GoodsTypeEntity {
children?: Eps.GoodsTypeEntity[];
}
const emit = defineEmits(["search"]);
const { service, router } = useCool();
//
const adv = reactive({
visible: false,
form: {
minPrice: "",
maxPrice: "",
typeId: [] as number[],
},
_form: null as any,
open() {
adv.visible = true;
//
if (!adv._form) {
adv._form = cloneDeep(adv.form);
}
//
type.refresh();
},
close() {
adv.visible = false;
},
reset() {
adv.form = cloneDeep(adv._form);
adv.confirm();
},
confirm() {
adv.close();
toSearch();
},
});
//
const flt = reactive({
form: {
sold: "",
price: "desc",
score: false,
},
onChange({ prop, value }: { prop: "sold" | "price" | "score"; value: never }) {
flt.form.sold = "";
flt.form.price = "";
flt.form.score = false;
flt.form[prop] = value;
switch (prop) {
case "score":
toSearch({
order: undefined,
sort: undefined,
});
break;
case "price":
case "sold":
toSearch({
order: value ? prop : undefined,
sort: value || undefined,
});
break;
}
},
});
//
const type = reactive({
list: [] as Type[],
loading: false,
async refresh() {
type.loading = true;
await service.goods.type.list().then((res) => {
type.list = deepTree(res);
});
type.loading = false;
},
select(item: Type) {
const i = adv.form.typeId.indexOf(item.id!);
if (i >= 0) {
adv.form.typeId.splice(i, 1);
} else {
adv.form.typeId.push(item.id!);
}
},
});
//
function toSearch(params?: any) {
const { minPrice, maxPrice, typeId } = adv.form;
const data = {
page: 1,
minPrice: minPrice === "" ? undefined : minPrice,
maxPrice: maxPrice === "" ? undefined : maxPrice,
typeId: isEmpty(typeId) ? undefined : typeId,
...params,
};
emit("search", data);
}
onMounted(() => {
const { typeId } = router.query;
if (typeId) {
adv.form.typeId = typeId.split(",").map(Number);
}
});
</script>
<style lang="scss" scoped>
.filter-adv {
background-color: $cl-color-bg;
height: 100%;
&__container {
height: calc(100% - 100rpx);
}
&__list {
padding: 24rpx;
}
&__item {
background-color: #fff;
margin-bottom: 24rpx;
padding: 24rpx;
border-radius: 24rpx;
.label {
display: block;
font-size: 26rpx;
margin-bottom: 24rpx;
line-height: 1;
}
.category {
min-height: 200rpx;
.m1 {
margin-bottom: 30rpx;
&-name {
display: flex;
align-items: center;
height: 80rpx;
font-size: 28rpx;
}
&-list {
width: 100%;
line-height: normal;
border: 2rpx solid #f7f7f7;
padding: 5rpx 20rpx;
box-sizing: border-box;
border-radius: 24rpx;
}
&:last-child {
margin-bottom: 0;
}
.m2 {
display: inline-flex;
justify-content: center;
align-items: center;
border-radius: 10rpx;
margin: 10rpx 20rpx 10rpx 0;
padding: 0 25rpx;
font-size: 22rpx;
height: 50rpx;
text-align: center;
border-radius: 50rpx;
color: #444;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
border: 2rpx solid #f7f7f7;
background-color: #f7f7f7;
&.is-active {
color: $cl-color-primary;
border-color: $cl-color-primary;
background-color: rgba($cl-color-primary, 0.1);
}
}
}
}
&:last-child {
margin-bottom: 0;
}
}
&__footer {
display: flex;
align-items: center;
justify-content: center;
height: 100rpx;
padding: 0 30rpx;
background-color: #fff;
.inner {
display: inline-flex;
border: 2rpx solid $cl-color-primary;
background-color: $cl-color-primary;
border-radius: 80rpx;
overflow: hidden;
flex: 1;
button {
height: 64rpx;
line-height: 64rpx;
padding: 0;
margin: 0;
font-size: 28rpx;
flex: 1;
background-color: transparent;
color: #fff;
&::after {
border: 0;
}
&.reset {
color: $cl-color-primary;
background-color: #fff;
border-radius: 80rpx 0 80rpx 80rpx;
}
}
}
}
}
</style>

View File

@ -0,0 +1,90 @@
<template>
<view class="goods-comment">
<cl-row type="flex" justify="space-between" :height="90">
<cl-text :size="28" bold :value="`商品评论(${total}`"></cl-text>
<cl-text :size="24" color="info" @tap="toAll">
全部
<text class="cl-icon-arrow-right"></text>
</cl-text>
</cl-row>
<view class="list">
<view class="item" v-for="item in list" :key="item.id" @tap="toAll">
<comment-item :item="item" demo />
</view>
<cl-empty
text="暂无评论"
icon="message"
:icon-size="200"
:fixed="false"
:padding="[30, 0, 130, 0]"
v-if="isEmpty(list)"
/>
</view>
</view>
</template>
<script lang="ts" setup>
import { onMounted, ref, type PropType } from "vue";
import { useCool } from "/@/cool";
import { isEmpty } from "lodash-es";
import CommentItem from "./comment-item.vue";
const props = defineProps({
info: Object as PropType<Eps.GoodsInfoEntity>,
});
const { service, router } = useCool();
const list = ref<Eps.GoodsCommentEntity[]>([]);
const total = ref(0);
function refresh() {
service.goods.comment
.page({
goodsId: router.query.id,
page: 1,
size: 2,
order: "createTime",
sort: "desc",
})
.then((res) => {
list.value = res.list;
total.value = res.pagination.total || 0;
});
}
function toAll() {
router.push({
path: "/pages/goods/comment",
query: {
goodsId: router.query.id,
},
});
}
onMounted(() => {
refresh();
});
</script>
<style lang="scss" scoped>
.goods-comment {
position: relative;
background-color: #fff;
padding: 0 30rpx 24rpx 30rpx;
margin-bottom: 24rpx;
.list {
.item {
margin-bottom: 24rpx;
&:last-child {
margin-bottom: 0;
}
}
}
}
</style>

View File

@ -0,0 +1,36 @@
<template>
<view class="goods-detail">
<cl-row type="flex" :height="90" :padding="[0, 30, 0, 30]">
<cl-text :size="28" bold value="商品详情"></cl-text>
</cl-row>
<view class="mp-html" v-if="info?.content">
<mp-html :content="info?.content"></mp-html>
</view>
<cl-empty
icon="comm"
text="暂无描述"
:icon-size="200"
:fixed="false"
:padding="[30, 0, 130, 0]"
v-else
/>
</view>
</template>
<script lang="ts" setup>
import { type PropType } from "vue";
defineProps({
info: Object as PropType<Eps.GoodsInfoEntity>,
});
</script>
<style lang="scss" scoped>
.goods-detail {
position: relative;
background-color: #fff;
margin-bottom: 24rpx;
}
</style>

View File

@ -0,0 +1,158 @@
<template>
<view class="goods-info">
<view class="banner">
<swiper class="cl-banner" indicator-dots>
<swiper-item v-for="(item, index) in banner" :key="index">
<image class="cl-banner-item__image" mode="aspectFill" :src="item" />
</swiper-item>
</swiper>
</view>
<view class="content">
<!-- 售价 -->
<cl-row type="flex" justify="space-between" :padding="[20, 0, 20, 0]">
<cl-skeleton
:height="54"
:loading="!info"
:loading-style="{
width: '200rpx',
}"
>
<cl-text
type="price"
bold
color="error"
:size="50"
:value="spec.info?.price || info?.price"
/>
</cl-skeleton>
<cl-skeleton
:loading-style="{
height: '38rpx',
width: '90rpx',
borderRadius: '38rpx',
}"
:loading="!info"
>
<cl-tag round size="small">已售 {{ info?.sold || 0 }}</cl-tag>
</cl-skeleton>
</cl-row>
<!-- 优惠券 -->
<cl-row :margin="[0, 0, 20, 0]">
<coupon-get />
</cl-row>
<!-- 标题 -->
<cl-skeleton
:margin="[0, 0, 20, 0]"
:loading-style="{
height: '48rpx',
}"
:loading="!info"
>
<cl-text bold block :line-height="1.5" :size="32">
{{ info?.title }} {{ spec.info?.name }}
</cl-text>
</cl-skeleton>
<!-- 其他 -->
<view class="menu">
<!-- 规格 -->
<view
class="item"
@tap="
spec.open({
action: 'select',
goods: info!,
})
"
>
<cl-icon class-name="shop-icon-sku" :size="52" :margin="[0, 10, 0, 0]" />
<cl-text :ellipsis="1">
{{ spec.text }}
</cl-text>
<text
class="cl-icon-close-border"
@tap.stop="spec.clear"
v-if="!!spec.info"
></text>
</view>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import { computed, type PropType } from "vue";
import { useUpload } from "/@/cool";
import { useSpec } from "/@/hooks";
import { isEmpty } from "lodash-es";
import CouponGet from "/@/components/coupon/get.vue";
const props = defineProps({
info: Object as PropType<Eps.GoodsInfoEntity>,
});
const { resizeImage } = useUpload();
const spec = useSpec();
//
const banner = computed(() => {
//
if (!isEmpty(spec.info?.images)) {
return spec.info?.images;
}
//
if (!isEmpty(props.info?.pics)) {
return props.info?.pics;
}
//
return [props.info?.mainPic];
});
</script>
<style lang="scss" scoped>
.goods-info {
margin-bottom: 24rpx;
.banner {
.cl-banner {
height: 760rpx;
.cl-banner-item__image {
border-radius: 0;
}
}
}
.content {
padding: 10rpx 30rpx;
background-color: #fff;
.menu {
.item {
display: flex;
align-items: center;
height: 80rpx;
background-color: $cl-color-bg;
padding: 0 16rpx;
border-radius: 12rpx;
margin-bottom: 20rpx;
padding-right: 60rpx;
position: relative;
.cl-icon-close-border {
position: absolute;
right: 20rpx;
color: $cl-color-info;
}
}
}
}
}
</style>

250
pages/goods/detail.vue Normal file
View File

@ -0,0 +1,250 @@
<template>
<cl-page>
<view class="page">
<cl-sticky>
<cl-topbar :border="false">
<cl-tabs
v-model="tabs.active"
:list="tabs.list"
justify="center"
un-color="#777"
@change="tabs.onChange"
v-if="tabs.scrollTop >= 200"
/>
<view class="cl-topbar__text" v-else>
<text class="cl-topbar__title">商品详情</text>
</view>
</cl-topbar>
</cl-sticky>
<!-- 商品信息 -->
<goods-info :info="info" />
<!-- 商品评论 -->
<goods-comment :info="info" />
<!-- 商品详情 -->
<goods-detail :info="info" />
<!-- 商品规格 -->
<goods-spec :ref="setRefs('goodsSpec')" />
<cl-footer border :flex="false" :padding="0">
<view class="footer">
<view class="icons">
<view class="item" @tap="toCs">
<cl-icon
class-name="shop-icon-message"
:size="56"
:margin="[0, 0, 8, 0]"
/>
<cl-text :size="22" value="客服" />
</view>
<view class="item" @tap="router.push('/pages/index/shopping-cart')">
<cl-badge :value="spCart.num" :margin="[10, 6, 0, 0]">
<cl-icon
class-name="shop-icon-q-order"
:size="56"
:margin="[0, 0, 8, 0]"
/>
</cl-badge>
<cl-text :size="22" value="购物车" />
</view>
</view>
<cl-button :disabled="!info" @tap="openSpec('spCart')">加入购物车</cl-button>
<cl-button :disabled="!info" type="primary" @tap="openSpec('buy')"
>立即购买</cl-button
>
</view>
</cl-footer>
</view>
</cl-page>
</template>
<script lang="ts" setup>
import { reactive, ref } from "vue";
import { useCool } from "/@/cool";
import { onPageScroll, onReady } from "@dcloudio/uni-app";
import { last } from "lodash-es";
import { useShoppingCart, useSpec } from "/@/hooks";
import GoodsInfo from "./components/goods-info.vue";
import GoodsComment from "./components/goods-comment.vue";
import GoodsDetail from "./components/goods-detail.vue";
import GoodsSpec from "/@/components/goods/spec.vue";
const { service, router, refs, setRefs } = useCool();
const spec = useSpec();
const spCart = useShoppingCart();
const { statusBarHeight = 0 } = uni.getSystemInfoSync();
//
const tabs = reactive({
scrollTop: 0,
active: "info",
list: [
{
label: "信息",
value: "info",
top: 0,
},
{
label: "评论",
value: "comment",
top: 0,
},
{
label: "详情",
value: "detail",
top: 0,
},
],
getSelector(name: string) {
let selector = `.goods-${name}`;
// #ifdef MP
selector = ".page >>> " + selector;
// #endif
return selector;
},
async getTop() {
return Promise.all(
tabs.list.map((e) => {
return new Promise((resolve) => {
uni.createSelectorQuery()
.select(tabs.getSelector(e.value))
.boundingClientRect((res) => {
e.top = res.top || 0 - 44;
resolve(true);
})
.exec();
});
}),
);
},
lock: null as any,
async onScroll(top: number) {
tabs.scrollTop = top;
if (!tabs.lock) {
if (!last(tabs.list)?.top) {
await tabs.getTop();
}
const d = last(tabs.list.filter((e) => top + 44 >= e.top));
if (d) {
tabs.active = d.value;
}
}
},
onChange(name: string) {
if (tabs.lock) {
clearTimeout(tabs.lock);
}
uni.createSelectorQuery()
.select(tabs.getSelector(name))
.boundingClientRect((res) => {
uni.pageScrollTo({
scrollTop: (res.top || 0) - statusBarHeight - 44 + tabs.scrollTop,
duration: 200,
});
tabs.lock = setTimeout(() => {
tabs.lock = null;
}, 500);
})
.exec();
},
});
//
const info = ref<Eps.GoodsInfoEntity>();
//
async function refresh() {
await service.goods.info
.info({
id: router.query.id,
})
.then((res) => {
info.value = res;
});
}
//
function toCs() {
router.push({
path: "/uni_modules/cool-cs/pages/chat",
query: {
goodsId: info.value?.id,
},
});
}
//
function openSpec(action: any) {
spec.open({
action,
goods: info.value!,
});
}
onReady(async () => {
const { id, specId } = router.query || {};
//
spec.clear();
//
await refresh();
// specId
if (specId) {
//
spec.setId(specId);
//
refs.goodsSpec.refresh({
goodsId: id,
});
}
});
onPageScroll((e) => {
tabs.onScroll(e.scrollTop);
});
</script>
<style lang="scss" scoped>
.page {
.footer {
display: flex;
align-items: center;
padding: 24rpx 24rpx 24rpx 0;
.icons {
display: flex;
flex: 1;
.item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 120rpx;
}
}
}
}
</style>

84
pages/goods/list.vue Normal file
View File

@ -0,0 +1,84 @@
<template>
<cl-page>
<view class="page">
<cl-sticky>
<!-- 搜索栏 -->
<cl-topbar :border="false" :show-back="false" :ref="setRefs('topbar')" with-mp>
<cl-search
v-model="keyWord"
placeholder="搜索商品名称"
@search="refresh({ page: 1 })"
>
<template #prepend>
<cl-icon name="arrow-left" size="22px" @tap="refs.topbar?.back" />
</template>
</cl-search>
</cl-topbar>
<!-- 筛选栏 -->
<filter-bar @search="refresh" />
</cl-sticky>
<!-- 商品列表 -->
<view class="list">
<cl-waterfall :ref="setRefs('list')" :column="2">
<template #default="{ list }">
<cl-waterfall-column v-for="(columns, index) in list" :key="index">
<goods-item :item="item" v-for="item in columns" :key="item.id" />
</cl-waterfall-column>
</template>
</cl-waterfall>
</view>
</view>
</cl-page>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { onReady } from "@dcloudio/uni-app";
import { useCool, usePager } from "/@/cool";
import GoodsItem from "/@/components/goods/item.vue";
import FilterBar from "./components/filter-bar.vue";
const { router, service, refs, setRefs } = useCool();
const { onRefresh, onData } = usePager();
const keyWord = ref("");
//
function refresh(params?: any) {
const { data, next } = onRefresh(params);
onData((list) => {
if (data.page == 1) {
refs.list.refresh(list);
} else {
refs.list.append(list);
}
});
return next(
service.goods.info.page({
...data,
keyWord: keyWord.value,
}),
);
}
onReady(() => {
keyWord.value = router.query.keyWord || "";
refresh({ order: "sortNum", sort: "desc", ...router.query });
});
defineExpose({
refresh,
});
</script>
<style lang="scss" scoped>
.page {
.list {
padding: 24rpx 14rpx;
}
}
</style>

164
pages/goods/search.vue Normal file
View File

@ -0,0 +1,164 @@
<template>
<cl-page status-bar-background="transparent">
<cl-topbar
background-color="transparent"
:border="false"
:show-back="false"
:ref="setRefs('topbar')"
with-mp
>
<cl-search v-model="keyWord" placeholder="搜索商品名称" @search="toSearch(keyWord)">
<template #prepend>
<cl-icon name="arrow-left" size="22px" @tap="refs.topbar?.back" />
</template>
</cl-search>
</cl-topbar>
<view class="page">
<view class="history">
<view class="head">
<text class="label">搜索历史</text>
<cl-icon class-name="shop-icon-delete" :size="36" @tap="history.clear()" />
</view>
<view class="list">
<text
class="item"
v-for="(item, index) in history.list"
:key="index"
@tap="toSearch(item)"
>
{{ item }}
</text>
<text class="item" v-if="isEmpty(history.list)"></text>
</view>
</view>
<view class="search">
<view class="head">
<text class="label">搜索发现</text>
</view>
<view class="list">
<text
class="item"
v-for="(item, index) in recommend.list"
:key="index"
@tap="toSearch(item.name!)"
>
{{ item.name }}
</text>
</view>
</view>
</view>
</cl-page>
</template>
<script lang="ts" setup>
import { reactive, ref } from "vue";
import { storage, useCool } from "/@/cool";
import { isEmpty } from "lodash-es";
import { onReady } from "@dcloudio/uni-app";
import { useUi } from "/$/cool-ui";
const { router, service, refs, setRefs } = useCool();
const ui = useUi();
//
const keyWord = ref("");
//
const history = reactive({
list: (storage.get("search.history") || []) as string[],
add() {
if (!keyWord.value || history.list.includes(keyWord.value)) {
return false;
}
history.list.push(keyWord.value);
history.update();
},
clear() {
ui.showConfirm({
title: "提示",
message: "确定要清空搜索历史吗?",
callback(action) {
if (action == "confirm") {
history.list = [];
history.update();
}
},
});
},
update() {
storage.set("search.history", history.list);
},
});
//
const recommend = reactive({
list: [] as Eps.GoodsSearchKeywordEntity[],
get() {
service.goods.searchKeyword.list().then((res) => {
recommend.list = res;
});
},
});
//
function toSearch(val: string) {
history.add();
router.push({
path: "/pages/goods/list",
query: {
keyWord: val,
},
});
}
onReady(() => {
recommend.get();
});
</script>
<style lang="scss" scoped>
.page {
.history,
.search {
padding: 0 24rpx;
margin-bottom: 24rpx;
.head {
display: flex;
align-items: center;
justify-content: space-between;
height: 80rpx;
.label {
display: inline-block;
font-size: 28rpx;
font-weight: bold;
}
}
.list {
.item {
display: inline-flex;
align-items: center;
height: 46rpx;
padding: 0 24rpx;
margin: 0 24rpx 24rpx 0;
font-size: 24rpx;
border-radius: 40rpx;
background-color: #fff;
}
}
}
}
</style>

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

@ -0,0 +1,243 @@
<template>
<cl-page background-color="#fff" fullscreen>
<view class="page-category">
<view class="menu-a">
<scroll-view class="scroller" scroll-y>
<view class="list">
<view
class="item"
v-for="(item, index) in list"
:key="index"
:class="{
'is-active': index == active,
'is-prev': index == active - 1,
'is-next': index == active + 1,
}"
@tap="toView(index)"
>
{{ item.name }}
</view>
<view class="flex1"></view>
</view>
</scroll-view>
</view>
<view class="menu-b">
<scroll-view
class="scroller"
scroll-y
:scroll-into-view="view"
scroll-with-animation
enable-back-to-top
@scroll="onScroll"
>
<view
class="group"
v-for="(group, index) in list"
:key="group.id"
:id="`group-${index}`"
>
<view class="label">
<cl-divider background-color="#ffffff">{{ group.name }}</cl-divider>
</view>
<view class="list">
<cl-row>
<cl-col :span="8" v-for="item in group.children" :key="item.id">
<view class="item" @tap="toSearch(item)">
<cl-image
mode="aspectFit"
:size="120"
:radius="24"
:src="resizeImage(item.pic, 200)"
:margin="[0, 0, 20, 0]"
/>
<cl-text :size="24" :ellipsis="1" :value="item.name" />
</view>
</cl-col>
</cl-row>
</view>
</view>
</scroll-view>
</view>
</view>
<tabbar />
</cl-page>
</template>
<script lang="ts" setup>
import Tabbar from "./components/tabbar.vue";
import { useCool, useUpload } from "/@/cool";
import { useUi } from "/$/cool-ui";
import { deepTree } from "/@/cool/utils";
import { ref } from "vue";
import { onPullDownRefresh, onReady } from "@dcloudio/uni-app";
const { service, router } = useCool();
const { resizeImage } = useUpload();
const ui = useUi();
const list = ref<Eps.GoodsTypeEntity[]>();
const view = ref("");
const active = ref(0);
//
async function refresh() {
ui.showLoading();
await service.goods.type.list().then((res) => {
list.value = deepTree(res);
list.value.reduce((a, b) => {
b.top = uni.upx2px(Math.ceil((b.children || []).length / 3) * 224 + 80) + a;
return b.top;
}, 0);
});
ui.hideLoading();
}
//
let lock: any;
function clearLock() {
if (lock) {
clearTimeout(lock);
lock = null;
}
}
//
function onScroll(e: { detail: { scrollTop: number } }) {
if (!lock) {
list.value?.find((a, i) => {
if (a.top >= e.detail.scrollTop) {
active.value = i;
return true;
}
});
}
}
//
function toView(index: number) {
clearLock();
active.value = index;
view.value = `group-${index}`;
lock = setTimeout(() => {
clearLock();
}, 500);
}
//
function toSearch(item: Eps.GoodsTypeEntity) {
router.push({
path: "/pages/goods/list",
query: {
typeId: item.id,
},
});
}
onPullDownRefresh(async () => {
await refresh();
uni.stopPullDownRefresh();
});
onReady(() => {
refresh();
});
</script>
<style lang="scss" scoped>
.page-category {
display: flex;
height: calc(100% - 120rpx);
.scroller {
height: 100%;
}
.menu-a {
width: 180rpx;
flex-shrink: 0;
overflow: hidden;
border-radius: 0 24rpx 0 0;
.list {
display: flex;
flex-direction: column;
height: 100%;
.flex1 {
background-color: #f7f7f7;
flex: 1;
}
}
.item {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
padding: 30rpx 0;
font-size: 26rpx;
position: relative;
color: $cl-color-placeholder;
background-color: #f7f7f7;
&.is-active {
background-color: #fff;
color: $cl-color-primary;
font-weight: 500;
&::after {
content: "";
height: 34rpx;
width: 6rpx;
background-color: currentColor;
border-radius: 10rpx;
position: absolute;
left: 0;
top: calc(50% - 17rpx);
}
& + .flex1 {
border-top-right-radius: 24rpx;
}
}
&.is-prev {
border-bottom-right-radius: 24rpx;
}
&.is-next {
border-top-right-radius: 24rpx;
}
}
}
.menu-b {
flex: 1;
.group {
.label {
padding: 0 30rpx;
}
.list {
.item {
display: flex;
flex-direction: column;
align-items: center;
padding: 10rpx;
margin-bottom: 40rpx;
}
}
}
}
}
</style>

View File

@ -0,0 +1,57 @@
<template>
<view class="banner">
<cl-banner :list="list" type="card" :height="300" @select="toPath">
<template #item="{ item }">
<cl-skeleton
height="100%"
:loading="!item.pic"
:loading-style="{
borderRadius: '24rpx',
}"
>
<image mode="aspectFill" :src="item.pic" />
</cl-skeleton>
</template>
</cl-banner>
</view>
</template>
<script lang="ts" setup>
import { onShow } from "@dcloudio/uni-app";
import { ref } from "vue";
import { useCool } from "/@/cool";
const { service, router } = useCool();
const list = ref<Eps.InfoBannerEntity[]>([{}, {}, {}]);
function refresh() {
service.info.banner.list({ order: "sortNum", sort: "desc" }).then((res) => {
list.value = res;
});
}
function toPath(index: number) {
const { path } = list.value[index];
if (path) {
router.push(path);
}
}
onShow(() => {
refresh();
});
</script>
<style lang="scss" scoped>
.banner {
margin: 32rpx 0;
image {
height: 100%;
width: 100%;
border-radius: 24rpx;
}
}
</style>

View File

@ -0,0 +1,179 @@
<template>
<view class="coupon-activity" @tap="toGet" v-if="info">
<view class="a">
<view class="text">
<text class="name">{{ info?.title }}</text>
<text class="desc">{{ info?.description }}</text>
</view>
<text class="tag" v-if="info">点击领券</text>
</view>
<view class="b">
<template v-if="info">
<text class="amount">{{ info?.amount || 0 }}</text>
<text class="doc">
{{ doc }}
</text>
</template>
</view>
<view class="c"></view>
<text class="d">* 更多精选尽在酷卖</text>
</view>
</template>
<script lang="ts" setup>
import { onShow } from "@dcloudio/uni-app";
import { computed, ref } from "vue";
import { useCool } from "/@/cool";
import { useUi } from "/$/cool-ui";
const { service } = useCool();
const ui = useUi();
//
const info = ref<CouponInfo>();
// 使
const doc = computed(() => {
const { type, condition } = info.value || {};
switch (type) {
case 0:
return `${condition?.fullAmount}可用`;
}
});
//
function refresh() {
service.market.coupon.info
.page({ size: 1, page: 1, order: "createTime", sort: "asc" })
.then((res) => {
info.value = res.list[0];
info.value.amount = Math.floor(info.value.amount!);
});
}
//
function toGet() {
service.market.coupon.user
.receive({
couponId: info.value?.id,
})
.then(() => {
ui.showToast("领取成功");
})
.catch((err) => {
ui.showToast(err.message);
});
}
onShow(() => {
refresh();
});
</script>
<style lang="scss" scoped>
.coupon-activity {
display: flex;
position: relative;
height: 160rpx;
letter-spacing: 1rpx;
margin: 50rpx 0 100rpx 0;
.a {
background: linear-gradient(140deg, rgba(#2b2e3d, 0.7), #2b2e3d 60%);
height: 140rpx;
width: calc(100% - 250rpx);
border-radius: 12rpx;
position: absolute;
left: 24rpx;
bottom: 1rpx;
z-index: 2;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24rpx;
box-sizing: border-box;
.name {
display: block;
font-size: 28rpx;
font-weight: 500;
}
.desc {
font-size: 20rpx;
color: #ccc;
}
.tag {
padding: 2rpx 10rpx;
border-radius: 4rpx;
background-color: #eb10ab;
color: #fff;
font-size: 20rpx;
margin-right: 16rpx;
}
}
.b {
height: 160rpx;
width: 220rpx;
background-color: #e2e2e2;
box-sizing: border-box;
position: absolute;
right: 24rpx;
bottom: 0;
border-radius: 12rpx;
z-index: 3;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.amount {
font-size: 60rpx;
font-weight: bold;
line-height: 1;
color: #2b2e3d;
&::after {
content: "元";
font-size: 48rpx;
position: relative;
top: -4rpx;
}
}
.doc {
font-size: 22rpx;
color: #666;
}
}
.c {
content: "";
display: block;
height: 40rpx;
width: 40rpx;
background-color: #868686;
border-radius: 40rpx;
z-index: 1;
position: absolute;
right: 216rpx;
top: 2rpx;
}
.d {
font-size: 22rpx;
font-weight: 500;
position: absolute;
bottom: -46rpx;
left: 30rpx;
}
}
</style>

View File

@ -0,0 +1,93 @@
<template>
<view class="hot-types">
<scroll-view scroll-x class="scroller">
<view class="inner">
<view class="item" v-for="item in list" :key="item.id" @tap="toPath(item)">
<cl-skeleton
:height="120"
:width="120"
:margin="[0, 0, 20, 0]"
:loading="!item.id"
:loading-style="{
borderRadius: '100%',
}"
>
<cl-image :radius="120" :src="item.pic" background-color="#fff" />
</cl-skeleton>
<cl-skeleton
:height="28"
:loading="!item.id"
:loading-style="{
width: '90rpx',
borderRadius: '6rpx',
}"
>
<cl-text :size="24" align="center" block>{{ item.name }}</cl-text>
</cl-skeleton>
</view>
</view>
</scroll-view>
</view>
</template>
<script lang="ts" setup>
import { onShow } from "@dcloudio/uni-app";
import { ref } from "vue";
import { useCool } from "/@/cool";
import { deepTree } from "/@/cool/utils";
interface Item extends Eps.GoodsTypeEntity {
children?: Item[];
}
const { service, router } = useCool();
const list = ref<Item[]>([{}, {}, {}, {}, {}]);
function refresh() {
service.goods.type.list({ order: "sortNum", sort: "desc" }).then((res) => {
list.value = deepTree(res);
});
}
function toPath(item: Item) {
if (item.id) {
router.push({
path: "/pages/goods/list",
query: {
typeId: item.children?.map((e) => e.id).join(","),
},
});
}
}
onShow(() => {
refresh();
});
</script>
<style lang="scss" scoped>
.hot-types {
height: 180rpx;
overflow: hidden;
margin: 50rpx 0 50rpx 0;
.scroller {
height: 200rpx;
.inner {
display: flex;
align-items: center;
.item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 24rpx;
}
}
}
}
</style>

View File

@ -0,0 +1,146 @@
<template>
<cl-footer :flex="false" border :z-index="399" :padding="0">
<view class="tabbar">
<view
class="item"
v-for="(item, index) in list"
:key="index"
:class="{
'is-active': item.active,
}"
@tap="toLink(item.pagePath)"
>
<view class="custom" v-if="item.pagePath == 'custom'">
<view class="icon">
<image src="/static/chat.png" mode="aspectFit" />
</view>
</view>
<template v-else>
<view class="icon">
<image :src="item.icon" mode="aspectFit" />
</view>
<text class="label">{{ item.text }}</text>
<view class="badge" v-if="item.number > 0">{{ item.number || 0 }}</view>
</template>
</view>
</view>
</cl-footer>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from "vue";
import { useCool } from "/@/cool";
import { useShoppingCart } from "/@/hooks";
const { router } = useCool();
const spCart = useShoppingCart();
const pagePath = router.path;
const list = computed(() => {
const arr = [...router.tabs];
return arr.map((e, i) => {
const active = pagePath?.includes(e.pagePath);
return {
icon: "/" + (active ? e.selectedIconPath : e.iconPath),
active,
number: i == 2 ? spCart.num : 0,
...e,
};
});
});
function toLink(pagePath: string) {
if (pagePath == "custom") {
// #ifdef H5
location.href = "https://cool-js.com/";
// #endif
} else {
router.push("/" + pagePath);
}
}
uni.hideTabBar();
onMounted(() => {});
</script>
<style lang="scss" scoped>
.tabbar {
display: flex;
height: 120rpx;
width: 100%;
.item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
position: relative;
.icon {
height: 46rpx;
width: 46rpx;
image {
height: 100%;
width: 100%;
}
}
.label {
font-size: 24rpx;
color: #bfbfbf;
}
.badge {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 20rpx;
transform: translateX(20rpx);
font-size: 20rpx;
height: 36rpx;
min-width: 36rpx;
padding: 0 6rpx;
background-color: #f56c6c;
border: 4rpx solid #fff;
color: #fff;
border-radius: 18rpx;
font-weight: bold;
box-sizing: border-box;
}
&.is-active {
.label {
color: $cl-color-primary;
}
}
.custom {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
.icon {
background: linear-gradient(
to bottom right,
#408fff,
#6b69f8,
#a35df2,
#d14bd8,
#e9388a
);
border-radius: 100%;
padding: 16rpx;
}
}
}
}
</style>

147
pages/index/home.vue Normal file
View File

@ -0,0 +1,147 @@
<template>
<cl-page>
<view class="page">
<!-- 搜索 -->
<cl-sticky>
<view class="search-bar" @tap="toSearch">
<view class="search-bar__inner">
<cl-icon name="search" :margin="[0, 12, 0, 0]"> </cl-icon>
<cl-text value="搜索商品名称"></cl-text>
</view>
</view>
</cl-sticky>
<!-- 轮播图 -->
<banner />
<!-- 热门分类 -->
<hot-category />
<!-- 优惠券活动 -->
<coupon-activity />
<!-- 热门推荐 -->
<view class="hot">
<view class="hot__header">
<text class="hot__title">热门推荐</text>
<text class="hot__desc">TOP PICKS</text>
</view>
<view class="hot__container">
<cl-waterfall :ref="setRefs('list')" :column="2">
<template #default="{ list }">
<cl-waterfall-column v-for="(columns, index) in list" :key="index">
<goods-item :item="item" v-for="item in columns" :key="item.id" />
</cl-waterfall-column>
</template>
<template #empty>
<cl-empty :fixed="false" :height="500" />
</template>
</cl-waterfall>
</view>
</view>
<!-- 版本检测 -->
<cl-version-upgrade :ref="setRefs('versionUpgrade')" />
</view>
<tabbar />
</cl-page>
</template>
<script lang="ts" setup>
import { router, useCool, usePager } from "/@/cool";
import { onReady, onShareAppMessage } from "@dcloudio/uni-app";
import Tabbar from "./components/tabbar.vue";
import GoodsItem from "/@/components/goods/item.vue";
import CouponActivity from "./components/coupon-activity.vue";
import HotCategory from "./components/hot-category.vue";
import Banner from "./components/banner.vue";
const { service, refs, setRefs } = useCool();
const { onRefresh, onData } = usePager();
//
function refresh(params?: any) {
const { data, next } = onRefresh(params, { loading: false });
onData((list) => {
if (data.page == 1) {
refs.list.refresh(list);
} else {
refs.list.append(list);
}
});
return next(service.goods.info.page(data));
}
//
function toSearch() {
router.push("/pages/goods/search");
}
onReady(() => {
refresh({ order: "sortNum", sort: "desc" });
// #ifdef APP
refs.versionUpgrade?.check();
// #endif
});
onShareAppMessage(() => {
return {
title: "能用钱解决的事,就不要客气",
path: "/pages/index/home",
};
});
defineExpose({
refresh,
});
</script>
<style lang="scss" scoped>
.page {
.search-bar {
background-color: #fff;
padding: 24rpx;
&__inner {
display: flex;
align-items: center;
height: 70rpx;
width: 100%;
border-radius: 70rpx;
background-color: #f7f7f7;
box-sizing: border-box;
padding: 0 24rpx;
}
}
.hot {
&__header {
display: flex;
flex-direction: column;
align-items: center;
margin: 40rpx 0;
}
&__title {
font-size: 32rpx;
margin-bottom: 10rpx;
}
&__desc {
font-size: 22rpx;
color: #999;
letter-spacing: 5rpx;
}
&__container {
padding: 0 14rpx;
}
}
}
</style>

298
pages/index/my.vue Normal file
View File

@ -0,0 +1,298 @@
<template>
<cl-page status-bar-background="#2b2e3d">
<view class="page-my">
<view class="bg"></view>
<view class="container">
<view class="info">
<!-- 头像 -->
<view class="avatar">
<cl-avatar :size="100" :src="user.info?.avatarUrl" />
</view>
<view class="det">
<!-- 昵称 -->
<cl-text :size="36" bold block color="#fff" @tap="toLogin">
{{ user.info?.nickName || "-" }}
</cl-text>
<!-- 手机号 -->
<button
class="phone"
:open-type="user.info?.phone ? '' : 'getPhoneNumber'"
@getphonenumber="toBindPhone"
>
<cl-text color="info">手机号</cl-text>
<cl-text color="info">
{{ user.info?.phone || "未绑定,点击获取" }}
</cl-text>
</button>
</view>
<!-- 设置 -->
<!-- #ifndef MP-WEIXIN -->
<text class="cl-icon-set" @tap="router.push('/pages/user/set')"></text>
<!-- #endif -->
</view>
<!-- 我的订单 -->
<view class="order">
<view class="label">
<cl-text :size="28" bold>我的订单</cl-text>
<view class="more" @tap="order.toList()">
<cl-text color="info" :size="24"> 全部 </cl-text>
<cl-icon color="info" name="arrow-right"></cl-icon>
</view>
</view>
<view class="status">
<view
class="item"
v-for="(item, index) in order.types"
:key="index"
@tap="order.toList(item.value)"
>
<cl-badge
:value="item.number"
:max="99"
:margin="14"
type="error"
plain
>
<cl-icon :class-name="item.icon" :size="70" color="primary" />
</cl-badge>
<cl-text color="info" :size="24" :margin="[10, 0, 0, 0]">
{{ item.label }}
</cl-text>
</view>
</view>
</view>
<!-- 菜单 -->
<cl-list :radius="24" :border="false">
<cl-list-item label="我的优惠券" @tap="router.push('/pages/market/coupon')">
<template #icon>
<cl-icon class-name="shop-icon-coupon" :size="50" />
</template>
</cl-list-item>
<cl-list-item
label="在线客服"
@tap="router.push('/uni_modules/cool-cs/pages/chat')"
>
<template #icon>
<cl-icon class-name="shop-icon-custom" :size="50" />
</template>
</cl-list-item>
<cl-list-item label="收货地址" @tap="router.push('/pages/user/address-list')">
<template #icon>
<cl-icon class-name="shop-icon-location" :size="50" />
</template>
</cl-list-item>
</cl-list>
<!-- 设置 -->
<cl-list :radius="24" :border="false">
<cl-list-item label="设置" @tap="router.push('/pages/user/set')">
<template #icon>
<cl-icon class-name="shop-icon-set" :size="50" />
</template>
</cl-list-item>
</cl-list>
</view>
</view>
<tabbar />
</cl-page>
</template>
<script lang="ts" setup>
import { reactive } from "vue";
import { useCool, useStore, useWx } from "/@/cool";
import { useUi } from "/$/cool-ui";
import { onShow } from "@dcloudio/uni-app";
import Tabbar from "./components/tabbar.vue";
const { service, router } = useCool();
const { user } = useStore();
const ui = useUi();
const wx = useWx();
//
const order = reactive({
types: [
{
label: "待付款",
icon: "shop-icon-order-paid",
value: 0,
number: 0,
},
{
label: "待发货",
icon: "shop-icon-order-not-shipped",
value: 1,
number: 0,
},
{
label: "待收货",
icon: "shop-icon-order-received",
value: 2,
number: 0,
},
{
label: "待评价",
icon: "shop-icon-order-comment",
value: 3,
number: 0,
},
{
label: "退款/售后",
icon: "shop-icon-order-refund",
value: "5,6",
number: 0,
},
],
//
toList(status?: string | number) {
router.push({
path: "/pages/order/list",
query: {
status,
},
});
},
//
refresh() {
service.order.info.userCount().then((res) => {
order.types[0].number = res["待付款"];
order.types[1].number = res["待发货"];
order.types[2].number = res["待收货"];
order.types[3].number = res["待评价"];
order.types[4].number = Number(res["退款中"]) + Number(res["已退款"]);
});
},
});
//
function toLogin() {
if (!user.token) {
user.logout();
}
}
//
function toBindPhone(e: { detail: any }) {
service.user.info
.miniPhone({
...e.detail,
code: wx.code.value,
})
.then((phone) => {
ui.showToast("手机号绑定成功");
user.update({
phone,
});
})
.catch((err) => {
ui.showToast(err.message);
});
}
onShow(() => {
order.refresh();
toLogin();
});
</script>
<style lang="scss" scoped>
.page-my {
position: relative;
overflow: hidden;
.bg {
position: absolute;
left: 0;
top: 0;
height: 700rpx;
width: 100%;
background: linear-gradient(to bottom, #2b2e3d, rgba(#2b2e3d, 0.8), #f6f7fa);
}
.container {
padding: 0 24rpx;
position: relative;
.info {
display: flex;
align-items: center;
position: relative;
padding: 40rpx 10rpx;
.avatar {
margin-right: 30rpx;
}
.phone {
display: inline-block;
border: 0;
padding: 0;
margin: 0;
line-height: normal;
font-size: 24rpx;
margin-top: 14rpx;
background-color: transparent;
&::after {
border: 0;
}
}
.cl-icon-set {
position: absolute;
right: 10rpx;
top: 34rpx;
font-size: 40rpx;
color: #fff;
}
}
.order {
padding: 24rpx;
margin-bottom: 24rpx;
border-radius: 24rpx;
background-color: #ffffff;
.label {
display: flex;
justify-content: space-between;
margin-bottom: 12rpx;
.more {
display: flex;
align-items: center;
}
}
.status {
display: flex;
padding: 24rpx 0;
.item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
position: relative;
}
}
}
}
}
</style>

View File

@ -0,0 +1,239 @@
<template>
<cl-page status-bar-background="#f6f7fa">
<view class="page">
<cl-sticky>
<cl-topbar
title="购物车"
:border="false"
:show-back="false"
background-color="#f6f7fa"
>
<template #prepend>
<cl-row :margin="[0, 0, 0, 30]" v-if="!isEmpty(spCart.list)">
<cl-text
value="完成"
@tap="op.command('delete-confirm')"
v-if="op.mode == 'delete'"
/>
<cl-text value="管理" @tap="op.change('delete')" v-else />
</cl-row>
</template>
</cl-topbar>
</cl-sticky>
<view class="list">
<template v-if="!isEmpty(spCart.list)">
<goods-group :data="spCart.list" spec-edit show-checkbox @spec="toEdit">
<template #count="{ item }">
<cl-input-number
v-model="item.count"
:min="0"
:max="item.spec?.stock"
@change="onStockChange(item)"
/>
</template>
</goods-group>
</template>
<cl-empty
icon="spopping-cart"
text="购物车竟然是空的"
height="calc(100% - 120rpx)"
v-else
>
<cl-button round type="primary" @tap="router.push('/pages/goods/list')"
>去逛逛</cl-button
>
</cl-empty>
</view>
<!-- 底部操作按钮 -->
<cl-footer
:flex="false"
:bottom="120"
:height="104"
padding="20rpx 24rpx"
v-if="!isEmpty(spCart.list)"
>
<cl-row type="flex">
<cl-checkbox round :model-value="selectAll" @change="onSelectAllChange">
全选
</cl-checkbox>
<view class="flex1" />
<template v-if="op.mode == 'settle'">
<cl-text value="合计:" />
<cl-text type="price" color="error" :value="settleAmount" :size="40" />
<cl-button round type="primary" :margin="[0, 0, 0, 20]" @tap="toSettle"
>去结算</cl-button
>
</template>
<template v-else-if="op.mode == 'delete'">
<cl-button round @tap="toDel()">删除</cl-button>
</template>
</cl-row>
</cl-footer>
</view>
<!-- 商品规格 -->
<goods-spec />
<tabbar />
</cl-page>
</template>
<script lang="ts" setup>
import { computed, reactive } from "vue";
import { useShoppingCart, useSpec } from "/@/hooks";
import { assign, isEmpty } from "lodash-es";
import { useCool } from "/@/cool";
import { useUi } from "/$/cool-ui";
import GoodsSpec from "/@/components/goods/spec.vue";
import GoodsGroup from "/@/components/goods/group.vue";
import Tabbar from "./components/tabbar.vue";
type OpMode = "settle" | "delete";
const { router } = useCool();
const spCart = useShoppingCart();
const spec = useSpec();
const ui = useUi();
//
const op = reactive({
mode: "settle" as OpMode,
change(key: OpMode) {
op.mode = key;
},
command(key: string) {
switch (key) {
case "delete-confirm":
op.done();
break;
}
},
done() {
op.mode = "settle";
},
});
//
const settleAmount = computed(() => {
return spCart.list
.filter((e) => e.checked)
.reduce((a, b) => {
return a + b.count * (b.spec.price || 0);
}, 0);
});
//
const selectAll = computed(() => {
return !isEmpty(spCart.list) && !spCart.list.find((e) => !e.checked);
});
//
function onSelectAllChange(val?: boolean) {
spCart.list.forEach((e) => {
e.checked = val;
});
}
//
function toEdit(item: OrderGoods) {
spec.open({
action: "edit",
goods: item.goodsInfo,
specId: item.spec.id,
count: item.count,
callback(action) {
if (action == "edit") {
//
if (spec.info?.id == item.spec.id) {
assign(item, spec.info);
assign(item.goodsInfo, spec.goods);
item.count = spec.num;
}
//
else {
//
spCart.del(item.id!);
//
spCart.add({
count: spec.num,
spec: spec.info!,
goodsInfo: spec.goods!,
});
}
}
},
});
}
//
function toDel() {
const ids = spCart.list.filter((e) => e.checked).map((e) => e.id);
if (isEmpty(ids)) {
ui.showToast("请先选择商品");
} else {
ui.showConfirm({
title: "提示",
message: "确定删除选中商品吗?",
callback(action) {
if (action == "confirm") {
ids.forEach((id) => {
spCart.del(id!);
});
}
},
});
}
}
//
function toSettle() {
const buyList = spCart.list.filter((e) => e.checked);
if (isEmpty(buyList)) {
ui.showToast("请先选择商品");
} else {
router.push({
path: "/pages/order/submit",
params: {
buyList,
},
});
}
}
//
function onStockChange(item: OrderGoods) {
if (item.count == 0) {
ui.showConfirm({
title: "提示",
message: "确定删除该商品吗?",
callback(action) {
if (action == "confirm") {
spCart.del(item.id!);
} else {
item.count = 1;
}
},
});
}
}
</script>
<style lang="scss" scoped>
.list {
padding: 0 24rpx;
}
</style>

40
pages/market/coupon.vue Normal file
View File

@ -0,0 +1,40 @@
<template>
<cl-page background-color="#fff">
<view class="page">
<view class="list">
<coupon-item v-for="item in list" :key="item.id" :item="item" />
</view>
<cl-empty icon="coupon" v-if="isEmpty(list)" />
</view>
</cl-page>
</template>
<script lang="ts" setup>
import { onReady } from "@dcloudio/uni-app";
import { useCool, usePager } from "/@/cool";
import CouponItem from "/@/components/coupon/item/index.vue";
import { isEmpty } from "lodash-es";
const { service } = useCool();
const { list, onRefresh } = usePager();
function refresh(params?: any) {
const { data, next } = onRefresh(params);
next(service.market.coupon.user.page(data));
}
onReady(() => {
refresh();
});
defineExpose({
refresh,
});
</script>
<style lang="scss" scoped>
.page {
padding: 24rpx;
}
</style>

99
pages/order/comment.vue Normal file
View File

@ -0,0 +1,99 @@
<template>
<cl-page background-color="#fff">
<view class="page">
<view class="star">
<cl-text block :margin="[0, 0, 24, 0]">{{ text }}</cl-text>
<cl-rate v-model="form.starCount" />
</view>
<cl-textarea
v-model="form.content"
:height="300"
count
:radius="24"
:border="false"
:maxlength="300"
:margin="[24, 0, 24, 0]"
background-color="#f6f7fa"
placeholder="请输入您的评论内容"
/>
<cl-upload :ref="setRefs('upload')" v-model="form.pics" multiple :size="[200, 200]" />
</view>
<cl-footer>
<cl-button custom type="primary" :loading="loading" @tap="submit">提交</cl-button>
</cl-footer>
</cl-page>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from "vue";
import { useCool } from "/@/cool";
import { useUi } from "/$/cool-ui";
const { service, router, refs, setRefs } = useCool();
const ui = useUi();
const form = reactive({
content: "",
starCount: 5,
pics: [],
});
//
const loading = ref(false);
//
const text = computed(() => {
return ["太差了!", "不好用!", "还可以!", "推荐!", "强力推荐!"][form.starCount - 1];
});
async function submit() {
const { orderId, goodsId } = router.query;
if (!form.content) {
return ui.showToast("请输入评论内容");
}
if (refs.upload.check()) {
return ui.showToast("图片上传中,请稍等");
}
loading.value = true;
await service.goods.comment
.submit({
orderId,
data: {
orderId,
goodsId,
...form,
},
})
.then(() => {
ui.showTips("评价已提交,感谢您的反馈", () => {
router.back();
});
})
.catch((err) => {
ui.showToast(err.message);
});
loading.value = false;
}
</script>
<style lang="scss" scoped>
.page {
padding: 24rpx;
.star {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 24rpx 0;
}
}
</style>

View File

@ -0,0 +1,113 @@
<template>
<cl-popup
v-model="visible"
direction="bottom"
:border-radius="[32, 32, 0, 0]"
:padding="0"
title="请选择取消订单原因(必选)"
show-close-btn
>
<view class="order-cancel">
<scroll-view class="scroller" scroll-y>
<view class="list">
<cl-radio-group v-model="remark" fill>
<cl-radio
v-for="(item, index) in dict.get('orderCancelReason')"
:key="index"
:label="item.label"
:height="80"
>
{{ item.label }}
</cl-radio>
</cl-radio-group>
</view>
</scroll-view>
<cl-footer :fixed="false" :vt="[visible]">
<cl-button
custom
type="primary"
:loading="loading"
:disabled="!remark"
@tap="submit"
>
提交
</cl-button>
</cl-footer>
</view>
</cl-popup>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { useCool, useStore } from "/@/cool";
import { useUi } from "/$/cool-ui";
const emit = defineEmits(["success"]);
const { service } = useCool();
const { dict } = useStore();
const ui = useUi();
//
const visible = ref(false);
//
const loading = ref(false);
//
const remark = ref();
// id
const orderId = ref();
//
function open(data: OrderInfo) {
visible.value = true;
remark.value = "";
orderId.value = data.id;
}
//
function close() {
visible.value = false;
}
//
async function submit() {
loading.value = true;
await service.order.info
.cancel({
orderId: orderId.value,
remark: remark.value,
})
.then(() => {
ui.showToast("订单取消成功");
emit("success");
close();
})
.catch((err) => {
ui.showToast(err.message);
});
loading.value = false;
}
defineExpose({
open,
close,
});
</script>
<style lang="scss" scoped>
.order-cancel {
.scroller {
max-height: 60vh;
.list {
padding: 0 32rpx;
}
}
}
</style>

View File

@ -0,0 +1,121 @@
<template>
<cl-popup
v-model="visible"
direction="bottom"
:border-radius="[32, 32, 0, 0]"
:padding="0"
title="商品评价"
show-close-btn
>
<view class="order-comment">
<view class="list">
<view class="item" v-for="(item, index) in list" :key="index">
<cl-image
:src="resizeImage(item.mainPic!, 200)"
:size="[140, 140]"
:radius="24"
/>
<cl-row :margin="[0, 0, 0, 32]">
<cl-text
block
bold
:size="28"
:margin="[0, 0, 10, 0]"
:line-height="1.2"
:ellipsis="2"
>
{{ item.title }}
</cl-text>
<cl-button disabled type="info" size="small" v-if="item.isComment">
已评价
</cl-button>
<cl-button type="primary" size="small" @tap="toComment(item)" v-else>
去评价
</cl-button>
</cl-row>
</view>
</view>
</view>
</cl-popup>
</template>
<script lang="ts" setup>
import { computed, nextTick, ref } from "vue";
import { useCool, useUpload } from "/@/cool";
import { uniqBy } from "lodash-es";
const emit = defineEmits(["success"]);
const { router } = useCool();
const { resizeImage } = useUpload();
//
const visible = ref(false);
//
const info = ref<OrderInfo>();
//
const list = computed(() => {
return uniqBy(info.value?.goodsList, "goodsId").map((e) => {
return {
isComment: e.isComment,
...e?.goodsInfo,
};
});
});
//
function open(data: OrderInfo) {
info.value = data;
nextTick(() => {
//
if (list.value.length == 1) {
toComment(list.value[0]);
} else {
visible.value = true;
}
});
}
//
function close() {
visible.value = false;
}
//
function toComment(item: Eps.GoodsInfoEntity) {
close();
router.push({
path: "/pages/order/comment",
query: {
goodsId: item.id,
orderId: info.value?.id,
},
});
}
defineExpose({
open,
close,
});
</script>
<style lang="scss" scoped>
.order-comment {
.list {
padding: 0 32rpx;
.item {
display: flex;
align-items: center;
margin-bottom: 32rpx;
}
}
}
</style>

View File

@ -0,0 +1,41 @@
<template>
<cl-row type="flex" justify="end">
<template v-if="info?.status == 0">
<cl-button @tap.stop="command('cancel')">取消订单</cl-button>
<cl-button type="primary" @tap.stop="command('pay')">立即支付</cl-button>
</template>
<template v-if="[2, 3, 4, 5, 6].includes(info.status!)">
<cl-button @tap.stop="command('logistics')">查看物流</cl-button>
</template>
<template v-if="[1, 2].includes(info.status!)">
<cl-button @tap.stop="command('refund')"> 售后\退款 </cl-button>
</template>
<template v-if="info.status == 2">
<cl-button type="primary" @tap.stop="command('confirm')">确认收货</cl-button>
</template>
<template v-if="[3, 4].includes(info.status!)">
<cl-button type="primary" @tap.stop="command('comment')">评价</cl-button>
</template>
</cl-row>
</template>
<script lang="ts" setup>
import type { PropType } from "vue";
const props = defineProps({
info: {
type: Object as PropType<OrderInfo>,
default: () => ({}),
},
});
const emit = defineEmits(["command"]);
function command(key: string) {
emit("command", key, props.info);
}
</script>

View File

@ -0,0 +1,80 @@
<template>
<view class="order-op">
<order-cancel :ref="setRefs('cancel')" @success="onSuccess" />
<order-pay :ref="setRefs('pay')" @success="onSuccess" />
<order-comment :ref="setRefs('comment')" @success="onSuccess" />
</view>
</template>
<script lang="ts" setup>
import { useUi } from "/$/cool-ui";
import { useCool } from "/@/cool";
import OrderCancel from "./cancel.vue";
import OrderPay from "./pay.vue";
import OrderComment from "./comment.vue";
const emit = defineEmits(["success"]);
const { refs, setRefs, router, service } = useCool();
const ui = useUi();
function open(key: string, data: OrderInfo) {
const orderId = data.id;
if (refs[key]) {
refs[key].open(data);
} else {
switch (key) {
case "confirm":
ui.showConfirm({
title: "确认已收到货",
message: "确认收货后无法发起退款等售后申请,请谨慎操作",
async beforeClose(action, { done, hideLoading, showLoading }) {
if (action == "confirm") {
showLoading();
await service.order.info
.confirm({
orderId,
})
.then(() => {
ui.showToast("确认收货成功");
onSuccess();
})
.catch((err) => {
ui.showToast(err.message);
});
hideLoading();
}
done();
},
});
break;
default:
router.push({
path: `/pages/order/${key}`,
query: {
orderId,
},
});
break;
}
}
}
function close(key: string) {
refs[key].close();
}
function onSuccess(data?: any) {
emit("success", data);
}
defineExpose({
open,
close,
});
</script>

View File

@ -0,0 +1,110 @@
<template>
<cl-popup
v-model="visible"
direction="bottom"
:border-radius="[32, 32, 0, 0]"
:padding="0"
title="支付"
show-close-btn
>
<view class="order-pay">
<cl-row :margin="[16, 0, 32, 32]">
<cl-text :size="22" color="info">合计费用</cl-text>
<cl-text :size="46" bold :margin="[0, 6, 0, 6]">{{ paidAmount }}</cl-text>
<cl-text :size="22" color="info"></cl-text>
</cl-row>
<view class="list">
<cl-radio-group fill v-model="type">
<cl-radio
v-for="item in order.payTypes"
:key="item.key"
:label="item.key"
:height="80"
>
{{ item.label }}
<image class="icon" mode="aspectFill" :src="item.icon" />
</cl-radio>
</cl-radio-group>
</view>
<cl-footer :fixed="false" :vt="[visible]">
<cl-button custom type="primary" @tap="toPay">
确认支付{{ paidAmount }}
</cl-button>
</cl-footer>
</view>
</cl-popup>
</template>
<script lang="ts" setup>
import { computed, ref } from "vue";
import { useOrder } from "/@/hooks";
import { useUi } from "/$/cool-ui";
const emit = defineEmits(["success"]);
const ui = useUi();
const order = useOrder();
//
const visible = ref(false);
//
const type = ref();
//
const info = ref<OrderInfo>();
//
const paidAmount = computed(() => {
return (Number(info.value?.price) - Number(info.value?.discountPrice)).toFixed(2);
});
//
function open(data: OrderInfo) {
visible.value = true;
info.value = data;
type.value = order.payTypes[0].key;
}
//
function close() {
visible.value = false;
}
//
function toPay() {
close();
order
.toPay(info.value?.id!, type.value)
.then(() => {
emit("success");
})
.catch((err) => {
ui.showToast(err.message);
});
}
defineExpose({
open,
close,
});
</script>
<style lang="scss" scoped>
.order-pay {
.list {
padding: 0 32rpx 32rpx 32rpx;
}
.icon {
height: 56rpx;
width: 56rpx;
position: absolute;
right: 0;
top: calc(50% - 28rpx);
}
}
</style>

View File

@ -0,0 +1,23 @@
<template>
<cl-text :size="24" :color="status?.color || 'primary'">{{ status?.label }}</cl-text>
</template>
<script lang="ts" setup>
import { computed, type PropType } from "vue";
import { OrderStatus, RefundStatus } from "../dict";
const props = defineProps({
item: {
type: Object as PropType<OrderInfo>,
default: () => ({}),
},
});
const status = computed(() => {
if (props.item.refund) {
return RefundStatus.find((e) => e.value == props.item.refund?.status);
}
return OrderStatus.find((e) => e.value == props.item.status);
});
</script>

231
pages/order/detail.vue Normal file
View File

@ -0,0 +1,231 @@
<template>
<cl-page>
<cl-sticky>
<cl-topbar>
<cl-row type="flex" justify="center">
<cl-icon :class-name="status?.icon" :size="60" :margin="[0, 6, 0, 0]" />
<cl-text :size="28">{{ status?.desc }}</cl-text>
</cl-row>
</cl-topbar>
</cl-sticky>
<view class="page">
<!-- 收货地址 -->
<address-select :data="info?.address" disabled />
<!-- 商品列表 -->
<goods-group :data="info?.goodsList" readonly />
<view class="card">
<text class="label">价格明细</text>
<cl-list-item
:label="`商品总价(共${info?.goodsList?.length || 0}件商品)`"
:arrow-icon="false"
>
<template #icon>
<cl-icon class-name="shop-icon-q-order" :size="40" />
</template>
<cl-text :size="28" type="price" :value="info?.price" />
</cl-list-item>
<cl-list-item
label="优惠金额"
:arrow-icon="false"
@tap="showDiscount = !showDiscount"
>
<template #icon>
<cl-icon class-name="shop-icon-coupon" :size="40" />
</template>
<cl-text :size="28" color="error">-</cl-text>
<cl-text :size="28" type="price" color="error" :value="info?.discountPrice" />
<cl-icon
name="arrow-bottom"
:margin="[0, 0, 0, 10]"
:size="24"
v-if="info?.discountSource"
/>
</cl-list-item>
<!-- 优惠信息 -->
<view class="discount-info" v-if="showDiscount && info?.discountSource">
{{ info?.discountSource?.info.title }}
{{ info?.discountSource?.info.condition?.fullAmount }} 元减
{{ info?.discountSource?.info.amount }}
</view>
<cl-list-item label="实付金额" :arrow-icon="false">
<template #icon>
<cl-icon class-name="shop-icon-q-pay" :size="40" />
</template>
<cl-text :size="32" type="price" :value="paidAmount" />
</cl-list-item>
</view>
<view class="card" v-if="info?.refund">
<text class="label">售后/退款</text>
<cl-list-item label="退款金额" :arrow-icon="false">
<cl-text
:size="32"
type="price"
color="error"
:value="info?.refund?.amount || 0"
/>
</cl-list-item>
<cl-list-item label="退款状态" :arrow-icon="false">
{{ dict.getLabel(RefundStatus, info.refund.status) }}
</cl-list-item>
<cl-list-item label="申请原因" :arrow-icon="false">
{{ info.refund.reason }}
</cl-list-item>
<template v-if="info.refund.status == 2">
<cl-list-item label="拒绝原因" :arrow-icon="false" />
<cl-text block color="error" :margin="[0, 0, 20, 0]" :line-height="1.4">{{
info.refund.refuseReason
}}</cl-text>
</template>
</view>
<view class="card">
<text class="label">订单信息</text>
<cl-list-item label="订单编号" :arrow-icon="false">
<cl-text copy>{{ info?.orderNum }} </cl-text>
<cl-button
size="small"
type="primary"
:height="40"
:width="70"
:font-size="22"
:margin="[0, 0, 0, 10]"
@tap="toCopy"
>复制</cl-button
>
</cl-list-item>
<cl-list-item label="支付方式" :arrow-icon="false">
{{ dict.getLabel(PayType, info?.payType) }}
</cl-list-item>
<cl-list-item label="支付时间" :arrow-icon="false" v-if="info?.payType">
{{ info?.payTime }}
</cl-list-item>
<cl-list-item label="下单时间" :arrow-icon="false">
{{ info?.createTime }}
</cl-list-item>
<cl-list-item label="订单备注" :arrow-icon="false">
{{ info?.remark || "暂无备注" }}
</cl-list-item>
</view>
</view>
<!-- 底部操作按钮 -->
<cl-footer :flex="false" border :vt="[info?.status]" v-if="info">
<order-op-btns :info="info" @command="refs.orderOp?.open" />
</cl-footer>
<!-- 订单操作 -->
<order-op :ref="setRefs('orderOp')" @success="refresh()" />
</cl-page>
</template>
<script lang="ts" setup>
import { onPullDownRefresh, onReady, onShow } from "@dcloudio/uni-app";
import { useCool, useStore } from "/@/cool";
import { computed, ref } from "vue";
import { useUi } from "/$/cool-ui";
import { OrderStatus, PayType, RefundStatus } from "./dict";
import AddressSelect from "/@/components/address/select.vue";
import GoodsGroup from "/@/components/goods/group.vue";
import OrderOp from "./components/op.vue";
import OrderOpBtns from "./components/op-btns.vue";
const { router, service, refs, setRefs } = useCool();
const { dict } = useStore();
const ui = useUi();
//
const showDiscount = ref(false);
//
const info = ref<OrderInfo>();
//
const status = computed(() => {
return OrderStatus.find((e) => e.value == info.value?.status);
});
//
const paidAmount = computed(() => {
return Number(info.value?.price || 0) - Number(info.value?.discountPrice || 0);
});
//
async function refresh() {
ui.showLoading();
await service.order.info
.info({
id: router.query.id,
})
.then((res) => {
info.value = res;
})
.catch((err) => {
ui.showTips(err.message, () => {
router.back();
});
});
ui.hideLoading();
}
//
function toCopy() {
uni.setClipboardData({
data: info.value?.orderNum!,
});
}
onPullDownRefresh(async () => {
await refresh();
uni.stopPullDownRefresh();
});
onReady(() => {
refresh();
});
onShow(() => {
if (ui.loaded) {
refresh();
}
});
</script>
<style lang="scss" scoped>
.page {
padding: 24rpx;
.discount-info {
background-color: $cl-color-bg;
color: #444;
padding: 12rpx;
border-radius: 12rpx;
font-size: 24rpx;
text-align: right;
}
}
</style>

92
pages/order/dict/index.ts Normal file
View File

@ -0,0 +1,92 @@
export const OrderStatus = [
{
label: "待付款",
desc: "等待用户付款",
value: 0,
color: "info",
icon: "shop-icon-order-paid",
},
{
label: "待发货",
desc: "等待商家发货",
value: 1,
icon: "shop-icon-order-not-shipped",
},
{
label: "待收货",
desc: "等待用户收货",
value: 2,
icon: "shop-icon-order-received",
},
{
label: "待评价",
desc: "等待用户评价",
value: 3,
icon: "shop-icon-order-comment",
},
{
label: "已完成",
desc: "完成",
value: 4,
color: "success",
icon: "shop-icon-order-success",
},
{
label: "退款中",
desc: "后台退款中",
value: 5,
color: "error",
icon: "shop-icon-order-refund",
},
{
label: "已退款",
desc: "订单已退款",
value: 6,
color: "warning",
icon: "shop-icon-order-refund",
},
{
label: "已关闭",
desc: "订单已关闭",
value: 7,
color: "info",
icon: "shop-icon-order-close",
},
];
export const PayType = [
{
label: "未支付",
value: 0,
},
{
label: "微信",
value: 1,
key: "wxpay",
icon: "/static/icon/wxpay.png",
},
{
label: "支付宝",
value: 2,
key: "alipay",
icon: "/static/icon/alipay.png",
},
];
export const RefundStatus = [
{
label: "申请中",
value: 0,
color: "warning",
},
{
label: "已退款",
value: 1,
color: "success",
},
{
label: "拒绝退款",
value: 2,
color: "error",
},
];

240
pages/order/list.vue Normal file
View File

@ -0,0 +1,240 @@
<template>
<cl-page>
<view class="page">
<cl-sticky>
<cl-tabs
v-model="tabs.active"
un-color="#999"
:list="tabs.list"
fill
@change="tabs.onChange"
/>
</cl-sticky>
<view class="list">
<view
class="item"
v-for="item in list"
:key="item.id"
@tap="
router.push({
path: '/pages/order/detail',
query: {
id: item.id,
},
})
"
>
<!-- 订单号状态 -->
<cl-row type="flex" justify="space-between" :margin="[0, 0, 32, 0]">
<cl-text :size="28" bold>{{ item.orderNum }}</cl-text>
<order-status-tag :item="item" />
</cl-row>
<!-- 退款关闭原因 -->
<view class="reason" v-if="item.closeRemark || item.refund?.refuseReason">
<cl-text prefix-icon="cl-icon-warning-border" :size="24" :ellipsis="1">
{{ item.closeRemark || item.refund?.refuseReason }}
</cl-text>
</view>
<!-- 商品图片 -->
<view class="pics">
<scroll-view scroll-x class="scroller">
<view class="inner">
<cl-image
v-for="url in item.pics"
:key="url"
:src="resizeImage(url, 200)"
:size="[140, 140]"
:radius="24"
:margin="[0, 16, 0, 0]"
/>
</view>
</scroll-view>
<view class="cover">
<cl-text
type="price"
:size="30"
:value="item.price! - (item.discountPrice || 0)"
block
bold
:margin="[0, 0, 10, 0]"
/>
<cl-text :size="22" color="info"> {{ item.pics.length }} </cl-text>
</view>
</view>
<!-- 操作按钮 -->
<order-op-btns :info="item" @command="refs.orderOp?.open" />
</view>
<cl-empty icon="order" text="暂无订单" v-if="isEmpty(list)" />
</view>
</view>
<!-- 订单操作 -->
<order-op :ref="setRefs('orderOp')" @success="refresh()" />
</cl-page>
</template>
<script lang="ts" setup>
import { onReady, onShow } from "@dcloudio/uni-app";
import { useCool, usePager, useUpload } from "/@/cool";
import { reactive } from "vue";
import { isEmpty } from "lodash-es";
import { useUi } from "/$/cool-ui";
import OrderOp from "./components/op.vue";
import OrderOpBtns from "./components/op-btns.vue";
import OrderStatusTag from "./components/status-tag.vue";
const { service, router, refs, setRefs } = useCool();
const { resizeImage } = useUpload();
const ui = useUi();
const { list, onRefresh, onData } = usePager<OrderInfo>();
//
const tabs = reactive({
active: "0",
list: [
{
label: "全部",
},
{
label: "待付款",
value: "0",
},
{
label: "待发货",
value: "1",
},
{
label: "待收货",
value: "2",
},
{
label: "售后/退款",
value: "5,6",
},
{
label: "待评价",
value: "3",
},
{
label: "已完成",
value: "4",
},
],
onChange() {
refresh({
page: 1,
});
},
});
//
function refresh(params?: any, loading?: boolean) {
const { data, next } = onRefresh(params, { loading });
next(
service.order.info.page({
status: tabs.active?.split(","),
...data,
}),
);
}
onData((list) => {
list.forEach((e) => {
e.pics = (e.goodsList || []).map((a) => {
let arr = a.spec.images || [];
if (isEmpty(arr)) {
arr = [a.goodsInfo?.mainPic];
}
return arr[0];
});
});
});
onReady(() => {
tabs.active = router.query.status || undefined;
refresh();
});
onShow(() => {
if (ui.loaded) {
refresh({}, false);
}
});
defineExpose({
refresh,
});
</script>
<style lang="scss" scoped>
.page {
.list {
padding: 24rpx;
.item {
background-color: #fff;
padding: 32rpx;
border-radius: 24rpx;
margin-bottom: 24rpx;
.reason {
display: flex;
align-items: center;
border-radius: 16rpx;
background-color: $cl-color-bg;
padding: 16rpx;
margin-bottom: 32rpx;
}
.pics {
height: 140rpx;
margin-bottom: 32rpx;
overflow: hidden;
position: relative;
.scroller {
height: 160rpx;
.inner {
display: flex;
&::after {
content: "";
display: block;
width: 144rpx;
flex-shrink: 0;
}
}
}
.cover {
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-end;
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 160rpx;
background-color: rgba(255, 255, 255, 0.95);
}
}
&:last-child {
margin-bottom: 0;
}
}
}
}
</style>

108
pages/order/logistics.vue Normal file
View File

@ -0,0 +1,108 @@
<template>
<cl-page>
<view class="page">
<!-- 收货地址 -->
<address-select disabled />
<!-- 物流列表 -->
<view class="card">
<template v-if="logistics">
<cl-row type="flex" justify="space-between" :margin="[10, 0, 20, 0]">
<cl-text :size="24" color="info">
{{ logistics.expName }} {{ logistics.number }}
</cl-text>
<cl-text :size="24" color="info" @tap="toCopy">复制</cl-text>
</cl-row>
<!-- 运送记录 -->
<view class="record">
<cl-timeline>
<cl-timeline-item
v-for="(item, index) in logistics.list"
:key="index"
:timestamp="item.time"
:content="item.status"
/>
</cl-timeline>
</view>
</template>
<cl-empty :fixed="false" :height="600" text="暂无物流信息" v-else />
</view>
</view>
</cl-page>
</template>
<script lang="ts" setup>
import { onPullDownRefresh, onReady } from "@dcloudio/uni-app";
import { useCool } from "/@/cool";
import { ref } from "vue";
import { useUi } from "/$/cool-ui";
import AddressSelect from "/@/components/address/select.vue";
const { router, service } = useCool();
const ui = useUi();
//
const info = ref<OrderInfo>();
//
const logistics = ref();
//
async function refresh() {
const { orderId } = router.query;
ui.showLoading();
try {
service.order.info
.info({
id: orderId,
})
.then((res) => {
info.value = res;
});
await service.order.info
.logistics({
orderId,
})
.then((res) => {
logistics.value = res;
});
} catch (err: any) {
ui.showTips(err.message, () => {
router.back();
});
}
ui.hideLoading();
}
//
function toCopy() {
uni.setClipboardData({
data: info.value?.logistics.num!,
});
}
onPullDownRefresh(async () => {
await refresh();
uni.stopPullDownRefresh();
});
onReady(() => {
refresh();
});
</script>
<style lang="scss" scoped>
.page {
padding: 24rpx;
.record {
margin-top: 6rpx;
}
}
</style>

126
pages/order/refund.vue Normal file
View File

@ -0,0 +1,126 @@
<template>
<cl-page>
<view class="page">
<view class="card">
<cl-list-item label="申请原因" :arrow-icon="false" :margin="[0, -20, 0, 0]">
<cl-select-popup
v-model="form.reason"
:border="false"
:options="list"
title="选择申请原因"
/>
</cl-list-item>
<cl-list-item label="退款金额" :arrow-icon="false">
<cl-text type="price" :size="40" color="error" :value="refundAmount || 0" />
</cl-list-item>
</view>
<goods-group :data="info?.goodsList" readonly />
</view>
<cl-footer>
<cl-button custom type="primary" :loading="loading" @tap="submit">提交</cl-button>
</cl-footer>
</cl-page>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from "vue";
import { useCool, useStore } from "/@/cool";
import { onReady } from "@dcloudio/uni-app";
import { useUi } from "/$/cool-ui";
import GoodsGroup from "/@/components/goods/group.vue";
const { service, router } = useCool();
const ui = useUi();
const { dict } = useStore();
//
const form = reactive({
reason: "",
});
//
const loading = ref(false);
//
const info = ref<OrderInfo>();
// 退
const refundAmount = computed(() => {
return info.value?.goodsList?.reduce((a, b) => {
return a + b.price * b.count;
}, 0);
});
//
const list = computed(() => {
return dict.get("orderRefundReason").map((e) => {
return {
label: e.label,
value: e.label,
};
});
});
//
async function refresh() {
ui.showLoading();
await service.order.info
.info({
id: router.query.orderId,
})
.then((res) => {
info.value = res;
})
.catch((err) => {
ui.showTips(err.message, () => {
router.back();
});
});
ui.hideLoading();
}
//
async function submit() {
if (!form.reason) {
return ui.showToast("请选择申请原因");
}
loading.value = true;
await service.order.info
.refund({
orderId: router.query.orderId,
reason: form.reason,
})
.then(() => {
ui.showTips("退款申请提交成功,等待商家处理", () => {
router.back();
});
})
.catch((err) => {
ui.showToast(err.message);
});
loading.value = false;
}
onReady(() => {
refresh();
});
</script>
<style lang="scss" scoped>
.page {
padding: 24rpx;
.goods {
display: flex;
padding: 24rpx;
}
}
</style>

209
pages/order/submit.vue Normal file
View File

@ -0,0 +1,209 @@
<template>
<cl-page>
<view class="page">
<!-- 收货地址 -->
<address-select />
<!-- 商品列表 -->
<goods-group :data="list" />
<view class="card">
<text class="label">订单备注</text>
<cl-textarea
v-model="form.remark"
placeholder="选填,付款后商家可见"
background-color="#f7f7f7"
:border="false"
:margin="[8, 0, 10, 0]"
count
/>
</view>
<view class="card">
<text class="label">价格明细</text>
<cl-list-item :label="`商品总价(共${total}件商品)`" :arrow-icon="false">
<template #icon>
<cl-icon class-name="shop-icon-q-order" :size="40"></cl-icon>
</template>
<cl-text :size="28" type="price" :value="totalAmount"></cl-text>
</cl-list-item>
<cl-list-item label="优惠券">
<template #icon>
<cl-icon class-name="shop-icon-coupon" :size="40"></cl-icon>
</template>
<coupon-select :total-amount="totalAmount" :ref="setRefs('couponSelect')" />
</cl-list-item>
<cl-list-item label="合计" :arrow-icon="false">
<template #icon>
<cl-icon class-name="shop-icon-q-pay" :size="40"></cl-icon>
</template>
<cl-text :size="32" type="price" :value="paidAmount"></cl-text>
</cl-list-item>
</view>
<view class="card">
<text class="label">支付方式</text>
<cl-radio-group fill v-model="form.payType">
<cl-radio
v-for="item in order.payTypes"
:key="item.key"
:label="item.key"
:height="80"
>
{{ item.label }}
<image class="pay-icon" mode="aspectFill" :src="item.icon" />
</cl-radio>
</cl-radio-group>
</view>
</view>
<cl-footer :flex="false" border :padding="24">
<cl-row type="flex" justify="space-between">
<cl-text type="price" color="error" :size="50" :value="paidAmount" />
<cl-button type="primary" :width="200" :loading="loading" @tap="submit"
>提交订单</cl-button
>
</cl-row>
</cl-footer>
</cl-page>
</template>
<script lang="ts" setup>
import { onReady } from "@dcloudio/uni-app";
import { useCool } from "/@/cool";
import { computed, reactive, ref } from "vue";
import { useAddress, useShoppingCart } from "/@/hooks";
import { useUi } from "/$/cool-ui";
import AddressSelect from "/@/components/address/select.vue";
import CouponSelect from "/@/components/coupon/select.vue";
import GoodsGroup from "/@/components/goods/group.vue";
import { useOrder } from "/@/hooks";
const { router, service, refs, setRefs } = useCool();
const address = useAddress();
const ui = useUi();
const spCart = useShoppingCart();
const order = useOrder();
//
const form = reactive({
payType: "wxpay" as "wxpay" | "alipay",
remark: "",
});
//
const loading = ref(false);
//
const list = ref<OrderGoods[]>([]);
//
const total = computed(() => {
return list.value.reduce((a, b) => {
return a + b.count;
}, 0);
});
//
const totalAmount = computed(() => {
return list.value.reduce((a, b) => {
return a + (b.spec.price || 0) * b.count;
}, 0);
});
//
const paidAmount = computed(() => {
return totalAmount.value - (refs.couponSelect?.checked?.amount || 0);
});
//
async function submit() {
if (!address.info?.id) {
return ui.showToast("请选择收货地址");
}
loading.value = true;
await service.order.info
.create({
data: {
remark: form.remark,
goodsList: list.value.map((e) => {
return {
goodsInfo: e.goodsInfo,
spec: e.spec,
count: e.count,
goodsId: e.goodsInfo.id,
};
}),
couponId: refs.couponSelect?.checked?.id,
addressId: address.info?.id,
title: "购买商品",
},
})
.then(async (res) => {
//
list.value.forEach((e) => {
spCart.delBySpecId(e.spec.id!);
});
//
function next() {
router.push({
path: "/pages/order/detail",
query: {
id: res.id,
},
mode: "redirectTo",
});
}
await order
.toPay(res.id)
.then(() => {
next();
})
.catch((err) => {
ui.showTips(err.message, () => {
next();
});
});
})
.catch((err) => {
ui.showTips(err.message, () => {
router.push({
path: "/pages/order/list",
mode: "redirectTo",
});
});
});
loading.value = false;
}
onReady(() => {
list.value = router.params.buyList;
});
</script>
<style lang="scss" scoped>
.page {
padding: 24rpx;
.pay-icon {
height: 56rpx;
width: 56rpx;
position: absolute;
right: 0;
top: calc(50% - 28rpx);
}
}
</style>

57
pages/user/about.vue Normal file
View File

@ -0,0 +1,57 @@
<template>
<cl-page>
<view class="page-about">
<view class="logo">
<image src="/static/logo.png" />
</view>
<!-- 版本 -->
<cl-version-about />
<cl-list>
<cl-list-item label="联系我们" />
</cl-list>
<cl-text
block
align="center"
:size="24"
color="info"
:line-height="1.2"
:margin="[80, 0, 0, 0]"
>
{{ app.info.desc }}
</cl-text>
</view>
</cl-page>
</template>
<script lang="ts" setup>
import { onReady } from "@dcloudio/uni-app";
import { useApp } from "/@/cool";
const app = useApp();
onReady(() => {
uni.setNavigationBarTitle({
title: `关于${app.info.name}`,
});
});
</script>
<style lang="scss" scoped>
.page-about {
.logo {
display: flex;
justify-content: center;
padding: 100rpx 0;
image {
height: 160rpx;
width: 160rpx;
border-radius: 24rpx;
box-shadow: 0 25rpx 30rpx -25rpx #666666;
}
}
}
</style>

151
pages/user/address-edit.vue Normal file
View File

@ -0,0 +1,151 @@
<template>
<cl-page>
<view class="page">
<cl-form :ref="setRefs('form')" v-model="form" label-position="top">
<view class="card">
<cl-form-item
label="联系人"
prop="contact"
:rules="{
required: true,
message: '联系人不能为空',
}"
>
<cl-input v-model="form.contact" placeholder="请填写联系人" />
</cl-form-item>
<cl-form-item
label="手机号码"
prop="phone"
:rules="{
required: true,
message: '手机号码不能为空',
}"
>
<cl-input
v-model="form.phone"
:maxlength="11"
placeholder="请填写手机号码"
/>
</cl-form-item>
<cl-form-item
label="省市区"
prop="province"
required
:rules="{
required: true,
message: '省市区不能为空',
}"
>
<cl-select-city v-model="pca" @change="onPcaChange" />
</cl-form-item>
<cl-form-item
label="详细地址"
prop="address"
:rules="{
required: true,
message: '详细地址不能为空',
}"
:margin="[0, 0, 10, 0]"
>
<cl-textarea v-model="form.address" count placeholder="街道、楼牌号等" />
</cl-form-item>
</view>
<view class="card">
<cl-row type="flex" justify="space-between" :padding="[10, 0, 10, 0]">
<cl-row>
<cl-text :size="28" block :margin="[0, 0, 6, 0]">设置默认地址</cl-text>
<cl-text :size="22" color="info">提醒下单会优先使用该地址</cl-text>
</cl-row>
<cl-switch v-model="form.isDefault" />
</cl-row>
</view>
</cl-form>
</view>
<cl-footer>
<cl-button custom type="primary" @tap="save">保存</cl-button>
</cl-footer>
</cl-page>
</template>
<script lang="ts" setup>
import { ref } from "vue";
import { useCool } from "/@/cool";
import { onReady } from "@dcloudio/uni-app";
import { useUi } from "/$/cool-ui";
const { service, router, refs, setRefs } = useCool();
const ui = useUi();
//
const pca = ref<string[]>([]);
//
const form = ref<Eps.UserAddressEntity>({});
//
function onPcaChange([province, city, district]: string[]) {
form.value.province = province;
form.value.city = city;
form.value.district = district;
}
//
async function refresh() {
ui.showLoading();
await service.user.address
.info({
id: router.query.id,
})
.then((res) => {
form.value = res;
pca.value = [res.province!, res.city!, res.district!];
})
.catch((err) => {
ui.showToast(err.message);
});
ui.hideLoading();
}
//
function save() {
refs.form.validate((valid: boolean) => {
if (valid) {
service.user.address[form.value.id ? "update" : "add"](form.value)
.then(() => {
ui.showTips("地址保存成功", () => {
router.back();
});
})
.catch((err) => {
ui.showToast(err.message);
});
}
});
}
onReady(() => {
const { id } = router.query;
if (id) {
refresh();
}
uni.setNavigationBarTitle({
title: id ? "编辑地址" : "添加地址",
});
});
</script>
<style lang="scss" scoped>
.page {
padding: 24rpx;
}
</style>

116
pages/user/address-list.vue Normal file
View File

@ -0,0 +1,116 @@
<template>
<cl-page>
<view class="page">
<view class="list">
<view class="item" v-for="(item, index) in list" :key="item.id">
<cl-list-item swipe="right" :arrow-icon="false" :radius="16">
<template #menu>
<cl-button type="error" @tap="del(item.id!, index)">删除</cl-button>
</template>
<address-item :item="item">
<template #icon>
<cl-icon name="edit" :size="34" @tap="edit(item.id)" />
</template>
</address-item>
</cl-list-item>
</view>
</view>
<cl-empty icon="address" v-if="isEmpty(list)" />
</view>
<cl-footer>
<cl-button custom type="primary" @tap="edit()">添加收货地址</cl-button>
</cl-footer>
</cl-page>
</template>
<script lang="ts" setup>
import { onReady, onShow } from "@dcloudio/uni-app";
import { useCool, usePager } from "/@/cool";
import { isEmpty } from "lodash-es";
import { useUi } from "/$/cool-ui";
import AddressItem from "/@/components/address/item.vue";
const { service, router } = useCool();
const { list, onRefresh } = usePager<Eps.UserAddressEntity>();
const ui = useUi();
//
function refresh(params?: any, loading?: boolean) {
const { data, next } = onRefresh(params, { loading });
next(service.user.address.page(data));
}
//
function edit(id?: number) {
router.push({
path: "/pages/user/address-edit",
query: {
id,
},
});
}
//
function del(id: number, index: number) {
ui.showConfirm({
title: "提示",
message: "确定删除该地址吗?",
callback(action) {
if (action == "confirm") {
service.user.address
.delete({
ids: [id],
})
.then(() => {
list.value.splice(index, 1);
ui.showToast("删除成功");
})
.catch((err) => {
ui.showToast(err.message);
});
}
},
});
}
onShow(() => {
if (ui.loaded) {
refresh({}, false);
}
});
onReady(() => {
refresh();
});
defineExpose({
refresh,
});
</script>
<style lang="scss" scoped>
.page {
padding: 24rpx;
.list {
.item {
margin-bottom: 24rpx;
address-item {
flex: 1;
}
:deep(.address-item) {
padding: 14rpx 4rpx;
}
&:last-child {
margin-bottom: 0;
}
}
}
}
</style>

132
pages/user/captcha.vue Normal file
View File

@ -0,0 +1,132 @@
<template>
<cl-page background-color="#fff">
<view class="page-captcha">
<cl-topbar :border="false"> </cl-topbar>
<view class="container">
<text class="label">输入验证码</text>
<text class="tips">已发送至 +86 {{ form.phone }}</text>
<view class="code">
<cl-captcha
focus
v-model="form.smsCode"
:length="len"
:gutter="26"
@done="next"
/>
</view>
<cl-button
type="primary"
:disabled="form.smsCode.length !== len"
:loading="saving"
fill
:height="90"
:font-size="30"
@tap="next"
>
确定
</cl-button>
<view class="send">
<sms-btn
size="small"
:border="false"
:phone="form.phone"
:ref="setRefs('smsBtn')"
/>
</view>
</view>
</view>
</cl-page>
</template>
<script lang="ts" setup>
import { onReady } from "@dcloudio/uni-app";
import { reactive, ref } from "vue";
import { useCool, useStore } from "/@/cool";
import { useUi } from "/$/cool-ui";
import SmsBtn from "/@/components/sms-btn.vue";
const { service, router, refs, setRefs } = useCool();
const { user } = useStore();
const ui = useUi();
//
const len = 4;
//
const saving = ref(false);
//
const form = reactive({
smsCode: "",
phone: "",
});
//
function next() {
saving.value = true;
service.user.login
.phone(form)
.then(async (res) => {
// token
user.setToken(res);
//
await user.get();
//
router.nextLogin();
})
.catch((err) => {
ui.showTips(err.message || "登录失效,请重试~");
saving.value = false;
form.smsCode = "";
});
}
onReady(() => {
form.phone = router.query.phone || "";
refs.smsBtn.startCountdown();
});
</script>
<style lang="scss" scoped>
.page-captcha {
.container {
display: flex;
flex-direction: column;
width: 80%;
margin: 0 auto;
padding-top: 140rpx;
}
.label {
font-size: 52rpx;
font-weight: 500;
margin-bottom: 44rpx;
font-weight: bold;
color: #151515;
}
.tips {
font-size: 28rpx;
color: #151515;
font-weight: 500;
}
.code {
margin: 34rpx -26rpx 62rpx -26rpx;
}
.send {
display: flex;
justify-content: center;
font-size: 24rpx;
margin-top: 30rpx;
}
}
</style>

37
pages/user/doc.vue Normal file
View File

@ -0,0 +1,37 @@
<template>
<view class="doc mp-html">
<mp-html :content="content"></mp-html>
</view>
</template>
<script setup lang="ts">
import { onReady } from "@dcloudio/uni-app";
import { ref } from "vue";
import { useCool } from "/@/cool";
const { router, service } = useCool();
const content = ref("");
onReady(() => {
const { title, key } = router.query;
uni.setNavigationBarTitle({
title,
});
service.base.comm
.param({
key,
})
.then((res) => {
content.value = res;
});
});
</script>
<style lang="scss" scoped>
.doc {
padding: 20rpx;
}
</style>

67
pages/user/edit.vue Normal file
View File

@ -0,0 +1,67 @@
<template>
<cl-page>
<view class="page">
<view class="form">
<cl-form label-position="top">
<cl-form-item label="昵称">
<cl-input
v-model="form.nickName"
type="nickname"
:border="false"
:height="80"
:border-radius="12"
placeholder="请填写昵称"
/>
</cl-form-item>
</cl-form>
</view>
<cl-footer>
<cl-button custom type="primary" :loading="loading" @tap="save"> 保存 </cl-button>
</cl-footer>
</view>
</cl-page>
</template>
<script lang="ts" setup>
import { reactive, ref } from "vue";
import { useCool, useStore } from "/@/cool";
import { useUi } from "/$/cool-ui";
import { onReady } from "@dcloudio/uni-app";
const { router } = useCool();
const { user } = useStore();
const ui = useUi();
const loading = ref(false);
const form = reactive({
nickName: "",
});
async function save() {
loading.value = true;
await user.update(form).catch((err) => {
ui.showToast(err.message);
});
loading.value = false;
ui.showTips("用户信息保存成功", () => {
router.back();
});
}
onReady(() => {
form.nickName = user.info?.nickName || "";
});
</script>
<style lang="scss" scoped>
.page {
.form {
padding: 20rpx 24rpx;
}
}
</style>

473
pages/user/login.vue Normal file
View File

@ -0,0 +1,473 @@
<template>
<cl-page background-color="#fff">
<cl-topbar :border="false" background-color="transparent" />
<view class="page-login">
<!-- Logo -->
<view class="logo">
<image src="/static/logo.png" mode="aspectFill" />
<text>{{ app.info.name }}</text>
</view>
<div class="container">
<!-- 登录方式 -->
<view class="mode" :class="[`is-${mode}`]">
<!-- 手机号 -->
<template v-if="mode == 'phone'">
<text class="label">手机号登录</text>
<view class="phone">
<text>+86</text>
<cl-input
v-model="phone"
type="number"
placeholder="请填写手机号码"
:border="false"
:maxlength="11"
:font-size="30"
background-color="transparent"
/>
</view>
<sms-btn
:ref="setRefs('smsBtn')"
:phone="phone"
@success="phoneLogin(false)"
>
<template #default="{ disabled, btnText }">
<cl-button
fill
type="primary"
:height="90"
:font-size="30"
:disabled="disabled"
@tap="phoneLogin"
>
{{ btnText }}
</cl-button>
</template>
</sms-btn>
</template>
<!-- 微信登录 -->
<template v-else-if="mode == 'wx'">
<cl-button
type="primary"
fill
:height="90"
:font-size="30"
:loading="loading"
@tap="wxLogin"
>
微信一键登录
</cl-button>
</template>
<!-- 协议 -->
<view class="agree">
<agree-btn :ref="setRefs('agreeBtn')" />
</view>
</view>
</div>
<!-- 其他登录方式 -->
<view class="other" v-if="!isEmpty(platformsEnv)">
<cl-divider width="400rpx" background-color="#ffffff">
<cl-text color="#ccc" value="其他登录方式" />
</cl-divider>
<view class="platform">
<view
class="platform__item"
v-for="(item, index) in platformsEnv"
:key="index"
@tap="changeMode(item)"
>
<image :src="item.icon" mode="aspectFit" v-if="item.icon" />
<text>{{ item.label }}</text>
</view>
</view>
</view>
</view>
</cl-page>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from "vue";
import { onReady } from "@dcloudio/uni-app";
import { useApp, useCool, useStore, useWx } from "/@/cool";
import { useUi } from "/$/cool-ui";
import SmsBtn from "/@/components/sms-btn.vue";
import AgreeBtn from "/@/components/agree-btn.vue";
import { config } from "/@/config";
import { ctx } from "virtual:ctx";
import { cloneDeep, isEmpty } from "lodash-es";
interface Platform {
label: string;
value: any;
icon?: string;
hidden?: boolean;
onClick?: () => void;
}
const { service, router, refs, setRefs, storage } = useCool();
const { user } = useStore();
const app = useApp();
const ui = useUi();
const wx = useWx();
//
const loading = ref(false);
//
const phone = ref(storage.get("phone") || "");
//
const mode = ref();
//
const platforms = ref<Platform[]>([
{
label: "通过手机登录",
value: "phone",
icon: "/pages/user/static/icon/phone.png",
hidden: false,
},
{
label: "通过微信登录",
value: "wx",
icon: "/pages/user/static/icon/wx.png",
hidden: true,
},
]);
//
const platformsEnv = computed(() => {
let arr = cloneDeep(platforms.value);
// #ifdef H5
if (wx.isWxBrowser()) {
arr[1].hidden = false;
}
// #endif
// #ifdef MP-WEIXIN
arr[1].hidden = false;
// #endif
//
arr = arr.filter((e) => !e.hidden);
//
if (!mode.value) {
mode.value = arr[0]?.value;
}
//
return arr.filter((e) => e.value != mode.value);
});
//
function changeMode(item: Platform) {
if (item.onClick) {
item.onClick();
} else {
mode.value = item.value;
}
}
//
async function nextLogin(key: "mini" | "mp" | "uniPhone", data: any) {
return service.user.login[key](data)
.then(async (res) => {
// token
user.setToken(res);
//
await user.get();
//
router.nextLogin(key);
})
.catch((err) => {
ui.showTips(err.message);
});
}
//
function phoneLogin(sms?: boolean) {
if (sms) {
check(() => {
refs.smsBtn.open();
});
} else {
storage.set("phone", phone.value);
router.push({
path: "/pages/user/captcha",
query: {
phone: phone.value,
},
});
}
}
//
function wxLogin() {
check(async () => {
// #ifdef APP
if (wx.hasApp()) {
wx.appLogin().then((code) => {
//
});
} else {
ui.showConfirm({
title: "温馨提示",
message: "您还未安装微信~",
showCancelButton: false,
confirmButtonText: "去下载",
callback(action) {
if (action == "confirm") {
wx.downloadApp();
}
},
});
}
// #endif
// #ifdef MP-WEIXIN
loading.value = true;
await wx
.miniLogin()
.then(async (res) => {
await nextLogin("mini", res);
})
.catch((err) => {
ui.showToast(err.message);
});
loading.value = false;
// #endif
// #ifdef H5
wx.mpAuth();
// #endif
});
}
//
function mpLogin() {
// #ifdef H5
wx.mpLogin().then(async (code) => {
if (code) {
ui.showLoading();
await nextLogin("mp", { code });
ui.hideLoading();
}
});
// #endif
}
//
function check(cb: () => void) {
if (refs.agreeBtn.check()) {
cb();
}
}
//
const univerify = reactive({
error: "",
check() {
// #ifdef APP
uni.preLogin({
provider: "univerify",
success() {
platforms.value.push({
label: "手机号一键登录",
value: "univerify",
onClick() {
univerify.login();
},
});
},
fail(err) {
univerify.error = err.metadata?.msg || "当前环境不支持一键登录,请切换至验证码登录";
},
});
// #endif
},
login() {
uni.login({
provider: "univerify",
univerifyStyle: {
authButton: {
normalColor: "#6b69f8",
highlightColor: "#6b69f8",
disabledColor: "#73aaf5",
textColor: "#ffffff",
title: "一键登录",
borderRadius: "12px",
},
privacyTerms: {
defaultCheckBoxState: true, //
textColor: "#BBBBBB",
termsColor: "#5496E3",
prefix: "我已阅读并同意",
suffix: "并使用本机号码登录",
privacyItems: [
// 2urltitle.
{
url: `${config.baseUrl}/app/base/comm/html?key=userAgreement`,
title: "用户协议",
},
{
url: `${config.baseUrl}/app/base/comm/html?key=privacyPolicy`,
title: "隐私政策",
},
],
},
},
async success(res: { authResult: any }) {
await nextLogin("uniPhone", {
appId: ctx.appid,
...res.authResult,
});
uni.closeAuthView();
},
fail() {
if (univerify.error) {
ui.showToast(univerify.error);
}
},
});
},
});
//
univerify.check();
onReady(() => {
//
mpLogin();
});
</script>
<style lang="scss" scoped>
.page-login {
.logo {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding-top: 20%;
image {
display: block;
height: 150rpx;
width: 150rpx;
border-radius: 24rpx;
margin-bottom: 30rpx;
box-shadow: 0 25rpx 30rpx -25rpx #666666;
}
text {
font-size: 36rpx;
font-weight: bold;
letter-spacing: 1rpx;
color: #333;
}
}
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: 80rpx;
.mode {
width: 100%;
padding: 0 80rpx;
box-sizing: border-box;
.label {
display: block;
font-size: 36rpx;
font-weight: 500;
margin-bottom: 30rpx;
}
&.is-phone {
.phone {
display: flex;
align-items: center;
background-color: #eeeeee;
border-radius: 10rpx;
height: 90rpx;
margin-bottom: 30rpx;
font-size: 30rpx;
text {
display: inline-block;
padding: 0 40rpx;
border-right: $cl-border-width solid $cl-border-color;
font-weight: bold;
color: #404040;
}
input {
height: 100%;
flex: 1;
padding: 0 30rpx;
}
}
}
}
.agree {
text-align: center;
margin: 50rpx -60rpx 0 0;
}
}
.other {
margin-top: 100rpx;
.platform {
display: flex;
flex-direction: column;
align-items: center;
margin: 20rpx 0 60rpx 0;
&__item {
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #000000;
height: 30px;
width: 130px;
margin-bottom: 28rpx;
border-radius: 6px;
background-color: #ffffff;
image {
height: 32rpx;
width: 32rpx;
margin-right: 10rpx;
}
text {
font-size: 24rpx;
color: #000000;
}
}
}
}
}
</style>

154
pages/user/set.vue Normal file
View File

@ -0,0 +1,154 @@
<template>
<cl-page>
<view class="page-set">
<template v-if="user.info">
<cl-text value="账号" :margin="[0, 0, 20, 20]" block />
<cl-list :radius="16">
<cl-list-item label="头像" :arrow-icon="false">
<view class="avatar">
<!-- #ifdef MP-WEIXIN -->
<button open-type="chooseAvatar" @chooseavatar="uploadAvatar">
<cl-avatar round :size="88" :src="user.info.avatarUrl" />
</button>
<!-- #endif -->
<!-- #ifndef MP-WEIXIN -->
<cl-avatar
round
:size="88"
:src="user.info.avatarUrl"
@tap="uploadAvatar()"
/>
<!-- #endif -->
</view>
</cl-list-item>
<cl-list-item label="昵称" @tap="router.push('/pages/user/edit')">
<cl-text :value="user.info.nickName" />
</cl-list-item>
<cl-list-item label="手机号" :arrow-icon="false">
<cl-text :value="user.info.phone" />
</cl-list-item>
<cl-list-item label="ID" :arrow-icon="false" :border="false">
<cl-text :value="user.info.id" />
</cl-list-item>
</cl-list>
</template>
<cl-text value="关于" :margin="[30, 0, 20, 20]" block />
<cl-list :radius="16">
<cl-list-item
:label="`关于${app.info.name}`"
@tap="router.push('/pages/user/about')"
/>
<cl-list-item
label="用户协议"
@tap="
router.push({
path: '/pages/user/doc',
query: {
key: 'userAgreement',
title: '用户协议',
},
})
"
/>
<cl-list-item
label="隐私政策"
@tap="
router.push({
path: '/pages/user/doc',
query: {
key: 'privacyPolicy',
title: '隐私政策',
},
})
"
/>
</cl-list>
<cl-text value="反馈" :margin="[30, 0, 20, 20]" block />
<cl-list :radius="16">
<cl-list-item
label="意见反馈"
@tap="router.push('/uni_modules/cool-app/pages/feedback/list')"
/>
<cl-list-item
label="投诉举报"
@tap="router.push('/uni_modules/cool-app/pages/complain/list')"
/>
</cl-list>
<cl-list :radius="16">
<cl-list-item label="切换账号" @tap="router.push('/pages/user/login')" />
<cl-list-item label="退出登录" :arrow-icon="false" @tap="user.logout()">
<cl-icon :size="36" name="exit" />
</cl-list-item>
</cl-list>
</view>
</cl-page>
</template>
<script lang="ts" setup>
import { onReady } from "@dcloudio/uni-app";
import { useApp, useCool, useStore } from "/@/cool";
import { useUi } from "/$/cool-ui";
const { router, upload } = useCool();
const { user } = useStore();
const ui = useUi();
const app = useApp();
//
function uploadAvatar(e?: { detail: { avatarUrl: string } }) {
function next(path: string) {
upload({ path }).then((url) => {
ui.showToast("头像更新成功");
user.update({
avatarUrl: url,
});
});
}
if (e) {
next(e.detail.avatarUrl);
} else {
uni.chooseImage({
count: 1,
success(res) {
next(res.tempFiles[0].path);
},
});
}
}
onReady(() => {
user.get();
});
</script>
<style lang="scss" scoped>
.page-set {
padding: 20rpx 24rpx;
.avatar {
padding: 10rpx 0;
height: 88rpx;
button {
padding: 0;
margin: 0;
line-height: normal;
background-color: transparent;
&::after {
border: 0;
}
}
}
}
</style>

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