This commit is contained in:
2026-01-05 12:47:14 +08:00
commit 1fc846fae3
1614 changed files with 162035 additions and 0 deletions

View File

@@ -0,0 +1,60 @@
import type { ExtractPropTypes } from 'vue'
import { CLICK_EVENT } from '../_constants'
import { commonProps, isString, makeArrayProp, makeObjectProp, makeStringProp } from '../_utils'
import type { FollowType, ImagesType, InfoType, VideosType } from './type'
export const commentProps = {
...commonProps,
/**
* @description 头部样式展示类型,可选: `default``complex`
*/
headerType: makeStringProp<'default' | 'complex'>('default'),
/**
* @description 评论图片展示行数,可选: `one``multi`
*/
imagesRows: makeStringProp<'one' | 'multi'>('one'),
/**
* @description 设置评论内容省略行数
*/
ellipsis: {
type: [String, Number, Boolean],
default: false,
},
/**
* @description 视频信息
*/
videos: makeArrayProp<VideosType>([]),
/**
* @description 图片信息
*/
images: makeArrayProp<ImagesType>([]),
/**
* @description 评论详情
*/
info: makeObjectProp<InfoType>({} as InfoType),
/**
* @description
*/
labels: {
type: Function,
default: () => '',
},
/**
* @description 追评内容
*/
follow: makeObjectProp<FollowType>({} as FollowType),
/**
* @description 配置底部按钮
*/
operation: makeArrayProp<'replay' | 'like' | 'more'>(['replay', 'like', 'more']),
}
export type CommentProps = ExtractPropTypes<typeof commentProps>
export const commentEmits = {
[CLICK_EVENT]: (val: object) => val instanceof Object,
clickOperate: (val: string) => isString(val),
clickImages: (val: any) => val instanceof Object,
}
export type CommentEmits = typeof commentEmits

View File

@@ -0,0 +1,122 @@
<script setup lang="ts">
import { computed, defineComponent } from 'vue'
import { CLICK_EVENT, PREFIX } from '../_constants'
import { getMainClass } from '../_utils'
import NutIcon from '../icon/icon.vue'
import { commentEmits, commentProps } from './comment'
import ComentBottom from './components/CmtBottom.vue'
import CommentHeader from './components/CmtHeader.vue'
import ComentImages from './components/CmtImages.vue'
const props = defineProps(commentProps)
const emit = defineEmits(commentEmits)
const classes = computed(() => {
return getMainClass(props, componentName)
})
const conEllipsis = computed(() => {
if (props.ellipsis)
return props.ellipsis
return props.headerType === 'complex' ? 6 : 2
})
function clickOperate(t: string) {
emit('clickOperate', t)
}
function handleClick() {
emit(CLICK_EVENT, props.info)
}
function clickImages(value: any) {
emit('clickImages', value)
}
</script>
<script lang="ts">
const componentName = `${PREFIX}-comment`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view v-if="info && Object.keys(info)" :class="classes" :style="customStyle">
<!-- 根据展示信息的多少分为3种展示风格simplebasecomplex -->
<CommentHeader
:type="headerType"
:info="info"
:labels="labels"
@handle-click="handleClick"
>
<template #labels>
<slot name="commentLabels" />
</template>
</CommentHeader>
<slot name="feature" />
<!-- #ifndef H5 -->
<!-- BUG web端使用 rich-text自定义 style 会导致内存泄漏 -->
<rich-text
class="nut-comment__main"
:style="`-webkit-line-clamp:${conEllipsis}`"
:nodes="info.content"
@click="handleClick"
/>
<!-- #endif -->
<!-- #ifdef H5 -->
<text
class="nut-comment__main"
:style="`-webkit-line-clamp:${conEllipsis}`"
@click="handleClick"
>
{{ info.content }}
</text>
<!-- #endif -->
<ComentImages
:images="images"
:videos="videos"
:type="imagesRows"
@click-images="clickImages"
/>
<view v-if="follow && follow.days > 0" class="nut-comment__follow" @click="handleClick">
<view class="nut-comment__follow-title">
购买{{ follow.days }}天后追评
</view>
<view class="nut-comment__follow-com">
{{ follow.content }}
</view>
<view
v-if="follow.images && follow.images.length > 0"
class="nut-comment__follow-img"
@click="clickImages(follow.images)"
>
{{ follow.images.length }} 张追评图片 <NutIcon name="right" size="12px" />
</view>
</view>
<ComentBottom
:type="headerType"
:info="info"
:operation="operation"
@click-operate="clickOperate"
@handle-click="handleClick"
/>
<slot name="commentShopReply" />
</view>
</template>
<style lang="scss">
@import './index';
</style>

View File

@@ -0,0 +1,188 @@
<script setup lang="ts">
import type { PropType } from 'vue'
import { defineComponent, onMounted, ref } from 'vue'
import { PREFIX } from '../../_constants'
import { useTranslate } from '../../../locale'
import NutIcon from '../../icon/icon.vue'
const props = defineProps({
type: {
type: String,
default: 'base', // simplebasecomplex
},
info: {
type: Object,
default: () => ({}),
},
operation: {
type: Array as PropType<string[]>,
default: () => ['replay', 'like', 'more'],
},
})
const emit = defineEmits(['clickOperate', 'handleClick'])
const showPopver = ref(false)
const mergeOp = ref([])
onMounted(() => {
const deOp = ['replay', 'like', 'more']
if (props.operation) {
props.operation.forEach((name: string) => {
if (deOp.includes(name))
(mergeOp.value as any).push(name)
})
}
})
function operate(type: string) {
if (type === 'more')
showPopver.value = !showPopver.value
emit('clickOperate', type)
}
function handleClick() {
emit('handleClick')
}
</script>
<script lang="ts">
const componentName = `${PREFIX}-comment-bottom`
const { translate } = useTranslate(componentName)
export default defineComponent ({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view class="nut-comment-bottom">
<view class="nut-comment-bottom__lable" @click="handleClick">
<span v-if="type !== 'complex'" style="display: inline;white-space:none;">
{{ info.size }}
</span>
</view>
<view class="nut-comment-bottom__cpx">
<template v-for="(name, i) in mergeOp" :key="i">
<view class="nut-comment-bottom__cpx-item" :class="[`nut-comment-bottom__cpx-item--${name}`]" @click="operate(name)">
<template v-if="name !== 'more'">
<text>{{ info[name] }}</text>
<NutIcon v-if="name === 'like'" name="fabulous" />
<NutIcon v-else name="comment" />
</template>
<template v-if="name === 'more'">
<NutIcon name="more-x" />
<view v-if="showPopver" class="nut-comment-bottom__cpx-item-popover" @click="operate('popover')">
{{
translate('complaintsText')
}}
</view>
</template>
</view>
</template>
</view>
</view>
</template>
<style lang="scss">
.nut-theme-dark {
.nut-comment {
&-bottom {
&__cpx {
color: $dark-color;
&-item {
text {
color: $dark-color;
}
}
}
}
}
}
.nut-comment {
&-bottom {
display: flex;
justify-content: space-between;
margin-right: 5px;
color: $comment-bottom-label-color;
&__lable {
flex: 1;
margin-right: 10px;
// stylelint-disable-next-line at-rule-no-unknown
@include oneline-ellipsis;
}
&__cpx {
display: flex;
align-items: center;
justify-content: flex-end;
color: $black;
&-item {
position: relative;
display: flex;
align-items: center;
margin-right: 18px;
text {
margin-right: 5px;
color: $black;
}
&:last-child {
margin-right: 0;
}
&-popover {
position: absolute;
top: 35px;
right: 18px;
width: max-content;
padding: 10px;
background: $white;
border-radius: 5px 0 5px 5px;
box-shadow: 0 0 6px $disable-color;
&::after {
position: absolute;
top: -20px;
right: 0;
width: 0;
height: 0;
content: "";
border-top: 10px solid transparent;
border-right: 0 solid transparent;
border-bottom: 10px solid $white;
border-left: 14px solid transparent;
}
&::before {
position: absolute;
top: -22px;
right: -1px;
width: 0;
height: 0;
content: "";
border-top: 10px solid transparent;
border-right: 0 solid transparent;
border-bottom: 10px solid rgb(114 113 113 / 10%);
border-left: 14px solid transparent;
}
}
}
}
}
}
</style>

View File

@@ -0,0 +1,242 @@
<script setup lang="ts">
import { defineComponent } from 'vue'
import { PREFIX } from '../../_constants'
import NutRate from '../../rate/rate.vue'
defineProps({
type: {
type: String,
default: 'default', // defaultcomplex
},
info: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['handleClick'])
function handleClick() {
emit('handleClick')
}
</script>
<script lang="ts">
const componentName = `${PREFIX}-comment-header`
export default defineComponent ({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view>
<view v-if="info" class="nut-comment-header" @click="handleClick">
<view class="nut-comment-header__user">
<view class="nut-comment-header__user-avter">
<image v-if="info.avatar" :src="info.avatar" />
</view>
<view v-if="type === 'default'" :class="[`nut-comment-header__user-${type}`]">
<view :class="[`nut-comment-header__user-${type}-name`]">
<text>{{ info.nickName }}</text>
<slot name="labels" />
</view>
<view class="nut-comment-header__user-score">
<!-- eslint-disable vue/no-mutating-props -->
<NutRate
v-model="info.score"
size="12"
spacing="5"
readonly
@change="handleClick"
/>
</view>
</view>
<view v-else :class="[`nut-comment-header__user-${type}`]">
<text :class="[`nut-comment-header__user-${type}-name`]">
{{ info.nickName }}
</text>
<slot name="labels" />
</view>
</view>
<view v-if="info.time" class="nut-comment-header__time">
{{ info.time }}
</view>
</view>
<view v-if="type === 'complex'" :class="[`nut-comment-header__${type}-score`]">
<NutRate
v-model="info.score"
size="12"
spacing="3"
readonly
/>
<i :class="[`nut-comment-header__${type}-score-i`]" />
<view :class="[`nut-comment-header__${type}-score-size`]">
{{ info.size }}
</view>
</view>
</view>
</template>
<style lang="scss">
.nut-theme-dark {
.nut-comment {
&-header {
&__user {
&-name {
color: $dark-color;
}
&-default {
&-name {
color: $dark-color;
}
}
}
}
}
}
.nut-comment {
&-header {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
&__user {
display: flex;
flex: 1;
align-items: center;
&-avter {
width: 20px;
height: 20px;
margin-right: 10px;
overflow: hidden;
border-radius: 50%;
image {
width: 20px;
height: 20px;
}
}
&-name {
/* stylelint-disable-next-line at-rule-no-unknown */
@include oneline-ellipsis;
width: auto;
max-width: 80px;
margin-right: 5px;
font-size: 12px;
color: $comment-header-user-name-color;
}
&-default {
flex: 1;
&-name {
display: flex;
align-items: center;
margin-bottom: 3px;
/* stylelint-disable-next-line at-rule-no-unknown */
@include oneline-ellipsis;
font-size: 12px;
color: $comment-header-user-name-color;
> text {
margin-right: 8px;
}
}
}
&-complex {
display: flex;
align-items: center;
color: $comment-header-user-name-color;
&-name {
max-width: 80px;
margin-right: 10px;
/* stylelint-disable-next-line at-rule-no-unknown */
@include text-ellipsis;
}
image {
max-width: 50px;
height: 16px;
}
}
&-score {
.nut-rate-item {
display: block !important;
line-height: 10px;
.nut-icon {
line-height: 10px;
}
}
}
}
&__time {
width: 100px;
font-size: 12px;
color: $comment-header-time-color;
text-align: right;
}
&__complex-score {
display: flex;
align-items: center;
margin-bottom: 10px;
.nut-rate-item {
display: block !important;
line-height: 12px;
.nut-icon {
line-height: 12px;
}
}
&-i {
display: inline-block;
width: 1px;
height: 6px;
margin: 0 8px 0 6px;
font-style: inherit;
background: $text-color;
opacity: 0.4;
}
&-size {
/* stylelint-disable-next-line at-rule-no-unknown */
@include oneline-ellipsis;
}
}
&__labels--item {
display: inline-block;
height: 16px;
margin-right: 4px;
&:last-child {
margin-right: 0;
}
}
}
}
</style>

View File

@@ -0,0 +1,228 @@
<script setup lang="ts">
import type { PropType } from 'vue'
import { defineComponent, onMounted, ref, watch } from 'vue'
import { PREFIX } from '../../_constants'
import NutIcon from '../../icon/icon.vue'
const props = defineProps({
type: {
type: String,
default: 'one', // one multi
},
videos: {
type: Array as PropType<VideosType[]>,
default: () => [],
},
images: {
type: Array as PropType<ImagesType[]>,
default: () => [],
},
})
const emit = defineEmits(['click', 'clickImages'])
interface VideosType {
id: number | string
mainUrl: string
videoUrl: string
}
interface ImagesType {
smallImgUrl: string
bigImgUrl: string
imgUrl: string
}
const totalImages = ref<(VideosType | ImagesType)[]>([])
watch(
() => [props.videos, props.images],
(value) => {
if (value[0].length > 0) {
value[0].forEach((el: any) => {
el.type = 'video'
})
}
totalImages.value = (value[0] as any).concat(value[1])
},
{ deep: true },
)
onMounted(() => {
if (props.videos.length > 0) {
props.videos.forEach((el: any) => {
el.type = 'video'
})
}
totalImages.value = (props.videos as any).concat(props.images)
})
function showImages(type: string, index: string | number) {
const { videos, images } = props
const i = type === 'img' ? (index as number) - videos.length : index
emit('clickImages', {
type,
index: i,
value: type === 'img' ? images[i as number] : videos[i as number],
})
}
</script>
<script lang="ts">
const componentName = `${PREFIX}-comment-images`
export default defineComponent({
name: componentName,
options: {
virtualHost: true,
addGlobalClass: true,
styleIsolation: 'shared',
},
})
</script>
<template>
<view :class="`nut-comment-images nut-comment-images--${type}`">
<!-- videos -->
<view
v-for="(itV, index) in videos"
:key="itV.id"
class="nut-comment-images__item nut-comment-images__item--video"
@click="showImages('video', index)"
>
<image :src="itV.mainUrl" />
<view class="nut-comment-images__play" />
</view>
<!-- images -->
<template v-for="(itI, index) in images" :key="index">
<view
v-if="(type === 'multi' && videos.length + index < 9) || type !== 'multi'"
class="nut-comment-images__item nut-comment-images__item--imgbox"
@click="showImages('img', index + videos.length)"
>
<image :src="itI.smallImgUrl ? itI.smallImgUrl : itI.imgUrl" />
<view
v-if="type === 'multi' && totalImages.length > 9 && videos.length + index > 7"
class="nut-comment-images__mask"
>
<text> {{ totalImages.length }} </text>
<NutIcon name="right" size="12px" />
</view>
</view>
</template>
</view>
</template>
<style lang="scss">
.nut-comment {
&-images {
display: flex;
margin: 10px 0 12px;
overflow: auto hidden;
&__item {
position: relative;
flex-shrink: 0;
width: 80px;
height: 80px;
margin-right: 5px;
overflow: hidden;
border-radius: 6px;
image {
width: 80px;
height: 80px;
}
// &--imgbox {
// // background: #f00;
// }
&--video {
// stylelint-disable-next-line rule-empty-line-before
image {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
// height: auto;
}
}
}
&__mask {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 90px;
font-size: 12px;
line-height: 90px;
color: rgb(255 255 255 / 100%);
background: rgb(0 0 0 / 50%);
}
}
&-images--multi {
flex-wrap: wrap;
justify-content: space-between;
width: 100%;
margin: 10px auto 15px;
overflow: hidden;
.nut-comment-images__item {
width: calc(34% - 8px);
height: 90px;
margin: 8px 8px 0 0;
image {
width: 100%;
height: 100%;
}
.svg-demo {
width: 40px;
height: 40px;
}
&:nth-child(3n) {
margin-right: 0;
}
}
&::after {
display: block;
width: 105px;
content: "";
}
}
&-images__play {
position: absolute;
top: 50%;
left: 50%;
width: 40px;
height: 40px;
background: rgb(0 0 0 / 50%);
border-radius: 50%;
transform: translate(-50%);
transform: translate(-50%, -50%);
&::after {
position: absolute;
top: 11px;
left: 15px;
display: block;
content: "";
border-top: 9px solid transparent;
border-bottom: 9px solid transparent;
border-left: 15px solid #fff;
}
}
}
</style>

View File

@@ -0,0 +1,83 @@
@import '../rate/index';
.nut-theme-dark {
.nut-comment {
&__follow {
&-title {
color: $dark-color;
}
}
.nut-comment-shop {
border-top: 1px solid $dark-color;
}
}
}
.nut-comment {
width: 100%;
font-size: 12px;
&__main {
display: -webkit-box;
overflow: hidden;
word-break: break-all;
white-space: pre-wrap;
-webkit-box-orient: vertical;
}
&__follow {
&-title {
position: relative;
padding-left: 8px;
font-size: 14px;
font-weight: bold;
color: $black;
svg {
position: absolute;
top: 13%;
left: 0;
color: $primary-color;
opacity: 0.4;
transform: rotate(90deg);
}
}
&-com {
margin: 8px 0 8px 8px;
@include moreline-ellipsis(6);
}
&-img {
display: flex;
align-items: center;
margin: 0 0 8px 8px;
}
}
.nut-comment-shop {
width: 100%;
padding-top: 10px;
margin-top: 20px;
border-top: 1px solid rgb(0 0 0 / 10%);
@include moreline-ellipsis(6);
text {
color: $comment-shop-color;
}
}
}

View File

@@ -0,0 +1,2 @@
export type * from './comment'
export type * from './type'

View File

@@ -0,0 +1,60 @@
export interface VideosType {
id: number | string
mainUrl: string
videoUrl: string
}
export interface ImagesType {
smallImgUrl: string
bigImgUrl: string
imgUrl: string
}
export interface InfoType {
/**
* @description 评论详情
*/
content: string
/**
* @description 评论人的姓名
*/
nickName: string
/**
* @description 评论星星数
*/
score: string | number
/**
* @description 评论人头像
*/
avatar: string
/**
* @description 评论时间
*/
time: string
/**
* @description 评论人购买的商品规格
*/
size: string
/**
* @description 此评论的回复数
*/
replay: number
/**
* @description 此评论的点赞数
*/
like: number
}
export interface FollowType {
/**
* @description 购买多少天后进行追评
*/
days: number
/**
* @description 追评内容
*/
content: string
/**
* @description 追评图片
*/
images: []
}