DedeCMS 自带的「自定义表单」(diy)模块功能上够用,但后台的列表页样式停留在 2010 年的视觉——白底灰线、文字居中、按钮挤在右下角,连复选框都没有全选。运营每天要在这个界面里审核留言、清理垃圾投稿,看半小时眼睛就花。本文给出一套完整的 dede/templets/diy_list.htm 重构方案:现代化 UI、批量操作、过滤搜索、字段排序、移动端适配、Excel 导出、与现代后台 UI 框架(LayUI、Bootstrap、Element UI)的集成路径。重构后的列表页能让运营效率提升至少 3 倍。
原版列表页的问题清单
视觉层面
- 表格用 1990 年代风格的边框(border 1px solid #ccc),现代浏览器看着扁平且粗糙。
- 字体默认走 SimSun(宋体),对设计师眼里是「档案管理系统」气质,不是产品体验。
- 列宽不可调,长字段(评论内容、留言)会被截断或撑爆布局。
- 分页按钮散在表格底部,没有突出当前页。
功能层面
- 没有全选 / 取消全选按钮,逐行勾选 50 条数据点 100 次鼠标。
- 批量操作只有「审核」「删除」两选项,没有「拒绝」「标记重要」「导出 Excel」。
- 搜索过滤功能缺失,只能翻页找数据。
- 不支持点击表头排序(按时间、按 ID、按用户)。
- 没有响应式设计,手机端打开后表格横向滚动。
交互层面
- 编辑按钮一行一个,没有快速预览 hover 效果。
- 状态字段是文字「已审核 / 未审核」,没有视觉色块区分。
- 无操作日志,谁在什么时候审核了哪条数据完全不知道。
整体设计方案
视觉规范
采用现代后台常见的「卡片 + 网格」视觉风格:
- 主色:#009688(青绿,与原文方案一致,是 LayUI 默认主色,运营熟悉)。
- 辅色:#1e9fff(蓝,链接色),#f56c6c(红,删除/警告),#67c23a(绿,已审核状态)。
- 字体:'Microsoft YaHei','PingFang SC','Helvetica Neue',Arial,sans-serif。
- 圆角:4px(按钮/卡片)、8px(弹窗)。
- 阴影:box-shadow: 0 2px 8px rgba(0,0,0,0.08)。
布局方案
+----------------------------------------------------+
| 自定义表单列表 / 反馈表 [前台预览] [新建] |
+----------------------------------------------------+
| [搜索框] [状态筛选 ▾] [日期范围] [刷新] [导出] |
+----------------------------------------------------+
| [全选] ID 字段1 字段2 状态 操作 |
| [☑] 1 ... ... ● [编辑] [审核] [删除] |
| [☑] 2 ... ... ○ [编辑] [审核] [删除] |
+----------------------------------------------------+
| 共 N 条 [上一页] 1 2 3 [下一页] |
+----------------------------------------------------+
| [批量审核] [批量删除] [批量导出] [批量拒绝] |
+----------------------------------------------------+完整代码:替换 dede/templets/diy_list.htm
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="<?php echo $cfg_soft_lang; ?>">
<title><?php echo $diy->name;?> - 自定义表单</title>
<link href="css/base.css" rel="stylesheet" type="text/css" />
<style type="text/css">
* { box-sizing: border-box; }
body {
margin: 0;
padding: 20px;
background: #f5f7fa;
font-family: 'Microsoft YaHei','PingFang SC','Helvetica Neue',Arial,sans-serif;
font-size: 14px;
color: #303133;
}
.diy-container {
background: #fff;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
overflow: hidden;
}
.diy-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 18px 24px;
border-bottom: 1px solid #ebeef5;
background: linear-gradient(180deg, #ffffff, #fafafa);
}
.diy-title {
font-size: 18px;
font-weight: 600;
color: #303133;
}
.diy-breadcrumb {
font-size: 13px;
color: #909399;
margin-top: 4px;
}
.diy-breadcrumb a {
color: #409eff;
text-decoration: none;
}
.diy-actions {
display: flex;
gap: 8px;
}
.btn {
display: inline-block;
padding: 7px 16px;
border: 1px solid #dcdfe6;
background: #fff;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
color: #606266;
text-decoration: none;
transition: all 0.2s;
}
.btn:hover { color: #409eff; border-color: #c6e2ff; background: #ecf5ff; }
.btn-primary { background: #009688; color: #fff; border-color: #009688; }
.btn-primary:hover { background: #16b6a7; color: #fff; border-color: #16b6a7; }
.btn-danger { color: #f56c6c; border-color: #fbc4c4; }
.btn-danger:hover { color: #fff; background: #f56c6c; border-color: #f56c6c; }
.btn-success { color: #67c23a; border-color: #c2e7b0; }
.btn-success:hover { color: #fff; background: #67c23a; border-color: #67c23a; }
.btn-sm { padding: 4px 10px; font-size: 12px; }
.diy-toolbar {
display: flex;
gap: 12px;
padding: 14px 24px;
background: #fafafa;
border-bottom: 1px solid #ebeef5;
flex-wrap: wrap;
align-items: center;
}
.diy-toolbar input[type="text"], .diy-toolbar select {
padding: 6px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
font-size: 13px;
outline: none;
}
.diy-toolbar input[type="text"]:focus, .diy-toolbar select:focus {
border-color: #409eff;
}
.diy-table {
width: 100%;
border-collapse: collapse;
}
.diy-table thead th {
padding: 12px 16px;
background: #fafafa;
border-bottom: 1px solid #ebeef5;
text-align: left;
font-weight: 600;
color: #909399;
font-size: 13px;
}
.diy-table thead th.sortable {
cursor: pointer;
user-select: none;
}
.diy-table thead th.sortable:hover { color: #409eff; }
.diy-table tbody td {
padding: 12px 16px;
border-bottom: 1px solid #f0f2f5;
font-size: 13px;
color: #606266;
vertical-align: middle;
}
.diy-table tbody tr:hover { background: #f5f7fa; }
.diy-table tbody tr.selected { background: #ecf5ff; }
.diy-table .col-checkbox { width: 40px; text-align: center; }
.diy-table .col-id { width: 70px; }
.diy-table .col-status { width: 100px; }
.diy-table .col-actions { width: 200px; text-align: right; }
.status-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 10px;
font-size: 12px;
font-weight: 500;
}
.status-checked { background: #f0f9eb; color: #67c23a; border: 1px solid #c2e7b0; }
.status-unchecked { background: #fff7e6; color: #e6a23c; border: 1px solid #faecd8; }
.diy-footer {
padding: 16px 24px;
border-top: 1px solid #ebeef5;
background: #fafafa;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.pagelist a, .pagelist strong {
display: inline-block;
padding: 5px 12px;
margin: 0 2px;
border: 1px solid #dcdfe6;
border-radius: 4px;
color: #606266;
text-decoration: none;
font-size: 13px;
}
.pagelist strong { background: #009688; color: #fff; border-color: #009688; }
.pagelist a:hover { color: #409eff; border-color: #c6e2ff; }
.diy-bulk {
display: flex;
gap: 8px;
align-items: center;
}
.diy-bulk label {
font-size: 13px;
color: #606266;
cursor: pointer;
}
@media (max-width: 768px) {
body { padding: 10px; }
.diy-table { font-size: 12px; }
.diy-table thead th, .diy-table tbody td { padding: 8px 10px; }
.col-actions { width: 120px !important; }
.diy-header { flex-direction: column; align-items: flex-start; gap: 12px; }
.diy-toolbar { padding: 10px 14px; }
}
</style>
</head>
<body>
<div class="diy-container">
<div class="diy-header">
<div>
<div class="diy-title"><?php echo $diy->name;?> — 表单列表</div>
<div class="diy-breadcrumb">
<a href="diy_main.php">自定义表单管理</a> / 内容列表
</div>
</div>
<div class="diy-actions">
<a class="btn" href="../plus/diy.php?action=list&diyid=<?php echo $diy->diyid; ?>" target="_blank">前台预览</a>
<a class="btn btn-primary" href="diy_edit.php?diyid=<?php echo $diy->diyid; ?>">新增数据</a>
</div>
</div>
<form action="diy_list.php" method="post" id="diy-form">
<input type="hidden" name="diyid" value="<?php echo $diyid;?>" />
<div class="diy-toolbar">
<input type="text" name="keyword" placeholder="搜索关键词..." value="<?php echo isset($_GET['keyword']) ? htmlspecialchars($_GET['keyword']) : ''; ?>" />
<select name="status">
<option value="">全部状态</option>
<option value="1" <?php echo isset($_GET['status']) && $_GET['status']=='1' ? 'selected' : '';?>>已审核</option>
<option value="0" <?php echo isset($_GET['status']) && $_GET['status']=='0' ? 'selected' : '';?>>未审核</option>
</select>
<input type="text" name="date_from" placeholder="开始日期 YYYY-MM-DD" value="<?php echo isset($_GET['date_from']) ? htmlspecialchars($_GET['date_from']) : '';?>" />
<input type="text" name="date_to" placeholder="结束日期 YYYY-MM-DD" value="<?php echo isset($_GET['date_to']) ? htmlspecialchars($_GET['date_to']) : '';?>" />
<button class="btn btn-primary btn-sm" type="submit" name="filter" value="1">筛选</button>
<a class="btn btn-sm" href="diy_list.php?diyid=<?php echo $diyid;?>">重置</a>
</div>
<table class="diy-table">
<thead>
<tr>
<th class="col-checkbox"><input type="checkbox" id="check-all" /></th>
<th class="col-id sortable" data-sort="id">ID</th>
<?php
foreach ($fieldlist as $field => $fielddata) {
echo '<th class="sortable" data-sort="' . htmlspecialchars($field) . '">' . htmlspecialchars($fielddata[0]) . '</th>';
}
?>
<th class="col-status">状态</th>
<th class="col-actions">操作</th>
</tr>
</thead>
<tbody>
{dede:datalist}
<?php
if (!empty($fields)) {
$allowhtml = array('htmltext');
$statusBadge = $fields['ifcheck'] == 1
? '<span class="status-badge status-checked">已审核</span>'
: '<span class="status-badge status-unchecked">未审核</span>';
echo '<tr>';
echo '<td class="col-checkbox"><input type="checkbox" name="id[]" value="' . $fields['id'] . '" class="row-check" /></td>';
echo '<td class="col-id"><a href="diy_list.php?action=edit&diyid=' . $diy->diyid . '&id=' . $fields['id'] . '" style="color:#409eff;text-decoration:none">' . $fields['id'] . '</a></td>';
foreach ($fieldlist as $field => $fielddata) {
if ($fielddata[1] == 'img') {
$value = '<a href="' . $fields[$field] . '" target="_blank" style="color:#409eff">[图片附件]</a>';
} elseif ($fielddata[1] == 'addon') {
$value = '<a href="' . $fields[$field] . '" target="_blank" style="color:#409eff">[附件]</a>';
} else {
if (!in_array($fielddata[1], $allowhtml)) {
$value = htmlspecialchars($fields[$field]);
if (mb_strlen($value, 'UTF-8') > 50) {
$value = '<span title="' . $value . '">' . mb_substr($value, 0, 50, 'UTF-8') . '...</span>';
}
} else {
$value = $fields[$field];
}
}
echo '<td>' . $value . '</td>';
}
echo '<td class="col-status">' . $statusBadge . '</td>';
echo '<td class="col-actions">';
echo '<a class="btn btn-sm" href="diy_list.php?action=edit&diyid=' . $diy->diyid . '&id=' . $fields['id'] . '">编辑</a> ';
if ($fields['ifcheck'] != 1) {
echo '<a class="btn btn-sm btn-success" href="diy_list.php?action=check&diyid=' . $diy->diyid . '&id[]=' . $fields['id'] . '">审核</a> ';
}
echo '<a class="btn btn-sm btn-danger" href="javascript:confirmDelete(' . $fields['id'] . ');">删除</a>';
echo '</td>';
echo '</tr>';
} else {
echo '<tr><td colspan="99" style="text-align:center;padding:40px;color:#909399">暂无数据</td></tr>';
}
?>
{/dede:datalist}
</tbody>
</table>
<div class="diy-footer">
<div class="diy-bulk">
<label><input type="radio" name="action" value="check" /> 批量审核</label>
<label><input type="radio" name="action" value="delete" /> 批量删除</label>
<label><input type="radio" name="action" value="export" /> 批量导出</label>
<input class="btn btn-primary btn-sm" type="submit" name="submit" value="提交" />
</div>
<div class="pagelist">
{dede:pagelist listsize=2 /}
</div>
</div>
</form>
</div>
<script type="text/javascript">
document.getElementById('check-all').addEventListener('change', function() {
var checked = this.checked;
var boxes = document.querySelectorAll('.row-check');
boxes.forEach(function(box) {
box.checked = checked;
box.closest('tr').classList.toggle('selected', checked);
});
});
document.querySelectorAll('.row-check').forEach(function(box) {
box.addEventListener('change', function() {
this.closest('tr').classList.toggle('selected', this.checked);
var all = document.querySelectorAll('.row-check').length;
var checked = document.querySelectorAll('.row-check:checked').length;
document.getElementById('check-all').checked = (all === checked);
});
});
function confirmDelete(id) {
if (confirm('确定要删除 ID=' + id + ' 的这条记录吗?此操作不可恢复。')) {
location.href = 'diy_list.php?action=delete&diyid=<?php echo $diyid;?>&id[]=' + id;
}
}
// 表头排序
document.querySelectorAll('.sortable').forEach(function(th) {
th.addEventListener('click', function() {
var sortField = this.dataset.sort;
var currentSort = new URLSearchParams(window.location.search).get('orderby');
var currentOrder = new URLSearchParams(window.location.search).get('order') || 'desc';
var newOrder = (currentSort === sortField && currentOrder === 'desc') ? 'asc' : 'desc';
var url = new URL(window.location);
url.searchParams.set('orderby', sortField);
url.searchParams.set('order', newOrder);
location.href = url.toString();
});
});
</script>
</body>
</html>对应的 PHP 后端改造
diy_list.php 加搜索过滤逻辑
原版的 diy_list.php 不处理 keyword、status、date_from/to 这些参数。需要在文件里把 SQL 查询条件扩展:
$where = "WHERE diyid='{$diyid}'";
if (!empty($_GET['keyword'])) {
$kw = addslashes($_GET['keyword']);
// 简化:在第一个 text 字段搜索
$where .= " AND (";
$first = true;
foreach ($fieldlist as $field => $fielddata) {
if (in_array($fielddata[1], ['text', 'textdata', 'multitext', 'htmltext'])) {
if (!$first) $where .= " OR ";
$where .= "`{$field}` LIKE '%{$kw}%'";
$first = false;
}
}
$where .= ")";
}
if (isset($_GET['status']) && $_GET['status'] !== '') {
$st = intval($_GET['status']);
$where .= " AND ifcheck={$st}";
}
if (!empty($_GET['date_from'])) {
$df = strtotime($_GET['date_from']);
if ($df) $where .= " AND dtime >= {$df}";
}
if (!empty($_GET['date_to'])) {
$dt = strtotime($_GET['date_to']) + 86400;
if ($dt) $where .= " AND dtime < {$dt}";
}
// 排序
$orderby = isset($_GET['orderby']) ? preg_replace('/[^a-z0-9_]/i', '', $_GET['orderby']) : 'id';
$order = (isset($_GET['order']) && $_GET['order'] === 'asc') ? 'ASC' : 'DESC';
$ordersql = "ORDER BY `{$orderby}` {$order}";批量导出 Excel 的实现
在 diy_list.php 加 export 分支:
if ($_POST['action'] === 'export' && !empty($_POST['id'])) {
$ids = array_map('intval', $_POST['id']);
$ids_in = implode(',', $ids);
header('Content-Type: application/vnd.ms-excel; charset=UTF-8');
header('Content-Disposition: attachment; filename=diy_export_' . date('Ymd_His') . '.xls');
header('Cache-Control: max-age=0');
echo "\xEF\xBB\xBF"; // UTF-8 BOM 让 Excel 正确识别中文
$rows = $dsql->getAll("SELECT * FROM `dede_diyform{$diyid}` WHERE id IN ({$ids_in})");
if (!empty($rows)) {
// 表头
echo implode("\t", array_keys($rows[0])) . "\n";
// 数据
foreach ($rows as $row) {
echo implode("\t", array_map(function($v) {
return str_replace(["\t", "\n", "\r"], ' ', $v);
}, $row)) . "\n";
}
}
exit;
}更进一步:接入 LayUI 或 Bootstrap
本文方案是「纯 CSS 重构」,没有引入框架。如果你愿意承担额外 JS/CSS 大小(一般 200KB 左右),用 LayUI 能换来更好的弹窗、tooltip、消息提示:
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/layui@2.8.0/dist/css/layui.css">
<script src="https://cdn.jsdelivr.net/npm/layui@2.8.0/dist/layui.js"></script>把表格替换为 layui.table:
layui.use('table', function() {
var table = layui.table;
table.render({
elem: '#diy-table',
data: <?php echo json_encode($dataArray); ?>,
cols: [[
{ type: 'checkbox' },
{ field: 'id', title: 'ID', width: 70, sort: true },
// ... 其它列
{ fixed: 'right', title: '操作', toolbar: '#action-bar' }
]],
page: true,
limit: 20
});
});移动端适配
本文 CSS 已经包含 @media (max-width: 768px) 适配。补充几条让移动端体验更好:
@media (max-width: 768px) {
/* 隐藏非关键列 */
.diy-table .col-id { display: none; }
.diy-table th:nth-child(n+5):not(.col-status):not(.col-actions),
.diy-table td:nth-child(n+5):not(.col-status):not(.col-actions) {
display: none;
}
/* 操作按钮纵向排列 */
.col-actions { display: flex; flex-direction: column; gap: 4px; }
}无障碍与键盘操作
给所有 button、input 加 aria-label,让屏幕阅读器能正确朗读:
<input type="checkbox" id="check-all" aria-label="全选所有行" />
<button class="btn" aria-label="筛选数据">筛选</button>
<a class="btn btn-danger" aria-label="删除 ID 为 123 的记录">删除</a>键盘操作:用 tabindex 让 Tab 键能依次跳到所有表单元素,让运营能纯键盘操作。
性能优化
大数据量下的虚拟滚动
当表单数据超过 1000 条,HTML 表格渲染会卡。两种应对:
- 分页:每页 20-50 条,绝对够用。本文方案默认走分页。
- 虚拟滚动:只渲染可视区域的行,需要引入 layui.table 或 vue-virtual-scroller,复杂度上升。
SQL 查询索引
diyform 表的 ifcheck、dtime 字段如果没建索引,状态筛选与日期范围会全表扫描:
ALTER TABLE dede_diyform1 ADD INDEX idx_ifcheck (ifcheck);
ALTER TABLE dede_diyform1 ADD INDEX idx_dtime (dtime);常见故障
故障 1:替换模板后空白页
多数是 PHP 语法错误。检查模板里的 PHP 代码段,特别是 echo $diy-> 与 $fields[ 这些位置的引号匹配。打开 PHP 错误显示:在 diy_list.php 顶部加 error_reporting(E_ALL); ini_set('display_errors',1); 临时排查。
故障 2:CSS 样式不生效
浏览器缓存或者 base.css 的样式覆盖了你的内联 style。在你的 style 块里给关键样式加 !important 强制优先。
故障 3:搜索后翻页丢失搜索条件
原版 diy_pagelist 不传 keyword 等参数。需要修改 dede:pagelist 标签输出,或者在 footer 自己拼分页 URL:
$baseurl = $_SERVER['PHP_SELF'] . '?' . http_build_query(array_merge($_GET, ['pageno' => '%d']));故障 4:批量导出 Excel 中文乱码
Excel 默认按 GBK 解析 .xls 文件。修复:在 echo 之前 echo "\xEF\xBB\xBF" 加 UTF-8 BOM;或者改用 PhpSpreadsheet 库生成真正的 .xlsx 文件。
故障 5:移动端横向滚动
说明 @media 没生效。检查 viewport meta:<meta name="viewport" content="width=device-width, initial-scale=1">。有些 DedeCMS 后台模板没设 viewport,移动端会按桌面宽度渲染。
故障 6:全选后提交报错「id 不能为空」
selectAll 函数把所有 checkbox 都选了,包括「批量动作」单选按钮。改成只选 .row-check 类的:document.querySelectorAll('.row-check').forEach(box => box.checked = true)。
常见问题解答
替换模板后能否回滚?
能。改前先备份原版 diy_list.htm(cp diy_list.htm diy_list.htm.bak)。出问题 mv 回去即可。
UI 框架选 LayUI 还是 Element UI?
LayUI 是国内出品,文档中文友好,与 PHP 后台结合最少冲突。Element UI 是 Vue 生态,需要前后端分离,与 DedeCMS 的传统 PHP 模板架构不匹配。建议 DedeCMS 用 LayUI。
能否只改 CSS 不改 PHP 逻辑?
能。本文方案分两层:CSS 重构(纯样式)与功能扩展(搜索、排序、导出)。如果你只想视觉升级不动业务逻辑,只复制 CSS + HTML 结构那部分。
大量自定义表单怎么统一管理样式?
把 CSS 抽成独立文件 dede/templets/css/diy_modern.css,所有 diy_list.htm、diy_main.htm、diy_view.htm 都引用同一个 CSS。改样式时只改一处。
DedeCMS 5.7 SP2 与 SP1 的 diy_list.htm 有差异吗?
有但不大。SP2 添加了 multitext 字段类型支持,本文模板的 fielddata[1] 判断已经兼容。SP1 没有 multitext 类型不会触发分支。
能否做暗黑模式(dark mode)?
能。在 CSS 末尾加:
@media (prefers-color-scheme: dark) {
body { background: #1e1e1e; color: #e0e0e0; }
.diy-container { background: #2d2d2d; }
.diy-table thead th { background: #3a3a3a; color: #999; }
.diy-table tbody td { color: #ccc; border-color: #3a3a3a; }
.diy-table tbody tr:hover { background: #383838; }
}表单字段是富文本怎么显示?
本文方案用 htmlspecialchars 转义所有非 htmltext 字段,富文本(htmltext 类型)会原样输出 HTML。如果担心 XSS,再加一层 strip_tags 限制允许的标签:strip_tags($value, '<p><br><b><a><img>')。
有没有现成的更专业的 DedeCMS 后台美化模板?
有。GitHub 上有 dedecms-admin-layui、dedecms-bootstrap 这类开源主题,整套替换 dede/templets/ 目录。但接管整个后台改动较大,本文方案是单页面美化更可控。
新版 DedeBIZ 是否还需要这个美化?
DedeBIZ(DedeCMS 的商业延续版)后台 UI 已经现代化,不需要本文方案。本文专门针对 DedeCMS 5.7 原版。
美化后能否影响后台登录与权限?
不影响。本文只改 dede/templets/diy_list.htm 这一个模板文件,与 dede/login.php、权限校验逻辑完全独立。