这是一个系列 Blog。作者将以 PHP 全栈工程师的视角,借助 AI 工具(TRAE、claude code、codex、deepseek、豆包等),从零系统学习 Golang 语言开发,并最终完成 ai-go-admin(github | gitee)开源项目的制作(欢迎 star),全程进行实战记录与经验分享。
上一期我们完成了“管理员个人资料页面、管理员日志优化”,这一期的开发目标是:新增 area 接口,并实现区域三联动选择组件。
今天的主要开发工具依然是 TRAE IDE。
增加 area 接口
模型和迁移
这里直接从 BuildAdmin 中复制建表 SQL,再让 AI 协助生成模型和迁移文件。同时依旧按照之前的规范,将 AI GO ADMIN 系统自带的杂项模型统一放入 common.go,而不是拆分成多个独立文件,以避免项目初期就出现大量模型文件,影响代码结构的简洁性。提示词如下:
建立 area 表的迁移和模型:
迁移放在: @cmd/migrate/migrations/000002_common.up.sql模型放在: @internal/model/common.go
参考 SQL:
CREATE TABLE `area`(`id` int NOT NULL AUTO_INCREMENT COMMENT 'ID',`pid` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '上级ID',`name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '名称',`level` tinyint UNSIGNED NOT NULL DEFAULT 0 COMMENT '等级',`code` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '行政区划代码',`zip` varchar(6) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '邮编',PRIMARY KEY (`id`) USING BTREE) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '省份地区表' ROW_FORMAT = DYNAMIC;
AI 生成好的模型如下。它大概是看到其他模型都定义了 TableName 方法,所以这里也顺手补上了,这个处理是正确的:
// Area 省份地区模型type Area struct {IDuint `gorm:"comment:ID;primarykey;autoIncrement" json:"id"`Pid uint `gorm:"comment:上级ID;not null;default:0;index" json:"pid"`Namestring `gorm:"comment:名称;type:varchar(64);not null;default:''" json:"name"`Level uint8`gorm:"comment:等级;not null;default:0;index" json:"level"`Codestring `gorm:"comment:行政区划代码;type:varchar(32);not null;default:''" json:"code"`Zip string `gorm:"comment:邮编;type:varchar(32);not null;default:''" json:"zip"`}// TableName 指定 Area 模型表名func (Area) TableName() string {return "areas"}
迁移 SQL 如下:
-- ===== areas 省份地区表 =====CREATE TABLE IF NOT EXISTS "__PREFIX__areas" ("id"bigserial PRIMARY KEY,"pid" bigint NOT NULL DEFAULT 0,"name"varchar(64) NOT NULL DEFAULT '',"level" smallint NOT NULL DEFAULT 0,"code"varchar(32) NOT NULL DEFAULT '',"zip" varchar(32) NOT NULL DEFAULT '');CREATE INDEX IF NOT EXISTS "__PREFIX__idx_areas_pid" ON "__PREFIX__areas" ("pid");CREATE INDEX IF NOT EXISTS "__PREFIX__idx_areas_level" ON "__PREFIX__areas" ("level");COMMENT ON TABLE "__PREFIX__areas" IS '省份地区数据表';COMMENT ON COLUMN "__PREFIX__areas"."id" IS 'ID';COMMENT ON COLUMN "__PREFIX__areas"."pid" IS '上级ID';COMMENT ON COLUMN "__PREFIX__areas"."name" IS '名称';COMMENT ON COLUMN "__PREFIX__areas"."level" IS '等级';COMMENT ON COLUMN "__PREFIX__areas"."code" IS '行政区划代码';COMMENT ON COLUMN "__PREFIX__areas"."zip" IS '邮编';
这份迁移里并不包含具体的省、市、区数据,因为这类地区数据量非常大,直接内置在框架中并不合适。后续会通过单独的 SQL 文件提供,开发者按需下载和导入即可。这里先给出一个种子数据示例:
INSERT INTO areas VALUES (1, 0, '北京市', 1, '110000', '100000');INSERT INTO areas VALUES (2, 1, '北京市', 2, '110100', '100000');INSERT INTO areas VALUES (3, 2, '东城区', 3, '110101', '100010');INSERT INTO areas VALUES (4, 2, '西城区', 3, '110102', '100032');INSERT INTO areas VALUES (5, 2, '朝阳区', 3, '110105', '100020');INSERT INTO areas VALUES (6, 2, '丰台区', 3, '110106', '100071');
目前系统中的省份地区数据还没有同步到最新版本,但这未必是坏事。把背后的设计逻辑想清楚后,你会发现:权威数据源已经找到了,后续更新方案也已经明确,现在只是暂时没有执行而已。真正落地时,现有数据中的每一条记录都非常关键。
- 不能删:一旦删除,ID 不但需要重新排序,而且所有依赖该 ID 的关联数据都会失去关联
- 不能直接修改区域名称等字段:一旦改动,同样可能影响已有的业务关联
这时你可能会说,现在还是一个新项目,并不存在历史关联数据。可问题在于,还要再往后想一步:这次改完之后,未来省市区数据如果再次更新怎么办?
更稳妥的解决方案,其实只能是增加记录状态标记。后续新增一个 status 字段,把那些“删除、改名”的地区数据统一标记为过时状态,这样既不会影响已有关联数据,前端省份区域选择组件也可以在读取时自动忽略过时数据;至于新增区域,直接插入到末尾即可。也就是说,这次看似“偷懒”,其实是在为后续地区数据规范化更新提前铺路。
增加 area 接口
这里直接让 AI 参考 @../ba238/app/api/controller/Ajax.php 中的 area 方法,分析其内部的 get_area 函数,然后在本项目中实现 @internal/handler/common/util.go 的 area 接口。
最终代码如下。用大白话来说,就是根据传入的 province 和 city 参数,动态获取对应的下级区域数据:
// Area 用于获取省、市、区地区数据func Area(c *gin.Context) {city := c.DefaultQuery("city", "")province := c.DefaultQuery("province", "")pid := 0level := 1if province != "" {pid, _ = strconv.Atoi(province)level = 2if city != "" {pid, _ = strconv.Atoi(city)level = 3}}areas, err := repoCommon.NewAreaRepository().FindByPidAndLevel(c.Request.Context(), pid, level)if err != nil {httpx.Fail(c, httpx.WithMessage("地区数据查询失败"))return}httpx.Success(c, httpx.WithData(areas))}
一开始,AI 其实没有把对应的仓储层一并创建出来,而是直接在控制器里写了 GORM 查询语句,把数据查询逻辑塞进了控制器。后面我只补充了一句提示词,问题就很快解决了:建立 area 对应的仓储层,并改为通过仓储发起查询,创建位置放在 @internal/repository/common/。
增加 area 选择三联动公共组件
刚刚才完成 area 接口,趁着上下文还在,继续让 AI 接着实现区域三联动组件:参考 @../ba238/web/src/components/baInput/index.vue 中 type=city 的实现方式,完成本项目的 @web/src/components/agInput/components/areaSelect.vue 组件。
是的,这里是把原本参考代码中的一个 type 逻辑,单独抽离成独立组件。到目前为止,我们封装的输入类组件已经包括:areaSelect、agUpload、array、iconSelect、remoteSelect 这五种,它们都属于组件库里没有直接提供、但后台管理系统中又比较常用的场景。像 el-input 这种基础组件就没必要二次封装了。AI 时代里,过度封装带来的理解成本,本质上也是在浪费 token。当然,后续还是会提供一个统一入口的 agInput/index.vue 组件,通过传入 type 直接渲染对应输入框,主要服务于动态表单场景,平常开发并不会强制使用。
写这段 blog 的同时,AI 那边也把组件做完了。组件本身没有太多特殊逻辑,核心就是使用了 el-cascader 来实现省市区三级联动,样式效果就是本文开篇图片里的展示。
组件中还加了一个节点缓存功能,用来缓存已经请求过的地区节点数据。我看到 AI 使用了 reactive 包裹这份缓存数据,但其实完全没必要,因为这部分数据并不会直接参与模板渲染,也不是 computed/watch 的响应式依赖,直接用普通对象就够了,于是让它去掉:
有道理。nodeCache 只做数据暂存,不在模板中渲染,也不作为响应式依赖被 computed/watch 消费,用普通对象即可。同理 lastLazy 也可以简化。
然后 AI 还给绑定值写了一个 set:
const selectedValue = computed({get: () => props.modelValue ?? [],set: (val) => emit('update:modelValue', val ?? []),})
但与此同时,它又在 el-cascader 上额外写了 onChange 事件,并在里面再次执行 emit('update:modelValue', val ?? [])。这显然是重复处理了,除非特定场景确实需要额外触发 emit,否则就是多余代码,所以也让它改掉。
今天 AI 的表现确实有点一般。总共没多少行代码,而且基本都是前端逻辑,结果还是频繁出小问题。
增加重置表单的公共函数
这部分本身并不复杂,主要是记录一个容易忽略但很实用的细节:表单的初始值,也就是“重置后恢复到的值”,是通过 formRef.value.setInitialValues() 来设置的;如果没有手动设置,那么系统会将表单第一次渲染时的绑定值,默认作为初始值。
/** * el-form 表单重置 * @param formEl */export const resetForm = (formEl?: FormInstance | null) => {// resetFields 对该表单项进行重置,将其值重置为初始值并移除校验结果typeof formEl?.resetFields == 'function' && formEl.resetFields()}
