@@ -23,6 +23,24 @@
@ status -change = " handleScanStatusChange "
/ >
< / div >
< div v-if = "!selectedCustomer" class="filter-line" >
< t -dropdown -menu class = "filter-menu" >
< t-dropdown-item
v-model = "rankDateFilterType"
:label = "rankDateFilterLabel"
:options = "rankDateOptions"
@change ="selectRankDateFilter"
/ >
< t-dropdown-item
v-model = "rankRegistrationEventIdList"
:label = "rankRegistrationEventFilterLabel"
:options = "rankRegistrationEventOptions"
options -columns = " 2 "
multiple
@change ="reloadCustomerRegistrationRank"
/ >
< / t-dropdown-menu >
< / div >
< / div >
< section v-if = "selectedCustomer" class="summary" >
< div v-for = "item in summaryList" :key="item.label" class="summary-item" >
@@ -48,34 +66,94 @@
< h2 v-text = "item.title" / >
< div : ref = "element => setChartRef(item.id, element)" class = "chart" / >
< / section >
< div v-if = "!selectedCustomer" class="empty-block" > 请选择客户后查看专属图表 < / div >
< div v-if = "!selectedCustomer" v-text="rankSummaryText" > < / div >
< t -cell -group
v-if = "!selectedCustomer && customerRegistrationRankList.length"
class = "rank-list"
theme = "card"
>
< t-cell
v-for = "item in customerRegistrationRankList"
:key = "item.customerId"
class = "rank-item"
: title = "item.customerName || '未知客户'"
arrow
hover
@click ="selectRankCustomer(item.customerId || '')"
>
< template # leftIcon >
< span class = "rank-left" >
< span class = "rank-no" v-text = "item.rank" / >
< t-image
v-if = "getRankCustomerPhotoURL(item)"
:src = "getRankCustomerPhotoURL(item)"
: alt = "item.customerName || '客户头像'"
fit = "cover"
class = "rank-photo"
/ >
< span v-else class = "rank-photo fallback" v-text = "getRankCustomerInitial(item)" / >
< / span >
< / template >
< template # description >
< div v-text = "getRankCustomerMeta(item)" > < / div >
< div class = "rank-stat" >
< strong v-text = "`${item.totalRegistrationDays || 0} 天`" / >
< small v-text = "`${item.recentContinuousRegistrationDays || 0} 天连续`" / >
< / div >
< / template >
< / t-cell >
< / t-cell-group >
< div v-else class = "empty-block" v-text = "rankLoading ? '正在加载排行榜' : '暂无登记排行数据'" / >
< customer-selector
v-model = "selectedCustomerId"
v -model :visible = "selectorVisible"
@change ="handleCustomerSelected"
/ >
< t-popup v-model = "rankStartPickerVisible" placement="bottom" >
< t -date -time -picker
v-model = "rankDateRange[0]"
:default-value = "rankDateRange[0]"
format = "YYYY-MM-DD HH:mm"
: mode = "['date', 'minute']"
title = "开始时间"
@confirm ="confirmRankStartTime"
/ >
< / t-popup >
< t-popup v-model = "rankEndPickerVisible" placement="bottom" >
< t -date -time -picker
v-model = "rankDateRange[1]"
:default-value = "rankDateRange[1]"
format = "YYYY-MM-DD HH:mm"
: mode = "['date', 'minute']"
title = "结束时间"
@confirm ="confirmRankEndTime"
/ >
< / t-popup >
< / div >
< / template >
< script setup lang = "ts" >
import type { ECharts , EChartsOption } from "echarts" ;
import type { DropdownValue } from "tdesign-mobile-vue" ;
import { BarChart , LineChart } from "echarts/charts" ;
import { GridComponent , LegendComponent , TooltipComponent } from "echarts/components" ;
import { init , use } from "echarts/core" ;
import { CanvasRenderer } from "echarts/renderers" ;
import { AttachmentAPI , Time } from "timi-web" ;
import CustomerAPI from "@/api/CustomerAPI" ;
import RegistrationEventAPI from "@/api/RegistrationEventAPI" ;
import RegistrationEventRecordAPI from "@/api/RegistrationEventRecordAPI" ;
import type { Customer } from "@/types/Customer" ;
import type { RegistrationEvent } from "@/types/RegistrationEvent" ;
import type { RegistrationEventRecord } from "@/types/RegistrationEventRecord" ;
import type { CustomerRegistrationRankView , RegistrationEventRecord , RegistrationEventRecordRangeReq } from "@/types/RegistrationEventRecord" ;
import CodeScanner from "@/components/CodeScanner.vue" ;
import CustomerSelector from "@/components/CustomerSelector.vue" ;
import RegistrationEventRecordCalendar from "@/components/RegistrationEventRecordCalendar.vue" ;
import { useSettingStore } from "@/store/settingStore" ;
import { Time } from "timi-web" ;
import { gaoPermissionCodes } from "@/utils/gaoPermission" ;
type RankDateFilterType = "all" | "today" | "thisMonth" | "custom" ;
interface SummaryItem {
label : string ;
value : string ;
@@ -104,6 +182,12 @@ const settingStore = useSettingStore();
const route = useRoute ( ) ;
const chartRefs = new Map < string , HTMLElement > ( ) ;
const chartInstances = new Map < string , ECharts > ( ) ;
const rankDateOptions : Array < { label : string ; value : RankDateFilterType } > = [
{ label : "全部" , value : "all" } ,
{ label : "今天" , value : "today" } ,
{ label : "本月" , value : "thisMonth" } ,
{ label : "自定义范围" , value : "custom" }
] ;
const routeCustomerId = computed ( ( ) => String ( route . params . id || "" ) ) ;
const routeCustomerCode = computed ( ( ) => String ( route . query . customerCode || "" ) ) ;
const customerCode = ref ( routeCustomerCode . value ) ;
@@ -117,6 +201,13 @@ const selectedRegistrationEventIdList = ref<string[]>([]);
const monthCursor = ref ( getMonthStart ( new Date ( ) ) ) ;
const monthRecords = ref < RegistrationEventRecord [ ] > ( [ ] ) ;
const calendarLoading = ref ( false ) ;
const customerRegistrationRankList = ref < CustomerRegistrationRankView [ ] > ( [ ] ) ;
const rankDateFilterType = ref < RankDateFilterType > ( "all" ) ;
const rankDateRange = ref ( buildRankPresetRange ( "all" ) ) ;
const rankRegistrationEventIdList = ref < string [ ] > ( [ ] ) ;
const rankLoading = ref ( false ) ;
const rankStartPickerVisible = ref ( false ) ;
const rankEndPickerVisible = ref ( false ) ;
let latestDecodedText = "" ;
const registrationEventNameMap = computed ( ( ) => {
@@ -180,6 +271,27 @@ const chartList = computed<ChartProfile[]>(() => {
}
] ;
} ) ;
const rankDateFilterLabel = computed ( ( ) => rankDateOptions . find ( item => item . value === rankDateFilterType . value ) ? . label || "全部" ) ;
const rankRegistrationEventOptions = computed ( ( ) => registrationEventList . value . map ( item => ( {
label : item . name || "未命名事件" ,
value : item . id || ""
} ) ) . filter ( item => item . value ) ) ;
const rankRegistrationEventFilterLabel = computed ( ( ) => {
if ( ! rankRegistrationEventIdList . value . length || rankRegistrationEventIdList . value . length === registrationEventList . value . length ) {
return "全部事件" ;
}
return registrationEventList . value
. filter ( item => item . id && rankRegistrationEventIdList . value . includes ( item . id ) )
. map ( item => item . name || "未命名事件" )
. join ( "、" ) ;
} ) ;
const rankSummaryText = computed ( ( ) => {
if ( rankDateFilterType . value === "custom" ) {
return ` ${ rankDateRange . value [ 0 ] || "-" } ~ ${ rankDateRange . value [ 1 ] || "-" } | ${ rankRegistrationEventFilterLabel . value } ` ;
}
return ` 范围: ${ rankDateFilterLabel . value } | ${ rankRegistrationEventFilterLabel . value } ` ;
} ) ;
watch ( chartList , async ( ) => {
await nextTick ( ) ;
@@ -208,6 +320,7 @@ watch([selectedCustomer, monthCursor], async () => {
onMounted ( async ( ) => {
await loadActions ( ) ;
await loadCustomerRegistrationRank ( ) ;
if ( routeCustomerId . value ) {
await loadCustomerById ( routeCustomerId . value ) ;
} else if ( customerCode . value ) {
@@ -262,6 +375,7 @@ function resizeCharts(): void {
async function loadActions ( ) : Promise < void > {
registrationEventList . value = await RegistrationEventAPI . enabledList ( ) ;
selectedRegistrationEventIdList . value = registrationEventList . value . map ( item => item . id ) . filter ( ( item ) : item is string => Boolean ( item ) ) ;
rankRegistrationEventIdList . value = selectedRegistrationEventIdList . value ;
}
async function loadCustomer ( code : string ) : Promise < void > {
@@ -309,6 +423,10 @@ async function handleCustomerSelected(id: string): Promise<void> {
await loadCustomerById ( id ) ;
}
async function selectRankCustomer ( id : string ) : Promise < void > {
await loadCustomerById ( id ) ;
}
async function loadMonthRecords ( ) : Promise < void > {
const customerId = selectedCustomer . value ? . id ;
if ( ! customerId ) {
@@ -330,6 +448,54 @@ async function loadMonthRecords(): Promise<void> {
}
}
async function loadCustomerRegistrationRank ( ) : Promise < void > {
rankLoading . value = true ;
try {
customerRegistrationRankList . value = await RegistrationEventRecordAPI . customerRegistrationRank ( buildRankStatReq ( ) ) ;
} finally {
rankLoading . value = false ;
}
}
async function reloadCustomerRegistrationRank ( ) : Promise < void > {
await loadCustomerRegistrationRank ( ) ;
}
async function selectRankDateFilter ( value : DropdownValue ) : Promise < void > {
if ( Array . isArray ( value ) ) {
return ;
}
rankDateFilterType . value = String ( value ) as RankDateFilterType ;
if ( rankDateFilterType . value !== "custom" ) {
rankDateRange . value = buildRankPresetRange ( rankDateFilterType . value ) ;
await loadCustomerRegistrationRank ( ) ;
return ;
}
if ( ! rankDateRange . value [ 0 ] || ! rankDateRange . value [ 1 ] ) {
rankDateRange . value = buildRankPresetRange ( "today" ) ;
}
rankStartPickerVisible . value = true ;
}
function confirmRankStartTime ( ) : void {
rankStartPickerVisible . value = false ;
rankEndPickerVisible . value = true ;
}
async function confirmRankEndTime ( ) : Promise < void > {
rankEndPickerVisible . value = false ;
normalizeRankDateRange ( ) ;
await loadCustomerRegistrationRank ( ) ;
}
function normalizeRankDateRange ( ) : void {
if ( rankDateRange . value [ 0 ] && rankDateRange . value [ 1 ] && rankDateRange . value [ 1 ] < rankDateRange . value [ 0 ] ) {
rankDateRange . value = [ rankDateRange . value [ 1 ] , rankDateRange . value [ 0 ] ] ;
}
}
// ---------- 扫码 ----------
async function toggleScanner ( ) : Promise < void > {
@@ -370,6 +536,24 @@ function getRecordRegistrationEventName(record?: RegistrationEventRecord): strin
return record . registrationEvent ? . name || ( record . registrationEventId ? registrationEventNameMap . value . get ( record . registrationEventId ) : "" ) || "未知事件" ;
}
function getRankCustomerMeta ( item : CustomerRegistrationRankView ) : string {
const codeText = item . customerCode ? ` 编码 ${ item . customerCode } ` : "暂无编码" ;
const countText = ` ${ item . totalRegistrationCount || 0 } 次登记 ` ;
const latestText = item . latestRegisteredAt ? ` 最近 ${ formatDateTime ( item . latestRegisteredAt ) } ` : "暂无最近登记" ;
return ` ${ codeText } | ${ countText } | ${ latestText } ` ;
}
function getRankCustomerPhotoURL ( item : CustomerRegistrationRankView ) : string {
if ( ! item . customerPhotoAttachmentId ) {
return "" ;
}
return AttachmentAPI . getReadURL ( item . customerPhotoAttachmentId ) ;
}
function getRankCustomerInitial ( item : CustomerRegistrationRankView ) : string {
return ( item . customerName || "客" ) . slice ( 0 , 1 ) ;
}
// ---------- 图表 ----------
function buildBaseOption ( ) : EChartsOption {
@@ -522,6 +706,52 @@ function getDateEnd(date: Date): Date {
return new Date ( date . getFullYear ( ) , date . getMonth ( ) , date . getDate ( ) , 23 , 59 , 59 , 999 ) ;
}
function buildRankStatReq ( ) : Partial < RegistrationEventRecordRangeReq > | undefined {
const selectedAllEvent = ! rankRegistrationEventIdList . value . length || rankRegistrationEventIdList . value . length === registrationEventList . value . length ;
if ( rankDateFilterType . value === "all" ) {
return selectedAllEvent ? undefined : {
registrationEventIdList : rankRegistrationEventIdList . value
} ;
}
return {
beginRegisteredAt : Time . toTimestamp ( parseRankDateTime ( rankDateRange . value [ 0 ] , false ) ) ,
endRegisteredAt : Time . toTimestamp ( parseRankDateTime ( rankDateRange . value [ 1 ] , true ) ) ,
registrationEventIdList : selectedAllEvent ? undefined : rankRegistrationEventIdList . value
} ;
}
function buildRankPresetRange ( type : Exclude < RankDateFilterType , " custom " > ) : [ string , string ] {
if ( type === "all" ) {
return [ "" , "" ] ;
}
const now = new Date ( ) ;
if ( type === "thisMonth" ) {
const monthStart = getMonthStart ( now ) ;
return [ ` ${ Time . formatDate ( monthStart ) } 00:00 ` , ` ${ Time . formatDate ( getDateEnd ( now ) ) } 23:59 ` ] ;
}
const dayStart = new Date ( now ) ;
dayStart . setHours ( 0 , 0 , 0 , 0 ) ;
return [ ` ${ Time . formatDate ( dayStart ) } 00:00 ` , ` ${ Time . formatDate ( getDateEnd ( now ) ) } 23:59 ` ] ;
}
function parseRankDateTime ( value : string , isEnd : boolean ) : Date {
if ( ! value ) {
return isEnd ? new Date ( ) : new Date ( 0 ) ;
}
const [ dateText , timeText = isEnd ? "23:59" : "00:00" ] = value . split ( " " ) ;
const [ year , month , day ] = dateText . split ( "-" ) . map ( Number ) ;
const [ hour , minute ] = timeText . split ( ":" ) . map ( Number ) ;
return new Date ( year , month - 1 , day , hour || 0 , minute || 0 , isEnd ? 59 : 0 , 0 ) ;
}
function formatDateTime ( value : number ) : string {
const date = Time . toDateObject ( value ) ;
return date ? Time . formatDateTime ( date ) : "-" ;
}
< / script >
< style scoped lang = "less" >
@@ -556,6 +786,30 @@ function getDateEnd(date: Date): Date {
width : 3 rem ;
}
}
. filter - line {
. filter - menu {
width : 100 % ;
overflow : hidden ;
background : # fff ;
border : 1 px solid rgba ( 148 , 163 , 184 , .18 ) ;
border - radius : .625 rem ;
& : after {
display : none ;
}
: deep ( . t - dropdown - menu _ _item ) {
min - width : 0 ;
}
: deep ( . t - dropdown - menu _ _title ) {
overflow : hidden ;
white - space : nowrap ;
text - overflow : ellipsis ;
}
}
}
}
. summary {
@@ -607,6 +861,120 @@ function getDateEnd(date: Date): Date {
margin : 0 1 rem ;
}
. rank - section {
gap : .75 rem ;
margin : 0 1 rem ;
display : flex ;
padding : 1 rem ;
background : rgba ( 255 , 255 , 255 , .92 ) ;
box - shadow : var ( -- td - shadow - 1 ) ;
border - radius : .75 rem ;
flex - direction : column ;
. rank - header {
gap : .75 rem ;
display : flex ;
align - items : flex - start ;
justify - content : space - between ;
h2 {
margin : 0 ;
color : # 0 f172a ;
font - size : 1 rem ;
font - weight : 700 ;
}
p {
margin : .25 rem 0 0 0 ;
color : # 52607 a ;
font - size : .75 rem ;
}
}
. rank - list {
margin : 0 ;
}
. rank - item {
: deep ( . t - cell _ _title ) {
flex : 1 1 0 ;
min - width : 0 ;
}
: deep ( . t - cell _ _title - text ) ,
: deep ( . t - cell _ _description ) {
overflow : hidden ;
white - space : nowrap ;
text - overflow : ellipsis ;
}
: deep ( . t - cell _ _right ) {
min - width : 4 rem ;
}
}
. rank - left {
gap : .5 rem ;
display : flex ;
align - items : center ;
}
. rank - no {
width : 2 rem ;
color : # 2563 eb ;
height : 2 rem ;
display : flex ;
background : # dbeafe ;
font - size : .875 rem ;
align - items : center ;
font - weight : 700 ;
border - radius : 999 px ;
justify - content : center ;
}
. rank - photo {
width : 2.75 rem ;
height : 2.75 rem ;
border - radius : .5 rem ;
& . fallback {
color : # 2563 eb ;
display : flex ;
font - size : 1 rem ;
background : # dbeafe ;
align - items : center ;
font - weight : 700 ;
justify - content : center ;
}
}
. rank - stat {
min - width : 0 ;
display : flex ;
text - align : right ;
flex - direction : column ;
strong ,
small {
overflow : hidden ;
white - space : nowrap ;
text - overflow : ellipsis ;
}
strong {
color : # 0 f172a ;
font - size : .9375 rem ;
font - weight : 700 ;
}
small {
margin - top : .25 rem ;
color : # 52607 a ;
font - size : .75 rem ;
}
}
}
. chart - section {
h2 {
margin - bottom : .875 rem ;
@@ -633,6 +1001,13 @@ function getDateEnd(date: Date): Date {
. customer - data - charts - page {
. selector {
background : # 10161 f ;
. filter - line {
. filter - menu {
background : # 172033 ;
border - color : rgba ( 148 , 163 , 184 , .16 ) ;
}
}
}
. summary {
@@ -651,7 +1026,8 @@ function getDateEnd(date: Date): Date {
}
. calendar - section ,
. chart - section {
. chart - section ,
. rank - section {
background : rgba ( 15 , 23 , 42 , .88 ) ;
box - shadow : 0 .75 rem 2 rem rgba ( 2 , 6 , 23 , .32 ) ;
@@ -660,6 +1036,40 @@ function getDateEnd(date: Date): Date {
}
}
. rank - section {
. rank - header {
p {
color : # 94 a3b8 ;
}
. rank - filter {
border - color : rgba ( 148 , 163 , 184 , .16 ) ;
}
}
. rank - no {
color : # bfdbfe ;
background : rgba ( 37 , 99 , 235 , .28 ) ;
}
. rank - photo {
& . fallback {
color : # bfdbfe ;
background : rgba ( 37 , 99 , 235 , .28 ) ;
}
}
. rank - stat {
strong {
color : # e2e8f0 ;
}
small {
color : # 94 a3b8 ;
}
}
}
. empty - block {
color : # 94 a3b8 ;
background : rgba ( 30 , 41 , 59 , .9 ) ;