WIP: 绘制Data Recevice status Monitoring弹窗内容(进行中)

This commit is contained in:
Xu Zhimeng 2023-06-12 20:34:21 +08:00
parent 1ccf2c12db
commit 12f010ecb8
12 changed files with 686 additions and 110 deletions

View File

@ -22,6 +22,7 @@
"cron-parser": "^2.10.0",
"dayjs": "^1.8.0",
"dom-align": "1.12.0",
"echarts": "^4.9.0",
"enquire.js": "^2.1.6",
"js-cookie": "^2.2.0",
"lodash.get": "^4.4.2",

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,26 @@
<template>
<div class="custom-chart" ref="containerRef"></div>
</template>
<script>
import echarts from 'echarts'
export default {
props: {
option : {
type: Object,
default: () => ({})
}
},
data() {
return {}
},
mounted() {
this.chart = echarts.init(this.$refs.containerRef)
this.chart.setOption(this.option)
}
}
</script>
<style lang="less" scoped>
.custom-chart {
height: 100%;
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<a-select v-bind="$attrs" v-model="innerValue" show-arrow>
<a-select v-bind="$attrs" v-model="innerValue" show-arrow @change="onChange">
<img slot="suffixIcon" src="@/assets/images/global/select-down.png" alt="" />
</a-select>
</template>
@ -16,12 +16,15 @@ export default {
innerValue: this.value
}
},
methods: {
onChange(val) {
this.$emit('input', val)
this.$emit('change', val)
}
},
watch: {
value () {
this.innerValue = this.value
},
innerValue () {
this.$emit('input', this.innerValue)
}
}
}

View File

@ -0,0 +1,79 @@
<template>
<a-tree class="custom-tree" v-model="checkedKeys" checkable :selectedKeys="[]" :tree-data="treeData" @select="onTreeSelect">
<a-icon slot="switcherIcon" type="down" />
</a-tree>
</template>
<script>
export default {
props: {
treeData: {
type: Array,
default: () => []
},
value: {
type: Array
}
},
data() {
return {
checkedKeys: [] //
}
},
methods: {
onTreeSelect(_, { node }) {
const selectedKey = node.eventKey
const parentKey = node.$parent.eventKey
const findIndex = this.checkedKeys.findIndex(key => key == selectedKey)
if (parentKey) {
//
if (-1 == findIndex) {
//
this.checkedKeys.push(selectedKey)
const parentNode = this.treeData.find(tree => tree.key == parentKey) //
const childrenKeys = parentNode.children.map(child => child.key)
if (childrenKeys.every(key => this.checkedKeys.includes(key))) {
//
this.checkedKeys.push(parentKey)
}
} else {
//
this.checkedKeys.splice(findIndex, 1)
const findParent = this.checkedKeys.findIndex(key => key == parentKey)
if (-1 !== findParent) {
this.checkedKeys.splice(findParent, 1)
}
}
} else {
//
const parentNode = this.treeData.find(tree => tree.key == selectedKey)
const childrenKeys = parentNode.children.map(child => child.key)
const findParent = this.checkedKeys.findIndex(key => key == selectedKey)
if (-1 == findParent) {
// ,
this.checkedKeys.push(selectedKey)
this.checkedKeys.push(...childrenKeys)
} else {
// ,
this.checkedKeys = this.checkedKeys.filter(key => key !== selectedKey && !childrenKeys.includes(key))
}
}
}
},
watch: {
value() {
this.checkedKeys = this.value
},
checkedKeys() {
this.$emit('input', this.checkedKeys)
}
}
}
</script>
<style></style>

View File

@ -1,5 +1,5 @@
<template>
<div ref="containerRef" class="scroll-container" :class="{ 'scroll-end': scrollEnd }" @scroll="onScroll">
<div ref="containerRef" class="scroll-container" :class="{ 'scroll-end': scrollEnd }">
<slot></slot>
</div>
</template>
@ -8,32 +8,42 @@ export default {
props: {
direction: {
type: String,
default: 'horizontal', // horizontal | vertical
default: 'horizontal' // horizontal | vertical
},
scrollContainer: {
type: Function
}
},
data() {
this.isCustomContainer = this.scrollContainer && typeof this.scrollContainer == 'function' //
return {
scrollEnd: false,
scrollEnd: false
}
},
mounted() {
this.onScroll()
window.addEventListener('resize', this.onScroll)
},
destroyed() {
window.removeEventListener('resize', this.onScroll)
this.containerEle = this.isCustomContainer ? this.scrollContainer() : this.$refs.containerRef
this.containerEle.addEventListener('scroll', () => {
this.checkScrollEnd()
})
this.containerEle.addEventListener('transitionend', () => { //
this.checkScrollEnd()
})
},
methods: {
onScroll() {
const ele = this.$refs.containerRef
/**
* 检查是否滚动到尾部
*/
checkScrollEnd() {
if (this.direction == 'horizontal') {
this.scrollEnd = ele.scrollLeft + ele.offsetWidth == ele.scrollWidth
this.scrollEnd = this.containerEle.scrollLeft + this.containerEle.offsetWidth == this.containerEle.scrollWidth
} else {
this.scrollEnd = ele.scrollTop + ele.offsetHeight == ele.scrollHeight
this.scrollEnd = this.containerEle.scrollTop + this.containerEle.offsetHeight == this.containerEle.scrollHeight
}
this.$emit('scrollEnd', this.scrollEnd)
},
},
}
}
}
</script>
<style lang="less" scoped>

View File

@ -93,9 +93,9 @@
val = e
}
console.log(val);
this.$emit('change', val);
this.$emit('change', val? val: undefined);
//LOWCOD-2146 SQL
this.$emit('input', val);
this.$emit('input', val? val: undefined);
},
setCurrentDictOptions(dictOptions){
this.dictOptions = dictOptions

View File

@ -57,6 +57,8 @@ import CustomModal from '@/components/CustomModal'
import CustomDatePicker from '@/components/CustomDatePicker'
import CustomMonthPicker from '@/components/CustomMonthPicker'
import CustomEmpty from '@/components/CustomEmpty'
import CustomChart from '@/components/CustomChart'
console.log('%c [ CustomChart ]-61', 'font-size:13px; background:pink; color:#bf2c9f;', CustomChart)
Vue.prototype.rules = rules
@ -79,6 +81,7 @@ Vue.component('custom-modal', CustomModal)
Vue.component('custom-date-picker', CustomDatePicker)
Vue.component('custom-month-picker', CustomMonthPicker)
Vue.component('custom-empty', CustomEmpty)
Vue.component('custom-chart', CustomChart)
SSO.init(() => {
main()

View File

@ -286,7 +286,7 @@ body {
}
&-table {
background-color: #06282A !important;
background-color: #06282a !important;
}
&-tbody {
@ -334,6 +334,49 @@ body {
}
}
// 数字输入框
@input-number-handler-active-bg: #06404c;
@input-number-handler-hover-bg: #06404c;
@input-number-handler-bg: #06404c;
@input-number-handler-border-color: transparent;
.ant-input-number {
border-radius: 0;
border: 1px solid #0b8c82;
&-handler {
height: 50% !important;
&:hover {
height: 50% !important;
}
.anticon {
display: none !important;
}
// 数组输入框右侧的上下箭头
&::after {
content: '';
display: inline-block;
width: 0;
height: 0;
border: 5px solid transparent;
border-left-width: 4px;
border-right-width: 4px;
border-bottom-color: #4b859e;
position: relative;
top: -1px;
}
&-down {
border-top: none;
&::after {
transform: rotate(180deg);
top: 1px;
}
}
}
}
// 单选样式
.ant-radio {
&-wrapper {

View File

@ -86,7 +86,6 @@ export default {
//
setZoom(zoom) {
console.log('%c [ zoom ]-89', 'font-size:13px; background:pink; color:#bf2c9f;', zoom)
if (zoom < this.minZoom) {
zoom = this.minZoom
}

View File

@ -13,7 +13,7 @@
<div class="map-pane-operators-main-operator">
<div>
<img v-if="analyzeModalVisible" src="@/assets/images/station-operation/analyze-active.png" />
<img v-if="dataStatusModalVisible" src="@/assets/images/station-operation/analyze-active.png" />
<img v-else src="@/assets/images/station-operation/analyze.png" @click="handleOpenAnalyzeModal" />
</div>
<div>
@ -54,13 +54,7 @@
<!-- 站点选择树 -->
<div class="station-list-tree">
<a-tree
v-model="checkedKeys"
:selectedKeys="[]"
:tree-data="treeData"
checkable
@select="onTreeSelect"
></a-tree>
<custom-tree v-model="checkedKeys" :tree-data="treeData"></custom-tree>
</div>
<!-- 站点选择树 结束 -->
</div>
@ -125,16 +119,84 @@
</div>
<!-- 主体部分结束 -->
<!-- 分析弹窗开始 -->
<custom-modal v-model="analyzeModalVisible" enableFullScreen title="Data Recevice status Monitoring" width="64%" :showFooter="false">
分析弹窗内容
<!-- 数据监控状态弹窗开始 -->
<custom-modal
v-model="dataStatusModalVisible"
enableFullScreen
:bodyStyle="{ padding: '15px 0 10px' }"
title="Data Recevice status Monitoring"
:width="1230"
:showFooter="false"
>
<div class="data-receive-status">
<!-- 左侧配置栏 -->
<div class="data-receive-status-list" :class="{ open: leftPaneShow }">
<div class="data-receive-status-list-container">
<div class="data-receive-status-list-item">
<div class="title">
<span>
Particulate Station
</span>
<img src="@/assets/images/station-operation/toggle.png" @click="leftPaneShow = !leftPaneShow" />
</div>
<div class="content">
<custom-tree v-model="dataStatusCheckedKeys" :tree-data="treeData"></custom-tree>
</div>
</div>
<div class="data-receive-status-list-item">
<div class="title">
<span>
Attribute Configuration
</span>
<img src="@/assets/images/station-operation/toggle.png" @click="leftPaneShow = !leftPaneShow" />
</div>
<div class="content">
<a-form-model class="attribute-form" layout="vertical">
<a-form-model-item label="Cache time">
<a-input-number type="number"></a-input-number>
<span>day</span>
</a-form-model-item>
<a-form-model-item label="Scale interval">
<a-input-number type="number"></a-input-number>
<span>min</span>
</a-form-model-item>
<a-form-model-item label="Timeline length">
<a-input-number type="number"></a-input-number>
<span>min</span>
</a-form-model-item>
<a-form-model-item label="Update interval time">
<a-input-number type="number"></a-input-number>
<span>min</span>
</a-form-model-item>
<div class="attribute-form-footer">
<a-button type="primary">SAVE</a-button>
</div>
</a-form-model>
</div>
</div>
</div>
<div class="data-receive-status-list-toggle-show-btn" v-if="!leftPaneShow">
<img src="@/assets/images/station-operation/toggle-2.png" @click="leftPaneShow = true" />
</div>
</div>
<!-- 左侧配置栏结束 -->
<!-- 右侧图表展示栏 -->
<div class="data-receive-status-chart" :class="{ 'on-screen': !leftPaneShow }">
<custom-chart :option="option"></custom-chart>
</div>
<!-- 右侧图表展示栏结束 -->
</div>
</custom-modal>
<!-- 分析弹窗结束 -->
<!-- 数据监控状态弹窗结束 -->
</div>
</template>
<script>
import CustomModal from '@/components/CustomModal/index.vue'
import FilterImage from './filterImage'
import CustomTree from '@/components/CustomTree/index.vue'
import * as echarts from 'echarts'
// Filter
const filterList = [
@ -277,6 +339,123 @@ const dataSource = [
}
]
var data = []
var dataCount = 10
var startTime = +new Date()
var categories = ['categoryA', 'categoryB', 'categoryC']
var types = [
{ name: 'JS Heap', color: '#7b9ce1' },
{ name: 'Documents', color: '#bd6d6c' },
{ name: 'Nodes', color: '#75d874' },
{ name: 'Listeners', color: '#e0bc78' },
{ name: 'GPU Memory', color: '#dc77dc' },
{ name: 'GPU', color: '#72b362' }
]
// Generate mock data
categories.forEach(function(category, index) {
var baseTime = startTime
for (var i = 0; i < dataCount; i++) {
var typeItem = types[Math.round(Math.random() * (types.length - 1))]
var duration = Math.round(Math.random() * 10000)
data.push({
name: typeItem.name,
value: [index, baseTime, (baseTime += duration), duration],
itemStyle: {
normal: {
color: typeItem.color
}
}
})
baseTime += Math.round(Math.random() * 2000)
}
})
function renderItem(params, api) {
var categoryIndex = api.value(0)
var start = api.coord([api.value(1), categoryIndex])
var end = api.coord([api.value(2), categoryIndex])
var height = api.size([0, 1])[1] * 0.6
var rectShape = echarts.graphic.clipRectByRect(
{
x: start[0],
y: start[1] - height / 2,
width: end[0] - start[0],
height: height
},
{
x: params.coordSys.x,
y: params.coordSys.y,
width: params.coordSys.width,
height: params.coordSys.height
}
)
return (
rectShape && {
type: 'rect',
transition: ['shape'],
shape: rectShape,
style: api.style()
}
)
}
const option = {
tooltip: {
formatter: function(params) {
return params.marker + params.name + ': ' + params.value[3] + ' ms'
}
},
title: {
text: '2021-9-10 性能分析',
left: 'center'
},
dataZoom: [
{
type: 'slider',
filterMode: 'weakFilter',
showDataShadow: false,
top: 10,
labelFormatter: ''
},
{
type: 'inside',
filterMode: 'weakFilter'
}
],
grid: {
height: 300
},
xAxis: {
min: startTime,
scale: true,
axisLabel: {
formatter: function(val) {
return Math.max(0, val - startTime) + 'ms'
}
}
},
yAxis: {
data: categories
},
series: [
{
type: 'custom',
renderItem: renderItem,
itemStyle: {
opacity: 0.8
},
encode: {
x: [1, 2],
y: 0
},
data: data
}
]
}
export default {
props: {
panMovePix: {
@ -289,21 +468,28 @@ export default {
}
},
components: {
CustomModal
CustomModal,
CustomTree
},
data() {
this.columns = columns
return {
active: 1,
isFullScreen: false, //
analyzeModalVisible: false, //
checkedKeys: [], //
filterList, //
stateList, //
dataSource: dataSource // Infomation Radius
dataSource: dataSource, // Infomation Radius
dataStatusModalVisible: true, //
dataStatusCheckedKeys: [], // -
leftPaneShow: true, //
option // echarts
}
},
created() {
@ -326,51 +512,6 @@ export default {
this.isFullScreen = false
},
// Stations
onTreeSelect(_, { node }) {
const selectedKey = node.eventKey
const parentKey = node.$parent.eventKey
const findIndex = this.checkedKeys.findIndex(key => key == selectedKey)
if (parentKey) {
//
if (-1 == findIndex) {
//
this.checkedKeys.push(selectedKey)
const parentNode = this.treeData.find(tree => tree.key == parentKey) //
const childrenKeys = parentNode.children.map(child => child.key)
if (childrenKeys.every(key => this.checkedKeys.includes(key))) {
//
this.checkedKeys.push(parentKey)
}
} else {
//
this.checkedKeys.splice(findIndex, 1)
const findParent = this.checkedKeys.findIndex(key => key == parentKey)
if (-1 !== findParent) {
this.checkedKeys.splice(findParent, 1)
}
}
} else {
//
const parentNode = this.treeData.find(tree => tree.key == selectedKey)
const childrenKeys = parentNode.children.map(child => child.key)
const findParent = this.checkedKeys.findIndex(key => key == selectedKey)
if (-1 == findParent) {
// ,
this.checkedKeys.push(selectedKey)
this.checkedKeys.push(...childrenKeys)
} else {
// ,
this.checkedKeys = this.checkedKeys.filter(key => key !== selectedKey && !childrenKeys.includes(key))
}
}
},
//
handleSelectAll() {
this.checkedKeys = this.treeData.reduce((prev, curr) => {
@ -387,7 +528,7 @@ export default {
//
handleOpenAnalyzeModal() {
this.analyzeModalVisible = true
this.dataStatusModalVisible = true
},
//
@ -423,6 +564,7 @@ export default {
}
</script>
<style lang="less" scoped>
//
.map-pane {
position: absolute;
right: 10px;
@ -656,4 +798,141 @@ export default {
}
}
}
//
//
.data-receive-status {
height: 700px;
overflow: hidden;
position: relative;
@borderColor: rgba(65, 111, 127, 0.5);
&-list {
float: left;
width: 230px;
height: 100%;
margin-left: 15px;
transform: translateX(-245px);
transition: transform 0.3s cubic-bezier(0.075, 0.82, 0.165, 1);
&.open {
transform: translateX(0);
}
&-container {
height: 100%;
border: 1px solid @borderColor;
border-top: 0;
display: flex;
flex-direction: column;
}
&-item {
&:first-child {
height: calc(100% - 305px);
.content {
height: calc(100% - 50px);
overflow: auto;
margin: 7px;
margin-right: 0;
}
}
&:nth-child(2) {
.content {
padding-left: 16px;
}
}
.title {
height: 37px;
line-height: 37px;
padding-left: 18px;
background-color: #08363c;
border-top: 1px solid @borderColor;
border-bottom: 1px solid @borderColor;
display: flex;
align-items: center;
justify-content: space-between;
padding-right: 10px;
user-select: none;
img {
cursor: pointer;
}
}
}
&-toggle-show-btn {
position: absolute;
right: -13px;
top: 50%;
transform: translateY(-50%);
cursor: pointer;
}
.attribute-form {
.ant-input-number {
width: 173px;
height: 24px;
::v-deep {
.ant-input-number-input {
height: 24px;
line-height: 24px;
font-size: 12px;
}
}
}
::v-deep {
.ant-form-item {
margin-bottom: 0;
padding-bottom: 0;
&-label {
padding-top: 7px;
padding-bottom: 2px;
}
&-children {
display: flex;
align-items: center;
> span {
margin-left: 7px;
color: #5b9cba;
}
}
}
}
&-footer {
margin-top: 9px;
text-align: center;
.ant-btn {
width: 120px;
background-color: #00bded;
}
}
}
}
&-chart {
position: absolute;
right: 15px;
width: calc(100% - 270px);
height: 100%;
border: 1px solid @borderColor;
transition: width 0.3s cubic-bezier(0.075, 0.82, 0.165, 1);
&.on-screen {
width: calc(100% - 30px);
}
}
}
.fullscreen {
.data-receive-status {
height: calc(100vh - 70px);
}
}
//
</style>

View File

@ -22,30 +22,64 @@
<!-- 头部操作栏 -->
<div class="title-operator">
<a-popover v-model="searchVisible" trigger="click" placement="bottom">
<div @click.stop="searchVisible = !searchVisible">
<img src="@/assets/images/station-operation/search.png" alt="" />
</div>
<template slot="content">
<a-input-search placeholder="input search text" @search="onSearch">
<img slot="enterButton" src="@/assets/images/station-operation/search.png" alt="" />
</a-input-search>
</template>
</a-popover>
<a-popover trigger="click" placement="bottom">
<div @click.stop>
<img src="@/assets/images/station-operation/filter.png" alt="" />
</div>
<template slot="content">
<div>筛选</div>
</template>
</a-popover>
<div @click.stop="handleShowSearch">
<img src="@/assets/images/station-operation/search.png" alt="" />
</div>
<div @click.stop="handleShowFilter">
<img src="@/assets/images/station-operation/filter.png" alt="" />
</div>
</div>
<!-- 头部操作栏结束 -->
</div>
</template>
<ScrollContainer direction="verticle" class="date-list">
<div class="date-list-content">
<ScrollContainer
ref="scrollContainerRef"
direction="verticle"
:scrollContainer="getScrollContainer"
class="date-list has-search"
:class="{
'show-search': searchPlacementVisible
}"
>
<div class="search-filter-placement">
<!-- 搜索 -->
<a-input-search
v-if="searchVisible"
placeholder="Input Search Text"
v-model="filter.searchText"
allow-clear
@search="onFilterChange"
>
<img slot="enterButton" src="@/assets/images/station-operation/search.png" alt="" />
</a-input-search>
<!-- 搜索结束 -->
<!-- 筛选 -->
<a-row v-if="filterVisible" :gutter="10" style="width: 100%">
<a-col :span="12">
<j-dict-select-tag
v-model="filter.status"
:getPopupContainer="getDictSelectTagContainer"
placeholder="Select Status"
dictCode="STATION_STATUS"
style="width: 100%"
@change="onFilterChange"
></j-dict-select-tag>
</a-col>
<a-col :span="12">
<custom-select
v-model="filter.type"
:options="stationTypeList"
allow-clear
show-search
@change="onFilterChange"
placeholder="Select Type"
></custom-select>
</a-col>
</a-row>
<!-- 筛选结束 -->
</div>
<div ref="customScrollContainerRef" class="date-list-content">
<a-spin v-if="isGettingDateList"></a-spin>
<template v-else>
<div class="date-list-item" v-for="item of dateList" :key="item.id">
@ -168,6 +202,7 @@ import MapMarker from './components/MapMarker.vue'
import MapPane from './components/MapPane.vue'
import ScrollContainer from '@/components/ScrollContainer/index.vue'
import { getAction } from '../../api/manage'
import { cloneDeep } from 'lodash'
export default {
components: {
@ -179,13 +214,28 @@ export default {
data() {
return {
activeKey: '1',
isGettingDateList: false,
dateList: [],
searchVisible: false
searchPlacementVisible: false, //
searchVisible: false, //
filter: {
searchText: undefined,
status: undefined,
type: undefined
},
filterVisible: false, //
stationTypeList: []
}
},
created() {
this.getStationList()
this.getStationTypeList()
},
methods: {
//
@ -197,6 +247,7 @@ export default {
result: { records }
} = await getAction('/gardsStations/findPage?pageIndex=1&pageSize=999')
if (success) {
this.originalDateList = cloneDeep(records)
this.dateList = records
}
} catch (error) {
@ -206,13 +257,67 @@ export default {
}
},
// All Date
onSearch() {
this.searchVisible = false
//
async getStationTypeList() {
try {
const res = await getAction('/gardsStations/findType')
this.stationTypeList = res.filter(item => item).map(item => ({ label: item, value: item }))
} catch (error) {
console.error(error)
}
},
handleShowSearch() {
if (this.filterVisible) {
this.searchVisible = true
this.filterVisible = false
this.searchPlacementVisible = true
} else {
this.searchPlacementVisible = !this.searchPlacementVisible
if (this.searchPlacementVisible) {
this.searchVisible = true
}
}
},
//
handleShowFilter() {
if (this.searchVisible) {
this.searchVisible = false
this.filterVisible = true
this.searchPlacementVisible = true
} else {
this.searchPlacementVisible = !this.searchPlacementVisible
if (this.searchPlacementVisible) {
this.filterVisible = true
}
}
},
// All Date
onFilter() {}
onFilterChange() {
this.dateList = this.originalDateList.filter(dateItem => {
const filterSearchText =
!this.filter.searchText ||
-1 !== dateItem.stationCode.toLowerCase().indexOf(this.filter.searchText.toLowerCase())
const filterStatus = !this.filter.status || this.filter.status == dateItem.status
const filterType = !this.filter.type || this.filter.type == dateItem.type
return filterSearchText && filterStatus && filterType
})
this.$nextTick(() => {
this.$refs.scrollContainerRef.checkScrollEnd()
})
},
getScrollContainer() {
return this.$refs.customScrollContainerRef
},
getDictSelectTagContainer() {
return document.body
}
}
}
</script>
@ -248,6 +353,8 @@ export default {
border-top: 1px solid rgba(12, 235, 201, 0.3);
border-bottom: 4px solid rgba(12, 235, 201, 0.2);
height: auto;
background-color: rgba(1, 18, 20, 0.6);
.ant-collapse-arrow {
right: 9px;
transition: transform 0.24s;
@ -281,7 +388,6 @@ export default {
font-size: 18px;
font-weight: bold;
color: #0cebc9;
background-color: rgba(1, 18, 20, 0.6);
&-text {
padding-left: 20px;
@ -356,6 +462,15 @@ export default {
padding: 2px 0 10px 7px;
overflow: auto;
@searchHeight: 45px;
.search-filter-placement {
height: 0;
display: flex;
align-items: center;
transition: height 0.3s ease-in-out;
overflow: hidden;
}
&-content {
.ant-spin {
width: 100%;
@ -365,6 +480,24 @@ export default {
}
}
&.has-search {
.date-list-content {
transition: height 0.3s ease-in-out;
height: 100%;
overflow: auto;
}
}
&.show-search {
.search-filter-placement {
height: @searchHeight;
}
.date-list-content {
height: calc(100% - @searchHeight);
}
}
&-item {
width: 322px;
&-title {