Files
FamilyBook/app/Region.php
T
laoyang 52cc1ecccb v24 第 9 批:名册九条(分组按钮/加入名册/生日月日/地址四级/千人性能/拖动修复/删除人员/返回名册/Semi DatePicker)
- #131 名册卡片加「分组」按钮(details+radio,显示当前分组,选中即改)
- #132 修复「从已有人里加入名册」无反应
- #133 生日只显示月和日(阳历 MM-DD / 农历中文名),PersonQuery::birthdaysOf 取代 birthdayOf
- #134 地址结构化为 国家/省/市/区+详细:regions 表(3429 行全国区划)+ Region.php +
  ?r=regions 级联接口 + 表单 .addr-grid + 老地址回填(Schema v16)
- #135 千人名册性能:.person content-visibility:auto + 名册页 ob_gzhandler
  (实测 服务端 1.15s/3.8MB → 0.17s/87KB;客户端导航→load 1.35s → 0.37s)
- #136 拖动排序改 Pointer Events(Chrome 下 HTML5 DnD 在 DOM 变动时断)
- #137 个人详情页底部「删除人员」
- #138 编辑资料页加「返回名册」
- #139 所有日期选择换 Semi Design DatePicker(渐进增强:原生四字段保 POST 契约,
  懒加载 vendor semi UMD,值回写原字段;工作经历 type=month 一并升级;宣纸主题对齐)

验证:e2e 785/0、batch9 37/0、upgrade 126/0、picker 28/0、mobile 688/0、interact 97/0、
batch_e 91/0、import 21/0、douban 22/0、drag 5/0、semi-integrate 28/0、semi-probe 17/0、
geom 5/5;部署后线上自检 16/16(含 4 个 semi 资源 200 + regions 路由)。
2026-10-04 08:56:40 +08:00

295 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 全国行政区划(省 / 地级市 / 区县三级)。
*
* 用户 2026-10-04 第 4 条:「我们的地址,应该也是这样的,由:国家、省、市、区和详细地址组成。
* 如果没有非常详细的地址,则只记录国家、省、市和区,
* 需要把全国的行政区划,写入数据库中进行对应。」
*
* 为什么要有这张表:
* · 通讯录导进来的地址是一整串自由文本(「山东省青岛市市南区香港中路 76 号」),
* 不拆开就只能整串存 —— 于是"按省找人""看看哪些人在青岛"永远做不了;
* · 手工填地址时,省/市/区要有**级联下拉**,否则"市"那一栏会出现
* 「青岛市」和「山东青岛市」两种写法,等于没有结构化;
* · 拆出来的每一级都要能**对得上标准名**,才能与导入的数据互相匹配
* (不然导入的和手填的是两套写法,仍然是两套)。
*
* 数据来源:`assets/data/regions.json`(3429 条:31 省 / 342 市 / 3056 区县),
* 安装与结构升级时灌进 `regions` 表 —— **文件是种子,库是权威**。
* ⚠️ 只灌一次(按 COUNT(*) 判空),重复灌不会重复也不会覆盖用户改动。
*
* ⚠️ 这里**只存中国大陆三级**。国家单独一个自由文本字段(addr_country):
* 国外的行政区划要维护到区县一级是另一件事,先不臆造。
*/
declare(strict_types=1);
namespace App;
final class Region
{
/** @var array<string,array{name:string,level:int,parent:string}>|null */
private static ?array $all = null;
private static function db(): \PDO
{
return Database::get();
}
/** 种子数据文件(相对站点根) */
public static function seedFile(): string
{
return dirname(__DIR__) . '/assets/data/regions.json';
}
/**
* 把种子数据灌进 regions 表。
*
* @return int 本次真的插入的条数(已灌过则返回 0)
*/
public static function seed(?\PDO $db = null): int
{
$db = $db ?: self::db();
$n = (int)$db->query('SELECT COUNT(*) FROM `regions`')->fetchColumn();
if ($n > 0) {
return 0;
}
$file = self::seedFile();
$raw = @file_get_contents($file);
if ($raw === false) {
return 0;
}
$rows = json_decode($raw, true);
if (!is_array($rows)) {
return 0;
}
// ⚠️ 一张表一次性灌 3429 条 —— 用事务 + 预编译,别一条一条 autocommit
// (三千次 fsync 会把安装拖到几十秒,看着像卡死)。
$db->beginTransaction();
// ⚠️ 用 INSERT IGNORE 而不是 INSERT:东莞、中山这类"市直管镇"的城市,
// 数据集里同一个 9 位代码会出现在两棵树下(区一级与镇一级),
// 用普通 INSERT 会在第 2170 行炸掉、把后面一千多条全丢掉
// (实测:库里只剩 19 个省,界面上"海南省之后全没了")。
// IGNORE 之后"先来的那条留下",总量与唯一性都对。
$st = $db->prepare(
'INSERT IGNORE INTO `regions` (`code`, `name`, `level`, `parent_code`) VALUES (?, ?, ?, ?)'
);
$done = 0;
foreach ($rows as $r) {
if (!is_array($r) || count($r) < 3) {
continue;
}
$code = (string)$r[0];
$name = (string)$r[1];
$parent = (string)$r[2];
$level = $parent === '' ? 1 : (strlen($parent) <= 2 ? 2 : 3);
$st->execute([$code, $name, $level, $parent === '' ? null : $parent]);
$done++;
}
$db->commit();
self::$all = null;
return $done;
}
/** 全表(一次请求内只读一次库) */
public static function all(): array
{
if (self::$all !== null) {
return self::$all;
}
$out = [];
try {
foreach (self::db()->query('SELECT code, name, level, parent_code FROM `regions`')->fetchAll() as $r) {
$out[(string)$r['code']] = [
'name' => (string)$r['name'],
'level' => (int)$r['level'],
'parent' => (string)($r['parent_code'] ?? ''),
];
}
} catch (\Throwable $ex) {
$out = []; // 表还没建好(极早期):调用方拿到空列表,不至于整页 500
}
self::$all = $out;
return $out;
}
/** 省级列表:[['code'=>..,'name'=>..], ...] */
public static function provinces(): array
{
$out = [];
foreach (self::all() as $code => $r) {
if ($r['level'] === 1) {
$out[] = ['code' => (string)$code, 'name' => $r['name']];
}
}
usort($out, static fn($a, $b) => strcmp((string)$a['code'], (string)$b['code']));
return $out;
}
/** 某级的下级列表(省→市、市→区县) */
public static function childrenOf(string $code): array
{
if ($code === '') {
return self::provinces();
}
$out = [];
foreach (self::all() as $c => $r) {
if ($r['parent'] === $code) {
$out[] = ['code' => $c, 'name' => $r['name']];
}
}
usort($out, static fn($a, $b) => strcmp((string)$a['code'], (string)$b['code']));
return $out;
}
/** 代码 → 标准名 */
public static function nameOf(?string $code): string
{
$code = (string)$code;
if ($code === '') {
return '';
}
$all = self::all();
return $all[$code]['name'] ?? '';
}
/** 代码 → 上级代码(往上找两级,用来回填「省」) */
public static function parentOf(?string $code): string
{
$code = (string)$code;
$all = self::all();
return (string)($all[$code]['parent'] ?? '');
}
/**
* 把一整串自由文本地址拆成结构化的五段。
*
* 判据是**从前往后逐级前缀匹配**:先认省、再在它下面认市、再在市下面认区县,
* 剩下的整串当详细地址。之所以只能这么干:中文地址没有分隔符,
* 「山东省青岛市市南区香港中路76号」全靠"哪个标准名出现在开头"来切。
*
* ⚠️ 匹配要对**省/市/自治区的后缀**做容错:「山东省」和「山东」都要认,
* 「青岛市」和「青岛」都要认 —— 用户手填、通讯录导出、快递地址三种写法都不一样。
*
* @return array{country:?string,province:?string,city:?string,district:?string,detail:?string}
*/
public static function parse(string $text): array
{
$out = ['country' => null, 'province' => null, 'city' => null,
'district' => null, 'detail' => null];
$text = trim(preg_replace('/\s+/u', ' ', (string)$text) ?? '');
if ($text === '') {
return $out;
}
// ① 国家:只有**写在开头**的显式国家名才算(否则「中国」可能只是路名的一部分)
foreach (['中华人民共和国', '中国'] as $c) {
if (mb_strpos($text, $c) === 0) {
$out['country'] = '中国';
$text = ltrim(mb_substr($text, mb_strlen($c)), ' ,,');
break;
}
}
$rest = $text;
// ② 省
foreach (self::provinces() as $p) {
$hit = self::matchPrefix($rest, $p['name']);
if ($hit !== null) {
$out['province'] = $p['name'];
$rest = $hit;
break;
}
}
// ③ 市:优先在已认出的省下面找;省没认出来就在全国范围找
$pool = $out['province'] !== null
? self::childrenOf(self::codeOfName($out['province'], 1))
: self::allCities();
foreach ($pool as $c) {
$hit = self::matchPrefix($rest, $c['name']);
if ($hit !== null) {
$out['city'] = $c['name'];
$rest = $hit;
break;
}
}
// ④ 区县:在已认出的市下面找
if ($out['city'] !== null) {
foreach (self::childrenOf(self::codeOfName($out['city'], 2)) as $d) {
$hit = self::matchPrefix($rest, $d['name']);
if ($hit !== null) {
$out['district'] = $d['name'];
$rest = $hit;
break;
}
}
}
$rest = trim($rest, " ,,。;;\t\n\r\0\x0B");
if ($rest !== '') {
$out['detail'] = $rest;
}
return $out;
}
/** 全部地级市(省没认出来时的兜底池子) */
private static function allCities(): array
{
$out = [];
foreach (self::all() as $code => $r) {
if ($r['level'] === 2) {
$out[] = ['code' => (string)$code, 'name' => $r['name']];
}
}
// 长名优先:否则「市辖区」会先于「市南区」被匹配掉
usort($out, static fn($a, $b) => mb_strlen($b['name']) <=> mb_strlen($a['name']));
return $out;
}
/** 标准名 → 代码(限某一级,避免「市辖区」那种重名撞车) */
public static function codeOfName(?string $name, int $level): string
{
$name = trim((string)$name);
if ($name === '') {
return '';
}
foreach (self::all() as $code => $r) {
if ($r['level'] === $level && $r['name'] === $name) {
return (string)$code;
}
}
return '';
}
/**
* 看 $name 是不是 $text 的开头(容掉省/市/自治区这类后缀)。
* 命中返回**吃掉之后剩下的串**,没命中返回 null。
*/
private static function matchPrefix(string $text, string $name): ?string
{
$name = trim($name);
if ($name === '' || $text === '') {
return null;
}
// 「山东省」→ 也能认「山东」;「青岛市」→ 也能认「青岛」。
// ⚠️ 反过来不行(不能拿短名去吃长名),所以两种写法都试一遍。
$short = preg_replace('/(省|市|自治区|特别行政区|壮族自治区|回族自治区|维吾尔自治区|自治州|地区|盟)$/u', '', $name);
$cands = [$name];
if ($short !== null && $short !== '' && $short !== $name) {
$cands[] = $short;
}
foreach ($cands as $cand) {
if (mb_strlen($cand) < 2) {
continue; // 太短会误吃(如「东区」)
}
if (mb_strpos($text, $cand) === 0) {
return ltrim(mb_substr($text, mb_strlen($cand)), ' ,,');
}
}
return null;
}
}