Ruby 释出自己的 ruby gem 01 where_streets 实现省市县镇四级联动

hfpp2012 for 汕尾市求知科技有限公司 · 2022年09月07日 · 最后由 ciscolive 回复于 2022年09月08日 · 588 次阅读

原文链接:https://www.qiuzhi99.com/articles/ruby/97610.html

最近大半年做了一个新项目,把 rails 7 新特性探究得差不多,学习了很多东西,也有一些经验可以分享出来。

首先要分享的是,我最近写了十几个 gem,本来打算私用的,现在改变想法,把它分享出来,希望对大家有所帮助。

01. where_streets 实现省市县镇四级联动

02. acts_as_avatar 给系统自动加上数种随机头像

03. custom_trix 增强型 trix

04. activestorage_upyun 又拍云存储

05. error404 自定义错误页面

06. flag_icons 国家图标

07. ip_locator ip 地址定位

08. niceadmin 漂亮的后台

09. rails_boxicons boxicons svg 图标

10. ant_design_icon ant design svg 图标

11. rails_fontawesome6 svg 图标

12. tinymce_extended tinymce 增强

13. table_for 增强

14. rails_remixicon 增强

15. youdao_translate_all 有道云翻译 api

16. helper_extended helper 个性增强

17. loading_svg 图标

18. activestorage_tencent 和 tencent_cos 腾讯 cos 云存储

19. iconfont 官网图标封装

20. where_city 通过坐标查城市位置

除了 gem 可能还有其他经验分享,等我慢慢梳理更新。

where_streets

源码:https://github.com/hfpp2012copy/where_streets (欢迎 star)

实现省市县镇多级联动功能,网络上关于 ruby 的 gem 好像是有,不过有些老了,不太好用。

我自己实现了一个,很简单。

我会把源码和使用方法分享出来。

大家可以一起学习讨论。

先看功能:

也可以是三级联动的,把镇去掉就行。

还有下面这种形式:

安装使用

https://rubygems.org/gems/where_streets

第一步:安装

bundle add where_streets

第二步:直接在表单中用

例如:

<%= bootstrap_form_with model: admin_user, url: admin_account_path, method: :put, data: { controller: "form--request ts--cities-select dropzone-uploader" }, html: { class: "needs-validation mt-3" } do |form| %>
  <%= form.fields_for :avatar do |f| %>
    <%= f.avatar_file_field :upload_avatar, remove_path: remove_avatar_admin_users_path %>
  <% end %>
  <%= form.fields_for :admin_profile do |f| %>
    <%= f.text_field :fullname %>
    <%= f.rich_text_area :about, style: "height: 100px;" %>
  <% end %>
  <%= form.text_field :email, disabled: true %>
  <%= form.select :province, WhereStreets.find_provinces, { include_blank: t("labels.please_select") }, data: { ts__cities_select_target: "province" } %>
  <%= form.select :city, WhereStreets.find_cities(form.object.province || ""), { include_blank: t("labels.please_select") }, data: { ts__cities_select_target: "city" } %>
  <%= form.select :county, WhereStreets.find_counties(form.object.province || "", form.object.city || ""), { include_blank: t("labels.please_select") }, data: { ts__cities_select_target: 'county' } %>
  <%= form.select :town, WhereStreets.find_towns(form.object.province || "", form.object.city || "", form.object.county || ""), { include_blank: t("labels.please_select") }, data: { ts__cities_select_target: 'town' } %>
  <%= form.update_button %>
<% end %>

这里我用了 stimulus。

我也分享出来:

主要是这个 ts--cities-select controller

// app/javascript/controllers/ts/cities_select_controller.js
import { Controller } from "@hotwired/stimulus";
import { get } from "@rails/request.js";
import TomSelect from "tom-select";

// Connects to data-controller="ts--cities-select"
export default class extends Controller {
  static targets = ["province", "city", "county", "town"];

  initialize() {
    ["province", "city", "county", "town"].forEach((name) : {
      Object.defineProperty(this, `${name}Value`, {
        get: function () {
          return this[`${name}Target`].value;
        },
      });

      Object.defineProperty(
        this,
        `tomSelect${name[0].toUpperCase() + name.slice(1)}`,
        {
          get: function () {
            if (!this[name])
              this[name] = new TomSelect(this[`${name}Target`], {
                plugins: ["clear_button"],
              });

            return this[name];
          },
        }
      );
    });
  }

  connect() {
    this.tomSelectProvince;
    this.tomSelectCity;
    this.tomSelectCounty;
    if (this.hasTownTarget) this.tomSelectTown;

    this.provinceTarget.addEventListener("change", () : {
      this.fetchData({
        url: "/admin/cities/cities_filter",
        query: { province: this.provinceValue },
        selectTarget: this.tomSelectCity,
      });
    });

    this.cityTarget.addEventListener("change", () : {
      this.fetchData({
        url: "/admin/cities/counties_filter",
        query: { province: this.provinceValue, city: this.cityValue },
        selectTarget: this.tomSelectCounty,
      });
    });

    this.countyTarget.addEventListener("change", () : {
      if (!this.hasTownTarget) return;

      this.fetchData({
        url: "/admin/cities/towns_filter",
        query: {
          province: this.provinceValue,
          city: this.cityValue,
          county: this.countyValue,
        },
        selectTarget: this.tomSelectTown,
      });
    });
  }

  async fetchData(options) {
    const { url, query, selectTarget } = options;

    const response = await get(url, {
      query,
      responseKind: "json",
    });

    if (response.ok) {
      this.setOptionsData(await response.json, selectTarget);
    } else {
      console.log(response);
    }
  }

  setOptionsData(items, selectTarget) {
    selectTarget.clear();
    selectTarget.clearOptions();
    selectTarget.addOptions(items);
  }

  render_option(data, escape) {
    if (data.sub)
      return `
      <div>
        <div class="text">${escape(data.text)}</div>
        <div class="sub">${escape(data.sub)}</div>
      </div>`;
    else return `<div>${escape(data.text)}</div>`;
  }
}

这里有用到 tom-select,可以用 yarn add tom-select 安装一下。

查看上面的 js 代码,可以看到,这里用到了 api 查询(比如 /admin/cities/towns_filter),因为每次选择位置后,都会发请求去得到数据,比如选了某个省,会把这个省的所有城市数据得到。

我这里可以新建一个 controller 就搞定。

# frozen_string_literal: true

module Admin
  class CitiesController < BaseController
    # @route GET /admin/cities/cities_filter (admin_cities_cities_filter)
    def cities_filter
      @cities = WhereStreets.find_cities(params[:province])
      render json: @cities.map { |city| { text: city, value: city } }
    end

    # @route GET /admin/cities/counties_filter (admin_cities_counties_filter)
    def counties_filter
      @counties = WhereStreets.find_counties(params[:province], params[:city])
      render json: @counties.map { |county| { text: county, value: county } }
    end

    # @route GET /admin/cities/towns_filter (admin_cities_towns_filter)
    def towns_filter
      @towns = WhereStreets.find_towns(params[:province], params[:city], params[:county])
      render json: @towns.map { |town| { text: town, value: town } }
    end
  end
end

最后你把在 model 里存几个字段,比如 province, city 等存到数据库为就好。

源码或原理分享

原理比较简单,主要就是读取网络上最新的 json 文件进行解析数据。

提供了一些查找功能,比如找一个省下的所有城市,一个城市下的所有区等等。

require "singleton"
require "forwardable"
require "fast_blank"
require "msgpack"

class WhereStreets
  autoload :VERSION, "where_streets/version"

  FILE = MessagePack.unpack(File.read(File.expand_path("../pcas.mp", __dir__))).freeze

  include Singleton

  class << self
    extend Forwardable
    def_delegators :instance, :find_provinces, :find_cities, :find_counties, :find_towns
  end

  def find_provinces
    FILE.keys
  end

  def find_cities(province)
    return [] if province.blank?

    handle_error do
      FILE[province.to_s].keys
    end
  end

  def find_counties(province, city)
    return [] if [province, city].any? { |i| i.blank? }

    handle_error do
      FILE[province.to_s][city.to_s].keys
    end
  end

  def find_towns(province, city, county)
    return [] if [province, city, county].any? { |i| i.blank? }

    handle_error do
      FILE[province.to_s][city.to_s][county.to_s]
    end
  end

  private

  def handle_error
    yield
  rescue StandardError : e
    puts e.inspect
    puts e.backtrace
    []
  end
end

这些在源码里可以研究到,最新一版本是用了 msgpack 这个库,也可以不用它,之前没有用的,可以通过代码去查之前的版本。

里面还有一些测试代码,可以看看其用法:

require "test_helper"

class WhereStreetsTest < Minitest::Test
  def test_that_it_has_a_version_number
    refute_nil ::WhereStreets::VERSION
  end

  def test_provinces
    assert_equal 31,
                 WhereStreets.find_provinces.length
  end

  def test_cities
    assert_equal %w[广州市 韶关市 深圳市 珠海市 汕头市 佛山市 江门市 湛江市 茂名市 肇庆市 惠州市 梅州市 汕尾市 河源市 阳江市 清远市 东莞市 中山市 潮州市 揭阳市 云浮市],
                 WhereStreets.find_cities("广东省")
    assert_equal ["市辖区"], WhereStreets.find_cities("上海市")

    assert_empty WhereStreets.find_cities("")
  end

  def test_counties
    assert_equal %w[罗湖区 福田区 南山区 宝安区 龙岗区 盐田区 龙华区 坪山区 光明区],
                 WhereStreets.find_counties("广东省", "深圳市")
    assert_equal %w[黄浦区 徐汇区 长宁区 静安区 普陀区 虹口区 杨浦区 闵行区 宝山区 嘉定区 浦东新区 金山区 松江区 青浦区 奉贤区 崇明区],
                 WhereStreets.find_counties("上海市", "市辖区")
    assert_empty WhereStreets.find_counties("", "")
    # assert_empty WhereStreets.find_counties("广东省", "海丰市")
  end

  def test_towns
    assert_equal %w[梅陇镇 小漠镇 鹅埠镇 赤石镇 鮜门镇 联安镇 陶河镇 赤坑镇 大湖镇 可塘镇 黄羌镇 平东镇 海城镇 公平镇 附城镇 城东镇],
                 WhereStreets.find_towns("广东省", "汕尾市", "海丰县")
    assert_empty WhereStreets.find_towns("", "", "")
  end
end

最后

以后有位置数据需要更新,只要替换 json 文件就行,这样就能保证使用到最新的位置信息。

哪里找最新的位置信息呢?

我是在这里找的:https://github.com/modood/Administrative-divisions-of-China

补充一下:

我自己的项目关于 view 的用法被我封装了,可以用起来更简单:

只要一行代码。

<%= form.cities_select :location, town: true %>

要出现镇就把 town 设为 true, 反之就没有。

之所有这么简单是因为进行了封装,而且用了 bootstrap_form

可以参考了解一下(仅供参考):

# config/initializer/bootstrap_form.rb

module BootstrapForm
  class FormBuilder
    def cities_select(field_name, town: false, **_options)
      location_select = province_select + city_select + county_select

      location_select += town_select if town

      content_tag(:div, class: "mb-3 row", data: { controller: "ts--cities-select" }) do
        concat label(field_name, class: label_col)
        concat(content_tag(:div, class: control_col) do
          content_tag(:div, location_select, class: "row")
        end)
      end
    end

    private

    def province_select
      content_tag(
        :div,
        select_without_bootstrap(
          :province,
          WhereStreets.find_provinces,
          { include_blank: t("labels.please_select") },
          data: { ts__cities_select_target: "province" },
          class: "form-control"
        ),
        class: "col"
      )
    end

    def city_select
      content_tag(
        :div,
        select_without_bootstrap(
          :city,
          WhereStreets.find_cities(object.province || ""),
          { include_blank: t("labels.please_select") },
          data: { ts__cities_select_target: "city" },
          class: "form-control"
        ),
        class: "col"
      )
    end

    def county_select
      content_tag(
        :div,
        select_without_bootstrap(
          :county,
          WhereStreets.find_counties(object.province || "", object.city || ""),
          { include_blank: t("labels.please_select") },
          data: { ts__cities_select_target: "county" },
          class: "form-control"
        ),
        class: "col"
      )
    end

    def town_select
      content_tag(
        :div,
        select_without_bootstrap(
          :town,
          WhereStreets.find_towns(object.province || "", object.city || "", object.county || ""),
          { include_blank: t("labels.please_select") },
          data: { ts__cities_select_target: "town" },
          class: "form-control"
        ),
        class: "col"
      )
    end

  end
end

主要是这里 cities_select 这个方法。(具体自己看吧)

最近别忘了 follow 和 star(换了一个新的 github 号)

有问题可以一起交流,wechat: qiuzhi99pro

其它的等我慢慢更新。想学 ruby 和 rails 的可以看看我录制的教程哈:https://www.qiuzhi99.com/playlists/ruby.html

原文链接:https://www.qiuzhi99.com/articles/ruby/97610.html

楼主应该是 85 年之前的老程序员?

mingyuan0715 回复

没,90 后

支持随总~~~

需要 登录 后方可回复, 如果你还没有账号请 注册新账号