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 路由)。
This commit is contained in:
2026-10-04 08:56:40 +08:00
parent 864e281265
commit 52cc1ecccb
19 changed files with 1629 additions and 154 deletions
+14 -3
View File
@@ -107,6 +107,7 @@ final class ContactImport
if ($up === 'BEGIN:VCARD') {
$cur = ['name' => '', 'phone' => '', 'phones' => [], 'email' => '', 'emails' => [],
'org' => '', 'title' => '', 'address' => '', 'note' => '', 'birthday' => '',
'addr_country' => '', 'addr_province' => '', 'addr_city' => '', 'addr_district' => '',
'url' => '', 'nickname' => '', 'jobrank' => ''];
continue;
}
@@ -183,13 +184,22 @@ final class ContactImport
}
break;
case 'ADR':
if ($cur['address'] === '') {
// ADR: 邮箱;扩展;街道;城市;省;邮编;国家 —— 只要街道+城市这段
if ($cur['address'] === '' && $cur['addr_province'] === '') {
/* vCard 的 ADR **本身就是分段的**(RFC 6350):
PO盒;扩展;街道;城市;省/州;邮编;国家
所以这里不再拼成一整串 —— 直接拆成国家/省/市/街道四段存下来,
再由 ContactImporter 拿它们去对标准行政区划名。
⚠️ 区县(第 8 段)几乎没人填,剩下那段街道再交给 Region::parse()
去认区 —— 它本来就是干这个的。 */
$p = explode(';', $val);
$street = trim((string)($p[2] ?? ''));
$city = trim((string)($p[3] ?? ''));
$prov = trim((string)($p[4] ?? ''));
$cur['address'] = trim($prov . $city . $street);
$ctry = trim((string)($p[6] ?? ''));
$cur['addr_country'] = $ctry;
$cur['addr_province'] = $prov;
$cur['addr_city'] = $city;
$cur['address'] = $street;
}
break;
case 'NOTE':
@@ -238,6 +248,7 @@ final class ContactImport
$cells = str_getcsv($lines[$i], $delim, '"', '\\');
$rec = ['name' => '', 'phone' => '', 'phones' => [], 'email' => '', 'emails' => [],
'org' => '', 'title' => '', 'address' => '', 'note' => '', 'birthday' => '',
'addr_country' => '', 'addr_province' => '', 'addr_city' => '', 'addr_district' => '',
'url' => '', 'nickname' => '', 'jobrank' => ''];
$filled = false;
foreach ($cols as $idx => $key) {
+61 -4
View File
@@ -86,11 +86,10 @@ final class ContactImporter
}
// ---- ③ 没命中:新建 ----
$d = [
$d = array_merge([
'name' => $name !== '' ? $name : $phone,
'phone' => $phone,
'email' => (string)($r['email'] ?? ''),
'address' => (string)($r['address'] ?? ''),
'org' => (string)($r['org'] ?? ''),
// 职务 + 职称合成一列(与 PersonJob::joinTitle 同一口径:
// 两个都有时用 " · " 分开,只有一个就只用那个)
@@ -109,7 +108,7 @@ final class ContactImporter
'birthday_lunar' => '',
'birthday_lunar_leap' => 0,
'birthday_id_card' => '',
];
], self::addrFields($r));
try {
$pid = Person::create($familyId, $d);
} catch (\Throwable $ex) {
@@ -172,6 +171,53 @@ final class ContactImporter
*
* @return string[] 这次补上了哪些(中文标签,用来告诉用户"改了什么")
*/
/**
* 把一条导入记录里的地址整理成 persons 的五个字段。
*
* 分两种来源:
* · vCard 的 ADR **本来就是分段的**(街道/城市/省/国家各占一段)→ 直接用,
* 再用 Region::parse() 从街道那一段里认出「区 / 县」;
* · CSV 那种一整串自由文本 → 整串交给 Region::parse() 逐级前缀匹配拆开。
*
* ⚠️ 认不出来的部分**一律留在 address(详细地址)里**,绝不丢:
* 结构化是为了"以后能按省找人",不是为了让地址变短。
*
* @return array{address:string,addr_country:string,addr_province:string,addr_city:string,addr_district:string}
*/
private static function addrFields(array $r): array
{
$country = trim((string)($r['addr_country'] ?? ''));
$province = trim((string)($r['addr_province'] ?? ''));
$city = trim((string)($r['addr_city'] ?? ''));
$district = trim((string)($r['addr_district'] ?? ''));
$detail = trim((string)($r['address'] ?? ''));
if ($province === '' && $city === '' && $detail !== '') {
// 一整串自由文本 → 逐级前缀匹配
$p = Region::parse($detail);
$country = $country !== '' ? $country : (string)($p['country'] ?? '');
$province = (string)($p['province'] ?? '');
$city = (string)($p['city'] ?? '');
$district = (string)($p['district'] ?? '');
$detail = (string)($p['detail'] ?? '');
} elseif ($detail !== '' && $district === '') {
// 已经有省/市(vCard 给的),从剩下的街道里再认一次区县
$p = Region::parse($detail);
if (($p['district'] ?? null) !== null) {
$district = (string)$p['district'];
$detail = (string)($p['detail'] ?? '');
}
}
return [
'address' => $detail,
'addr_country' => $country,
'addr_province' => $province,
'addr_city' => $city,
'addr_district' => $district,
];
}
private static function fillMissing(array $person, array $r, int $meId): array
{
$pid = (int)$person['id'];
@@ -179,9 +225,12 @@ final class ContactImporter
$labels = []; // 给人看的"补了什么"
// ---- ① persons 上的标量列:他空着才补 ----
$addr = self::addrFields($r);
$scalar = [
'email' => ['邮箱', (string)($r['email'] ?? ''), 200],
'address' => ['住址', (string)($r['address'] ?? ''), 200],
// ⚠️ 地址要走 addrFields() 拆过的那份:直接拿 $r['address'] 会把
// vCard 里"省+市+街道"拼成的一整串原样塞进详细地址,等于没结构化。
'address' => ['住址', $addr['address'], 200],
'org' => ['工作单位', (string)($r['org'] ?? ''), 120],
'note' => ['备注', (string)($r['note'] ?? ''), 2000],
];
@@ -200,6 +249,14 @@ final class ContactImporter
$cols['`title` = ?'] = mb_substr($newTitle, 0, 120, 'UTF-8');
$labels[] = '职务/职称';
}
// 地址的四级结构:同样**只补空着的**(他已经在表单里选过省市区就不动)
foreach (['addr_country' => '国家', 'addr_province' => '省',
'addr_city' => '市', 'addr_district' => '区/县'] as $col => $label) {
if (trim((string)($person[$col] ?? '')) === '' && trim($addr[$col]) !== '') {
$cols["`$col` = ?"] = mb_substr(trim($addr[$col]), 0, 60, 'UTF-8');
$labels[] = $label;
}
}
// 生日:他一个生日都没有时才补。有生日就绝不动 —— 生日是强身份信息,错了很难发现。
$hasBd = trim((string)Birthday::solarOf($person)) !== ''
|| trim((string)($person['birthday_lunar'] ?? '')) !== '';
+35 -5
View File
@@ -165,9 +165,10 @@ final class Person
$sql = 'INSERT INTO persons
(family_id, kind, name, gender, birth_rank, birthday,
birthday_id_card, birthday_lunar, birthday_lunar_leap, slogan, avatar,
phone_hash, phone_encrypted, id_card_encrypted, email, address, org, title, note,
is_placeholder)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
phone_hash, phone_encrypted, id_card_encrypted, email, address,
addr_country, addr_province, addr_city, addr_district,
org, title, note, is_placeholder)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)';
$st = Database::get()->prepare($sql);
$st->execute([
$familyId,
@@ -186,6 +187,10 @@ final class Person
($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null,
($d['email'] ?? '') !== '' ? $d['email'] : null,
($d['address'] ?? '') !== '' ? $d['address'] : null,
self::addrOrNull($d, 'addr_country'),
self::addrOrNull($d, 'addr_province'),
self::addrOrNull($d, 'addr_city'),
self::addrOrNull($d, 'addr_district'),
($d['org'] ?? '') !== '' ? mb_substr(trim((string)$d['org']), 0, 120, 'UTF-8') : null,
($d['title'] ?? '') !== '' ? mb_substr(trim((string)$d['title']), 0, 120, 'UTF-8') : null,
($d['note'] ?? '') !== '' ? $d['note'] : null,
@@ -202,7 +207,9 @@ final class Person
name = ?, gender = ?, birth_rank = ?, birthday = ?,
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
phone_hash = ?, phone_encrypted = ?, id_card_encrypted = ?,
email = ?, address = ?, org = ?, title = ?, note = ?, is_placeholder = ?
email = ?, address = ?,
addr_country = ?, addr_province = ?, addr_city = ?, addr_district = ?,
org = ?, title = ?, note = ?, is_placeholder = ?
WHERE id = ?';
Database::get()->prepare($sql)->execute([
$d['name'],
@@ -219,6 +226,10 @@ final class Person
($d['id_card'] ?? '') !== '' ? Crypto::encrypt($d['id_card']) : null,
($d['email'] ?? '') !== '' ? $d['email'] : null,
($d['address'] ?? '') !== '' ? $d['address'] : null,
self::addrOrNull($d, 'addr_country'),
self::addrOrNull($d, 'addr_province'),
self::addrOrNull($d, 'addr_city'),
self::addrOrNull($d, 'addr_district'),
($d['org'] ?? '') !== '' ? mb_substr(trim((string)$d['org']), 0, 120, 'UTF-8') : null,
($d['title'] ?? '') !== '' ? mb_substr(trim((string)$d['title']), 0, 120, 'UTF-8') : null,
($d['note'] ?? '') !== '' ? $d['note'] : null,
@@ -227,6 +238,19 @@ final class Person
]);
}
/**
* 地址四段:空串一律存 NULL。
*
* 为什么不用 `$d[$k] ?? null` 直接塞:表单里四个下拉没选时提交的是**空串**,
* 存进去就变成"国家 = ''",页面上判断"有没有填"永远为真,
* 于是每个没填地址的人都显示一个空的国家名(看着像数据坏了)。
*/
private static function addrOrNull(array $d, string $key): ?string
{
$v = trim((string)($d[$key] ?? ''));
return $v === '' ? null : mb_substr($v, 0, 60, 'UTF-8');
}
/**
* 自助改自己的资料。
*
@@ -244,7 +268,9 @@ final class Person
'UPDATE persons SET
name = ?, gender = ?, birthday = ?,
birthday_id_card = ?, birthday_lunar = ?, birthday_lunar_leap = ?, slogan = ?, avatar = ?,
email = ?, address = ?, org = ?, title = ?, note = ?
email = ?, address = ?,
addr_country = ?, addr_province = ?, addr_city = ?, addr_district = ?,
org = ?, title = ?, note = ?
WHERE id = ?'
)->execute([
$d['name'],
@@ -257,6 +283,10 @@ final class Person
($d['avatar'] ?? '') !== '' ? $d['avatar'] : null,
($d['email'] ?? '') !== '' ? $d['email'] : null,
($d['address'] ?? '') !== '' ? $d['address'] : null,
self::addrOrNull($d, 'addr_country'),
self::addrOrNull($d, 'addr_province'),
self::addrOrNull($d, 'addr_city'),
self::addrOrNull($d, 'addr_district'),
($d['org'] ?? '') !== '' ? mb_substr(trim((string)$d['org']), 0, 120, 'UTF-8') : null,
($d['title'] ?? '') !== '' ? mb_substr(trim((string)$d['title']), 0, 120, 'UTF-8') : null,
($d['note'] ?? '') !== '' ? $d['note'] : null,
+17 -16
View File
@@ -117,27 +117,28 @@ final class PersonQuery
}
/**
* 生日展示串:优先阳历(真实生日),没有就农历。带上**年份**(用户明确要求)。
* 生日展示串(用户 2026-10-04 第 3 条:**阴历和阳历都显示,只显示月和日,不需要年**)。
*
* @return array{0:string,1:string} [显示串, '阳历'|'农历'|'']
* 两种记法是同一个人的同一天,所以不像以前那样"二选一"——都列出来,
* 长辈看农历、年轻人看阳历,各自认自己的那个。
*
* 农历不写 '02-03' 这种数字:**写成「二月初三」**,否则"农历 02-03"看着就像
* 阳历的 2 月 3 号,反而比不显示更误导(闰月还要带「闰」字)。
*
* @return array<int,array{0:string,1:string}> [['阳历','03-07'], ['农历','二月初三']](没有的就不出现)
*/
public static function birthdayOf(array $row): array
public static function birthdaysOf(array $row): array
{
$solar = (string)Birthday::solarOf($row);
$d = Birthday::dateOnly($solar);
if ($d !== '') {
return [self::ymd($d), '阳历'];
$out = [];
$solar = Birthday::dateOnly((string)Birthday::solarOf($row));
if (preg_match('/^\d{4}-(\d{2})-(\d{2})$/', $solar, $m) === 1) {
$out[] = ['阳历', $m[1] . '-' . $m[2]];
}
$lun = Birthday::dateOnly((string)($row['birthday_lunar'] ?? ''));
if ($lun !== '') {
return [self::ymd($lun), '农历'];
if (preg_match('/^\d{4}-(\d{2})-(\d{2})$/', $lun, $m) === 1) {
$leap = !empty($row['birthday_lunar_leap']);
$out[] = ['农历', Lunar::monthName((int)$m[1], $leap) . Lunar::dayName((int)$m[2])];
}
return ['', ''];
}
/** '1998-03-07' → '1998-03-07'(保持 YYYY-MM-DD,用户要求带年份) */
private static function ymd(string $d): string
{
return preg_match('/^\d{4}-\d{2}-\d{2}$/', $d) ? $d : '';
return $out;
}
}
+294
View File
@@ -0,0 +1,294 @@
<?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;
}
}
+79 -1
View File
@@ -71,7 +71,7 @@ final class Schema
* ⚠️ 它**不再**作为"要不要跑升级"的判据 —— 见 fingerprint() 的说明。
* 它仍然要手工维护,因为它给人看(排查时一眼知道代码到哪一版了)。
*/
public const VERSION = '15';
public const VERSION = '16';
/**
* 「源码指纹」:拿**本文件自身的内容**算一个哈希,作为"要不要跑升级"的判据。
@@ -604,6 +604,19 @@ CREATE TABLE IF NOT EXISTS `task_comments` (
CONSTRAINT `fk_tc_task` FOREIGN KEY (`task_id`) REFERENCES `tasks` (`id`) ON DELETE CASCADE,
CONSTRAINT `fk_tc_person` FOREIGN KEY (`person_id`) REFERENCES `persons` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务评论(支持 @人)'
SQL,
<<<'SQL'
CREATE TABLE IF NOT EXISTS `regions` (
/* ⚠️ VARCHAR(12) 不是 6:东莞、中山这类「市直管镇」的城市,数据集里第四级
(镇 / 街道)是 9 位代码 —— 写 6 会让插入在第 2170 行被截断裂掉,
后面一千多条全丢(实测只剩 19 个省)。 */
`code` VARCHAR(12) NOT NULL COMMENT '行政区划代码:省 2 位 / 市 4 位 / 区县 6 位(市直管镇的镇 9 位)',
`name` VARCHAR(60) NOT NULL COMMENT '标准名称,如「北京市」「青岛市」「市南区」',
`level` TINYINT NOT NULL COMMENT '1=省级 2=地级市 3=区县(含市直管镇)',
`parent_code` VARCHAR(12) DEFAULT NULL COMMENT '上级代码;省级为 NULL',
PRIMARY KEY (`code`),
KEY `idx_region_parent` (`parent_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='全国行政区划(省/市/区县三级):地址结构化与导入时对齐用'
SQL,
];
}
@@ -807,6 +820,36 @@ SQL,
// 就要为页面上的每个人回读一次存储,几十个人 = 几十次外网往返。
self::addColumn($db, 'persons', 'avatar_color',
"ADD COLUMN `avatar_color` CHAR(7) DEFAULT NULL COMMENT '头像主色(上传时算一次;页面按它派生底色/字色)' AFTER `avatar`");
// ---- 地址结构化(版本 16)----
// 用户 2026-10-04 第 4 条:地址 = 国家 / 省 / 市 / 区 / 详细地址五级,
// 而且「如果没有非常详细的地址,就只记到区」—— 所以四级都可空,
// 只有 `address`(详细地址)是原来那一个自由文本列,语义不变。
// ⚠️ 存的是**标准名**不是代码:通讯录导进来的地址是自由文本,
// 按代码存就要求导入时 100% 匹配得上,匹配不上的整条地址就丢了;
// 存标准名则"对得上就规范化、对不上就照抄",永远不会更差。
self::addColumn($db, 'persons', 'addr_country',
"ADD COLUMN `addr_country` VARCHAR(60) DEFAULT NULL COMMENT '国家' AFTER `address`");
self::addColumn($db, 'persons', 'addr_province',
"ADD COLUMN `addr_province` VARCHAR(60) DEFAULT NULL COMMENT '省 / 自治区 / 直辖市 / 特别行政区(标准名,对应 regions.level=1)' AFTER `addr_country`");
self::addColumn($db, 'persons', 'addr_city',
"ADD COLUMN `addr_city` VARCHAR(60) DEFAULT NULL COMMENT '地级市(标准名,对应 regions.level=2)' AFTER `addr_province`");
self::addColumn($db, 'persons', 'addr_district',
"ADD COLUMN `addr_district` VARCHAR(60) DEFAULT NULL COMMENT '区 / 县(标准名,对应 regions.level=3)' AFTER `addr_city`");
// ⚠️ CREATE TABLE IF NOT EXISTS 不会改已存在的列宽 —— 老库(v16 之前建的
// `code VARCHAR(6)`)必须显式 MODIFY,否则东莞那种 9 位镇级代码插不进去。
$db->exec("ALTER TABLE `regions` MODIFY COLUMN `code`
VARCHAR(12) NOT NULL COMMENT '行政区划代码:省 2 位 / 市 4 位 / 区县 6 位(市直管镇的镇 9 位)'");
$db->exec("ALTER TABLE `regions` MODIFY COLUMN `parent_code`
VARCHAR(12) DEFAULT NULL COMMENT '上级代码;省级为 NULL'");
// 全国行政区划灌库(只是数据步骤 → 容错跑:种子文件丢了也不该挡住后面的升级)
self::tryStep('灌入全国行政区划(省/市/区县)', static function () use ($db) {
\App\Region::seed($db);
});
// 老地址(原来那一整串)拆成结构化:只补空字段,绝不覆盖用户已经改过的
self::tryStep('老地址拆成国家/省/市/区', static function () use ($db) {
self::backfillAddress($db);
});
// 老头像(v11 之前传的)补算一次:回读已存的图算主色。
// 纯数据步骤 → 容错跑:某张图读不回来(OSS 权限、对象丢了)只影响那一个人,
// 他会自动落到「按姓名取色」的兜底上,不该把后面的升级步骤挡住。
@@ -1386,6 +1429,41 @@ SQL);
}
}
/**
* 老地址(原来 `address` 里那一整串自由文本)拆成 国家/省/市/区。
*
* ⚠️ 只补**空着**的四个字段,绝不覆盖:用户如果已经手工选过省市区,
* 一次升级把他选的改成"从文本里猜的",比不猜更糟(猜错就悄悄错了)。
* ⚠️ 拆不出来的(比如只写了「老家」)就**原样留在 address 里** ——
* 详细地址那一栏本来就允许是任意文本,不拆等于没变,不会丢东西。
*/
private static function backfillAddress(\PDO $db): void
{
$rows = $db->query(
"SELECT id, address FROM persons
WHERE address IS NOT NULL AND address <> ''
AND addr_province IS NULL AND addr_city IS NULL AND addr_district IS NULL
ORDER BY id"
)->fetchAll();
if (!$rows) {
return;
}
$up = $db->prepare(
'UPDATE persons SET addr_country = ?, addr_province = ?, addr_city = ?,
addr_district = ?, address = ? WHERE id = ?'
);
foreach ($rows as $r) {
$p = \App\Region::parse((string)$r['address']);
if ($p['province'] === null && $p['city'] === null && $p['district'] === null) {
continue; // 什么都认不出来 → 整串留在详细地址里,不动它
}
$up->execute([
$p['country'], $p['province'], $p['city'],
$p['district'], $p['detail'] ?? '', (int)$r['id'],
]);
}
}
/** 列不存在才加 */
private static function addColumn(\PDO $db, string $table, string $column, string $alter): void
{