feat:消息中心和案例库

This commit is contained in:
李伊帆
2026-09-14 10:32:21 +08:00
parent 173cd6b975
commit f5d634bbaf
41 changed files with 3484 additions and 48 deletions

View File

@@ -0,0 +1,106 @@
<script setup lang="ts">
import type { MessageItem } from '@/types/message-center'
defineProps<{
item: MessageItem
}>()
const emit = defineEmits<{
tap: []
}>()
</script>
<template>
<view
class="message-item"
@tap="emit('tap')"
>
<view class="message-item__icon-wrap">
<image
class="message-item__icon"
:src="item.icon"
mode="aspectFit"
/>
</view>
<view class="message-item__content">
<view class="message-item__row">
<text class="message-item__title">
{{ item.title }}
</text>
<text class="message-item__time">
{{ item.time }}
</text>
</view>
<text class="message-item__preview">
{{ item.preview }}
</text>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.message-item {
display: flex;
align-items: flex-start;
width: 100%;
box-sizing: border-box;
padding: 24rpx 32rpx;
background: $color-bg-page;
&:last-child {
border-bottom: none;
}
}
.message-item__icon-wrap {
flex-shrink: 0;
margin-right: 24rpx;
}
.message-item__icon {
width: 88rpx;
height: 88rpx;
}
.message-item__content {
flex: 1;
min-width: 0;
padding-top: 4rpx;
}
.message-item__row {
display: flex;
align-items: center;
justify-content: space-between;
}
.message-item__title {
font-size: 30rpx;
font-weight: 600;
color: $color-text-primary;
}
.message-item__time {
flex-shrink: 0;
margin-left: 16rpx;
font-size: 24rpx;
color: $color-text-secondary;
}
.message-item__preview {
margin-top: 8rpx;
min-height: 78rpx;
font-size: 26rpx;
line-height: 1.5;
color: $color-text-secondary;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import type { ChatMessage } from '@/types/customer-service'
defineProps<{
message: ChatMessage
}>()
</script>
<template>
<view
class="chat-message"
:class="`chat-message--${message.role}`"
>
<image
class="chat-message__avatar"
:src="message.avatar"
mode="aspectFill"
/>
<view class="chat-message__body">
<text class="chat-message__nickname">
{{ message.nickname }}
</text>
<view
class="chat-message__bubble"
:class="`chat-message__bubble--${message.role}`"
>
<text class="chat-message__text">
{{ message.content }}
</text>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.chat-message {
display: flex;
align-items: flex-start;
margin-bottom: 32rpx;
&--user {
flex-direction: row-reverse;
.chat-message__body {
align-items: flex-end;
}
.chat-message__nickname {
text-align: right;
}
}
&--service {
flex-direction: row;
.chat-message__body {
align-items: flex-start;
}
}
}
.chat-message__avatar {
flex-shrink: 0;
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #fff7e6;
}
.chat-message__body {
display: flex;
flex-direction: column;
max-width: calc(100% - 96rpx);
margin: 0 16rpx;
}
.chat-message__nickname {
margin-bottom: 8rpx;
font-size: 22rpx;
color: $color-text-secondary;
}
.chat-message__bubble {
padding: 20rpx 24rpx;
border-radius: 16rpx;
&--user {
background: $color-primary;
}
&--service {
background: #f2f3f5;
}
}
.chat-message__text {
font-size: 28rpx;
line-height: 1.5;
word-break: break-all;
.chat-message__bubble--user & {
color: #ffffff;
}
.chat-message__bubble--service & {
color: $color-text-primary;
}
}
</style>

View File

@@ -0,0 +1,299 @@
<script setup lang="ts">
import { ref } from 'vue'
import { CUSTOMER_SERVICE_MESSAGES } from '@/constants/customer-service'
import type { ChatMessage } from '@/types/customer-service'
import { navigateBackWithFallback } from '@/utils/navigate-back'
import ChatMessageItem from './components/ChatMessage.vue'
const messages = ref<ChatMessage[]>(CUSTOMER_SERVICE_MESSAGES.map(item => ({ ...item })))
const inputValue = ref('')
const panelVisible = ref(false)
function handleBack() {
navigateBackWithFallback('/pages/message-center/index')
}
function togglePanel() {
panelVisible.value = !panelVisible.value
}
function handleSend() {
const content = inputValue.value.trim()
if (!content)
return
messages.value.push({
id: String(Date.now()),
role: 'user',
nickname: '昵称',
content,
avatar: '/static/icons/hi.png',
})
inputValue.value = ''
panelVisible.value = false
}
function handleAttachment(type: 'camera' | 'photo' | 'file') {
const labelMap = {
camera: '拍摄',
photo: '照片',
file: '文件',
}
uni.showToast({ title: `${labelMap[type]}功能开发中`, icon: 'none' })
}
</script>
<template>
<view class="customer-service">
<view class="customer-service__navbar">
<view
class="customer-service__back"
@tap="handleBack"
>
<image
class="customer-service__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="customer-service__title">
在线客服
</text>
<view class="customer-service__navbar-placeholder" />
</view>
<scroll-view
class="customer-service__scroll"
scroll-y
:show-scrollbar="false"
:scroll-into-view="messages.length ? `msg-${messages[messages.length - 1].id}` : ''"
>
<view class="customer-service__messages">
<view
v-for="message in messages"
:id="`msg-${message.id}`"
:key="message.id"
>
<ChatMessageItem :message="message" />
</view>
</view>
</scroll-view>
<view class="customer-service__footer">
<view class="customer-service__input-row">
<view class="customer-service__tool-btn">
<image
class="customer-service__tool-icon"
src="/static/icons/icon-chat-voice.png"
mode="aspectFit"
/>
</view>
<input
v-model="inputValue"
class="customer-service__input"
placeholder="请输入消息..."
placeholder-class="customer-service__placeholder"
confirm-type="send"
@confirm="handleSend"
>
<view
class="customer-service__tool-btn"
@tap="togglePanel"
>
<image
class="customer-service__tool-icon"
src="/static/icons/icon-chat-add.png"
mode="aspectFit"
/>
</view>
</view>
<view
v-if="panelVisible"
class="customer-service__panel"
>
<view
class="customer-service__panel-item"
@tap="handleAttachment('camera')"
>
<view class="customer-service__panel-icon-wrap">
<image
class="customer-service__panel-icon"
src="/static/icons/icon-chat-camera.png"
mode="aspectFit"
/>
</view>
<text class="customer-service__panel-label">
拍摄
</text>
</view>
<view
class="customer-service__panel-item"
@tap="handleAttachment('photo')"
>
<view class="customer-service__panel-icon-wrap">
<image
class="customer-service__panel-icon"
src="/static/icons/icon-chat-photo.png"
mode="aspectFit"
/>
</view>
<text class="customer-service__panel-label">
照片
</text>
</view>
<view
class="customer-service__panel-item"
@tap="handleAttachment('file')"
>
<view class="customer-service__panel-icon-wrap">
<image
class="customer-service__panel-icon"
src="/static/icons/icon-chat-file.png"
mode="aspectFit"
/>
</view>
<text class="customer-service__panel-label">
文件
</text>
</view>
</view>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.customer-service {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: $color-bg-page;
}
.customer-service__navbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 12rpx) 24rpx 20rpx;
background: $color-bg-page;
border-bottom: 1rpx solid #f0f0f0;
}
.customer-service__back {
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.customer-service__back-icon {
width: 40rpx;
height: 40rpx;
}
.customer-service__title {
font-size: 34rpx;
font-weight: 600;
color: $color-text-primary;
}
.customer-service__navbar-placeholder {
width: 64rpx;
height: 64rpx;
}
.customer-service__scroll {
flex: 1;
min-height: 0;
background: $color-bg-page;
}
.customer-service__messages {
padding: 24rpx 24rpx 32rpx;
}
.customer-service__footer {
flex-shrink: 0;
padding: 16rpx 24rpx calc(16rpx + env(safe-area-inset-bottom));
background: $color-bg-page;
border-top: 1rpx solid #f0f0f0;
}
.customer-service__input-row {
display: flex;
align-items: center;
gap: 16rpx;
}
.customer-service__tool-btn {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.customer-service__tool-icon {
width: 48rpx;
height: 48rpx;
}
.customer-service__input {
flex: 1;
height: 72rpx;
padding: 0 24rpx;
font-size: 28rpx;
color: $color-text-primary;
background: #f2f3f5;
border-radius: 36rpx;
}
.customer-service__placeholder {
color: $color-text-placeholder;
}
.customer-service__panel {
display: flex;
align-items: flex-start;
justify-content: space-around;
min-height: 400rpx;
margin-top: 24rpx;
padding: 40rpx 24rpx 48rpx;
border-top: 1rpx solid #f0f0f0;
}
.customer-service__panel-item {
display: flex;
flex-direction: column;
align-items: center;
}
.customer-service__panel-icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 112rpx;
height: 112rpx;
}
.customer-service__panel-icon {
width: 112rpx;
height: 112rpx;
}
.customer-service__panel-label {
margin-top: 12rpx;
font-size: 24rpx;
color: $color-text-secondary;
}
</style>

View File

@@ -1,15 +1,76 @@
<script setup lang="ts">
import { ref } from 'vue'
import AppTabBar from '@/components/AppTabBar.vue'
import { MESSAGE_LIST } from '@/constants/message-center'
import type { MessageItem } from '@/types/message-center'
import MessageItemCard from './components/MessageItem.vue'
const messageList = ref<MessageItem[]>(MESSAGE_LIST.map(item => ({ ...item })))
function handleReadAll() {
messageList.value = messageList.value.map(item => ({
...item,
unread: false,
}))
uni.showToast({ title: '已全部标为已读', icon: 'none' })
}
function handleMessageTap(item: MessageItem) {
if (item.type === 'service') {
messageList.value = messageList.value.map(row =>
row.id === item.id ? { ...row, unread: false } : row,
)
uni.navigateTo({
url: '/pages/message-center/customer-service/index',
})
return
}
if (item.type === 'workorder') {
uni.navigateTo({
url: '/pages/message-center/status-notification/index',
})
return
}
if (item.type === 'system') {
uni.navigateTo({
url: '/pages/message-center/system-notification/index',
})
}
}
</script>
<template>
<view class="placeholder-page">
<text class="placeholder-page__title">
消息中心
</text>
<text class="placeholder-page__desc">
功能开发中
</text>
<view class="message-center">
<view class="message-center__header">
<text class="message-center__title">
消息中心
</text>
<text
class="message-center__read-all"
@tap="handleReadAll"
>
全部已读
</text>
</view>
<scroll-view
class="message-center__scroll"
scroll-y
:show-scrollbar="false"
>
<view class="message-center__list">
<MessageItemCard
v-for="item in messageList"
:key="item.id"
:item="item"
@tap="handleMessageTap(item)"
/>
</view>
</scroll-view>
<AppTabBar active="message" />
</view>
</template>
@@ -17,25 +78,45 @@ import AppTabBar from '@/components/AppTabBar.vue'
<style scoped lang="scss">
@import '@/styles/variables.scss';
.placeholder-page {
.message-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
height: 100vh;
overflow: hidden;
background: $color-bg-page;
}
.placeholder-page__title {
.message-center__header {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 32rpx 28rpx;
background: $color-bg-page;
}
.message-center__title {
font-size: 36rpx;
font-weight: 600;
color: $color-text-primary;
}
.placeholder-page__desc {
margin-top: 16rpx;
font-size: 28rpx;
.message-center__read-all {
position: absolute;
right: 32rpx;
bottom: 28rpx;
font-size: 26rpx;
color: $color-text-secondary;
}
.message-center__scroll {
flex: 1;
min-height: 0;
}
.message-center__list {
width: 100%;
padding-bottom: calc(140rpx + env(safe-area-inset-bottom));
}
</style>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import type { StatusNotificationItem } from '@/types/status-notification'
defineProps<{
item: StatusNotificationItem
icon: string
}>()
</script>
<template>
<view class="status-notification-item">
<image
class="status-notification-item__icon"
:src="icon"
mode="aspectFit"
/>
<view class="status-notification-item__content">
<view class="status-notification-item__row">
<text class="status-notification-item__title">
{{ item.title }}
</text>
<text class="status-notification-item__time">
{{ item.time }}
</text>
</view>
<text class="status-notification-item__preview">
{{ item.preview }}
</text>
</view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.status-notification-item {
display: flex;
align-items: flex-start;
width: 100%;
box-sizing: border-box;
padding: 24rpx 32rpx;
background: $color-bg-page;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.status-notification-item__icon {
flex-shrink: 0;
width: 88rpx;
height: 88rpx;
margin-right: 24rpx;
}
.status-notification-item__content {
flex: 1;
min-width: 0;
padding-top: 4rpx;
}
.status-notification-item__row {
display: flex;
align-items: center;
justify-content: space-between;
}
.status-notification-item__title {
font-size: 30rpx;
font-weight: 600;
color: $color-text-primary;
}
.status-notification-item__time {
flex-shrink: 0;
margin-left: 16rpx;
font-size: 24rpx;
color: $color-text-secondary;
}
.status-notification-item__preview {
margin-top: 8rpx;
min-height: 78rpx;
font-size: 26rpx;
line-height: 1.5;
color: $color-text-secondary;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
}
</style>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
STATUS_NOTIFICATION_ICON,
STATUS_NOTIFICATION_LIST,
} from '@/constants/status-notification'
import type { StatusNotificationItem } from '@/types/status-notification'
import { navigateBackWithFallback } from '@/utils/navigate-back'
import StatusNotificationItemCard from './components/StatusNotificationItem.vue'
const keyword = ref('')
const notificationList = ref<StatusNotificationItem[]>(
STATUS_NOTIFICATION_LIST.map(item => ({ ...item })),
)
const filteredList = computed(() => {
const text = keyword.value.trim()
if (!text)
return notificationList.value
return notificationList.value.filter(item =>
item.title.includes(text) || item.preview.includes(text),
)
})
function handleBack() {
navigateBackWithFallback('/pages/message-center/index')
}
function handleReadAll() {
notificationList.value = notificationList.value.map(item => ({
...item,
unread: false,
}))
uni.showToast({ title: '已全部标为已读', icon: 'none' })
}
</script>
<template>
<view class="status-notification">
<view class="status-notification__header">
<view
class="status-notification__back"
@tap="handleBack"
>
<image
class="status-notification__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="status-notification__title">
状态通知
</text>
<text
class="status-notification__read-all"
@tap="handleReadAll"
>
全部已读
</text>
</view>
<view class="status-notification__search">
<image
class="status-notification__search-icon"
src="/static/icons/icon-search.svg"
mode="aspectFit"
/>
<input
v-model="keyword"
class="status-notification__search-input"
placeholder="搜索案例标题、描述、标签..."
placeholder-class="status-notification__search-placeholder"
>
</view>
<scroll-view
class="status-notification__scroll"
scroll-y
:show-scrollbar="false"
>
<view class="status-notification__list">
<StatusNotificationItemCard
v-for="item in filteredList"
:key="item.id"
:item="item"
:icon="STATUS_NOTIFICATION_ICON"
/>
</view>
</scroll-view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.status-notification {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: $color-bg-page;
}
.status-notification__header {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 32rpx 28rpx;
background: $color-bg-page;
}
.status-notification__back {
position: absolute;
left: 32rpx;
bottom: 28rpx;
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.status-notification__back-icon {
width: 40rpx;
height: 40rpx;
}
.status-notification__title {
font-size: 36rpx;
font-weight: 600;
color: $color-text-primary;
}
.status-notification__read-all {
position: absolute;
right: 32rpx;
bottom: 28rpx;
font-size: 26rpx;
color: $color-text-secondary;
}
.status-notification__search {
display: flex;
align-items: center;
flex-shrink: 0;
height: 72rpx;
margin: 0 32rpx 16rpx;
padding: 0 24rpx;
background: $color-bg-section;
border-radius: 36rpx;
}
.status-notification__search-icon {
flex-shrink: 0;
width: 32rpx;
height: 32rpx;
margin-right: 12rpx;
}
.status-notification__search-input {
flex: 1;
font-size: 26rpx;
color: $color-text-primary;
}
.status-notification__search-placeholder {
color: $color-text-placeholder;
}
.status-notification__scroll {
flex: 1;
min-height: 0;
}
.status-notification__list {
width: 100%;
padding-bottom: env(safe-area-inset-bottom);
}
</style>

View File

@@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import {
SYSTEM_NOTIFICATION_ICON,
SYSTEM_NOTIFICATION_LIST,
} from '@/constants/system-notification'
import type { StatusNotificationItem } from '@/types/status-notification'
import { navigateBackWithFallback } from '@/utils/navigate-back'
import StatusNotificationItemCard from '../status-notification/components/StatusNotificationItem.vue'
const keyword = ref('')
const notificationList = ref<StatusNotificationItem[]>(
SYSTEM_NOTIFICATION_LIST.map(item => ({ ...item })),
)
const filteredList = computed(() => {
const text = keyword.value.trim()
if (!text)
return notificationList.value
return notificationList.value.filter(item =>
item.title.includes(text) || item.preview.includes(text),
)
})
function handleBack() {
navigateBackWithFallback('/pages/message-center/index')
}
function handleReadAll() {
notificationList.value = notificationList.value.map(item => ({
...item,
unread: false,
}))
uni.showToast({ title: '已全部标为已读', icon: 'none' })
}
</script>
<template>
<view class="system-notification">
<view class="system-notification__header">
<view
class="system-notification__back"
@tap="handleBack"
>
<image
class="system-notification__back-icon"
src="/static/icons/icon-back.svg"
mode="aspectFit"
/>
</view>
<text class="system-notification__title">
系统列表
</text>
<text
class="system-notification__read-all"
@tap="handleReadAll"
>
全部已读
</text>
</view>
<view class="system-notification__search">
<image
class="system-notification__search-icon"
src="/static/icons/icon-search.svg"
mode="aspectFit"
/>
<input
v-model="keyword"
class="system-notification__search-input"
placeholder="搜索案例标题、描述、标签..."
placeholder-class="system-notification__search-placeholder"
>
</view>
<scroll-view
class="system-notification__scroll"
scroll-y
:show-scrollbar="false"
>
<view class="system-notification__list">
<StatusNotificationItemCard
v-for="item in filteredList"
:key="item.id"
:item="item"
:icon="SYSTEM_NOTIFICATION_ICON"
/>
</view>
</scroll-view>
</view>
</template>
<style scoped lang="scss">
@import '@/styles/variables.scss';
.system-notification {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
background: $color-bg-page;
}
.system-notification__header {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: calc(var(--status-bar-height, 44px) + 60rpx) 32rpx 28rpx;
background: $color-bg-page;
}
.system-notification__back {
position: absolute;
left: 32rpx;
bottom: 28rpx;
display: flex;
align-items: center;
justify-content: center;
width: 64rpx;
height: 64rpx;
}
.system-notification__back-icon {
width: 40rpx;
height: 40rpx;
}
.system-notification__title {
font-size: 36rpx;
font-weight: 600;
color: $color-text-primary;
}
.system-notification__read-all {
position: absolute;
right: 32rpx;
bottom: 28rpx;
font-size: 26rpx;
color: $color-text-secondary;
}
.system-notification__search {
display: flex;
align-items: center;
flex-shrink: 0;
height: 72rpx;
margin: 0 32rpx 16rpx;
padding: 0 24rpx;
background: $color-bg-section;
border-radius: 36rpx;
}
.system-notification__search-icon {
flex-shrink: 0;
width: 32rpx;
height: 32rpx;
margin-right: 12rpx;
}
.system-notification__search-input {
flex: 1;
font-size: 26rpx;
color: $color-text-primary;
}
.system-notification__search-placeholder {
color: $color-text-placeholder;
}
.system-notification__scroll {
flex: 1;
min-height: 0;
}
.system-notification__list {
width: 100%;
padding-bottom: env(safe-area-inset-bottom);
}
</style>