☰
Java课程设计通讯录管理系统:Swing+JDBC分层实现与避坑
2026/10/28 2:17:20 网站建设 项目流程

简介:面向高校Java课程设计与初学者实践,这套通讯录管理系统提供一套可直接参考的完整实现方案,围绕用户注册登录、通讯录浏览查找及增删改维护等核心需求展开,采用JDBC连接MySQL,界面可选Swing或JSP,适合用来巩固面向对象设计、GUI事件处理与数据库增删改查。压缩包共5个文件,以4个Java源码和1个SQL脚本为主,整体约8KB,Java部分对应登录注册、通讯录界面与数据库工具等模块,SQL脚本用于初始化表结构。已有1562人学习,说明其在课程设计场景中具有一定参考价值。读者可从中了解类的设计、继承与多态的实际落地,掌握Swing窗体、按钮、文本框等组件的事件交互,以及通过JDBC完成数据连接与CRUD操作,并借鉴模块化拆分思路进行二次开发或课程答辩准备。

1. 通讯录管理系统:java 课程设计 里最容易被低估的一道题

答辩前一周,A同学抱着笔记本找到我,说他的 java 课程设计 选题是通讯录管理系统,控制台版本两天就写完了,结果老师一句“加个图形界面,数据要存库里”,他直接卡了三天。这题看着像送分题,真动手才发现:表格删了一行界面不刷新、中文名字存进去变成问号、点几次查询之后程序直接卡死。通讯录管理系统的本质不是增删改查,而是“一份数据在界面、内存、数据库三个地方保持一致”。这篇按我拆过的实现路子,把技术选型、目录结构、DAO 与 Service 的分工、搜索和导入导出的加分项,以及答辩前最容易炸的几个点,一段段摊开讲。适合刚接手课程设计、想把项目真正跑起来并改出花样的同学。

2. 技术选型与骨架:Swing + JDBC 的三层目录怎么落地

2.1 为什么用 Swing + JDBC,而不是控制台 + 内存 List

课程设计的评分点通常集中在三处:界面能不能交互、数据能不能持久化、代码有没有分层。控制台 +ArrayList的方案两小时能交差,但它同时踩空后两项,稍微严一点的验收就会追问“关掉程序数据还在吗”。所以常见做法是 Swing 做界面、JDBC 直连数据库,中间不引入 Spring 那一套,理由很实在:依赖少、启动快、答辩时能讲清楚每一行在干什么。

Swing 的好处是 JDK 自带,不用配 Maven 仓库也能跑;JTable天生适合展示联系人列表,JTextField+JButton拼一个查询栏就够了。JDBC 同理,一个驱动 jar 加四行连接代码就能打通数据库,比 ORM 框架更适合讲清楚 SQL 是怎么发出去的。如果你机器上不方便装数据库,把 JDBC URL 换成 SQLite 或 H2 的文件模式,DAO 层代码几乎不用改,这一点在第 6 章还会展开。

需要提前定死的一条规矩:界面层不准出现 SQL,DAO 层不准出现JOptionPane。这条线一划,后面加功能才不会互相牵扯。

2.2 目录结构:model / dao / service / ui 各管一摊

包结构我一般按下面这样切,包名跟着学校要求走,层次不变:

src/ ├── model/ 实体类,只有字段和 getter/setter │ └── Contact.java ├── dao/ 只跟数据库打交道 │ ├── DBUtil.java │ └── ContactDao.java ├── service/ 业务规则:校验、去重、分页、导入导出 │ └── ContactService.java ├── ui/ 窗体、表格模型、事件监听 │ ├── MainFrame.java │ └── ContactTableModel.java └── App.java 入口,只负责 new MainFrame().setVisible(true)

每一层的职责边界要能一句话说清:model是数据长什么样,dao是数据怎么进出库,service是数据合不合规矩,ui是数据怎么给人看。A同学最初的写法是把Connection、PreparedStatement、JTable全塞进一个MainFrame里,七百多行,改一个字段要在五个地方同步,翻车是必然的。

分层带来的另一个好处是可测试。ContactService不依赖任何 Swing 组件,你在main方法里 new 一个出来就能验证手机号校验逻辑对不对,不用每次都把窗口点一遍。

2.3 建库建表:一张 contact 表和那几个必须的索引

表结构是后面所有代码的地基,字段类型选错,改起来要连带改实体类、DAO、表格列。这份 DDL 是我常用的版本:

CREATE DATABASE IF NOT EXISTS addrbook DEFAULT CHARACTER SET utf8mb4; USE addrbook; CREATE TABLE IF NOT EXISTS contact ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', name VARCHAR(32) NOT NULL COMMENT '姓名', mobile VARCHAR(20) NOT NULL COMMENT '手机号', phone VARCHAR(20) DEFAULT NULL COMMENT '座机', email VARCHAR(64) DEFAULT NULL, company VARCHAR(64) DEFAULT NULL, address VARCHAR(128) DEFAULT NULL, group_name VARCHAR(32) NOT NULL DEFAULT '默认分组' COMMENT '分组', pinyin VARCHAR(64) DEFAULT NULL COMMENT '姓名全拼', pinyin_abbr VARCHAR(16) DEFAULT NULL COMMENT '首字母缩写', remark VARCHAR(255) DEFAULT NULL, deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0正常 1已删除', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_mobile (mobile), KEY idx_name (name), KEY idx_group (group_name), KEY idx_pinyin_abbr (pinyin_abbr) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个参数值得解释。mobile用VARCHAR(20)而不是BIGINT,手机号不参与算术运算,用数字类型会丢掉前导零,长度也不保险。deleted是软删除标记,答辩时能多讲一句“删除可恢复”,比物理删除显得有设计感。uk_mobile唯一索引把“同一手机号不能重复录入”这条规则交给数据库兜底,Service 层再查一次是为了给出友好提示,两道防线都要有。pinyin和pinyin_abbr是为 4.1 节的拼音搜索预埋的,现在可以先留空,录入时顺手补上。

charset一定写utf8mb4,只写utf8在某些版本里存不了四字节字符,姓名里的生僻字会直接报错。

3. 核心链路拆解:一条联系人记录从录入到落库

3.1 实体类 Contact:字段类型选错后面全是坑

实体类看着最没技术含量,但它决定了后面每一层的书写成本。我的习惯是字段名和表字段一一对应,用包装类型而不是基本类型,因为null和0在业务上语义完全不同:

public class Contact { private Long id; // 新增时为 null,由数据库自增 private String name; private String mobile; private String phone; private String email; private String company; private String address; private String groupName = "默认分组"; private String pinyin; private String pinyinAbbr; private String remark; // 只读属性:表格里展示"姓名(分组)"用,不参与入库 public String getDisplayName() { return name + "(" + groupName + ")"; } // getter / setter 省略,IDE 生成即可 }

id用Long而不是long,是为了区分“这条记录还没入库”和“这条记录的主键是 0”。getDisplayName()这类派生属性不要写进 DAO,它属于展示逻辑,放在实体类里当只读方法最省事。

3.2 DBUtil:连接、驱动、关闭三个动作

连接管理单独抽一个工具类,别在 DAO 里到处DriverManager.getConnection:

public final class DBUtil { private static final String URL = "jdbc:mysql://127.0.0.1:3306/addrbook" + "?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai"; private static final String USER = "root"; private static final String PWD = "123456"; static { try { // 驱动只需注册一次,放在静态块里,类加载时执行 Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("驱动没进 classpath:" + e.getMessage()); } } private DBUtil() {} public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PWD); } /** 统一关闭,顺序无所谓,传 null 也安全 */ public static void close(AutoCloseable... resources) { for (AutoCloseable r : resources) { if (r != null) { try { r.close(); } catch (Exception ignored) { } } } } }

characterEncoding=utf8这一项是中文乱码的第一道保险,少了它,即使表是 utf8mb4 也可能在传输层出问题。serverTimezone不配的话,某些驱动版本连接时直接抛时区异常。close()用可变参数是为了让调用方写DBUtil.close(rs, ps, conn)一行收尾,比三层嵌套finally干净得多。真正上生产会换成连接池,课程设计阶段按需选择即可。

3.3 ContactDao:PreparedStatement 写增删改查

DAO 层只做一件事:把对象翻译成 SQL,把结果集翻译回对象。新增和条件查询这两段最能体现写法:

public class ContactDao { public int insert(Contact c) throws SQLException { String sql = "INSERT INTO contact(name, mobile, phone, email, company, address," + " group_name, pinyin, pinyin_abbr, remark) VALUES(?,?,?,?,?,?,?,?,?,?)"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, c.getName()); ps.setString(2, c.getMobile()); ps.setString(3, c.getPhone()); ps.setString(4, c.getEmail()); ps.setString(5, c.getCompany()); ps.setString(6, c.getAddress()); ps.setString(7, c.getGroupName()); ps.setString(8, c.getPinyin()); ps.setString(9, c.getPinyinAbbr()); ps.setString(10, c.getRemark()); int rows = ps.executeUpdate(); try (ResultSet keys = ps.getGeneratedKeys()) { if (keys.next()) c.setId(keys.getLong(1)); // 回填主键,界面才能定位到这一行 } return rows; } } /** keyword 为空时等同全量查,分页靠 limit/offset */ public List<Contact> search(String keyword, String group, int page, int size) throws SQLException { StringBuilder sql = new StringBuilder( "SELECT * FROM contact WHERE deleted = 0"); List<Object> args = new ArrayList<>(); if (keyword != null && !keyword.trim().isEmpty()) { sql.append(" AND (name LIKE ? OR mobile LIKE ? OR pinyin_abbr LIKE ?)"); String like = "%" + keyword.trim() + "%"; args.add(like); args.add(like); args.add(like); } if (group != null && !group.isEmpty() && !"全部".equals(group)) { sql.append(" AND group_name = ?"); args.add(group); } sql.append(" ORDER BY id DESC LIMIT ? OFFSET ?"); args.add(size); args.add((page - 1) * size); List<Contact> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql.toString())) { for (int i = 0; i < args.size(); i++) { ps.setObject(i + 1, args.get(i)); // 位置从 1 开始,别写成 0 } try (ResultSet rs = ps.executeQuery()) { while (rs.next()) list.add(mapRow(rs)); } } return list; } private Contact mapRow(ResultSet rs) throws SQLException { Contact c = new Contact(); c.setId(rs.getLong("id")); c.setName(rs.getString("name")); c.setMobile(rs.getString("mobile")); c.setGroupName(rs.getString("group_name")); c.setPinyinAbbr(rs.getString("pinyin_abbr")); // 其余字段同理 return c; } }

两个细节容易翻车。一是ps.setObject(i + 1, ...)的下标,JDBC 从 1 开始,写成 0 会直接抛异常;二是动态拼条件时必须用?占位,把用户输入直接拼进 SQL 字符串就是注入漏洞,虽然课程设计没人攻击你,但答辩老师一般会问这一句。

executeUpdate()的返回值是受影响行数,界面上判断它是否大于 0 再弹提示,比无条件弹“操作成功”靠谱。

3.4 ContactService:校验、去重、异常出口

Service 层是业务规则的唯一入口,界面只负责收集输入和展示结果:

public class ContactService { private final ContactDao dao = new ContactDao(); private static final Pattern MOBILE = Pattern.compile("^1[3-9]\\d{9}$"); private static final Pattern EMAIL = Pattern.compile("^[\\w.-]+@[\\w-]+\\.[\\w.]+$"); public void save(Contact c) throws BizException { if (isBlank(c.getName())) throw new BizException("姓名不能为空"); if (c.getName().trim().length() > 32) throw new BizException("姓名过长"); if (!MOBILE.matcher(nvl(c.getMobile())).matches()) throw new BizException("手机号格式不对"); if (!isBlank(c.getEmail()) && !EMAIL.matcher(c.getEmail()).matches()) throw new BizException("邮箱格式不对"); try { if (dao.existsByMobile(c.getMobile(), c.getId())) throw new BizException("该手机号已存在"); if (c.getId() == null) { c.setPinyin(PinyinUtil.full(c.getName())); c.setPinyinAbbr(PinyinUtil.abbr(c.getName())); dao.insert(c); } else { dao.update(c); } } catch (SQLException e) { // 数据库异常不往上抛原始堆栈,转成业务异常,界面只认 BizException throw new BizException("保存失败:" + e.getMessage(), e); } } }

existsByMobile(mobile, excludeId)里那个excludeId是给编辑场景用的:判断重复时必须排除自己,否则改个备注都会提示“手机号已存在”。这个 bug 我见过至少三次。

BizException是自定义的运行时异常,专门装“可以直接展示给用户的话”。SQLException属于黑匣子,堆栈留给日志,界面只弹一句人话。这一层做好,界面里的try-catch就能收成一处。

3.5 UI 层:JTable 与 TableModel 的绑定

表格的数据源必须是TableModel,不要在每次查询后new JTable(data, cols)重建:

public class ContactTableModel extends AbstractTableModel { private final String[] cols = {"ID", "姓名", "手机号", "分组", "公司", "备注"}; private List<Contact> data = new ArrayList<>(); public void setData(List<Contact> list) { this.data = (list == null) ? new ArrayList<>() : list; fireTableDataChanged(); // 通知表格整体刷新 } public Contact getRow(int row) { return data.get(row); } @Override public int getRowCount() { return data.size(); } @Override public int getColumnCount() { return cols.length; } @Override public String getColumnName(int i) { return cols[i]; } @Override public Object getValueAt(int row, int col) { Contact c = data.get(row); switch (col) { case 0: return c.getId(); case 1: return c.getName(); case 2: return c.getMobile(); case 3: return c.getGroupName(); case 4: return c.getCompany(); case 5: return c.getRemark(); default: return ""; } } @Override public boolean isCellEditable(int row, int col) { return false; // 只读,编辑走弹窗,避免双击改值和库不一致 } }

isCellEditable返回false是关键取舍。允许单元格直接编辑就得处理“改完怎么落库、失败怎么回滚”,课程设计阶段用“选中行 + 编辑按钮 + 弹窗表单”更稳。setData里每次都换一个新的List引用并fireTableDataChanged(),这样删除、搜索、分页三种场景共用一套刷新逻辑。

主窗体里的加载方法就三行:

private void reload() { try { List<Contact> list = service.search(keywordField.getText(), groupBox.getSelectedItem().toString(), page, PAGE_SIZE); model.setData(list); // 数据和表格是同一个对象,界面自动跟着变 statusLabel.setText("共 " + list.size() + " 条"); } catch (Exception ex) { JOptionPane.showMessageDialog(this, ex.getMessage(), "查询失败", JOptionPane.ERROR_MESSAGE); } }

reload()是全篇出现频率最高的方法:新增完调它、删除后调它、翻页调它、搜索也调它。把刷新收敛到一个入口,就不会出现“这个按钮刷、那个按钮不刷”的不一致。

4. 功能增强实战:搜索、分组、导入导出三个加分项

4.1 模糊搜索与拼音首字母检索

基础搜索用LIKE就够了,真正拉开差距的是拼音首字母:输入zs能搜到“张三”,答辩现场演示效果很好。做法是在录入时算出全拼和缩写存进表里,查询时匹配pinyin_abbr:

public final class PinyinUtil { // 只覆盖常用汉字区间,够课程设计用;冷僻字返回原字符,不影响搜索 private static final int BEGIN = 0x4E00, END = 0x9FA5; private static final String[] PINYIN = { "a","ai","an", /* ... 完整表按拼音顺序排列 */ }; public static String abbr(String name) { StringBuilder sb = new StringBuilder(); for (char ch : name.toCharArray()) { if (ch >= BEGIN && ch <= END) { sb.append(firstLetter(ch)); } else if (Character.isLetterOrDigit(ch)) { sb.append(Character.toLowerCase(ch)); // 英文名直接保留 } } return sb.toString(); } private static char firstLetter(char ch) { // 简化实现:按码表区间映射到声母首字母,查表逻辑略 return PinyinTable.firstLetterOf(ch); } }

要注意两点。拼音表不要手打,找个公开的码表生成一次静态数组即可;abbr的结果统一转小写,SQL 里用LIKE '%zs%'匹配,用户输大写小写都能命中。如果不想引入拼音表,退一步只做“姓名/手机号/公司”三字段模糊搜,也是合格的,别为了炫技把项目拖到交不上。

搜索还有一个隐性要求:输入框回车要能触发查询。给JTextField加addActionListener(e -> reload())一行搞定,演示时体验差别很明显。

4.2 分组维度:用 group_name 还是单独建表

联系人和分组是一对多,理论上应该拆成contact+group两张表,用group_id关联。课程设计阶段我建议先用group_name字符串冗余存着,理由是:改动量小、界面下拉框直接SELECT DISTINCT group_name就能填、导出 CSV 时不用连表。代价是改分组名要批量UPDATE。

如果你的验收要求里明确写了“数据库设计要有外键”,那就老老实实拆表,并在 DAO 里加一个GroupDao,ContactDao查询时LEFT JOIN带出分组名。两种方案都能拿分,关键是别做一半:既存了group_id又存了group_name,然后两边不同步,那是给自己挖坑。

分组的界面形态建议做成左侧JList+ 右侧表格的经典布局,点分组刷新右侧,比下拉框更像一个完整系统。

4.3 CSV 导入导出:为什么课程设计别急着上 POI

导入导出是“看起来高级、实现起来不难”的功能,性价比极高。常见做法是用 POI 读写 xlsx,但它要额外引三四个 jar,打包容易出岔子。CSV 纯文本,一个BufferedWriter就能搞定:

public void exportCsv(File file, List<Contact> list) throws IOException { // UTF-8 + BOM,否则 Excel 打开中文是乱码 try (Writer w = new OutputStreamWriter(new FileOutputStream(file), StandardCharsets.UTF_8)) { w.write('\uFEFF'); w.write("姓名,手机号,分组,公司,备注\n"); for (Contact c : list) { w.write(String.join(",", safe(c.getName()), safe(c.getMobile()), safe(c.getGroupName()), safe(c.getCompany()), safe(c.getRemark()))); w.write("\n"); } } } private String safe(String s) { if (s == null) return ""; // 字段里含逗号或引号时用双引号包裹,内部引号翻倍 if (s.contains(",") || s.contains("\"") || s.contains("\n")) { return "\"" + s.replace("\"", "\"\"") + "\""; } return s; }

导入时逐行split(",")只适用于最简场景,遇到带引号的字段就会错位。稍微严谨一点的写法是写个十来行的状态机解析,或者干脆限制“导入文件的字段里不准有逗号”,并在界面上写清楚。导入过程要包在一个事务里:中途某行格式不对就整体回滚,否则用户会得到一份导了一半的数据,这种中间状态最难排查。

导出前记得把当前筛选条件带上,用户点了“按分组筛选”再导出,期望的是当前这批数据,不是全库。

5. 避坑与排查:答辩前一晚最容易炸的 5 个点

5.1 删完一行,表格纹丝不动

现象:点删除按钮,控制台打印“删除成功”,数据库确实少了一条,但JTable里那行还在。

原因:界面刷新时重新new了一个List传给表格,或者干脆只调了dao.delete()没调reload();也有人model.setData()之后忘了fireTableDataChanged()。

解决:统一走reload(),setData内部一定调fireTableDataChanged()。删完还要处理选中行越界:原来选的是第 10 行,删完只剩 9 行,getSelectedRow()返回 -1,后续model.getRow(-1)直接IndexOutOfBoundsException。

5.2 中文全变成问号

现象:界面上输入“张三”,库里存的是???,或者反过来查出来是乱码。

原因:三处编码不一致——数据库/表不是utf8mb4、JDBC URL 少了characterEncoding=utf8、CSV 导出用了FileWriter(走系统默认编码)。三处任意一处漏了都会出问题。

解决:建库语句带DEFAULT CHARACTER SET utf8mb4,连接串带useUnicode=true&characterEncoding=utf8,所有文件读写显式传StandardCharsets.UTF_8。排查时先SELECT HEX(name) FROM contact看库里存的到底是不是正确字节,能快速定位是写入端还是展示端的问题。

5.3 点几次查询就卡死,报 Too many connections

现象:程序刚启动正常,来回点几十次查询后界面卡住,日志里一堆Too many connections。

原因:Connection、PreparedStatement、ResultSet用完没关,尤其是catch分支里直接throw走人,finally都没进。

解决:一律用 try-with-resources,或者统一走DBUtil.close(rs, ps, conn)。检查办法很土但有效:在数据库里执行SHOW PROCESSLIST,看连接数是不是只涨不跌。另外别在循环里开连接,for里每条记录getConnection()一次,几百条数据就能把连接打满。

5.4 双击单元格改完值,库里还是旧的

现象:用户直接在表格里双击改了手机号,界面上看着变了,重新查询又变回去了。

原因:isCellEditable返回了true,但没有setValueAt的实现,改了只存在JTable的内部缓存里,从没写过库。

解决:要么isCellEditable返回false,编辑走弹窗;要么老老实实重写setValueAt,在里面调service.update(),成功才fireTableCellUpdated(row, col),失败弹提示并让表格重新加载。半吊子的可编辑是最坑的,用户以为改成功了,其实什么都没发生。

5.5 打成 jar 双击没反应,报 ClassNotFound

现象:IDE 里跑得好好的,jar打包后启动直接闪退,命令行运行报ClassNotFoundException: com.mysql.cj.jdbc.Driver。

原因:驱动 jar 没一起打进去,或者META-INF/MANIFEST.MF里没写Main-Class。

解决:用 Maven 的maven-shade-plugin打成 fat jar,或者手动把驱动 jar 解压合并进自己的包。MANIFEST.MF至少要有两行:Main-Class: App和Class-Path: lib/mysql-connector-java.jar(如果驱动放外部 lib 目录)。打包这一步的血泪经验是:越早打一次,越早发现问题,别等到答辩当天早上才试。

6. 二次开发:把课程设计改成能写进简历的项目

课程设计交完不算完,这套骨架往上加东西的成本很低,改三个方向就能从“作业”变成“能聊十分钟的项目”。

第一个方向是换存储、去环境依赖。把DBUtil的 URL 改成 SQLite 的jdbc:sqlite:addrbook.db,驱动换成一个零依赖的 jar,程序就变成拷走即跑、不用装数据库。这一改能顺带讲清楚“面向接口编程”的价值:只要ContactDao的方法签名不变,上层一行都不用动。再进一步,把ContactDao抽成接口,写一个ContactDaoSqliteImpl和一个ContactDaoMemoryImpl(纯HashMap,用于单元测试),这就是最朴素的分层收益。

第二个方向是加身份和归属。加一张user表,登录后把user_id写进contact,查询自动带WHERE user_id = ?。这一步会逼着你处理“当前登录用户”这个上下文怎么传:我一般用一个Session单例持有当前用户,Service 层从里面取,界面层不感知。改完之后,多用户数据隔离、登录失败次数限制、密码加盐哈希,都是可以继续展开的点。

第三个方向是补测试和打包。给ContactService写几个 JUnit 用例,覆盖空姓名、错误手机号、重复手机号、编辑时排除自身这四种情况;用maven-shade-plugin打一个 fat jar;再写个README.md说明建库脚本和启动命令。这三件事加起来不到两小时,但简历上“有测试、可打包、有文档”的描述,比“实现了增删改查”有说服力得多。

改完怎么验证?我自己的顺序是:先清库跑一遍全流程(新增→搜索→编辑→删除→导出→导入),再故意输错手机号看提示是否友好,然后把窗口拉窄看布局会不会糊,最后换一台没装过开发环境的机器双击 jar。第四步最容易暴露问题,也最能体现项目的完成度。

说个我自己的教训。早些年我做课程设计,习惯先画界面再想数据,结果每次加字段都要在五个文件里改一遍,改到最后自己都不知道哪份是对的。从那以后我每次接这类项目,都先把表结构和 DAO 的方法签名写在纸上,确认insert / update / deleteById / findById / search / count这六个方法够用,再动手敲第一行界面代码。这个习惯让我少走了太多回头路。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询