Files
frontend-miniapp/src/components/explain/ExplainList.vue

1055 lines
26 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="explain-list">
<scroll-view
class="explain-scroll"
:scroll-y="true"
:show-scrollbar="false"
:lower-threshold="120"
@scrolltolower="handleScrollToLower"
>
<view class="explain-hero">
<view class="hero-copy">
<text class="hero-kicker">{{ isAllListMode ? '全部讲解' : '馆内讲解' }}</text>
<text class="hero-title">{{ heroTitle }}</text>
<text class="hero-desc">
{{ heroDescription }}
</text>
</view>
<view class="hero-meta-row">
<view v-if="showBrowseAll && !isAllListMode" class="hero-browse-all" @tap="handleBrowseAll">
<text class="hero-browse-all-text">全部</text>
</view>
<view class="hero-chip">
<text class="hero-chip-text">位置预览</text>
</view>
<view class="hero-chip muted">
<text class="hero-chip-text">{{ textOnlyCount }} 个图文讲解</text>
</view>
</view>
</view>
<view class="search-box">
<svg class="search-icon" width="20" height="20" viewBox="0 0 20 20" fill="none">
<circle cx="8.5" cy="8.5" r="5.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M12.5 12.5L17 17" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<input
class="search-input"
type="text"
placeholder="搜索讲解、空间、标本"
v-model="searchInputValue"
@input="handleSearchInput"
@confirm="handleSearchConfirm"
/>
<view v-if="searchInputValue" class="clear-btn" @tap="handleClearSearch">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="7" cy="7" r="6" fill="#8b9183"/>
<path d="M4.5 4.5L9.5 9.5M9.5 4.5L4.5 9.5" stroke="white" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</view>
</view>
<view v-if="loading" class="state-block">
<text class="state-title">正在加载讲解内容</text>
<text class="state-desc">稍后将展示当前楼层的场景空间与展品讲解</text>
</view>
<view v-else-if="error" class="state-block error">
<text class="state-title">讲解内容加载失败</text>
<text class="state-desc">{{ error }}</text>
</view>
<template v-else>
<view v-if="featuredCard && !isAllListMode" class="featured-section">
<view class="section-heading">
<text class="section-title">今日推荐讲解</text>
<text class="section-count">{{ featuredCard.audioStatusText }}</text>
</view>
<view class="featured-card" @tap="handleCardClick(featuredCard)">
<image
v-if="featuredCard.coverImage"
class="featured-cover"
:src="featuredCard.coverImage"
mode="aspectFill"
/>
<view v-else class="featured-cover placeholder">
<text class="placeholder-text">{{ contentTypeText(featuredCard.contentType).slice(0, 1) }}</text>
</view>
<view class="featured-body">
<text class="featured-title">{{ featuredCard.title }}</text>
<text class="featured-meta">{{ cardMeta(featuredCard) }}</text>
<text class="featured-summary">{{ featuredCard.summary || '该讲解暂无文稿。' }}</text>
<view class="featured-actions">
<view
v-if="featuredCard.audioStatus === 'playable'"
class="action-btn primary"
@tap.stop="handleAudioClick(featuredCard)"
>
<text class="action-text primary">开始讲解</text>
</view>
<view v-else class="action-btn disabled">
<text class="action-text disabled">{{ featuredCard.audioStatusText }}</text>
</view>
<view
v-if="featuredCard.poiId && featuredCard.locationActionText"
class="action-btn secondary"
@tap.stop="handleLocationClick(featuredCard)"
>
<text class="action-text">{{ featuredCard.locationActionText }}</text>
</view>
</view>
</view>
</view>
</view>
<view class="filter-section">
<view
v-for="filter in resolvedFilters"
:key="filter.id"
class="filter-chip"
:class="{ active: activeFilter === filter.id }"
@tap="handleFilterChange(filter.id)"
>
<text class="filter-text">{{ filter.label }}</text>
<text v-if="typeof filter.count === 'number'" class="filter-count">{{ filter.count }}</text>
</view>
</view>
<view v-if="hallSummaries.length && (!isAllListMode || activeFilter === 'hall')" class="hall-section">
<view class="section-heading">
<text class="section-title">场景空间</text>
<text class="section-count">{{ hallSummaries.length }} 个展厅</text>
</view>
<view class="hall-grid">
<view
v-for="hall in hallSummaries"
:key="hall.name"
class="hall-chip"
:class="{ active: activeHallName === hall.name }"
@tap="handleHallFilter(hall.name)"
>
<view class="hall-chip-main">
<text class="hall-name">{{ hall.name }}</text>
<text class="hall-count">{{ hall.count }}</text>
</view>
<view
v-if="hall.id"
class="hall-detail-action"
@tap.stop="handleHallDetailClick(hall.id)"
>
<text class="hall-detail-text">查看空间</text>
</view>
</view>
</view>
</view>
<view class="list-section">
<view class="section-heading">
<text class="section-title">{{ listTitle }}</text>
<text class="section-count">{{ listCountText }}</text>
</view>
<view v-if="displayedCards.length" class="card-list">
<view
v-for="card in displayedCards"
:key="card.id"
class="explain-card"
@tap="handleCardClick(card)"
>
<view class="card-main">
<view class="card-title-row">
<text class="card-title">{{ card.title }}</text>
<view class="status-pill" :class="`status-${card.audioStatus}`">
<text class="status-text">{{ card.audioStatusText }}</text>
</view>
</view>
<text class="card-meta">{{ cardMeta(card) }}</text>
<text v-if="card.summary" class="card-summary">{{ card.summary }}</text>
<view class="badge-row">
<text
v-for="badge in normalizedBadges(card)"
:key="`${card.id}-${badge}`"
class="badge"
>
{{ badge }}
</text>
</view>
</view>
<view class="card-actions">
<view
v-if="card.audioStatus === 'playable'"
class="small-action primary"
@tap.stop="handleAudioClick(card)"
>
<text class="small-action-text primary">播放</text>
</view>
<view class="small-action" @tap.stop="handleCardClick(card)">
<text class="small-action-text">详情</text>
</view>
<view
v-if="card.poiId && card.locationActionText"
class="small-action"
@tap.stop="handleLocationClick(card)"
>
<text class="small-action-text">{{ card.locationActionText }}</text>
</view>
</view>
</view>
<view v-if="hasMoreCards" class="list-more">
<view
v-if="showBrowseAll"
class="more-action primary"
@tap="handleBrowseAll"
>
<text class="more-action-text primary">查看全部 {{ filteredCards.length }} </text>
</view>
<view
v-else
class="more-action"
@tap="handleLoadMore"
>
<text class="more-action-text">加载更多 {{ nextLoadCount }} </text>
</view>
<text class="more-desc">
已展示 {{ displayedCards.length }} 剩余 {{ remainingCardsCount }}
</text>
</view>
</view>
<view v-else class="state-block empty">
<text class="state-title">未找到相关讲解</text>
<text class="state-desc">试试空间名称标本名称楼层或主题关键词</text>
</view>
</view>
</template>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import type {
ExplainCardViewModel
} from '@/view-models/explainViewModels'
export interface ExplainFilterOption {
id: 'all' | 'hall' | 'theme' | 'nearby' | 'playable'
label: string
count?: number
}
export type Exhibit = ExplainCardViewModel
const props = withDefaults(defineProps<{
cards?: ExplainCardViewModel[]
exhibits?: ExplainCardViewModel[]
filters?: ExplainFilterOption[]
loading?: boolean
error?: string
hotSearches?: string[]
listTitle?: string
initialRenderLimit?: number
pageSize?: number
showBrowseAll?: boolean
pageMode?: 'home' | 'all'
}>(), {
cards: () => [],
exhibits: () => [],
filters: () => [],
loading: false,
error: '',
hotSearches: () => [],
listTitle: '讲解列表',
initialRenderLimit: 16,
pageSize: 24,
showBrowseAll: false,
pageMode: 'home'
})
const emit = defineEmits<{
exhibitClick: [card: ExplainCardViewModel]
featuredClick: [card: ExplainCardViewModel]
audioClick: [card: ExplainCardViewModel]
locationClick: [card: ExplainCardViewModel]
hallClick: [hallId: string]
filterChange: [filter: ExplainFilterOption['id']]
search: [keyword: string]
browseAll: []
}>()
const searchInputValue = ref('')
const activeFilter = ref<ExplainFilterOption['id']>('all')
const activeHallName = ref('')
const visibleCardLimit = ref(props.initialRenderLimit)
const cards = computed(() => props.cards.length ? props.cards : props.exhibits)
const isAllListMode = computed(() => props.pageMode === 'all')
const contentTypeText = (type: ExplainCardViewModel['contentType']) => {
const textMap: Record<ExplainCardViewModel['contentType'], string> = {
hall: '空间讲解',
zone: '展区讲解',
exhibit: '展品讲解',
theme: '主题讲解'
}
return textMap[type]
}
const searchableCards = computed(() => cards.value.map((card) => ({
card,
searchText: [
card.title,
card.subtitle,
card.summary,
card.hallName,
card.floorLabel,
card.audioStatusText,
contentTypeText(card.contentType),
...card.badges
].filter(Boolean).join(' ').toLowerCase()
})))
const summarizeHalls = (sourceCards: ExplainCardViewModel[]) => {
const result = new Map<string, { id?: string, count: number }>()
sourceCards.forEach((card) => {
if (!card.hallName) return
const hallName = card.hallName
const existing = result.get(hallName)
result.set(hallName, {
id: existing?.id || card.hallId,
count: (existing?.count || 0) + 1
})
})
return Array.from(result.entries())
.map(([name, summary]) => ({ name, id: summary.id, count: summary.count }))
.sort((a, b) => b.count - a.count)
}
const getDominantFloorLabel = (sourceCards: ExplainCardViewModel[]) => {
const counts = new Map<string, number>()
sourceCards.forEach((card) => {
if (card.floorLabel) {
counts.set(card.floorLabel, (counts.get(card.floorLabel) || 0) + 1)
}
})
return Array.from(counts.entries()).sort((a, b) => b[1] - a[1])[0]?.[0] || '全馆'
}
const keywordMatchedCards = computed(() => {
const keyword = searchInputValue.value.trim().toLowerCase()
if (!keyword) return cards.value
return searchableCards.value
.filter((item) => item.searchText.includes(keyword))
.map((item) => item.card)
})
const hallSummaries = computed(() => summarizeHalls(keywordMatchedCards.value))
const baseFilteredCards = computed(() => {
let result = keywordMatchedCards.value
if (activeHallName.value) {
result = result.filter((card) => card.hallName === activeHallName.value)
}
return result
})
const dominantFloorLabel = computed(() => getDominantFloorLabel(baseFilteredCards.value))
const trimmedSearchKeyword = computed(() => searchInputValue.value.trim())
const filteredCards = computed(() => {
let result = baseFilteredCards.value
if (activeFilter.value === 'nearby') {
result = result.filter((card) => card.floorLabel === dominantFloorLabel.value)
}
if (activeFilter.value === 'playable') {
result = result.filter((card) => card.audioStatus === 'playable')
}
return result
})
const activeFloorLabel = computed(() => getDominantFloorLabel(filteredCards.value))
const heroTitle = computed(() => {
if (trimmedSearchKeyword.value) {
return `搜索“${trimmedSearchKeyword.value}” · ${filteredCards.value.length} 条结果`
}
if (activeFilter.value === 'playable') {
return `可播放讲解 · ${filteredCards.value.length}`
}
if (activeFilter.value === 'hall' && activeHallName.value) {
return `${activeHallName.value} · ${filteredCards.value.length}`
}
if (activeFilter.value === 'nearby') {
return `${activeFloorLabel.value}附近 · ${filteredCards.value.length}`
}
if (isAllListMode.value) {
return `${filteredCards.value.length} 条讲解`
}
return `馆内讲解 · ${filteredCards.value.length}`
})
const heroDescription = computed(() => (
isAllListMode.value
? `${playableCount.value} 个可播放音频 · ${textOnlyCount.value} 个图文讲解`
: `${hallSummaries.value.length} 个展厅 · ${playableCount.value} 个可播放音频`
))
const playableCount = computed(() => filteredCards.value.filter((card) => card.audioStatus === 'playable').length)
const textOnlyCount = computed(() => filteredCards.value.filter((card) => card.audioStatus !== 'playable').length)
const displayedCards = computed(() => filteredCards.value.slice(0, visibleCardLimit.value))
const hasMoreCards = computed(() => displayedCards.value.length < filteredCards.value.length)
const remainingCardsCount = computed(() => Math.max(filteredCards.value.length - displayedCards.value.length, 0))
const nextLoadCount = computed(() => Math.min(props.pageSize, remainingCardsCount.value))
const listCountText = computed(() => (
hasMoreCards.value
? `${displayedCards.value.length}/${filteredCards.value.length}`
: `${filteredCards.value.length}`
))
const defaultFilters = computed<ExplainFilterOption[]>(() => [
{ id: 'all', label: '全部', count: keywordMatchedCards.value.length },
{ id: 'hall', label: '展厅', count: hallSummaries.value.length },
{ id: 'nearby', label: '附近', count: baseFilteredCards.value.filter((card) => card.floorLabel === dominantFloorLabel.value).length },
{ id: 'playable', label: '可播放', count: baseFilteredCards.value.filter((card) => card.audioStatus === 'playable').length }
])
const resolvedFilters = computed<ExplainFilterOption[]>(() => (
props.filters.length ? props.filters : defaultFilters.value
).filter((filter) => filter.id !== 'theme'))
const featuredCard = computed(() => (
filteredCards.value.find((card) => card.audioStatus === 'playable')
|| filteredCards.value[0]
))
const cardMeta = (card: ExplainCardViewModel) => [
card.hallName,
card.floorLabel,
card.durationLabel,
card.languageLabel,
contentTypeText(card.contentType)
].filter(Boolean).join(' · ')
const normalizedBadges = (card: ExplainCardViewModel) => {
const badges = card.badges.length ? card.badges : [contentTypeText(card.contentType)]
return badges.slice(0, 3)
}
const resetDisplayedCards = () => {
visibleCardLimit.value = props.initialRenderLimit
}
const handleSearchInput = () => {
resetDisplayedCards()
emit('search', searchInputValue.value)
}
const handleSearchConfirm = () => {
resetDisplayedCards()
emit('search', searchInputValue.value.trim())
}
const handleClearSearch = () => {
searchInputValue.value = ''
activeHallName.value = ''
resetDisplayedCards()
emit('search', '')
}
const handleFilterChange = (filter: ExplainFilterOption['id']) => {
if (filter === 'theme') {
activeFilter.value = 'all'
activeHallName.value = ''
resetDisplayedCards()
emit('filterChange', filter)
return
}
activeFilter.value = filter
if (filter === 'hall') {
activeHallName.value = activeHallName.value || hallSummaries.value[0]?.name || ''
} else {
activeHallName.value = ''
}
resetDisplayedCards()
emit('filterChange', filter)
}
const handleHallFilter = (hallName: string) => {
activeFilter.value = 'hall'
activeHallName.value = activeHallName.value === hallName ? '' : hallName
resetDisplayedCards()
emit('filterChange', 'hall')
}
const handleHallDetailClick = (hallId: string) => {
emit('hallClick', hallId)
}
const handleCardClick = (card: ExplainCardViewModel) => {
emit('exhibitClick', card)
}
const handleAudioClick = (card: ExplainCardViewModel) => {
emit('audioClick', card)
}
const handleLocationClick = (card: ExplainCardViewModel) => {
emit('locationClick', card)
}
const handleLoadMore = () => {
if (!hasMoreCards.value) return
visibleCardLimit.value += props.pageSize
}
const handleScrollToLower = () => {
if (!props.showBrowseAll) {
handleLoadMore()
}
}
const handleBrowseAll = () => {
emit('browseAll')
}
</script>
<style scoped lang="scss">
.explain-list {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #f6f7f4;
}
.explain-scroll {
height: 100%;
padding: 0 16px calc(110px + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.explain-hero {
padding: 18px 0 14px;
box-sizing: border-box;
}
.hero-copy {
min-width: 0;
}
.hero-kicker,
.hero-title,
.hero-desc {
display: block;
}
.hero-kicker {
font-size: 12px;
line-height: 16px;
color: #5e6658;
}
.hero-title {
margin-top: 4px;
font-size: 22px;
line-height: 30px;
font-weight: 700;
color: #151713;
}
.hero-desc {
margin-top: 6px;
font-size: 13px;
line-height: 18px;
color: #5e6658;
}
.hero-meta-row {
margin-top: 14px;
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
}
.hero-chip {
height: 28px;
padding: 0 11px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
flex-shrink: 0;
background: #151713;
border-radius: 6px;
}
.hero-browse-all {
height: 28px;
padding: 0 13px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
flex-shrink: 0;
background: #ffffff;
border: 1px solid #d8dbd1;
border-radius: 6px;
}
.hero-browse-all-text {
font-size: 12px;
font-weight: 600;
color: #151713;
}
.hero-chip.muted {
background: #eef1e8;
}
.hero-chip-text {
font-size: 12px;
color: var(--museum-accent);
}
.hero-chip.muted .hero-chip-text {
color: #495143;
}
.search-box {
margin: 0 0 16px;
height: 42px;
display: flex;
align-items: center;
box-sizing: border-box;
background: #f5f6f1;
border: 1px solid #e2e5dc;
border-radius: 8px;
padding: 0 12px;
}
.search-icon {
flex-shrink: 0;
color: #596052;
margin-right: 8px;
}
.search-input {
flex: 1;
min-width: 0;
height: 40px;
font-size: 14px;
color: #151713;
background: transparent;
}
.clear-btn {
flex-shrink: 0;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
}
.section-heading {
margin: 12px 0 10px;
display: flex;
align-items: center;
justify-content: space-between;
}
.section-title {
font-size: 15px;
line-height: 21px;
font-weight: 700;
color: #151713;
}
.section-count {
font-size: 12px;
color: #788072;
}
.featured-card {
display: flex;
padding: 10px;
box-sizing: border-box;
background: #f7f8f3;
border: 1px solid #ebece5;
border-radius: 8px;
}
.featured-cover {
flex-shrink: 0;
width: 82px;
height: 94px;
border-radius: 8px;
background: #e5e8df;
}
.featured-cover.placeholder {
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-text {
font-size: 18px;
font-weight: 700;
color: #68725d;
}
.featured-body {
flex: 1;
min-width: 0;
margin-left: 12px;
}
.featured-title,
.featured-meta,
.featured-summary {
display: block;
}
.featured-title {
font-size: 17px;
line-height: 24px;
font-weight: 700;
color: #151713;
}
.featured-meta,
.card-meta {
margin-top: 3px;
font-size: 12px;
line-height: 17px;
color: #68725d;
}
.featured-summary,
.card-summary {
margin-top: 6px;
font-size: 12px;
line-height: 18px;
color: #4f574b;
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.featured-actions,
.card-actions,
.filter-section,
.badge-row,
.hall-grid {
display: flex;
align-items: center;
}
.featured-actions {
margin-top: 10px;
gap: 8px;
}
.action-btn,
.small-action {
height: 32px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 8px;
background: #ffffff;
border: 1px solid #d8dbd1;
}
.action-btn.primary,
.small-action.primary {
background: #151713;
border-color: #151713;
}
.action-btn.disabled {
background: #eef1e8;
border-color: #eef1e8;
}
.action-text,
.small-action-text {
font-size: 13px;
color: #151713;
}
.action-text.primary,
.small-action-text.primary {
color: var(--museum-accent);
}
.action-text.disabled {
color: #68725d;
}
.filter-section {
margin-top: 14px;
gap: 8px;
overflow-x: auto;
padding-bottom: 2px;
}
.filter-chip {
flex-shrink: 0;
min-width: 58px;
height: 34px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
box-sizing: border-box;
border-radius: 8px;
background: #f1f3ed;
}
.filter-chip.active {
background: #151713;
}
.filter-text,
.filter-count {
font-size: 13px;
color: #4f574b;
}
.filter-count {
color: #8b9385;
}
.filter-chip.active .filter-text,
.filter-chip.active .filter-count {
color: var(--museum-accent);
}
.hall-grid {
flex-wrap: wrap;
gap: 8px;
}
.hall-chip {
width: calc(50% - 4px);
min-height: 54px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
box-sizing: border-box;
background: #f7f8f3;
border: 1px solid #ebece5;
border-radius: 8px;
}
.hall-chip.active {
background: #eef4e8;
border-color: rgba(103, 118, 90, 0.28);
}
.hall-chip-main {
min-width: 0;
}
.hall-name {
display: block;
font-size: 13px;
line-height: 18px;
color: #151713;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hall-count {
display: block;
font-size: 13px;
line-height: 18px;
font-weight: 700;
color: #68725d;
}
.hall-detail-action {
flex-shrink: 0;
padding: 4px 8px;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #dfe5d5;
border-radius: 999px;
}
.hall-detail-text {
display: block;
font-size: 12px;
line-height: 16px;
color: #546548;
}
.card-list {
padding-bottom: calc(110px + env(safe-area-inset-bottom));
}
.list-more {
padding: 6px 0 18px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.more-action {
min-width: 156px;
height: 36px;
padding: 0 16px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
background: #ffffff;
border: 1px solid #d8dbd1;
border-radius: 8px;
}
.more-action.primary {
background: #151713;
border-color: #151713;
}
.more-action-text {
font-size: 13px;
color: #151713;
}
.more-action-text.primary {
color: var(--museum-accent);
}
.more-desc {
font-size: 12px;
line-height: 17px;
color: #788072;
}
.explain-card {
padding: 12px;
margin-bottom: 10px;
box-sizing: border-box;
background: #f7f8f3;
border: 1px solid #ebece5;
border-radius: 8px;
}
.card-title-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.card-title {
flex: 1;
min-width: 0;
font-size: 16px;
line-height: 22px;
font-weight: 700;
color: #151713;
}
.status-pill {
flex-shrink: 0;
height: 24px;
padding: 0 8px;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
border-radius: 6px;
background: #eef1e8;
}
.status-playable {
background: #e5e600;
}
.status-text {
font-size: 11px;
color: #4f574b;
}
.status-playable .status-text {
color: #151713;
font-weight: 600;
}
.badge-row {
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.badge {
height: 22px;
padding: 0 8px;
display: flex;
align-items: center;
box-sizing: border-box;
background: #ecefe7;
border-radius: 6px;
font-size: 11px;
color: #596052;
}
.card-actions {
margin-top: 10px;
gap: 8px;
}
.small-action {
height: 30px;
}
.state-block {
margin: 30px 0;
padding: 22px 16px;
box-sizing: border-box;
text-align: center;
background: #f7f8f3;
border: 1px solid #ebece5;
border-radius: 8px;
}
.state-title,
.state-desc {
display: block;
}
.state-title {
font-size: 15px;
line-height: 21px;
font-weight: 700;
color: #151713;
}
.state-desc {
margin-top: 6px;
font-size: 12px;
line-height: 18px;
color: #68725d;
}
.state-block.error {
border-color: #e5c2c2;
background: #fff7f7;
}
</style>