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()
usernames = serializers.SerializerMethodField()
class Meta:
model = Factory
fields = ['id', 'dealer_name', 'product_category', 'factory_name',
'brand', 'province', 'city', 'district',
'address', 'website', 'created_at', 'updated_at', 'material_count']
read_only_fields = ['id', 'created_at', 'updated_at', 'material_count']
'address', 'website', 'created_at', 'updated_at',
'material_count', 'usernames']
read_only_fields = ['id', 'created_at', 'updated_at', 'material_count', 'usernames']
def get_material_count(self, obj):
"""
获取工厂的材料数量
"""
return obj.materials.count()
def get_usernames(self, obj):
return list(obj.users.values_list('username', flat=True))
class FactoryListSerializer(serializers.ModelSerializer):
"""
工厂列表序列化器简化版
"""
usernames = serializers.SerializerMethodField()
class Meta:
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')
).order_by('-count')
# 按地区的工厂数量分布
region_stats = Factory.objects.values('province', 'city').annotate(
# 按省份的工厂数量分布
region_stats = Factory.objects.values('province').annotate(
count=Count('id')
).order_by('-count')
@ -167,7 +167,7 @@ def factory_statistics(request):
if request.user.role != 'admin':
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')
).order_by('-count'))

View File

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

View File

@ -8,6 +8,11 @@
<el-table-column prop="factory_name" label="工厂全称" />
<el-table-column prop="brand" 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="地区">
<template #default="scope">
{{ formatRegion(scope.row.province, scope.row.city, scope.row.district) }}
@ -54,6 +59,7 @@
<el-cascader
v-model="regionValue"
:options="regionOptions"
:props="{ value: 'label' }"
clearable
@change="onRegionChange"
/>
@ -79,7 +85,7 @@ import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { regionData } from 'element-china-area-data'
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'
const router = useRouter()
@ -154,7 +160,7 @@ const openEdit = async (row) => {
currentId.value = row.id
const detail = await fetchFactoryDetail(row.id)
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 = '编辑工厂'
dialogVisible.value = true
}

View File

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

View File

@ -1,4 +1,4 @@
<template>
<template>
<div>
<div class="toolbar" style="margin-bottom: 16px;">
<el-select v-model="subcategory" placeholder="按材料子类筛选" clearable @change="loadData" style="width: 220px">
@ -42,6 +42,7 @@
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import * as echarts from 'echarts'
import { THEME } from '@/utils/chartTheme'
import { fetchMaterialStats } from '@/api/statistics'
const router = useRouter()
@ -62,9 +63,9 @@ const onResize = () => charts.forEach((chart) => chart.resize())
const initCharts = () => {
charts = [
echarts.init(starChart.value),
echarts.init(advChart.value),
echarts.init(sceneChart.value)
echarts.init(starChart.value, THEME),
echarts.init(advChart.value, THEME),
echarts.init(sceneChart.value, THEME)
]
}
@ -78,15 +79,16 @@ const updateCharts = (data) => {
const levels = data.levels || [1, 2, 3]
charts[0].setOption({
tooltip: { trigger: 'axis' },
legend: { textStyle: { color: '#c9d7e6' } },
xAxis: { type: 'category', data: levels.map((l) => `${l}`), axisLabel: { color: '#9fb3c8' } },
yAxis: { type: 'value', axisLabel: { color: '#9fb3c8' } },
legend: {},
grid: { left: 40, right: 20, top: 30, bottom: 30 },
xAxis: { type: 'category', data: levels.map((l) => `${l}`) },
yAxis: { type: 'value' },
series: [
{ name: '质量', type: 'line', data: data.star_stats.quality_level, smooth: true },
{ name: '耐久', type: 'line', data: data.star_stats.durability_level, smooth: true },
{ name: '环保', type: 'line', data: data.star_stats.eco_level, smooth: true },
{ name: '低碳', type: 'line', data: data.star_stats.carbon_level, smooth: true },
{ name: '总评', type: 'line', data: data.star_stats.score_level, smooth: true }
{ name: '质量', type: 'line', data: data.star_stats.quality_level },
{ name: '耐久', type: 'line', data: data.star_stats.durability_level },
{ name: '环保', type: 'line', data: data.star_stats.eco_level },
{ name: '低碳', type: 'line', data: data.star_stats.carbon_level },
{ name: '总评', type: 'line', data: data.star_stats.score_level }
]
})
@ -103,17 +105,20 @@ const updateCharts = (data) => {
}))
charts[1].setOption({
tooltip: { trigger: 'axis' },
legend: { textStyle: { color: '#c9d7e6' } },
xAxis: { type: 'category', data: advTypes.map((adv) => mapLabel(choices.advantage, adv)), axisLabel: { color: '#9fb3c8' } },
yAxis: { type: 'value', axisLabel: { color: '#9fb3c8' } },
legend: {},
grid: { left: 40, right: 20, top: 30, bottom: 30 },
xAxis: { type: 'category', data: advTypes.map((adv) => mapLabel(choices.advantage, adv)) },
yAxis: { type: 'value' },
series
})
const sceneStats = data.application_scene_stats || []
charts[2].setOption({
xAxis: { type: 'category', data: sceneStats.map((item) => mapLabel(choices.application_scene, item.application_scene)), axisLabel: { color: '#9fb3c8' } },
yAxis: { type: 'value', axisLabel: { color: '#9fb3c8' } },
series: [{ type: 'bar', data: sceneStats.map((item) => item.count), itemStyle: { color: '#f2b24c' } }]
tooltip: { trigger: 'axis' },
grid: { left: 40, right: 20, top: 20, bottom: 30 },
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 class="stat-cards">
<div class="stat-card">
@ -55,6 +55,7 @@
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import * as echarts from 'echarts'
import { THEME } from '@/utils/chartTheme'
import { regionLabel } from '@/utils/region'
import { fetchOverviewStats } from '@/api/statistics'
@ -71,10 +72,10 @@ const onResize = () => charts.forEach((chart) => chart.resize())
const initCharts = () => {
charts = [
echarts.init(majorChart.value),
echarts.init(subChart.value),
echarts.init(brandChart.value),
echarts.init(regionChart.value)
echarts.init(majorChart.value, THEME),
echarts.init(subChart.value, THEME),
echarts.init(brandChart.value, THEME),
echarts.init(regionChart.value, THEME)
]
}
@ -91,26 +92,23 @@ const updateCharts = () => {
}))
charts[0].setOption({
tooltip: { trigger: 'item' },
series: [
{
type: 'pie',
radius: ['40%', '70%'],
data: majorData
}
]
series: [{
type: 'pie',
radius: ['40%', '70%'],
data: majorData
}]
})
const subData = stats.value.material_subcategory_stats || []
charts[1].setOption({
grid: { left: 80, right: 20, top: 20, bottom: 20 },
xAxis: { type: 'value', axisLabel: { color: '#9fb3c8' } },
xAxis: { type: 'value' },
yAxis: {
type: 'category',
data: subData.map((item) => item.material_subcategory),
axisLabel: { color: '#9fb3c8' }
data: subData.map((item) => item.material_subcategory)
},
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
}))
charts[2].setOption({
series: [
{
type: 'treemap',
data: brandData,
label: { show: true, color: '#0b121c' },
itemStyle: {
borderColor: '#0b121c'
}
}
]
tooltip: { trigger: 'item' },
series: [{
type: 'treemap',
data: brandData,
label: { show: true, color: '#e8f0f8', fontSize: 12 },
itemStyle: { borderColor: 'rgba(12, 24, 44, 0.6)', borderWidth: 2 },
levels: [{
itemStyle: { borderColor: 'rgba(12, 24, 44, 0.8)', borderWidth: 3, gapWidth: 3 }
}]
}]
})
const regionData = (stats.value.region_stats || []).map((item) => ({
name: `${regionLabel(item.province)}-${regionLabel(item.city)}`,
name: regionLabel(item.province),
value: item.count
}))
charts[3].setOption({
tooltip: { trigger: 'item' },
series: [
{
type: 'pie',
radius: ['30%', '70%'],
data: regionData
}
]
series: [{
type: 'pie',
radius: ['30%', '70%'],
data: regionData
}]
})
}