文件
huaqiapp/server/application/admin/controller/Manage.php
linda 56f5f60aa5 feat(admin): 后台登录页品牌化 + 26 个业务模块与前端对齐 + 数据同步打通
一、登录页重构(暖色调品牌版)
- 左右分栏卡片: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
2026-11-06 11:10:19 +08:00

504 行
17 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?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;
}
}