Files
xk-client-wx/subPackages/chat/components/MessageBubble.vue
李琦 2d2a8abfd4 feat(home/chat): 列表骨架屏、聊天室己方头像与协议/医生详情优化
- 新增分包组件 ListSkeleton(doctor/message/product/article/category/zone)
- 首页/消息/资讯首次加载改用骨架屏,刷新不再清空旧数据避免闪空
  · 去掉 home getList、消息 onShow 中的数组清空
  · 三页 pages.json 增加 easycom 与 componentPlaceholder 主包异步引用
- 聊天室己方头像改为读取本地 userinfo.avatarurl(登录后微信头像),
  mycollection 上传头像成功后同步写回 storage
- 修复协议抽屉点不开:page-container 改为 v-if + :show;
  协议名单独可点节点,匹配放宽 name/desc 双向 includes
- 医生气泡头像可点击进入 doctor-detail?readonly=1 只读医生详情,
  隐藏预约挂号区
2026-07-19 16:08:04 +08:00

789 lines
29 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="bubble-container" :class="{ 'mine': isMine, 'other': !isMine }">
<!-- 头像非己方可点进医生详情 -->
<image class="avatar" :src="avatar" mode="aspectFill" @click.stop="onAvatarClick"></image>
<!-- 消息内容包裹 -->
<view class="content-wrapper">
<!-- 文本/语音/视频/图片 气泡 -->
<view class="message-bubble"
:class="['type-' + msg.message_type, { 'no-padding': isMediaOrCard || isSystemPriceUpdate }]"
>
<!-- 0. 文本 -->
<text v-if="msg.message_type === 0" class="text-content" user-select="true">{{ parsedContent }}</text>
<!-- 1. 图片 -->
<image v-else-if="msg.message_type === 1"
:src="parsedContent"
mode="widthFix"
class="media-content image"
@click="$emit('previewImage', parsedContent)">
</image>
<!-- 2. 语音 (优化动态宽度 + 播放动画) -->
<view v-else-if="msg.message_type === 2"
class="audio-content"
:style="{ width: getAudioWidth(msg.duration) }"
@click="$emit('playAudio', msg)">
<!-- 我发的时长在左图标在右 -->
<block v-if="isMine">
<text class="duration">{{ msg.duration }}''</text>
<view class="voice-icon-wrap mine" :class="{ 'playing': isPlaying }">
<u-icon name="volume-fill" size="20" color="#fff"></u-icon>
</view>
</block>
<!-- 别人发的图标在左时长在右 -->
<block v-else>
<view class="voice-icon-wrap other" :class="{ 'playing': isPlaying }">
<u-icon name="volume-fill" size="20" color="#333"></u-icon>
</view>
<text class="duration">{{ msg.duration }}''</text>
</block>
</view>
<!-- 3. 视频 -->
<video v-else-if="msg.message_type === 3"
:src="parsedContent"
controls
class="media-content video">
</video>
<!-- 9. 系统消息 (优化回归原生服务通知风格) -->
<block v-else-if="msg.message_type === 9">
<!-- 改价通知卡片 -->
<view
v-if="parsedContent.type === 'price_update' || parsedContent.type === 'price_adjust'"
class="service-notify-card"
@click="parsedContent.order_id && $emit('viewOrder', parsedContent.order_id, parsedContent.order_no)"
>
<!-- 卡片头部 -->
<view class="sn-header">
<text class="sn-title">{{ parsedContent.type === 'price_update' ? '订单改价通知' : '费用调整提醒' }}</text>
</view>
<!-- 卡片内容区标准键值对列表 -->
<view class="sn-body">
<!-- 场景A: 运费/总价更新 -->
<block v-if="parsedContent.type === 'price_update'">
<view class="sn-row">
<text class="sn-label">变动说明</text>
<text class="sn-value">{{ parsedContent.message }}</text>
</view>
<view class="sn-row">
<text class="sn-label">最新应付</text>
<text class="sn-value price">¥{{ parsedContent.total_pay_price }}</text>
</view>
<view class="sn-row">
<text class="sn-label">订单编号</text>
<text class="sn-value sub">{{ parsedContent.order_no }}</text>
</view>
</block>
<!-- 场景B: 单品/明细调整 -->
<block v-else-if="parsedContent.type === 'price_adjust'">
<view class="sn-row">
<text class="sn-label">调整项目</text>
<text class="sn-value truncate">{{ parsedContent.drug_name || '医疗服务' }}</text>
</view>
<view class="sn-row">
<text class="sn-label">价格变动</text>
<view class="sn-value-group">
<text class="old">¥{{ parsedContent.old_price }}</text>
<u-icon name="arrow-right" size="10" color="#999" style="margin:0 4rpx;"></u-icon>
<text class="new">¥{{ parsedContent.new_price }}</text>
</view>
</view>
<view class="sn-row">
<text class="sn-label">变动金额</text>
<text class="sn-value" :class="parsedContent.adjust_amount >= 0 ? 'color-orange' : 'color-green'">
{{ parsedContent.adjust_amount >= 0 ? '增加' : '减少' }} ¥{{ Math.abs(parsedContent.adjust_amount) }}
</text>
</view>
<view class="sn-row">
<text class="sn-label">当前总额</text>
<text class="sn-value strong">¥{{ parsedContent.total_pay_price }}</text>
</view>
</block>
</view>
<!-- 底部操作栏 -->
<view class="sn-footer">
<text>查看详情</text>
<u-icon name="arrow-right" size="12" color="#ccc"></u-icon>
</view>
</view>
<!-- 普通系统文本 (保持原样) -->
<view v-else class="system-inner">
<text>{{ msg.message_content }}</text>
</view>
</block>
<!-- 卡片类消息 (4, 10, 11, 12, 13) -->
<view v-else-if="isCardType" class="card-content">
<!-- 类型10: 挂号 -->
<block v-if="msg.message_type === 10">
<view class="card-header bg-green">
<view class="title-group"><u-icon name="calendar-fill" size="16" color="#059669"></u-icon><text class="card-title">预约挂号</text></view>
<text class="status-tag" :class="'status-' + parsedContent.status">{{ getRegisterStatusText(parsedContent.status) }}</text>
</view>
<view class="card-body">
<view class="info-row"><text class="label">订单号</text><text class="value order-font">{{ parsedContent.order_no }}</text></view>
<view class="info-row"><text class="label">就诊人</text><text class="value">{{ (parsedContent.user_patient && parsedContent.user_patient.name) || '未知' }}</text></view>
<view class="info-row highlight"><text class="label">费用</text><text class="value price">¥{{ parsedContent.price }}</text></view>
<view class="text-block" v-if="parsedContent.chief_complaint">
<text class="block-label">主诉</text>
<text class="block-content">{{ parsedContent.chief_complaint }}</text>
</view>
<view v-if="registerCardDrugRows.length" class="drug-list-block">
<text class="block-label">所选药品</text>
<view v-for="row in registerCardDrugRows" :key="row._wxKey" class="info-row drug-spec-row">
<image
v-if="row.image"
class="drug-thumb"
:src="row.image"
mode="aspectFill"
@click.stop="$emit('previewImage', row.image)"
/>
<view class="drug-name-wrap">
<text class="label">{{ row.name || '药品' }}</text>
<text v-if="row.specification" class="spec-text">{{ row.specification }}</text>
</view>
<text class="value">×{{ row.quantity }}</text>
</view>
</view>
</view>
<view class="card-footer"><button class="action-btn" @click="$emit('viewRegister', parsedContent.id)">查看详情</button></view>
</block>
<!-- 类型11: 复诊用药确认扩展 flow与药店购药 type11 互斥展示 -->
<block v-else-if="msg.message_type === 11 && parsedContent && parsedContent.flow === 'follow_up_drug'">
<view class="card-header bg-gray">
<view class="title-group"><u-icon name="question-circle-fill" size="16" color="#64748B"></u-icon><text class="card-title">医生助理</text></view>
</view>
<view class="card-body">
<view class="text-block" v-if="parsedContent.question">
<text class="block-content">{{ parsedContent.question }}</text>
</view>
<view class="info-row" v-for="(row, idx) in (parsedContent.drugs || [])" :key="idx">
<text class="label">{{ row.name || '药品' }}</text>
<text class="value">×{{ row.quantity != null ? row.quantity : 1 }}</text>
</view>
<view v-if="followUpAnsweredLabel" class="info-row">
<text class="label">您的选择</text>
<text class="value">{{ followUpAnsweredLabel }}</text>
</view>
<view v-if="parsedContent.answer_status === 'pending'" class="follow-up-pending-readonly">
<text class="follow-up-pending-tip">请在挂号后的用药确认页完成选择此处不可更改</text>
</view>
</view>
</block>
<!-- 类型11: 患者就诊经历 -->
<block v-else-if="msg.message_type === 11">
<view class="card-header bg-gray">
<view class="title-group"><u-icon name="file-text-fill" size="16" color="#64748B"></u-icon><text class="card-title">患者就诊经历</text></view>
</view>
<view class="card-body">
<view class="info-row" v-if="parsedContent.drug_name">
<text class="label">药品名称</text>
<text class="value">{{ parsedContent.drug_name }}</text>
</view>
<view class="text-block" v-if="parsedContent.illness_info">
<text class="block-label">症状描述</text>
<text class="block-content">{{ parsedContent.illness_info }}</text>
</view>
<view class="info-row"><text class="label">是否就诊过</text><text class="value">{{ parsedContent.has_visited === '1' ? '是' : '否' }}</text></view>
<view class="info-row"><text class="label">是否使用过药品</text><text class="value">{{ parsedContent.has_used_drug === '1' ? '是' : '否' }}</text></view>
</view>
</block>
<!-- 类型4: 电子处方 -->
<block v-else-if="msg.message_type === 4">
<view class="card-header bg-blue">
<view class="title-group"><u-icon name="order" size="16" color="#0A84FF"></u-icon><text class="card-title">电子处方</text></view>
<text class="status-tag blue">{{ getStatusText(parsedContent.status) }}</text>
</view>
<view class="card-body">
<view class="info-row"><text class="label">诊断单号</text><text class="value small">{{ parsedContent.order_no }}</text></view>
<view class="info-row"><text class="label">金额</text><text class="value price">¥{{ parsedContent.total_pay_price }}</text></view>
</view>
<view class="card-footer split">
<text class="link-btn" @click="$emit('viewPrescription', parsedContent.id, parsedContent.order_no)">详情</text>
<button class="action-btn primary" @click="$emit('goToOrderMedicine', parsedContent.order_id, parsedContent.order_no)">一键购药</button>
</view>
</block>
<!-- 类型12: 商品 -->
<block v-else-if="msg.message_type === 12">
<view class="card-header bg-blue">
<view class="title-group"><u-icon name="shopping-cart-fill" size="16" color="#0A84FF"></u-icon><text class="card-title">推荐商品</text></view>
</view>
<view class="card-body">
<view class="info-row"><text class="label">名称</text><text class="value">{{ parsedContent.product_name }}</text></view>
<view class="info-row"><text class="label">价格</text><text class="value price">¥{{ parsedContent.price }}</text></view>
</view>
</block>
<!-- 类型13: 结束问诊 -->
<block v-else-if="msg.message_type === 13">
<view class="card-header bg-green">
<view class="title-group"><u-icon name="checkmark-circle-fill" size="16" color="#059669"></u-icon><text class="card-title">问诊结束</text></view>
</view>
<view class="card-body">
<text class="desc-text">{{ parsedContent.reason || '本次服务已完成' }}</text>
</view>
</block>
<!-- 类型14: 转诊消息 -->
<block v-else-if="msg.message_type === 14">
<TransferMessageBubble
:message-data="parsedContent"
@agree="handleTransferAgree"
@reject="handleTransferReject"
/>
</block>
<block v-else>
<view class="card-body"><text>不支持的消息类型</text></view>
</block>
</view>
<!-- 其他未知类型默认显示文本 -->
<text v-else class="text-content">{{ parsedContent }}</text>
</view>
</view>
</view>
</template>
<script>
import TransferMessageBubble from './TransferMessageBubble.vue'
import {
agreeTransferPrescriptionApi,
rejectTransferPrescriptionApi
} from '@/request/api/transferPrescription'
export default {
name: "MessageBubble",
components: {
TransferMessageBubble
},
props: {
msg: {
type: Object,
required: true
},
isMine: {
type: Boolean,
default: false
},
avatar: {
type: String,
default: ''
},
isPlaying: {
type: Boolean,
default: false
}
},
computed: {
// 纯文本、图片、语音、视频以外的特殊类型,不需要默认的气泡背景/Padding
isMediaOrCard() {
// 1:图片, 3:视频, 4,10,11,12,13,14: 卡片 (9单独处理)
return [1, 3, 4, 10, 11, 12, 13, 14].includes(this.msg.message_type);
},
// 是否是系统消息且为价格变动(需要独立卡片样式)
isSystemPriceUpdate() {
return this.msg.message_type === 9 && (this.parsedContent.type === 'price_update' || this.parsedContent.type === 'price_adjust');
},
isCardType() {
return [4, 10, 11, 12, 13, 14].includes(this.msg.message_type);
},
parsedContent() {
const { message_type, message_content } = this.msg;
// 包含9(System)在内的复杂类型尝试解析 JSON
if ([4, 9, 10, 11, 12, 13, 14].includes(message_type)) {
try {
return typeof message_content === 'object' ? message_content : JSON.parse(message_content);
} catch (e) {
return message_content;
}
}
return message_content;
},
/**
* 预约挂号卡片药品行:药名 + 规格 + 数量(与医生端一致,去掉底部「各 X 盒」)
*/
registerCardDrugRows() {
if (this.msg.message_type !== 10) return [];
const pc = this.parsedContent;
if (!pc || typeof pc !== 'object') return [];
const fallbackQty = pc.number != null && pc.number !== '' ? Number(pc.number) : 1;
const arr = pc.selected_western_drugs;
if (Array.isArray(arr) && arr.length) {
return arr.map((d, idx) => {
const q = d && d.quantity != null ? Number(d.quantity) : fallbackQty;
const id = d && (d.drug_id || d.id);
return {
_wxKey: 'rd-' + (id != null ? id : idx),
name: (d && d.name) || '',
specification: (d && (d.specification || d.spec || d.drug_spec)) || '',
image: (d && (d.image || d.drug_image)) || '',
quantity: q >= 1 ? q : 1,
};
});
}
if (pc.drug && pc.drug.name) {
return [{
_wxKey: 'rd-' + (pc.drug.drug_id || pc.drug.id || 0),
name: pc.drug.name,
specification: pc.drug.specification || pc.drug.spec || '',
image: pc.drug.image || pc.drug.drug_image || '',
quantity: fallbackQty >= 1 ? fallbackQty : 1,
}];
}
return [];
},
followUpAnsweredLabel() {
const pc = this.parsedContent;
if (!pc || pc.flow !== 'follow_up_drug') return '';
const st = pc.answer_status;
if (st === 'yes' || st === '1') return '是,曾使用过';
if (st === 'no' || st === '0') return '否,未使用过';
return '';
}
},
methods: {
/**
* 非己方头像点击:通知父页跳转医生详情;己方头像不响应
*/
onAvatarClick() {
if (this.isMine) return;
this.$emit('avatarClick');
},
// 计算语音条宽度 (Min: 100rpx, Max: 350rpx, Unit: 10rpx/sec)
getAudioWidth(duration) {
const min = 120;
const max = 350;
const unit = 12;
let w = min + (parseInt(duration) || 0) * unit;
return Math.min(w, max) + 'rpx';
},
getStatusText(status) { const map = { 0: '待审核', 1: '已审核', 2: '未通过' }; return map[status] || '状态未知'; },
getRegisterStatusText(status) { const map = { 0: '待就诊', 1: '已缴费', 2: '已就诊', 3: '已取消', 4: '已退费' }; return map[status] || '状态未知'; },
// 处理转诊同意
handleTransferAgree(messageData) {
if (!messageData || !messageData.transfer_id) {
uni.showToast({
title: '转诊信息不完整',
icon: 'none'
})
return
}
uni.showModal({
title: '确认转诊',
content: '确定同意转诊吗?',
success: (res) => {
if (res.confirm) {
agreeTransferPrescriptionApi({ id: messageData.transfer_id }).then((result) => {
if (result.data.code == 0) {
uni.showToast({
title: '同意转诊成功',
icon: 'success'
})
// 跳转到咨询页面
setTimeout(() => {
uni.redirectTo({
url: `/subPackages/transfer/transfer-consultation?transfer_id=${messageData.transfer_id}`,
fail: (err) => {
console.error('跳转失败:', err)
uni.showToast({ title: '跳转失败,请重试', icon: 'none' })
}
})
}, 1000)
} else {
uni.showToast({
title: result.data.message || '操作失败',
icon: 'none'
})
}
}).catch((error) => {
console.error('同意转诊失败:', error)
uni.showToast({
title: '操作失败,请重试',
icon: 'none'
})
})
}
}
})
},
// 处理转诊拒绝
handleTransferReject(messageData) {
if (!messageData || !messageData.transfer_id) {
uni.showToast({
title: '转诊信息不完整',
icon: 'none'
})
return
}
uni.showModal({
title: '拒绝转诊',
content: '确定拒绝转诊吗?',
success: (res) => {
if (res.confirm) {
rejectTransferPrescriptionApi({ id: messageData.transfer_id }).then((result) => {
if (result.data.code == 0) {
uni.showToast({
title: '已拒绝转诊',
icon: 'success'
})
// 触发消息更新事件
uni.$emit('chat-message-update')
} else {
uni.showToast({
title: result.data.message || '操作失败',
icon: 'none'
})
}
}).catch((error) => {
console.error('拒绝转诊失败:', error)
uni.showToast({
title: '操作失败,请重试',
icon: 'none'
})
})
}
}
})
}
}
}
</script>
<style lang="scss" scoped>
// 变量
$primary: #0A84FF;
$bg-mine: #0A84FF;
$bg-other: #FFFFFF;
$text-main: #333333;
$text-sub: #666666;
$radius-bubble: 12rpx;
$price-color: #FF4D4F; // 价格红
$success-color: #059669;
.bubble-container {
display: flex;
align-items: flex-start;
margin-bottom: 30rpx;
width: 100%;
.avatar {
width: 72rpx; height: 72rpx;
border-radius: 10rpx;
flex-shrink: 0;
background: #f2f2f2;
}
.content-wrapper {
display: flex;
flex-direction: column;
max-width: 72%; // 稍微放宽一点
}
&.other {
flex-direction: row;
.avatar { margin-right: 16rpx; }
.message-bubble {
background-color: $bg-other;
color: $text-main;
border-radius: 4rpx $radius-bubble $radius-bubble $radius-bubble;
// 添加小箭头
&::before {
content: ''; position: absolute; top: 20rpx; left: -10rpx;
width: 0; height: 0;
border-top: 10rpx solid transparent;
border-bottom: 10rpx solid transparent;
border-right: 12rpx solid $bg-other;
}
// 卡片或特殊系统消息不需要箭头
&.no-padding::before { display: none; }
}
}
&.mine {
flex-direction: row-reverse;
.avatar { margin-left: 16rpx; }
.message-bubble {
background-color: $bg-mine;
color: #fff;
border-radius: $radius-bubble 4rpx $radius-bubble $radius-bubble;
&::after {
content: ''; position: absolute; top: 20rpx; right: -10rpx;
width: 0; height: 0;
border-top: 10rpx solid transparent;
border-bottom: 10rpx solid transparent;
border-left: 12rpx solid $bg-mine;
}
&.no-padding::after { display: none; }
}
}
}
.message-bubble {
padding: 18rpx 24rpx;
font-size: 30rpx;
line-height: 1.5;
position: relative;
word-break: break-all;
box-shadow: 0 2rpx 4rpx rgba(0,0,0,0.05);
min-height: 72rpx;
display: flex; align-items: center;
&.no-padding {
padding: 0;
background: transparent;
box-shadow: none;
&::before, &::after { display: none; }
}
}
// 图片/视频
.media-content {
border-radius: 8rpx;
&.image { max-width: 300rpx; display: block; }
&.video { width: 300rpx; height: 170rpx; }
}
// ========== 语音条样式 (Audio) ==========
.audio-content {
display: flex; align-items: center;
// 宽度由 style 动态控制,这里不要锁死 width
justify-content: space-between;
.duration {
font-size: 26rpx;
white-space: nowrap;
}
.voice-icon-wrap {
display: flex; align-items: center; justify-content: center;
width: 44rpx; height: 44rpx;
// 对方发的消息,图标在左,波纹向右(默认)
&.other {
margin-right: 6rpx;
}
// 我发的消息,图标在右,波纹需要转向左侧 (rotate 180)
&.mine {
margin-left: 6rpx;
transform: rotate(180deg);
}
// 播放时的动画:呼吸/波动效果
&.playing {
animation: voice-pulse 1.2s infinite ease-in-out;
}
}
}
// 语音播放动画关键帧
@keyframes voice-pulse {
0% { opacity: 1; transform: scale(1) rotate(var(--rotate-angle, 0deg)); }
50% { opacity: 0.4; transform: scale(0.92) rotate(var(--rotate-angle, 0deg)); }
100% { opacity: 1; transform: scale(1) rotate(var(--rotate-angle, 0deg)); }
}
// 为了解决 rotate 和 scale 同时作用的问题,分别定义 mine 和 other
.voice-icon-wrap.mine.playing { --rotate-angle: 180deg; animation: voice-pulse 1.2s infinite ease-in-out; }
.voice-icon-wrap.other.playing { --rotate-angle: 0deg; animation: voice-pulse 1.2s infinite ease-in-out; }
// ========== 卡片样式 (通用) ==========
.card-content {
background: #fff;
border-radius: 16rpx;
overflow: hidden;
border: 1rpx solid #eee;
width: 480rpx; // 卡片定宽
.card-header {
padding: 16rpx 20rpx;
display: flex; justify-content: space-between; align-items: center;
border-bottom: 1rpx solid #f9f9f9;
&.bg-green { background: #F0FDF4; }
&.bg-blue { background: #EFF6FF; }
&.bg-gray { background: #F8FAFC; }
.title-group { display: flex; align-items: center; gap: 8rpx; .card-title { font-size: 26rpx; font-weight: 600; color: #333; } }
.status-tag { font-size: 20rpx; padding: 2rpx 10rpx; border-radius: 6rpx; background: #fff; color: $text-sub; border: 1rpx solid rgba(0,0,0,0.05); }
}
.card-body {
padding: 20rpx; display: flex; flex-direction: column; gap: 12rpx;
.info-row {
display: flex; justify-content: space-between; font-size: 24rpx;
.label { color: #888; }
.value { color: #333; font-weight: 500; text-align: right; max-width: 70%; }
.order-font { font-family: Courier, monospace; letter-spacing: 0.5rpx; }
.price { color: #ef4444; font-size: 28rpx; font-weight: bold; }
.small { font-size: 22rpx; color: #999; }
}
.drug-spec-row {
align-items: flex-start;
.drug-thumb {
width: 64rpx;
height: 64rpx;
border-radius: 8rpx;
background: #f3f4f6;
flex-shrink: 0;
margin-right: 12rpx;
}
.drug-name-wrap {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4rpx;
flex: 1;
min-width: 0;
}
.spec-text {
display: block;
font-size: 22rpx;
color: #9ca3af;
font-weight: 400;
text-align: left;
}
}
.drug-list-block {
.block-label {
display: block;
font-size: 22rpx;
color: #888;
margin-bottom: 8rpx;
}
}
.desc-text { font-size: 26rpx; color: #333; }
.text-block {
background: #f5f5f5; padding: 16rpx; border-radius: 8rpx;
.block-label { display: block; font-size: 22rpx; color: #999; margin-bottom: 6rpx; }
.block-content { font-size: 26rpx; color: #333; line-height: 1.4; }
}
}
.card-footer {
padding: 16rpx 20rpx; border-top: 1rpx solid #f0f0f0;
.action-btn {
width: 100%; height: 60rpx; line-height: 60rpx; font-size: 26rpx; border-radius: 30rpx; background: #fff; border: 1rpx solid #ddd; color: #555;
&.primary { background: $primary; color: #fff; border: none; }
&::after { border: none; }
}
&.split { display: flex; justify-content: space-between; align-items: center; .link-btn { font-size: 24rpx; color: $primary; padding: 10rpx; } .action-btn { width: 160rpx; } }
}
.follow-up-pending-readonly {
margin-top: 12rpx;
padding: 16rpx 20rpx;
background: #f7f8fa;
border-radius: 12rpx;
.follow-up-pending-tip {
font-size: 24rpx;
color: #909399;
line-height: 1.5;
}
}
}
// ========== 原生风格服务通知 (Service Notify Card) ==========
// 这是一个非常经典、耐看的布局,模仿微信/支付宝的服务通知
.service-notify-card {
background: #FFFFFF;
border-radius: 12rpx; // 稍微调小圆角,更正式
width: 480rpx;
border: 1rpx solid #eaeaea; // 纯粹的边框
overflow: hidden;
position: relative;
// 头部:极简
.sn-header {
padding: 24rpx 24rpx 16rpx 24rpx;
border-bottom: 1rpx solid #f5f5f5;
.sn-title {
font-size: 30rpx;
font-weight: 600;
color: #333;
}
}
// 内容区标准的左key右value列表
.sn-body {
padding: 20rpx 24rpx;
display: flex; flex-direction: column; gap: 16rpx;
.sn-row {
display: flex; justify-content: space-between; align-items: flex-start;
line-height: 1.4;
.sn-label {
font-size: 26rpx; color: #999; // 经典的标签灰
min-width: 110rpx; // 保证对齐
}
.sn-value {
font-size: 26rpx; color: #333; text-align: right; flex: 1;
&.truncate {
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 320rpx;
}
&.price { color: #333; font-weight: 600; font-size: 28rpx; } // 普通金额强调
&.sub { color: #999; font-family: monospace; }
&.strong { color: #333; font-weight: bold; font-size: 30rpx; }
// 涨跌颜色
&.color-orange { color: #fa8c16; }
&.color-green { color: #52c41a; }
}
// 价格变化组合
.sn-value-group {
display: flex; align-items: center; justify-content: flex-end;
.old { color: #999; text-decoration: line-through; font-size: 24rpx; }
.new { color: #333; font-weight: 600; font-size: 26rpx; }
}
}
}
// 底部:通用的查看详情
.sn-footer {
border-top: 1rpx solid #f5f5f5;
padding: 18rpx 24rpx;
display: flex; justify-content: space-between; align-items: center;
font-size: 24rpx; color: #666;
background: #fff;
&:active {
background: #f9f9f9; // 仅底栏有点击态
}
}
}
.system-inner {
background: rgba(0,0,0,0.05); padding: 8rpx 20rpx; border-radius: 10rpx; font-size: 22rpx; color: #999; align-self: center; margin: 0 auto;
}
</style>