一、登录页重构(暖色调品牌版)
- 左右分栏卡片:44% 品牌面板(花红渐变 + 光斑 + 内联 SVG 花瓣 Logo + 花期字标/口号)
+ 56% 表单面板(52px 输入框、内嵌矢量图标、聚焦暖色光晕、渐变主按钮)
- 与前台同一套设计令牌(#E8604C / #F58C7C / #FFF9F5 / #3E2C27),响应式适配
- 完整保留 #login-form/#pd-form-username/#profile-img/#errtips/token/验证码/保持登录等结构,后台登录 JS 零改动
二、后台业务模块(26 个,与前端 M1–M9 一一对应)
- 新增通用 Manage 控制器(列表/搜索/排序/新增/编辑/软删除/批量/列元数据)
- 新增 ManageConfig(26 张表的字段标签/类型/枚举/搜索配置)与 FormBuilder(表单控件渲染)
- 新增通用视图 manage/{index,add,edit,style}.html + public/assets/js/backend/manage.js
- 新增 26 个薄控制器(各 8 行,仅声明 )
- db/05_admin_menu.sql:花期运营菜单树 + 6 个通用操作权限,含站点名品牌化
三、品牌一致性
- site.php 站点名改为「花期运营中心」,顶栏 Logo 增加矢量花瓣
- 业务页暖色表头、渐变按钮、圆角输入、品牌色开关
四、验证(全绿)
- 后台自检 31 项:登录页品牌、框架菜单、26 模块页面 200 + 列表数据、后台编辑写库、
后台改动 → 前台 API 同步读取、数据还原
- 前台接口 82 项断言、契约 84 接口、Vue 20/20、SCSS 19/19、无 emoji 均通过
- 修复 5 个真实问题:方法签名不兼容致 500、菜单缓存、SQL 字符集乱码、
snake_case URL 与权限规则命名、会话文件属主导致的自检 500
504 行
17 KiB
PHP
504 行
17 KiB
PHP
<?php
|
||
|
||
namespace app\admin\controller;
|
||
|
||
use app\admin\library\FormBuilder;
|
||
use app\admin\library\ManageConfig;
|
||
use app\common\controller\Backend;
|
||
use think\Db;
|
||
use think\exception\PDOException;
|
||
use think\exception\ValidateException;
|
||
|
||
/**
|
||
* 运营后台 · 通用业务数据管理
|
||
*
|
||
* 与前端 M1–M9 业务模块一一对应(见 ManageConfig),统一提供:
|
||
* 列表(分页/搜索/排序)、新增、编辑、删除(软删除优先)、批量设置。
|
||
*
|
||
* 访问方式:manage/index?table=fa_content
|
||
*/
|
||
class Manage extends Backend
|
||
{
|
||
/** @var string 子类可指定固定表名(各业务模块控制器使用) */
|
||
protected $fixedTable = '';
|
||
|
||
/** @var string 当前业务表 */
|
||
protected $table = '';
|
||
|
||
/** @var array 表配置 */
|
||
protected $config = [];
|
||
|
||
/** @var array 真实字段(含类型) */
|
||
protected $columns = [];
|
||
|
||
public function _initialize()
|
||
{
|
||
parent::_initialize();
|
||
|
||
$table = $this->fixedTable ?: $this->request->param('table', '');
|
||
$config = ManageConfig::get($table);
|
||
if (!$config) {
|
||
$this->error('模块不存在或未授权');
|
||
}
|
||
|
||
$this->table = $table;
|
||
$this->config = $config;
|
||
$this->columns = $this->describeTable($table);
|
||
|
||
$this->view->assign([
|
||
'table' => $table,
|
||
'tableTitle' => $config['title'],
|
||
'tableGroup' => $config['group'],
|
||
'readonlyTable' => !empty($config['readonly_table']),
|
||
]);
|
||
// 所有业务模块复用同一套前端脚本与视图
|
||
$this->assignconfig('controllername', 'manage');
|
||
$this->assignconfig('table', $table);
|
||
$this->assignconfig('columns', $this->columnMeta());
|
||
}
|
||
|
||
/* ============================================================
|
||
* 列表
|
||
* ============================================================ */
|
||
public function index()
|
||
{
|
||
if ($this->request->isAjax()) {
|
||
return json($this->getList());
|
||
}
|
||
return $this->view->fetch('manage/index');
|
||
}
|
||
|
||
/** 列定义(供前端 bootstrapTable 构建) */
|
||
public function columns()
|
||
{
|
||
return json(['code' => 1, 'data' => ['columns' => $this->columnMeta()]]);
|
||
}
|
||
|
||
/* ============================================================
|
||
* 新增 / 编辑
|
||
* ============================================================ */
|
||
public function add()
|
||
{
|
||
if ($this->request->isPost()) {
|
||
return $this->save();
|
||
}
|
||
$users = $this->userOptions();
|
||
$this->view->assign('row', []);
|
||
$this->view->assign('users', $users);
|
||
$this->view->assign('formHtml', FormBuilder::render($this->config['fields'], [], $users));
|
||
return $this->view->fetch('manage/add');
|
||
}
|
||
|
||
public function edit($ids = null)
|
||
{
|
||
$ids = $this->request->param('ids', '');
|
||
if ($this->request->isPost()) {
|
||
return $this->save($ids);
|
||
}
|
||
|
||
$row = Db::name($this->shortTable())->where('id', (int)$ids)->find();
|
||
if (!$row) {
|
||
$this->error('记录不存在');
|
||
}
|
||
$users = $this->userOptions();
|
||
$formRow = $this->formatRowForForm($row);
|
||
$this->view->assign('row', $formRow);
|
||
$this->view->assign('users', $users);
|
||
$this->view->assign('formHtml', FormBuilder::render($this->config['fields'], $formRow, $users));
|
||
return $this->view->fetch('manage/edit');
|
||
}
|
||
|
||
protected function save($ids = '')
|
||
{
|
||
if (!empty($this->config['readonly_table'])) {
|
||
$this->error('该模块仅支持查看');
|
||
}
|
||
|
||
$data = $this->collectPost();
|
||
if (!$data) {
|
||
$this->error('没有需要保存的字段');
|
||
}
|
||
|
||
$short = $this->shortTable();
|
||
try {
|
||
if ($ids) {
|
||
$exist = Db::name($short)->where('id', (int)$ids)->find();
|
||
if (!$exist) {
|
||
$this->error('记录不存在');
|
||
}
|
||
if (isset($this->columns['updatetime'])) {
|
||
$data['updatetime'] = time();
|
||
}
|
||
Db::name($short)->where('id', (int)$ids)->update($data);
|
||
$id = (int)$ids;
|
||
} else {
|
||
if (isset($this->columns['createtime'])) {
|
||
$data['createtime'] = time();
|
||
}
|
||
if (isset($this->columns['updatetime'])) {
|
||
$data['updatetime'] = time();
|
||
}
|
||
$id = Db::name($short)->insertGetId($data);
|
||
}
|
||
} catch (ValidateException $e) {
|
||
$this->error($e->getMessage());
|
||
} catch (PDOException $e) {
|
||
$this->error($e->getMessage());
|
||
}
|
||
|
||
$this->success($ids ? '修改成功' : '新增成功', null, ['id' => $id]);
|
||
}
|
||
|
||
/* ============================================================
|
||
* 删除 / 批量
|
||
* ============================================================ */
|
||
public function del($ids = null)
|
||
{
|
||
if (!empty($this->config['readonly_table'])) {
|
||
$this->error('该模块仅支持查看');
|
||
}
|
||
$ids = $this->parseIds($ids);
|
||
if (!$ids) {
|
||
$this->error('参数错误');
|
||
}
|
||
|
||
$short = $this->shortTable();
|
||
if (isset($this->columns['deletetime'])) {
|
||
Db::name($short)->whereIn('id', $ids)->update(['deletetime' => time()]);
|
||
// 支持恢复
|
||
if ($this->request->param('restore')) {
|
||
Db::name($short)->whereIn('id', $ids)->update(['deletetime' => null]);
|
||
}
|
||
} else {
|
||
Db::name($short)->whereIn('id', $ids)->delete();
|
||
}
|
||
$this->success('删除成功');
|
||
}
|
||
|
||
/** 批量设置字段(如状态、审核结果) */
|
||
public function multi($ids = null)
|
||
{
|
||
if (!empty($this->config['readonly_table'])) {
|
||
$this->error('该模块仅支持查看');
|
||
}
|
||
$ids = $this->parseIds($ids);
|
||
if (!$ids) {
|
||
$this->error('参数错误');
|
||
}
|
||
$params = $this->request->post();
|
||
$allow = [];
|
||
foreach ($params as $field => $value) {
|
||
if ($field === 'ids' || !isset($this->columns[$field])) {
|
||
continue;
|
||
}
|
||
if (!isset($this->config['fields'][$field])) {
|
||
continue;
|
||
}
|
||
$allow[$field] = $this->castValue($field, $value);
|
||
}
|
||
if (!$allow) {
|
||
$this->error('没有可设置的字段');
|
||
}
|
||
if (isset($this->columns['updatetime'])) {
|
||
$allow['updatetime'] = time();
|
||
}
|
||
Db::name($this->shortTable())->whereIn('id', $ids)->update($allow);
|
||
$this->success('批量更新成功');
|
||
}
|
||
|
||
/* ============================================================
|
||
* 内部方法
|
||
* ============================================================ */
|
||
|
||
protected function getList()
|
||
{
|
||
$short = $this->shortTable();
|
||
$query = Db::name($short);
|
||
|
||
// 过滤已删除
|
||
if (isset($this->columns['deletetime']) && !$this->request->param('with_deleted')) {
|
||
$query->whereNull('deletetime');
|
||
}
|
||
|
||
// 关键词搜索
|
||
$search = trim((string)$this->request->param('search', ''));
|
||
if ($search !== '') {
|
||
$like = [];
|
||
foreach ($this->config['fields'] as $field => $def) {
|
||
if (!empty($def['search']) && isset($this->columns[$field])) {
|
||
$like[] = [$field, 'like', '%' . $search . '%'];
|
||
}
|
||
}
|
||
if ($like) {
|
||
$query->where(function ($q) use ($like) {
|
||
foreach ($like as $index => $item) {
|
||
if ($index === 0) {
|
||
$q->where($item[0], $item[1], $item[2]);
|
||
} else {
|
||
$q->whereOr($item[0], $item[1], $item[2]);
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// 精确过滤
|
||
$filter = $this->request->param('filter', '');
|
||
if ($filter) {
|
||
$filterArr = is_array($filter) ? $filter : json_decode(htmlspecialchars_decode($filter, ENT_QUOTES), true);
|
||
if (is_array($filterArr)) {
|
||
foreach ($filterArr as $field => $value) {
|
||
if ($value === '' || $value === null || !isset($this->columns[$field])) {
|
||
continue;
|
||
}
|
||
$query->where($field, $value);
|
||
}
|
||
}
|
||
}
|
||
|
||
$total = (clone $query)->count();
|
||
$limit = min(200, max(1, (int)$this->request->param('limit', 20)));
|
||
$offset = max(0, (int)$this->request->param('offset', 0));
|
||
|
||
$sort = $this->request->param('sort', '');
|
||
$order = strtolower($this->request->param('order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||
if ($sort && isset($this->columns[$sort])) {
|
||
$query->order($sort, $order);
|
||
} else {
|
||
$query->orderRaw($this->config['sort'] ?? 'id desc');
|
||
}
|
||
|
||
$rows = $query->limit($offset, $limit)->select();
|
||
$users = $this->userMap($rows);
|
||
|
||
$list = [];
|
||
foreach ($rows as $row) {
|
||
$list[] = $this->formatRow($row, $users);
|
||
}
|
||
|
||
return ['total' => $total, 'rows' => $list];
|
||
}
|
||
|
||
protected function columnMeta()
|
||
{
|
||
$meta = [['field' => 'id', 'title' => 'ID', 'sortable' => true, 'width' => 70]];
|
||
foreach ($this->config['fields'] as $field => $def) {
|
||
if (isset($def['form']) && $def['form'] === false) {
|
||
continue;
|
||
}
|
||
if (!isset($this->columns[$field])) {
|
||
continue;
|
||
}
|
||
$item = [
|
||
'field' => $field,
|
||
'title' => $def['label'],
|
||
'type' => $def['type'] ?? 'text',
|
||
'sortable' => true,
|
||
'search' => !empty($def['search']),
|
||
];
|
||
if (($def['type'] ?? '') === 'select') {
|
||
$item['options'] = $this->optionsOf($def);
|
||
}
|
||
$meta[] = $item;
|
||
}
|
||
return $meta;
|
||
}
|
||
|
||
protected function formatRow($row, $users = [])
|
||
{
|
||
$data = ['id' => (int)$row['id']];
|
||
foreach ($this->config['fields'] as $field => $def) {
|
||
if (!array_key_exists($field, $row)) {
|
||
continue;
|
||
}
|
||
$value = $row[$field];
|
||
$type = $def['type'] ?? 'text';
|
||
|
||
if ($type === 'user') {
|
||
$uid = (int)$value;
|
||
$data[$field] = $uid ? (isset($users[$uid]) ? $users[$uid] : '#' . $uid) : '-';
|
||
if (isset($data[$field . '_text']) === false) {
|
||
$data[$field . '_text'] = $data[$field];
|
||
}
|
||
} elseif ($type === 'select') {
|
||
$options = $this->optionsOf($def);
|
||
$data[$field] = isset($options[(string)$value]) ? $options[(string)$value] : $value;
|
||
} elseif ($type === 'bool') {
|
||
$data[$field] = $value ? '是' : '否';
|
||
} elseif ($type === 'datetime' || $type === 'date') {
|
||
$data[$field] = $this->timeText($field, $value);
|
||
} else {
|
||
$data[$field] = is_string($value) && mb_strlen($value) > 60 ? mb_substr($value, 0, 60) . '…' : $value;
|
||
}
|
||
if (isset($def['form']) && $def['form'] === false) {
|
||
unset($data[$field]);
|
||
}
|
||
}
|
||
if (isset($this->columns['deletetime'])) {
|
||
$data['is_deleted'] = !empty($row['deletetime']) ? 1 : 0;
|
||
}
|
||
return $data;
|
||
}
|
||
|
||
protected function formatRowForForm($row)
|
||
{
|
||
$data = $row;
|
||
foreach ($this->config['fields'] as $field => $def) {
|
||
if (!array_key_exists($field, $row)) {
|
||
continue;
|
||
}
|
||
$type = $def['type'] ?? 'text';
|
||
if ($type === 'datetime' || $type === 'date') {
|
||
$value = $row[$field];
|
||
if (is_numeric($value) && (int)$value > 1000000000) {
|
||
$data[$field] = $type === 'datetime' ? date('Y-m-d\TH:i', (int)$value) : date('Y-m-d', (int)$value);
|
||
} elseif (is_numeric($value) && (int)$value > 0 && (int)$value < 1000 && $type === 'datetime') {
|
||
// 秒数等小整数不转换
|
||
$data[$field] = $value;
|
||
}
|
||
}
|
||
if ($type === 'bool') {
|
||
$data[$field] = (int)$value;
|
||
}
|
||
}
|
||
return $data;
|
||
}
|
||
|
||
protected function collectPost()
|
||
{
|
||
$data = [];
|
||
foreach ($this->config['fields'] as $field => $def) {
|
||
if (isset($def['readonly']) && $def['readonly']) {
|
||
continue;
|
||
}
|
||
if (isset($def['form']) && $def['form'] === false) {
|
||
continue;
|
||
}
|
||
if (!isset($this->columns[$field])) {
|
||
continue;
|
||
}
|
||
$value = $this->request->post($field, null);
|
||
if ($value === null) {
|
||
continue;
|
||
}
|
||
$data[$field] = $this->castValue($field, $value);
|
||
}
|
||
return $data;
|
||
}
|
||
|
||
protected function castValue($field, $value)
|
||
{
|
||
$def = $this->config['fields'][$field] ?? [];
|
||
$type = $def['type'] ?? 'text';
|
||
|
||
if ($type === 'bool') {
|
||
return (int)!!$value;
|
||
}
|
||
if ($type === 'number') {
|
||
return (int)$value;
|
||
}
|
||
if ($type === 'decimal') {
|
||
return round((float)$value, 2);
|
||
}
|
||
if ($type === 'datetime') {
|
||
if ($value === '') {
|
||
return null;
|
||
}
|
||
$ts = strtotime(str_replace('T', ' ', (string)$value));
|
||
return $ts ?: null;
|
||
}
|
||
if ($type === 'date') {
|
||
return $value === '' ? null : date('Y-m-d', strtotime((string)$value));
|
||
}
|
||
if ($type === 'user') {
|
||
return (int)$value;
|
||
}
|
||
if ($type === 'textarea' || $type === 'text') {
|
||
return (string)$value;
|
||
}
|
||
return $value;
|
||
}
|
||
|
||
protected function timeText($field, $value)
|
||
{
|
||
if ($value === null || $value === '' || $value === '0') {
|
||
return '';
|
||
}
|
||
$columnType = $this->columns[$field] ?? 'varchar';
|
||
if (in_array($columnType, ['bigint', 'int', 'timestamp'], true)) {
|
||
$ts = (int)$value;
|
||
return $ts > 0 ? date('Y-m-d H:i', $ts) : '';
|
||
}
|
||
return (string)$value;
|
||
}
|
||
|
||
protected function optionsOf($def)
|
||
{
|
||
$options = $def['options'] ?? [];
|
||
if (is_string($options)) {
|
||
$options = ManageConfig::$enums[$options] ?? [];
|
||
}
|
||
return $options;
|
||
}
|
||
|
||
protected function describeTable($table)
|
||
{
|
||
$columns = [];
|
||
$rows = Db::query('SHOW COLUMNS FROM `' . str_replace('`', '', $table) . '`');
|
||
foreach ($rows as $row) {
|
||
$type = strtolower($row['Type']);
|
||
$columns[$row['Field']] = preg_match('/^(tiny|small|medium|big)?int/', $type) ? 'int'
|
||
: (preg_match('/^(decimal|float|double)/', $type) ? 'decimal'
|
||
: (preg_match('/^date$/', $type) ? 'date'
|
||
: (preg_match('/^(datetime|timestamp)/', $type) ? 'datetime' : 'varchar')));
|
||
}
|
||
return $columns;
|
||
}
|
||
|
||
protected function shortTable()
|
||
{
|
||
return str_replace('fa_', '', $this->table);
|
||
}
|
||
|
||
protected function parseIds($ids)
|
||
{
|
||
if (is_array($ids)) {
|
||
return array_map('intval', $ids);
|
||
}
|
||
return array_values(array_filter(array_map('intval', explode(',', (string)$ids))));
|
||
}
|
||
|
||
protected function userMap($rows)
|
||
{
|
||
$ids = [];
|
||
foreach ($rows as $row) {
|
||
if (isset($row['user_id']) && (int)$row['user_id'] > 0) {
|
||
$ids[] = (int)$row['user_id'];
|
||
}
|
||
if (isset($row['member_user_id']) && (int)$row['member_user_id'] > 0) {
|
||
$ids[] = (int)$row['member_user_id'];
|
||
}
|
||
}
|
||
if (!$ids) {
|
||
return [];
|
||
}
|
||
$users = Db::name('user')->whereIn('id', array_unique($ids))->column('nickname,username', 'id');
|
||
$map = [];
|
||
foreach ($users as $id => $item) {
|
||
$name = $item['nickname'] ?: $item['username'];
|
||
$map[$id] = $name . ' (#' . $id . ')';
|
||
}
|
||
return $map;
|
||
}
|
||
|
||
protected function userOptions()
|
||
{
|
||
$rows = Db::name('user')->order('id', 'desc')->limit(500)->field('id,username,nickname')->select();
|
||
$options = ['' => '未选择'];
|
||
foreach ($rows as $row) {
|
||
$options[$row['id']] = (($row['nickname'] ?: $row['username'])) . ' (#' . $row['id'] . ')';
|
||
}
|
||
return $options;
|
||
}
|
||
}
|