feat: 省市区存储文字、工厂关联用户账号、大屏图表主题优化

- 省市区字段改为存储文字名称而非编码

- 工厂序列化器新增usernames字段,列表和详情页展示关联用户账号

- 地区分布统计改为仅按省份聚合

- 新增ECharts screen-dark主题,统一配色和字号

- 大屏卡片背景由纯黑改为深蓝渐变

Made-with: Cursor
This commit is contained in:
caoqianming 2026-03-13 13:00:05 +08:00
parent 2ce0d75c41
commit c6ba742f9d
9 changed files with 145 additions and 78 deletions

View File

@ -7,25 +7,32 @@ class FactorySerializer(serializers.ModelSerializer):
工厂序列化器 工厂序列化器
""" """
material_count = serializers.SerializerMethodField() material_count = serializers.SerializerMethodField()
usernames = serializers.SerializerMethodField()
class Meta: class Meta:
model = Factory model = Factory
fields = ['id', 'dealer_name', 'product_category', 'factory_name', fields = ['id', 'dealer_name', 'product_category', 'factory_name',
'brand', 'province', 'city', 'district', 'brand', 'province', 'city', 'district',
'address', 'website', 'created_at', 'updated_at', 'material_count'] 'address', 'website', 'created_at', 'updated_at',
read_only_fields = ['id', 'created_at', 'updated_at', 'material_count'] 'material_count', 'usernames']
read_only_fields = ['id', 'created_at', 'updated_at', 'material_count', 'usernames']
def get_material_count(self, obj): def get_material_count(self, obj):
"""
获取工厂的材料数量
"""
return obj.materials.count() return obj.materials.count()
def get_usernames(self, obj):
return list(obj.users.values_list('username', flat=True))
class FactoryListSerializer(serializers.ModelSerializer): class FactoryListSerializer(serializers.ModelSerializer):
""" """
工厂列表序列化器简化版 工厂列表序列化器简化版
""" """
usernames = serializers.SerializerMethodField()
class Meta: class Meta:
model = Factory model = Factory
fields = ['id', 'factory_name', 'brand', 'province', 'city', 'dealer_name'] fields = ['id', 'factory_name', 'brand', 'province', 'city', 'dealer_name', 'usernames']
def get_usernames(self, obj):
return list(obj.users.values_list('username', flat=True))

View File

@ -48,8 +48,8 @@ def overview_statistics(request):
count=Count('id') count=Count('id')
).order_by('-count') ).order_by('-count')
# 按地区的工厂数量分布 # 按省份的工厂数量分布
region_stats = Factory.objects.values('province', 'city').annotate( region_stats = Factory.objects.values('province').annotate(
count=Count('id') count=Count('id')
).order_by('-count') ).order_by('-count')
@ -167,7 +167,7 @@ def factory_statistics(request):
if request.user.role != 'admin': if request.user.role != 'admin':
return Response({"detail": "无权访问"}, status=403) return Response({"detail": "无权访问"}, status=403)
region_stats = list(Factory.objects.values('province', 'city').annotate( region_stats = list(Factory.objects.values('province').annotate(
count=Count('id') count=Count('id')
).order_by('-count')) ).order_by('-count'))

View File

@ -1,4 +1,4 @@
.screen-body { .screen-body {
margin: 0; margin: 0;
background: radial-gradient(circle at top left, #203450 0%, #0c1422 45%, #070b12 100%); background: radial-gradient(circle at top left, #203450 0%, #0c1422 45%, #070b12 100%);
color: #e6edf5; color: #e6edf5;
@ -101,11 +101,11 @@
} }
.screen-card { .screen-card {
background: rgba(7, 14, 24, 0.92); background: linear-gradient(145deg, rgba(20, 38, 62, 0.88), rgba(12, 24, 44, 0.92));
border: 1px solid rgba(159, 226, 255, 0.18); border: 1px solid rgba(159, 226, 255, 0.12);
border-radius: 18px; border-radius: 18px;
padding: 16px; padding: 16px;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.45); box-shadow: 0 14px 40px rgba(0, 0, 0, 0.35);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
@ -147,9 +147,9 @@
.stat-card { .stat-card {
padding: 16px; padding: 16px;
border-radius: 14px; border-radius: 14px;
background: linear-gradient(140deg, rgba(78, 134, 184, 0.35), rgba(8, 18, 28, 0.9)); background: linear-gradient(140deg, rgba(78, 134, 184, 0.3), rgba(16, 30, 52, 0.85));
border: 1px solid rgba(255, 255, 255, 0.06); border: 1px solid rgba(159, 226, 255, 0.1);
box-shadow: inset 0 0 20px rgba(159, 226, 255, 0.08); box-shadow: inset 0 0 20px rgba(159, 226, 255, 0.06);
} }
.stat-card .label { .stat-card .label {

View File

@ -0,0 +1,47 @@
import * as echarts from 'echarts'
const COLORS = [
'#36d6fc', '#7b6cf6', '#f5a623', '#5dd9a7',
'#f06292', '#4fc3f7', '#ffd54f', '#9575cd',
'#4db6ac', '#ff8a65'
]
const TEXT = '#c9d7e6'
const AXIS = 'rgba(180, 200, 220, 0.55)'
const SPLIT = 'rgba(159, 226, 255, 0.06)'
echarts.registerTheme('screen-dark', {
color: COLORS,
backgroundColor: 'transparent',
textStyle: { color: TEXT, fontSize: 14 },
title: { textStyle: { color: TEXT, fontSize: 16 } },
legend: {
textStyle: { color: TEXT, fontSize: 13 }
},
tooltip: {
backgroundColor: 'rgba(12, 22, 38, 0.92)',
borderColor: 'rgba(54, 214, 252, 0.25)',
textStyle: { color: '#e6edf5', fontSize: 13 }
},
categoryAxis: {
axisLine: { lineStyle: { color: AXIS } },
axisTick: { lineStyle: { color: AXIS } },
axisLabel: { color: TEXT, fontSize: 13 },
splitLine: { lineStyle: { color: SPLIT } }
},
valueAxis: {
axisLine: { lineStyle: { color: AXIS } },
axisTick: { lineStyle: { color: AXIS } },
axisLabel: { color: TEXT, fontSize: 13 },
splitLine: { lineStyle: { color: SPLIT } }
},
line: {
smooth: true,
symbolSize: 6
},
pie: {
label: { color: TEXT, fontSize: 13 }
}
})
export const THEME = 'screen-dark'

View File

@ -13,6 +13,7 @@
<el-descriptions-item label="地区">{{ formatRegion(factory.province, factory.city, factory.district) }}</el-descriptions-item> <el-descriptions-item label="地区">{{ formatRegion(factory.province, factory.city, factory.district) }}</el-descriptions-item>
<el-descriptions-item label="地址">{{ factory.address }}</el-descriptions-item> <el-descriptions-item label="地址">{{ factory.address }}</el-descriptions-item>
<el-descriptions-item label="官网">{{ factory.website }}</el-descriptions-item> <el-descriptions-item label="官网">{{ factory.website }}</el-descriptions-item>
<el-descriptions-item label="用户账号">{{ (factory.usernames || []).join('、') || '-' }}</el-descriptions-item>
<el-descriptions-item label="材料数量">{{ factory.material_count }}</el-descriptions-item> <el-descriptions-item label="材料数量">{{ factory.material_count }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
</div> </div>

View File

@ -8,6 +8,11 @@
<el-table-column prop="factory_name" label="工厂全称" /> <el-table-column prop="factory_name" label="工厂全称" />
<el-table-column prop="brand" label="品牌" /> <el-table-column prop="brand" label="品牌" />
<el-table-column prop="dealer_name" label="经销商" /> <el-table-column prop="dealer_name" label="经销商" />
<el-table-column label="用户账号">
<template #default="scope">
{{ (scope.row.usernames || []).join('、') || '-' }}
</template>
</el-table-column>
<el-table-column label="地区"> <el-table-column label="地区">
<template #default="scope"> <template #default="scope">
{{ formatRegion(scope.row.province, scope.row.city, scope.row.district) }} {{ formatRegion(scope.row.province, scope.row.city, scope.row.district) }}
@ -54,6 +59,7 @@
<el-cascader <el-cascader
v-model="regionValue" v-model="regionValue"
:options="regionOptions" :options="regionOptions"
:props="{ value: 'label' }"
clearable clearable
@change="onRegionChange" @change="onRegionChange"
/> />
@ -79,7 +85,7 @@ import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { regionData } from 'element-china-area-data' import { regionData } from 'element-china-area-data'
import { useAuth } from '@/store/auth' import { useAuth } from '@/store/auth'
import { formatRegion } from '@/utils/region' import { formatRegion, regionLabel } from '@/utils/region'
import { fetchFactories, fetchFactoryDetail, createFactory, updateFactory, deleteFactory } from '@/api/factory' import { fetchFactories, fetchFactoryDetail, createFactory, updateFactory, deleteFactory } from '@/api/factory'
const router = useRouter() const router = useRouter()
@ -154,7 +160,7 @@ const openEdit = async (row) => {
currentId.value = row.id currentId.value = row.id
const detail = await fetchFactoryDetail(row.id) const detail = await fetchFactoryDetail(row.id)
Object.assign(form, detail) Object.assign(form, detail)
regionValue.value = [detail.province, detail.city, detail.district].filter(Boolean) regionValue.value = [detail.province, detail.city, detail.district].filter(Boolean).map(regionLabel)
dialogTitle.value = '编辑工厂' dialogTitle.value = '编辑工厂'
dialogVisible.value = true dialogVisible.value = true
} }

View File

@ -1,4 +1,4 @@
<template> <template>
<div> <div>
<div class="screen-grid"> <div class="screen-grid">
<div class="screen-card" style="grid-column: span 6;"> <div class="screen-card" style="grid-column: span 6;">
@ -32,6 +32,7 @@
import { ref, onMounted, onBeforeUnmount } from 'vue' import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { THEME } from '@/utils/chartTheme'
import { regionLabel, formatRegion } from '@/utils/region' import { regionLabel, formatRegion } from '@/utils/region'
import { fetchFactoryStats } from '@/api/statistics' import { fetchFactoryStats } from '@/api/statistics'
@ -45,19 +46,23 @@ const onResize = () => charts.forEach((chart) => chart.resize())
const initCharts = () => { const initCharts = () => {
charts = [ charts = [
echarts.init(regionChart.value), echarts.init(regionChart.value, THEME),
echarts.init(categoryChart.value) echarts.init(categoryChart.value, THEME)
] ]
} }
const updateCharts = (data) => { const updateCharts = (data) => {
const regionData = (data.region_stats || []).map((item) => ({ const regionData = (data.region_stats || []).map((item) => ({
name: `${regionLabel(item.province)}-${regionLabel(item.city)}`, name: regionLabel(item.province),
value: item.count value: item.count
})) }))
charts[0].setOption({ charts[0].setOption({
tooltip: { trigger: 'item' }, tooltip: { trigger: 'item' },
series: [{ type: 'pie', radius: ['30%', '70%'], data: regionData }] series: [{
type: 'pie',
radius: ['30%', '70%'],
data: regionData
}]
}) })
const factoryStats = data.factory_category_stats || [] const factoryStats = data.factory_category_stats || []
@ -76,10 +81,10 @@ const updateCharts = (data) => {
charts[1].setOption({ charts[1].setOption({
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { textStyle: { color: '#c9d7e6' } }, legend: {},
grid: { left: 40, right: 20, top: 30, bottom: 40 }, grid: { left: 40, right: 20, top: 30, bottom: 40 },
xAxis: { type: 'category', data: factoriesAxis, axisLabel: { color: '#9fb3c8', rotate: 20 } }, xAxis: { type: 'category', data: factoriesAxis, axisLabel: { rotate: 20 } },
yAxis: { type: 'value', axisLabel: { color: '#9fb3c8' } }, yAxis: { type: 'value' },
series series
}) })
} }

View File

@ -1,4 +1,4 @@
<template> <template>
<div> <div>
<div class="toolbar" style="margin-bottom: 16px;"> <div class="toolbar" style="margin-bottom: 16px;">
<el-select v-model="subcategory" placeholder="按材料子类筛选" clearable @change="loadData" style="width: 220px"> <el-select v-model="subcategory" placeholder="按材料子类筛选" clearable @change="loadData" style="width: 220px">
@ -42,6 +42,7 @@
import { ref, onMounted, onBeforeUnmount } from 'vue' import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { THEME } from '@/utils/chartTheme'
import { fetchMaterialStats } from '@/api/statistics' import { fetchMaterialStats } from '@/api/statistics'
const router = useRouter() const router = useRouter()
@ -62,9 +63,9 @@ const onResize = () => charts.forEach((chart) => chart.resize())
const initCharts = () => { const initCharts = () => {
charts = [ charts = [
echarts.init(starChart.value), echarts.init(starChart.value, THEME),
echarts.init(advChart.value), echarts.init(advChart.value, THEME),
echarts.init(sceneChart.value) echarts.init(sceneChart.value, THEME)
] ]
} }
@ -78,15 +79,16 @@ const updateCharts = (data) => {
const levels = data.levels || [1, 2, 3] const levels = data.levels || [1, 2, 3]
charts[0].setOption({ charts[0].setOption({
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { textStyle: { color: '#c9d7e6' } }, legend: {},
xAxis: { type: 'category', data: levels.map((l) => `${l}`), axisLabel: { color: '#9fb3c8' } }, grid: { left: 40, right: 20, top: 30, bottom: 30 },
yAxis: { type: 'value', axisLabel: { color: '#9fb3c8' } }, xAxis: { type: 'category', data: levels.map((l) => `${l}`) },
yAxis: { type: 'value' },
series: [ series: [
{ name: '质量', type: 'line', data: data.star_stats.quality_level, smooth: true }, { name: '质量', type: 'line', data: data.star_stats.quality_level },
{ name: '耐久', type: 'line', data: data.star_stats.durability_level, smooth: true }, { name: '耐久', type: 'line', data: data.star_stats.durability_level },
{ name: '环保', type: 'line', data: data.star_stats.eco_level, smooth: true }, { name: '环保', type: 'line', data: data.star_stats.eco_level },
{ name: '低碳', type: 'line', data: data.star_stats.carbon_level, smooth: true }, { name: '低碳', type: 'line', data: data.star_stats.carbon_level },
{ name: '总评', type: 'line', data: data.star_stats.score_level, smooth: true } { name: '总评', type: 'line', data: data.star_stats.score_level }
] ]
}) })
@ -103,17 +105,20 @@ const updateCharts = (data) => {
})) }))
charts[1].setOption({ charts[1].setOption({
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { textStyle: { color: '#c9d7e6' } }, legend: {},
xAxis: { type: 'category', data: advTypes.map((adv) => mapLabel(choices.advantage, adv)), axisLabel: { color: '#9fb3c8' } }, grid: { left: 40, right: 20, top: 30, bottom: 30 },
yAxis: { type: 'value', axisLabel: { color: '#9fb3c8' } }, xAxis: { type: 'category', data: advTypes.map((adv) => mapLabel(choices.advantage, adv)) },
yAxis: { type: 'value' },
series series
}) })
const sceneStats = data.application_scene_stats || [] const sceneStats = data.application_scene_stats || []
charts[2].setOption({ charts[2].setOption({
xAxis: { type: 'category', data: sceneStats.map((item) => mapLabel(choices.application_scene, item.application_scene)), axisLabel: { color: '#9fb3c8' } }, tooltip: { trigger: 'axis' },
yAxis: { type: 'value', axisLabel: { color: '#9fb3c8' } }, grid: { left: 40, right: 20, top: 20, bottom: 30 },
series: [{ type: 'bar', data: sceneStats.map((item) => item.count), itemStyle: { color: '#f2b24c' } }] xAxis: { type: 'category', data: sceneStats.map((item) => mapLabel(choices.application_scene, item.application_scene)) },
yAxis: { type: 'value' },
series: [{ type: 'bar', data: sceneStats.map((item) => item.count), barWidth: '50%' }]
}) })
} }

View File

@ -1,4 +1,4 @@
<template> <template>
<div> <div>
<div class="stat-cards"> <div class="stat-cards">
<div class="stat-card"> <div class="stat-card">
@ -55,6 +55,7 @@
import { ref, onMounted, onBeforeUnmount } from 'vue' import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { THEME } from '@/utils/chartTheme'
import { regionLabel } from '@/utils/region' import { regionLabel } from '@/utils/region'
import { fetchOverviewStats } from '@/api/statistics' import { fetchOverviewStats } from '@/api/statistics'
@ -71,10 +72,10 @@ const onResize = () => charts.forEach((chart) => chart.resize())
const initCharts = () => { const initCharts = () => {
charts = [ charts = [
echarts.init(majorChart.value), echarts.init(majorChart.value, THEME),
echarts.init(subChart.value), echarts.init(subChart.value, THEME),
echarts.init(brandChart.value), echarts.init(brandChart.value, THEME),
echarts.init(regionChart.value) echarts.init(regionChart.value, THEME)
] ]
} }
@ -91,26 +92,23 @@ const updateCharts = () => {
})) }))
charts[0].setOption({ charts[0].setOption({
tooltip: { trigger: 'item' }, tooltip: { trigger: 'item' },
series: [ series: [{
{ type: 'pie',
type: 'pie', radius: ['40%', '70%'],
radius: ['40%', '70%'], data: majorData
data: majorData }]
}
]
}) })
const subData = stats.value.material_subcategory_stats || [] const subData = stats.value.material_subcategory_stats || []
charts[1].setOption({ charts[1].setOption({
grid: { left: 80, right: 20, top: 20, bottom: 20 }, grid: { left: 80, right: 20, top: 20, bottom: 20 },
xAxis: { type: 'value', axisLabel: { color: '#9fb3c8' } }, xAxis: { type: 'value' },
yAxis: { yAxis: {
type: 'category', type: 'category',
data: subData.map((item) => item.material_subcategory), data: subData.map((item) => item.material_subcategory)
axisLabel: { color: '#9fb3c8' }
}, },
series: [ series: [
{ type: 'bar', data: subData.map((item) => item.count), itemStyle: { color: '#7cb4e3' } } { type: 'bar', data: subData.map((item) => item.count), barWidth: '60%' }
] ]
}) })
@ -119,31 +117,29 @@ const updateCharts = () => {
value: item.count value: item.count
})) }))
charts[2].setOption({ charts[2].setOption({
series: [ tooltip: { trigger: 'item' },
{ series: [{
type: 'treemap', type: 'treemap',
data: brandData, data: brandData,
label: { show: true, color: '#0b121c' }, label: { show: true, color: '#e8f0f8', fontSize: 12 },
itemStyle: { itemStyle: { borderColor: 'rgba(12, 24, 44, 0.6)', borderWidth: 2 },
borderColor: '#0b121c' levels: [{
} itemStyle: { borderColor: 'rgba(12, 24, 44, 0.8)', borderWidth: 3, gapWidth: 3 }
} }]
] }]
}) })
const regionData = (stats.value.region_stats || []).map((item) => ({ const regionData = (stats.value.region_stats || []).map((item) => ({
name: `${regionLabel(item.province)}-${regionLabel(item.city)}`, name: regionLabel(item.province),
value: item.count value: item.count
})) }))
charts[3].setOption({ charts[3].setOption({
tooltip: { trigger: 'item' }, tooltip: { trigger: 'item' },
series: [ series: [{
{ type: 'pie',
type: 'pie', radius: ['30%', '70%'],
radius: ['30%', '70%'], data: regionData
data: regionData }]
}
]
}) })
} }