四、核心代码分析
1、在Django的项目上配置好
这个是主项目文件夹的内容:
- backend:主项目文件
- api:为创建的子app项目
- frontend:为创建的前端Vite项目
//settings.py文件
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
'rest_framework',
'rest_framework_simplejwt',
'corsheaders',
'api',//要加入自己创建的app项目
'django_extensions',
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
#引包,顺序不能变
'corsheaders.middleware.CorsMiddleware',
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework_simplejwt.authentication.JWTAuthentication',
)
}
ROOT_URLCONF = "backend.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "backend.wsgi.application"
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
# DATABASES = {
# "default": {
# "ENGINE": "django.db.backends.sqlite3",
# "NAME": BASE_DIR / "db.sqlite3",
# }
# }
DATABASES = {
"default": {
#数据库类型
"ENGINE": "django.db.backends.mysql",
#数据库名称
"NAME": "house",
"HOST":"localhost",
"PORT":3306,
"USER":"root",
"PASSWORD":"root",
}
}
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = "zh-hans"#英文en-us 中文zh-hans
TIME_ZONE = "Asia/ShangHai" #美国UTC 中国Asia/ShangHai
USE_I18N = True
USE_TZ = True
# 配置静态文件路径
STATIC_URL = '/static/'
# 这里如果没有额外的静态文件目录,可以设置为空列表
STATICFILES_DIRS = []
# 确保STATIC_ROOT正确设置,使用 / 拼接路径
STATIC_ROOT = BASE_DIR /'staticfiles'
# Default primary key field type
# https://docs.djangoprozject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# 允许跨域请求
CORS_ALLOW_ALL_ORIGINS = True
要在url.py中注册app项目
from django.contrib import admin # type: ignore
from django.urls import path, include # type: ignore
# from django.conf import settings
# from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', include('api.urls')),
]
2、在Vite的项目中配置好
在Vite.config.js中对齐网址
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src')
}
},
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
})
在main.js中加入配置
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import { createPinia } from 'pinia'
import axios from 'axios'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'bootstrap/dist/css/bootstrap.min.css'
import 'font-awesome/css/font-awesome.min.css'
const app = createApp(App)
// // 设置axios默认配置
axios.defaults.baseURL = '/api'
axios.defaults.withCredentials = true
// 注册Element Plus组件
app.use(ElementPlus)
// 注册Element Plus图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(router)
app.use(createPinia())
app.mount('#app')
3、核心代码
// 状态管理
const houses = ref([]);
const selectedHouses = ref([]);
const page = ref(1);
const pageSize = ref(20);
const loading = ref(false);
const noMore = ref(false);
const showOnlySelected = ref(false);
// 筛选条件
const filters = reactive({
address: '',
orientation: '',
buildYear: ''
});
// 各城市图表筛选条件
const wuhanFilters = reactive({
orientation: '',
minArea: null,
maxArea: null,
minPrice: null,
maxPrice: null
});
const shanghaiFilters = reactive({
orientation: '',
minArea: null,
maxArea: null,
minPrice: null,
maxPrice: null
});
const zhengzhouFilters = reactive({
orientation: '',
minArea: null,
maxArea: null,
minPrice: null,
maxPrice: null
});
// 计算属性处理筛选逻辑
const filteredHouses = computed(() => {
return houses.value.filter(house => {
return (
(house.address.includes(filters.address)) &&
(filters.orientation? house.orientation === filters.orientation : true) &&
(filters.buildYear? house.build_year.toString() === filters.buildYear : true)
)
});
});
// 获取房屋数据
const fetchHouses = async (currentPage = 1, isInitial = false) => {
try {
loading.value = true;
const response = await axios.get('/api/houses/', {
params: {
page: currentPage,
pageSize: pageSize.value,
address: filters.address,
orientation: filters.orientation,
build_year: filters.buildYear
}
});
if (isInitial) {
houses.value = response.data;
} else {
houses.value = [...houses.value, ...response.data];
}
page.value = currentPage;
noMore.value = response.data.length < pageSize.value;
initTotalChart();
initCityCharts();
} catch (error) {
console.error('获取房屋数据失败', error);
ElMessage.error('获取房屋数据失败,请稍后重试');
} finally {
loading.value = false;
}
};
// 存储Chart实例
const totalChart = ref(null);
const wuhanChart = ref(null);
const shanghaiChart = ref(null);
const zhengzhouChart = ref(null);
// 图表引用
const totalChartRef = ref(null);
const wuhanChartRef = ref(null);
const shanghaiChartRef = ref(null);
const zhengzhouChartRef = ref(null);
// 初始化总的图表
const initTotalChart = () => {
// 销毁现有图表实例
if (totalChart.value) totalChart.value.destroy();
// 提取武汉、上海、郑州的二手房数据
const wuhanHouses = houses.value.filter(house => house.address.includes('武汉'));
const shanghaiHouses = houses.value.filter(house => house.address.includes('上海'));
const zhengzhouHouses = houses.value.filter(house => house.address.includes('郑州'));
// 计算各城市房屋总数
const houseCounts = [wuhanHouses.length, shanghaiHouses.length, zhengzhouHouses.length];
// 计算各城市平均面积
const wuhanAvgArea = wuhanHouses.length > 0 ?
wuhanHouses.reduce((sum, house) => sum + house.area, 0) / wuhanHouses.length : 0;
const shanghaiAvgArea = shanghaiHouses.length > 0 ?
shanghaiHouses.reduce((sum, house) => sum + house.area, 0) / shanghaiHouses.length : 0;
const zhengzhouAvgArea = zhengzhouHouses.length > 0 ?
zhengzhouHouses.reduce((sum, house) => sum + house.area, 0) / zhengzhouHouses.length : 0;
const avgAreas = [wuhanAvgArea, shanghaiAvgArea, zhengzhouAvgArea];
// 计算各城市平均总价
const wuhanAvgPrice = wuhanHouses.length > 0 ?
wuhanHouses.reduce((sum, house) => sum + house.total_price, 0) / wuhanHouses.length : 0;
const shanghaiAvgPrice = shanghaiHouses.length > 0 ?
shanghaiHouses.reduce((sum, house) => sum + house.total_price, 0) / shanghaiHouses.length : 0;
const zhengzhouAvgPrice = zhengzhouHouses.length > 0 ?
zhengzhouHouses.reduce((sum, house) => sum + house.total_price, 0) / zhengzhouHouses.length : 0;
const avgPrices = [wuhanAvgPrice, shanghaiAvgPrice, zhengzhouAvgPrice];
const cities = ['武汉', '上海', '郑州'];
totalChart.value = new Chart(totalChartRef.value, {
type: 'line',
data: {
labels: cities,
datasets: [
{
label: '房屋总数',
data: houseCounts,
borderColor: 'rgba(84, 112, 198, 0.8)',
backgroundColor: 'rgba(84, 112, 198, 0.2)',
tension: 0.3,
fill: true
},
{
label: '平均面积(㎡)',
data: avgAreas,
borderColor: 'rgba(145, 204, 117, 0.8)',
backgroundColor: 'rgba(145, 204, 117, 0.2)',
tension: 0.3,
fill: true
},
{
label: '平均总价(万元)',
data: avgPrices,
borderColor: 'rgba(238, 102, 102, 0.8)',
backgroundColor: 'rgba(238, 102, 102, 0.2)',
tension: 0.3,
fill: true
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: '武汉、上海、郑州二手房数据对比'
},
tooltip: {
mode: 'index',
intersect: false,
},
legend: {
position: 'top',
}
},
scales: {
x: {
title: {
display: true,
text: '城市'
}
},
y: {
title: {
display: true,
text: '数值'
},
beginAtZero: true
}
}
}
});
};
// 初始化所有城市图表
const initCityCharts = () => {
updateWuhanChart();
updateShanghaiChart();
updateZhengzhouChart();
};
// 筛选并更新武汉图表
const updateWuhanChart = () => {
if (wuhanChart.value) wuhanChart.value.destroy();
let filtered = houses.value.filter(house => house.address.includes('武汉'));
// 应用筛选条件
if (wuhanFilters.orientation) {
filtered = filtered.filter(house => house.orientation === wuhanFilters.orientation);
}
if (wuhanFilters.minArea !== null) {
filtered = filtered.filter(house => house.area >= wuhanFilters.minArea);
}
if (wuhanFilters.maxArea !== null) {
filtered = filtered.filter(house => house.area <= wuhanFilters.maxArea);
}
if (wuhanFilters.minPrice !== null) {
filtered = filtered.filter(house => house.total_price >= wuhanFilters.minPrice);
}
if (wuhanFilters.maxPrice !== null) {
filtered = filtered.filter(house => house.total_price <= wuhanFilters.maxPrice);
}
// 计算数据
const areaData = filtered.map(house => house.area);
const priceData = filtered.map(house => house.total_price);
// 创建图表
wuhanChart.value = new Chart(wuhanChartRef.value, {
type: 'scatter',
data: {
datasets: [{
label: '武汉二手房',
data: filtered.map(house => ({
x: house.area,
y: house.total_price,
r: house.area / 50 // 面积作为点的大小
})),
backgroundColor: 'rgba(84, 112, 198, 0.7)',
borderColor: 'rgba(84, 112, 198, 1)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: true,
text: `武汉二手房 (筛选后: ${filtered.length}套)`
},
tooltip: {
callbacks: {
label: function(context) {
return [
`面积: ${context.parsed.x}㎡`,
`总价: ${context.parsed.y}万元`,
];
}
}
}
},
scales: {
x: {
title: {
display: true,
text: '面积(㎡)'
}
},
y: {
title: {
display: true,
text: '总价(万元)'
}
}
}
}
});
};
<script>
import { useRouter } from 'vue-router';
import { ElMessage } from 'element-plus';
export default {
data() {
return {
accountName: '',
password: '',
confirmPassword: '',
captcha: '',
agreement: false,
passwordStrength: 0,
passwordStrengthWidth: 0,
passwordStrengthColor: 'red'
};
},
methods: {
calculatePasswordStrength(password) {
let strength = 0;
if (password.length >= 8) {
strength++;
}
if (/[A-Z]/.test(password)) {
strength++;
}
if (/[a-z]/.test(password)) {
strength++;
}
if (/[0-9]/.test(password)) {
strength++;
}
if (/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
strength++;
}
return strength;
},
updatePasswordStrength() {
this.passwordStrength = this.calculatePasswordStrength(this.password);
this.passwordStrengthWidth = (this.passwordStrength / 5) * 100;
if (this.passwordStrength === 0) {
this.passwordStrengthColor = 'red';
} else if (this.passwordStrength <= 2) {
this.passwordStrengthColor = 'orange';
} else {
this.passwordStrengthColor = 'green';
}
},
handleSubmit() {
if (this.password!== this.confirmPassword) {
alert('两次输入的密码不一致,请重新输入');
return;
}
if (!this.agreement) {
alert('请阅读并同意用户注册协议');
return;
}
// 保存注册信息到localStorage
localStorage.setItem('accountName', this.accountName);
localStorage.setItem('password', this.password);
localStorage.setItem('hasRegistered', 'true');
this.showSuccessMessageAndRedirect();
},
showSuccessMessageAndRedirect() {
ElMessage.success('账号注册成功');
this.$router.push({ name: 'Login' });
},
goToLogin() {
if (localStorage.getItem('hasRegistered') === 'true') {
this.$router.push({ name: 'Login' });
} else {
ElMessage.warning('请先完成注册');
}
}
}
};
</script>
五、功能测试
测试模块 | 用例编号 | 测试步骤 | 预期结果 |
数据加载 | TC001 | 进入页面 | 成功加载第一页数据并展示图表 |
数据加载 | TC002 | 滚动到页面底部 | 自动加载下一页数据 |
数据筛选 | TC003 | 输入地址关键词进行筛选 | 表格和图表更新为筛选后的数据 |
数据对比 | TC004 | 勾选两个房产并点击对比按钮 | 展示选中房产数据并更新对比图表 |
用户注册 | TC005 | 在注册页面输入用户名、符合规则密码,点击注册按钮 | 提示注册成功,跳转到登录页面 |
用户注册 | TC006 | 在注册页面输入用户名、密码、确认密码(与密码不一致),点击注册按钮 | 提示密码和确认密码不一致,不跳转 |
用户登录 | TC007 | 在登录页面输入已注册用户名、正确密码,点击登录按钮 | 成功登录,跳转到数据展示页面 |
用户登录 | TC008 | 在登录页面输入已注册用户名、错误密码,点击登录按钮 | 提示用户名或密码错误,不跳转 |
用户登录 | TC009 | 在登录页面输入未注册用户名、密码,点击登录按钮 | 提示用户名或密码错误,不跳转 |
用户登出 | TC010 | 在已登录的数据展示页面,点击退出登录按钮 | 清除会话信息,跳转到账号登录页面 |
六、总结分析
本房产数据分析平台项目基于Vite + Vue和Django构建前后端分离架构,经过需求分析、系统设计、功能开发与测试等多个阶段的努力,最终成功实现了房产数据可视化分析的核心功能。在项目推进过程中,团队充分发挥前后端技术优势,同时也面临并解决了诸多挑战。
在前端开发中,采用Vite作为项目构建工具。相比传统构建工具,Vite在冷启动时几乎实现秒级响应,显著提升了开发效率。搭配Vue 3框架,借助其先进的响应式系统和组合式API,实现了组件逻辑的高效复用与灵活组织。例如,在图表展示组件和数据筛选组件中,通过组合式API将数据获取、状态管理与视图渲染进行模块化拆分,使代码结构更加清晰,维护性大幅提高。
Element Plus组件库的引入为项目提供了丰富且美观的UI组件,保证了界面的一致性与交互友好性。而Chart.js则成为数据可视化的核心工具,通过定制不同类型的图表(如直方图、柱状图、散点图和饼图),将复杂的房产数据以直观易懂的方式呈现给用户。用户可以通过图表快速捕捉房产总价分布规律、不同朝向的单价差异、建造年份与总价的关联趋势以及房型分布占比等关键信息,有效满足了数据分析需求。
后端选用Django框架进行开发,其强大的ORM(对象关系映射)功能简化了与数据库的交互操作,无需编写复杂的SQL语句,即可实现对房产数据的增删改查。Django内置的路由系统和管理后台,进一步加快了API接口开发与数据管理的速度。通过定义RESTful风格的API接口,实现了前后端数据的高效传输,保证了数据格式的规范性与兼容性。
前后端分离架构的采用,前端专注于用户界面与交互逻辑开发,后端聚焦于数据处理与API接口实现,这种分工模式显著提高了开发效率。通过Axios库实现前后端数据交互,以JSON格式进行数据传输,保证了数据在不同环境下的兼容性和稳定性。在实际应用中,用户的筛选、对比等操作能够及时触发前端请求,后端迅速处理并返回结果,前端实时更新界面展示,实现了流畅的用户体验,达到了预期的功能目标。