买房焦虑,打造成都二手房交易行情大屏-实现篇

各位道友好呀,我是 "星辰编程理财",上篇《买房焦虑,打造成都二手房交易行情大屏-需求篇》梳理了房地产下行的逻辑、当前的环境政策,还定制了数据指标和数据源。今天,咱们就来看看如何实现这个成都二手房交易行情大屏,我会给大家讲讲技术选型、步骤、主要逻辑,但技术细节咱就点到为止,不往技术细节的深坑里扎。

整体选型

在实现这个成都二手房交易行情大屏之前,我得先给大家介绍一下iStock Shell,这是一个我开源的金融数据查询工具,虽然还没完全完善,但我相信随着需求的驱动,它会越来越好用,具体介绍请点击去官网查看。这次大屏功能实现将iStock Shell作为底层框架,在这基础上实现一个cdesfhq(成都二手房行情)的查询命令。 在实现过程中,我遇到了两个堵点:

  1. 二手房数据入库 :这些数据来自第三方平台,如房小团、贝壳等,需要入库才能被前端使用。而iStock Shell目前的设计思路是重前端、轻后端,大量业务逻辑交给前端处理,即iStock Shell的Web Worker层。
  2. 大屏组件:需要开发一个通用大屏组件去消费处理后的数据,整理出来需要卡片(card)组件、图表(chart)组件、大屏容器(data-grid)。目前已有chart组件,但card和data-grid还需要动手去实现。

棘手的数据入库

说到底,消费第三方数据就两条路:一是直接把数据 "原封不动" 送到前端;二是费点周折,处理好数据入库,再按需从库里捞出来给前端。第一种,iStock Shell轻松就能 hold 住,没啥大问题。第二种难点在于,得安排一堆定时任务去抓数据、入库,要是简单对付下当前业务,随便写写代码也能糊弄过去,但要想用轻巧代码实现规模化、标准化,还得前后端一体化,这难度就上去了。

数据查询分析这块,数据来源五花八门,但入库无非手动、自动 两种方式。手动入库,得弄个数据管理界面让人操作;自动入库,就得有标准化接口或方法。顺着这个思路找,Airtable 最先进入我的选型视野,后来越研究越深入,发现 teable 非常符合我的需求,就它了,就这样成了我的数据管理工具。

至于定时任务咋实现、遵循啥标准、咋管理,我这找了很久的方案没合适的,要是各位道友有方案,欢迎来讨论交流。考虑到这定时任务也不是当下成都二手房交易行情大屏的 "刚需",这项目时间拖得够久了,于是我先把大屏搞出来,后面再慢慢琢磨定时任务的方案。

大致实现步骤

组件实现

组件的实现相对简单,按照正常的开发需求流程来走。iStock Shell用 pnpm + 单一仓库模式管理,咱就在 src/packages/shell-ui/src 目录下新建组件目录,动手开干就完事儿。

部署数据管理服务

使用teable作为数据管理服务,它提供了容器化部署方案,非常方便,部署时可以参考该文档。teable提供Restful API接口,可以非常方便的增、删、改、查数据。 部署参考文件docker-compose.yml:

yml 复制代码
version: '3.1'

services:
  teable:
    container_name: teable
    image: ghcr.nju.edu.cn/teableio/teable:latest
    restart: always
    ports:
      - '5175:3000'
    volumes:
      - /root/data/teable/data:/app/.assets:rw
    env_file:
      - .env
    environment:
      - NEXT_ENV_IMAGES_ALL_REMOTE=true
    networks:
      - teable
    depends_on:
      teable-db-migrate:
        condition: service_completed_successfully
      teable-cache:
        condition: service_healthy
    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost:3000/health']
      start_period: 5s
      interval: 5s
      timeout: 3s
      retries: 3

  teable-db:
    container_name: postgres
    image: postgres:15.4
    restart: always
    ports:
      - '42345:5432'
    volumes:
      - /root/data/teable/db:/var/lib/postgresql/data:rw
    environment:
      - POSTGRES_DB=${POSTGRES_DB}
      - POSTGRES_USER=${POSTGRES_USER}
      - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
    networks:
      - teable
    healthcheck:
      test: ['CMD-SHELL', "sh -c 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}'"]
      interval: 10s
      timeout: 3s
      retries: 3

  teable-db-migrate:
    container_name: teable-db-migrate
    image: ghcr.nju.edu.cn/teableio/teable-db-migrate:latest
    environment:
      - PRISMA_DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
    networks:
      - teable
    depends_on:
      teable-db:
        condition: service_healthy

  teable-cache:
    container_name: redis
    image: redis:7.2.4
    restart: always
    expose:
      - '6379'
    volumes:
      - /root/data/teable/cache:/data:rw
    networks:
      - teable
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
    healthcheck:
      test: ['CMD', 'redis-cli', '--raw', 'incr', 'ping']
      interval: 10s
      timeout: 3s
      retries: 3

networks:
  teable:
    name: teable-network

volumes:
  teable-db: {}
  teable-data: {}
  teable-cache: {}

配置文件.env,自行修改填写:

env 复制代码
# replace the default password
POSTGRES_PASSWORD=
REDIS_PASSWORD=
SECRET_KEY=

# replace the following with a publicly accessible address
PUBLIC_ORIGIN=

# ---------------------

# Postgres
POSTGRES_HOST=teable-db
POSTGRES_PORT=5432
POSTGRES_DB=teable
POSTGRES_USER=teable

# Redis
REDIS_HOST=teable-cache
REDIS_PORT=6379
REDIS_DB=0

# App
PRISMA_DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
BACKEND_CACHE_PROVIDER=redis
BACKEND_CACHE_REDIS_URI=redis://default:${REDIS_PASSWORD}@${REDIS_HOST}:${REDIS_PORT}/${REDIS_DB}

# email
BACKEND_MAIL_HOST=
BACKEND_MAIL_PORT=465
BACKEND_MAIL_SECURE=true
BACKEND_MAIL_SENDER=
BACKEND_MAIL_SENDER_NAME=
BACKEND_MAIL_AUTH_USER=
BACKEND_MAIL_AUTH_PASS=

初始化命令开发

接下来,我们来实现成都二手房交易行情的命令。在项目根目录执行istock cmd init,初始化命令开发,然后按照提示填写相关信息即可。

Shell 复制代码
PS D:\project\myself\istock-shell> istock cmd init
? 在哪个命令域下开发命令? cdfc
? 您期望命令相关文件名为?(文件名用-符号分割) cdesf
cdfc命令域文件夹创建成功
初始化命令开发已完成

初始化完成后,src/worker/domains/cdfc目录下已经有了相关初始化文件。iStock Shell把业务逻辑拆成了三层(Model、Service、Controller),开发方式类似Nestjs框架,不过它是在 Web Worker 里施展拳脚。

模型定义

创建cdfcjysj.model.tscdfceshqsj.model.ts文件,在cdfcjysj.model.tscdfceshqsj.model.ts中,我们定义了数据类型。这些model不仅是对数据类型的描述,如果需要存本地数据库,也是对数据库表的描述文件。 cdfcjysj.model.ts:

TypeScript 复制代码
import { BaseModel, Model } from '@istock/iswork';

// 成都房产交易数据
@Model('cdfcjysj')
export class CdfcjysjModel extends BaseModel {
  时间!: string;
  类型!: '新房' | '二手房';
  区域类型!: '全市' | '中心城区' | '郊区新城';
  套!: number;
  面积!: number;
}

cdfceshqsj.model.ts:

TypeScript 复制代码
import { BaseModel, Model } from '@istock/iswork';

// 成都房产二手行情数据
@Model('cdfceshqsj')
export class CdfceshqsjModel extends BaseModel {
  时间!: string;
  数据类型!:
    | '新增挂牌均价'
    | '新增挂牌中位数'
    | '涨价房源占比'
    | '涨价房源幅度'
    | '成交均价'
    | '成交中位数'
    | '平均成交周期'
    | '降价房源占比'
    | '降价房源幅度'
    | '新增挂牌量'
    | '成交量'
    | '存量挂牌';

  值!: number;
  单位!: string;
  数据源!: '房小团' | '数据源';
}

业务逻辑实现

cdesf.service.ts中,我们需要实现主要的业务逻辑。值得重点关注的地方:

  1. 使用模型提供的run方法调用teable提供的Restful API接口,如:CdfcjysjModel.run。
  2. ChartService是一个全局的提供者,可以直接依赖注入到本服务(CdesfService)里面使用。ChartService服务实例提供generateChartConfig方法把数据转换为图表组件所需要的配置数据,调用方式this.chartService.generateChartConfig,具体使用方法请查看chart.service.tschart.base.service.ts文件源码。

cdesf.service.ts(部分核心代码):

TypeScript 复制代码
import { Injectable, type TModelData } from '@istock/iswork';
import { isNumber, toLocaleDateString } from '@istock/util';
import dayjs from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import { CdfcjysjModel } from './cdfcjysj.model';
import { CdfceshqsjModel } from './cdfceshqsj.model';
import { ChartService } from '../../global/chart/chart.service';
import { type TChartOptions } from '../../global/chart/chart.base.service';
import { EChartType } from '@domains/global/chart/chart.cmd';

type TCdfcjysjResponse = {
  records: Array<{
    fields: TModelData<CdfcjysjModel>;
  }>;
};

type TCdfceshqsjResponse = {
  records: Array<{
    fields: TModelData<CdfceshqsjModel>;
  }>;
};

type TTableQuery = {
  filter?: Record<string, any>;
  orderBy?: Array<Record<string, any>>;
};

type TDataGridCard = { title: string; list: Array<{ name: string; value: string; description?: string }> };

@Injectable()
export class CdesfService {
  constructor(private readonly chartService: ChartService) {
    dayjs.extend(isoWeek);
  }

  /**
   * 获取数据单位
   * @param list
   * @private
   */
  #getListDataUnit(list: Array<TModelData<CdfceshqsjModel>>) {
    // ...
  }

  /**
   * 对比或环比转百分比
   * @param num1
   * @param num2
   * @private
   */
  #toPercentageFormat(num1: number | string, num2: number | string) {
    // ...
  }

  /**
   * 获取交易量数据
   * @param query
   * @param tableId
   */
  async getHouseTradeData(query: TTableQuery = {}, tableId: string = 'tblpAzrLGogn42iVjuq') {
    const response = await CdfcjysjModel.run<TCdfcjysjResponse>(`/api/table/${tableId}/record`, {
      method: 'get',
      query: {
        fieldKeyType: 'name',
        take: 1000,
        ...query,
      },
    });
    return response.records.map((record) => record.fields);
  }

  /**
   * 获取二手交易数据
   * @param query
   * @param tableId
   */
  async getOldHouseTradeData(query: TTableQuery = {}, tableId: string = 'tblVvH7U9z7UJn8v6E5') {
    const response = await CdfceshqsjModel.run<TCdfceshqsjResponse>(`/api/table/${tableId}/record`, {
      method: 'get',
      query: {
        fieldKeyType: 'name',
        take: 1000,
        ...query,
      },
    });
    return response.records.map((record) => record.fields);
  }

  /**
   * 获取整个大屏数据
   */
  async getHouseDataGridData() {
    const [monthTradeData, monthOldHouseTradeData, weekOldHouseTradeData]: [
      Array<TModelData<CdfcjysjModel>>,
      Array<TModelData<CdfceshqsjModel>>,
      Array<TModelData<CdfceshqsjModel>>,
    ] = await Promise.all([
      this.getHouseTradeData({
        filter: {
          conjunction: 'and',
          filterSet: [
            { fieldId: 'fldMBBgvSSurOxULC8b', operator: 'is', value: '二手房' },
            { fieldId: 'fldMMxxENoRejGm6l7G', operator: 'is', value: '全市' },
          ],
        },
        orderBy: [{ fieldId: 'fldvd5o55rIJD3RCQaJ', order: 'desc' }],
      }),
      this.getOldHouseTradeData({
        orderBy: [{ fieldId: 'fldyFiNVC3JNtbm87Cb', order: 'desc' }],
      }),
      this.getOldHouseTradeData(
        {
          orderBy: [{ fieldId: 'fldF3RiarNlFpB3sQsD', order: 'desc' }],
        },
        'tbl4q2BqKL2tq9pk1xD'
      ),
    ]);
    const cards = this.getHouseDataGridCards(monthTradeData, monthOldHouseTradeData, weekOldHouseTradeData);
    const charts = this.getHouseDataGridCharts(
      [...monthTradeData].reverse(),
      [...monthOldHouseTradeData].reverse(),
      [...weekOldHouseTradeData].reverse()
    );
    return {
      cards,
      charts,
    };
  }

  /**
   * 获取卡片数据
   * @param monthTradeData
   * @param monthOldHouseTradeData
   * @param weekOldHouseTradeData
   */
  getHouseDataGridCards(
    monthTradeData: Array<TModelData<CdfcjysjModel>>,
    monthOldHouseTradeData: Array<TModelData<CdfceshqsjModel>>,
    weekOldHouseTradeData: Array<TModelData<CdfceshqsjModel>>
  ): TDataGridCard[] {
    // ...
    const cards: TDataGridCard[] = [
      {
        title: '价',
        list: [],
      },
      {
        title: '量',
        list: [],
      },
      {
        title: '周转',
        list: [],
      },
      {
        title: '回报',
        list: [],
      },
      {
        title: '长期人口',
        list: [],
      },
    ];
    // ...
    return cards;
  }

  /**
   * 获取图表数据
   * @param monthTradeData
   * @param monthOldHouseTradeData
   * @param weekOldHouseTradeData
   */
  getHouseDataGridCharts(
    monthTradeData: Array<TModelData<CdfcjysjModel>>,
    monthOldHouseTradeData: Array<TModelData<CdfceshqsjModel>>,
    weekOldHouseTradeData: Array<TModelData<CdfceshqsjModel>>
  ): TChartOptions[] {
    const colorRange = ['#c94400', '#744f36'];
    const lineChildren = [
      { type: 'line', encode: { shape: 'smooth' } },
      { type: 'point', encode: { shape: 'point' }, tooltip: false },
    ];
    const 月价格走势列表 = monthOldHouseTradeData
      .filter((item) => ['成交均价', '新增挂牌均价'].includes(item.数据类型))
      .map((item) => {
        if (item.时间) item.时间 = toLocaleDateString(new Date(item.时间), 'YYYYMM');
        return { 时间: item.时间, 价格: item.值, 数据类型: item.数据类型 };
      });
 
    const 月成交均价图表: TChartOptions = this.chartService.generateChartConfig(
      {
        数据: 月价格走势列表,
        横轴: '时间',
        纵轴: '价格',
        类别: '数据类型',
        配置: {
          title: {
            title: '月成交均价走势',
          },
          type: 'view',
          scale: { color: { range: colorRange } },
          height: 400,
          children: lineChildren,
        },
      },
      EChartType.Line
    );
    // ...
    return [
      { options: 周量走势图表 },
      { options: 月成交均价图表 },
      { options: 月成交量图表 },
      { options: 涨价降价占比图表 },
    ];
  }
}

命令配置

cdesf.cmd.ts文件中,我们对命令如何使用进行了描述。命令配置描述为TControllerMethodCmdRoute类型。 cdfcjysj.model.ts:

TypeScript 复制代码
import { type TControllerMethodCmdRoute } from '@istock/iswork';

const 成都二手房: TControllerMethodCmdRoute = {
  name: '成都二手房行情',
  cmd: 'cdesfhq',
  usage: 'cdesfhq',
  options: {},
  arguments: [],
  description: '成都二手房行情,数据来自房小团、贝壳。',
  remarks: '',
  example: 'cdesfhq',
};
export default {
  成都二手房,
};

控制逻辑

cdesf.controller.ts中,我们定义了getDataGrid方法将业务逻辑命令配置绑定起来,然后返回定义ShDataGridShText这两个组件接收消费数据。

cdesf.controller.ts:

TypeScript 复制代码
import { CmdRoute, Controller, Method } from '@istock/iswork';
import { CdesfService } from './cdesf.service';
import cmdJson from './cdesf.cmd';

@Controller({
  alias: 'cdesf',
})
export class CdesfController {
  constructor(private readonly cdesfService: CdesfService) {}

  @CmdRoute(cmdJson.成都二手房)
  @Method({
    alias: cmdJson.成都二手房.cmd,
  })
  async getDataGrid() {
    const dataGrid = await this.cdesfService.getHouseDataGridData();
    return {
      output: [
        {
          component: 'ShDataGrid',
          props: {
            ...dataGrid,
          },
        },
        {
          component: 'ShText',
          props: {
            texts: [
              {
                type: 'warning',
                text: '注:主要展示数据来源于成都房小团,卡片中的"周新增挂牌量"和图表中的"周成交量/新增挂牌量走势"数据来自贝壳。数据仅提供参考,不构成任何建议。',
                tag: 'span',
              },
            ],
          },
        },
      ],
    };
  }
}

引用

datasource-register.ts注册cdfcjysj.model.tscdfceshqsj.model.ts模型。将模型绑定到对应数据源上。

新建cdfc.domain.ts文件作为该命令的应用域入口文件,最后cdfc.domain.ts需要导入到root.domain.ts根应用域

typescript 复制代码
    import { Domain } from '@istock/iswork';
    import { CdesfController } from './cdesf/cdesf.controller';
    import { CdesfService } from './cdesf/cdesf.service';

    @Domain({
      name: 'cdfc',
      viewName: '成都房产',
      providers: [CdesfService],
      controllers: [CdesfController],
    })
    export class CdfcDomain {}

至此,开发完成!

实现效果

成都房产应用域下(输入命令yyjr cdfc),再输入cdesfhq命令,查看成都二手房交易行情。效果如下:

最后

说实话,成都的楼市确实让人看不懂,让人焦虑。通过本文开发提供的cdesfhq命令,希望能帮助你更深入地理解成都楼市,为你的决策提供有力的支持。如果这篇文章能给你带来一些启发和帮助,是莫大的荣幸与欣慰。另外如有任何问题或想法,欢迎留言交流。

相关推荐
孜然卷k7 分钟前
前端导出word文件,并包含导出Echarts图表等
前端·javascript
家里有只小肥猫28 分钟前
uniApp小程序保存canvas图片
前端·小程序·uni-app
前端大全31 分钟前
Chrome 推出全新的 DOM API,彻底革新 DOM 操作!
前端·chrome
iuce42 分钟前
手写一个自己的Promise(上)
前端·javascript
八角丶42 分钟前
元素尺寸的获取方式及区别
前端·javascript·html
冴羽1 小时前
Svelte 最新中文文档教程(16)—— Context(上下文)
前端·javascript·svelte
前端小臻1 小时前
关于css中bfc的理解
前端·css·bfc
白嫖不白嫖1 小时前
网页版的俄罗斯方块
前端·javascript·css
HappyAcmen1 小时前
关于Flutter前端面试题及其答案解析
前端·flutter
顾比魁1 小时前
pikachu之CSRF防御:给你的请求加上“网络身份证”
前端·网络·网络安全·csrf