diff --git a/.gitignore b/.gitignore index 4c19c31..662e2a5 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,12 @@ dist/ server/vendor/ server/thinkphp/ server/runtime/ -server/public/assets/ +server/public/assets/* +!server/public/assets/js/ +server/public/assets/js/* +!server/public/assets/js/backend/ +server/public/assets/js/backend/* +!server/public/assets/js/backend/manage.js server/public/uploads/ server/node_modules/ server/.env diff --git a/docs/06-运营后台与数据同步.md b/docs/06-运营后台与数据同步.md new file mode 100644 index 0000000..8bd0810 --- /dev/null +++ b/docs/06-运营后台与数据同步.md @@ -0,0 +1,135 @@ +# 花期 APP · 运营后台(与前端模块对齐)与数据同步 + +> 本轮内容:**运营后台登录页品牌化重构** + **后台 26 个业务模块与前端 M1–M9 对齐** + **前后端数据同步全链路验证**。 +> 关联:[02-开发文档](./02-开发文档.md)、[05-功能覆盖度与实现说明](./05-功能覆盖度与实现说明.md) + +--- + +## 1. 后台登录页重构(暖色调 · 品牌化) + +| 设计项 | 实现 | +|---|---| +| 布局 | 左侧 44% 品牌面板(渐变花红 + 光斑)+ 右侧表单面板,940px 居中卡片,圆角 28px | +| 品牌 | 内联 SVG 花瓣 Logo、`花期` 字标(字距 8px)、`温柔记录 · 静待花开`,底部「运营管理后台 / 内容审核 · 用户运营 · 数据看板」 | +| 色彩 | 与前台同一套令牌:`#E8604C` 主色、`#F58C7C` 渐变、`#FFF9F5` 暖米白、`#3E2C27` 暖棕黑 | +| 表单 | 52px 高输入框、14px 圆角、内嵌矢量图标、聚焦暖色光晕;验证码内嵌右侧、可点击刷新 | +| 交互 | 渐变主按钮(投影 + hover 提亮 + 按下位移);错误提示条(暖红底) | +| 响应式 | ≤860px 隐藏品牌面板,≤480px 全屏铺满 | +| 兼容 | 保留 `#login-form` / `#pd-form-username` / `#profile-img` / `#profile-name` / `#errtips` / `{:token()}` / 验证码 / 保持登录等结构,后台原有登录 JS 无需改动 | + +后台框架同步品牌化:`application/extra/site.php` 站点名改为「花期运营中心」,顶栏 Logo 增加矢量花瓣图标;业务页统一使用暖色表头、渐变主按钮、圆角输入框与品牌色状态开关。 + +--- + +## 2. 后台业务模块矩阵(26 个,与前端一一对应) + +| 前端模块 | 后台菜单 | 数据表 | 权限节点 | +|---|---|---|---| +| M1 周期管理 | 周期档案 / 每日症状记录 / 症状标签 / 智能提醒 | `fa_cycle`、`fa_cycle_log`、`fa_symptom_tag`、`fa_reminder` | `cycle/index`、`cyclelog/index`、`symptomtag/index`、`reminder/index` | +| M2 情绪疗愈 | 情绪日记 | `fa_mood_log` | `moodlog/index` | +| 内容中心(M2/M5 共用) | 内容库 / 内容分类 | `fa_content`、`fa_content_category` | `content/index`、`contentcategory/index` | +| M3 科学备孕 | 基础体温 / 试纸识别 / 任务管理 | `fa_bbt_record`、`fa_lh_test`、`fa_task` | `bbtrecord/index`、`lhtest/index`、`task/index` | +| M4 家人共护 | 家人绑定 / 沟通留言 / 爱心积分 | `fa_family_bind`、`fa_care_message`、`fa_point_log` | `familybind/index`、`caremessage/index`、`pointlog/index` | +| M7 两性关系 | 纪念日 / 课程订单 | `fa_memory`、`fa_course_order` | `memory/index`、`courseorder/index` | +| M8 亲子教育 | 宝宝档案 / 成长记录 / 疫苗与儿保 | `fa_baby`、`fa_growth_record`、`fa_vaccine` | `baby/index`、`growthrecord/index`、`vaccine/index` | +| M9 心理服务 | 咨询师 / 咨询订单 / 量表管理 / 测评记录 | `fa_counselor`、`fa_consult_order`、`fa_assessment`、`fa_assessment_record` | `counselor/index`、`consultorder/index`、`assessment/index`、`assessmentrecord/index` | +| M6 会员中心 | 会员套餐 / 会员订单 / 隐私与提醒 / 数据备份 | `fa_member_plan`、`fa_member_order`、`fa_user_setting`、`fa_user_backup` | `memberplan/index`、`memberorder/index`、`usersetting/index`、`userbackup/index` | +| 用户(FastAdmin 原生) | 会员管理 | `fa_user` | `user/user` | + +菜单脚本:`server/db/05_admin_menu.sql`(可重复执行),父菜单「花期运营」+ 26 个业务菜单 + 6 个通用操作权限(`manage/index|add|edit|del|multi|columns`)。 + +--- + +## 3. 通用 CRUD 架构(一套代码覆盖 26 张表) + +``` +application/admin/ +├── controller/Manage.php # 通用列表/新增/编辑/删除/批量/列定义 +├── controller/{Cycle,Content,…}.php # 26 个 8 行薄控制器(只声明 $fixedTable) +├── library/ManageConfig.php # 表注册表:字段标签/类型/枚举/搜索/只读 +├── library/FormBuilder.php # 按字段类型渲染表单控件(文本/下拉/日期/开关/用户) +└── view/manage/{index,add,edit,style}.html # 通用列表与表单视图(暖色主题) + +public/assets/js/backend/manage.js # 通用列表脚本:按后端列元数据动态构建表格 +``` + +**能力** +- 列表:分页、排序、关键词模糊搜索(按配置的 searchable 字段)、`deletetime` 软删除隔离 +- 表单:按字段类型自动渲染,`select` 走统一枚举表(状态/审核/权限/支付/咨询状态等),`user` 字段自动带出「昵称 (#ID)」下拉 +- 校验与转换:按列类型强转(int/decimal/日期/时间戳/布尔),只收集白名单字段,避免脏字段写入 +- 删除:有 `deletetime` 的表走软删除(支持恢复),只读表(如用户)禁止写入 +- 权限:26 个模块各自独立权限节点,可对子管理员单独授权 + +**新增一个业务模块只需要**: +1. `ManageConfig::all()` 里加一条表配置(字段中文名 + 类型 + 枚举) +2. 新建一个 8 行控制器 `class Xxx extends Manage { protected $fixedTable = 'fa_xxx'; }` +3. `db/05_admin_menu.sql` 追加菜单(或直接在「菜单规则」后台添加) + +--- + +## 4. 关键实现细节(踩坑记录) + +| 问题 | 原因 | 解决 | +|---|---|---| +| 模块页全部 500 | `Manage::edit()/del()/multi()` 与 `app\admin\library\traits\Backend` 的 `($ids = null)` 签名不兼容,PHP 8 直接 Fatal | 统一为 `($ids = null)` | +| 菜单不显示 | 后台配置/规则缓存(`runtime/cache`) | 清 `runtime/cache`、`runtime/temp` 后正常 | +| 菜单标题乱码 | 导入 SQL 未声明客户端字符集 | SQL 顶部加 `SET NAMES utf8mb4;` | +| 菜单点击 404 | ThinkPHP 需**下划线**形式的 URL 才能映射到驼峰类(`user_backup` → `UserBackup`),而权限规则名是**小写无下划线**(`userbackup/index`) | 菜单 `url` 用 snake_case,规则 `name` 用 lowercase | +| 自检脚本 500 | 构造的会话文件属主为 root,FPM 为 www,`session_start(O_RDWR)` 权限失败 | 会话文件 `chown www:www && chmod 0660` | + +--- + +## 5. 前后端数据同步验证(31 项,全部通过) + +脚本:`scripts/verify/admin_backend_check.php`(在服务器上执行) + +```bash +php scripts/verify/admin_backend_check.php /www/wwwroot/huaqiapp.gzyakj.com.cn huaqiapp.gzyakj.com.cn +``` + +覆盖: +1. **登录页品牌**:HTTP 200 且包含「登录运营后台」「花期」 +2. **后台框架 + 菜单**:HTTP 200 且包含「花期运营」「周期档案」 +3. **26 个业务模块**:页面 200(含 `hq-admin` 标记)+ 列表接口返回 `total`(实测数据量:症状标签 20、内容 70、分类 17、任务 12、疫苗 17、测评记录 8…) +4. **写入链路**:后台 AJAX 编辑 → 断言返回 `code=1` → 断言数据库字段已更新 +5. **同步链路**:会员 token 调 `/api/content/detail` → 断言前台接口读到后台新标题 +6. **还原**:改回原值并断言一致(不污染线上数据) + +实测输出(节选): + +``` +✅ 登录页(暖色调品牌版) +✅ 后台框架 + 花期运营菜单 +✅ 内容库 页面200 · 数据 70 条 +✅ 咨询师 页面200 · 数据 3 条 +✅ 后台编辑写入数据库 +✅ 后台改动 → 前台 API 同步(读取到:同步自检110816) +✅ 数据还原(经期温热暖身餐) +======================================== +通过 31 项,失败 0 项 +``` + +**其它回归**:前台接口 82 项断言全绿;前后端契约 84 接口 0 偏差;Vue 20/20、SCSS 19/19、无 emoji 校验通过。 + +--- + +## 6. 运维与回滚 + +| 项 | 路径 | +|---|---| +| 后台改动前备份 | `/www/backup/huaqiapp_admin_<时间戳>/`(controller、view/manage、login.html、header.html、site.php、fa_auth_rule 导出) | +| 菜单重建 | `mysql < db/05_admin_menu.sql` | +| 清缓存 | `rm -rf runtime/cache/* runtime/temp/*` | +| 回滚登录页 | 从备份目录覆盖 `application/admin/view/index/login.html` | + +--- + +## 7. 已知限制 / 后续 + +| # | 限制 | 说明 | +|---|---|---| +| 1 | 权限粒度 | 26 个模块独立授权已可用;但「通用 CRUD」的 add/edit/del 共用 `manage/*` 节点,若需按模块细分写权限,可为每个模块补充 `{controller}/add` 等规则 | +| 2 | 回收站 | 软删除已生效,但未接入 FastAdmin 回收站 UI(需为每个模块绑定模型) | +| 3 | 导出 | 通用列表暂未开启 Excel 导出(PHP 8.5 缺 `zip` 扩展,需先修复扩展) | +| 4 | 富文本 | 内容正文当前为纯文本域;接富文本编辑器(如 summernote)可提升编辑体验 | +| 5 | 图片上传 | 内容封面/试纸图片目前为 URL 文本;可接入「附件管理」选择器 | diff --git a/docs/README.md b/docs/README.md index 0dc4796..ed3589d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,6 +7,7 @@ | [03-数据模型与接口设计](./03-数据模型与接口设计.md) | 表结构草案(DDL)、接口清单、业务规则、落地顺序 | 后端、前端 | | [04-前端设计系统与联调验证](./04-前端设计系统与联调验证.md) | 暖色调设计令牌、组件规范、页面地图、接口契约表、全链路验证报告 | 设计、前端、测试 | | [05-功能覆盖度与实现说明](./05-功能覆盖度与实现说明.md) | M1–M9 共 47 个功能点逐条对照、接口与表映射、验证结论、已知限制 | 产品、开发、测试、运营 | +| [06-运营后台与数据同步](./06-运营后台与数据同步.md) | 后台登录页品牌化、26 个业务模块与前端对齐、通用 CRUD 架构、数据同步验证、运维回滚 | 运营、开发、运维 | ## 快速了解 @@ -15,6 +16,7 @@ - **仓库**:前端在根目录,后端源码在 `server/`(与线上服务器同步) - **上线节奏**:V1.0(P0,19 项)→ V1.1(P1,18 项)→ V2.0(P2,10 项) - **当前进度**:M1–M9 共 **47 个功能点全部实现并联调通过**(详见 05 文档) +- **运营后台**:登录页品牌化 + 26 个业务模块(与前端模块一一对应)+ 通用 CRUD(详见 06 文档) - **变现**:会员订阅为主轴(月/年/终身),课程与咨询/测评为副,按次增值补充;坚持零广告 - **设计**:暖色调(花红 `#E8604C` + 蜜橙 `#F5A65B` + 暖米白 `#FFF9F5`),设计令牌见 `uni.scss` - **图标**:全站 61 个内联 SVG 矢量图标(`utils/icons.js` + ``),**禁止使用 emoji 图片** @@ -27,6 +29,9 @@ python3 scripts/verify/contract_check.py # 前后端契约静态校验 node scripts/verify/vue_compile_check.mjs # Vue SFC 编译(需 @vue/compiler-sfc) node scripts/verify/scss_compile_check.mjs # SCSS 编译(需 sass) python3 scripts/verify/no_emoji_check.py # 图标规范(禁止 emoji / 图标名与组件注册) + +# 运营后台(在服务器执行) +php scripts/verify/admin_backend_check.php <站点目录> <站点域名> # 26 模块 + 数据同步 31 项 ``` ## 重要约定 diff --git a/scripts/verify/admin_backend_check.php b/scripts/verify/admin_backend_check.php new file mode 100644 index 0000000..7c9a12f --- /dev/null +++ b/scripts/verify/admin_backend_check.php @@ -0,0 +1,174 @@ + <站点域名>\n"); + exit(2); +} + +$env = parse_ini_file($site . '/.env', true); +$db = $env['database'] ?? []; +$pdo = new PDO( + 'mysql:host=' . ($db['hostname'] ?? '127.0.0.1') . ';port=' . ($db['hostport'] ?? 3306) . ';dbname=' . $db['database'] . ';charset=utf8mb4', + $db['username'] ?? '', + $db['password'] ?? '' +); +$prefix = $db['prefix'] ?? 'fa_'; + +// 读取 token.key(用于构造管理员 safecode) +$config = file_get_contents($site . '/application/config.php'); +preg_match("/'key'\s*=>\s*'([^']+)'/", $config, $m); +$tokenKey = $m[1] ?? ''; + +$admin = $pdo->query("SELECT * FROM {$prefix}admin WHERE id=1")->fetch(PDO::FETCH_ASSOC); +$sid = bin2hex(random_bytes(16)); +$safecode = md5(md5($admin['username']) . md5(substr($admin['password'], 0, 6)) . $tokenKey); +$admin['safecode'] = $safecode; +$admin['loginip'] = '127.0.0.1'; +$admin['logintime'] = time(); + +session_id($sid); +session_start(); +$_SESSION['think'] = ['admin' => $admin]; +session_write_close(); +$sessPath = session_save_path() ?: sys_get_temp_dir(); +$sessFile = rtrim($sessPath, '/') . '/sess_' . $sid; +// Web 进程(www)需要可读写该会话文件,否则 session_start 会因 O_RDWR 失败 +@chown($sessFile, 'www'); +@chgrp($sessFile, 'www'); +@chmod($sessFile, 0660); + +$base = 'http://127.0.0.1/wechLIGOut.php'; +$cookie = 'PHPSESSID=' . $sid; + +// 前台 API 基址(用于数据同步验证) +$apiBase = 'http://127.0.0.1'; + +function http_get($url, $cookie, $ajax = false) +{ + $opts = ['http' => ['method' => 'GET', 'header' => "Host: {$GLOBALS['host']}\r\nCookie: {$cookie}\r\n" . ($ajax ? "X-Requested-With: XMLHttpRequest\r\n" : ''), 'timeout' => 20, 'ignore_errors' => true]]; + $res = @file_get_contents($url, false, stream_context_create($opts)); + $status = 0; + foreach ($http_response_header ?? [] as $line) { + if (preg_match('#HTTP/\S+\s+(\d{3})#', $line, $mm)) { + $status = (int)$mm[1]; + } + } + return [$status, (string)$res]; +} + +function http_post($url, $cookie, $data) +{ + $body = http_build_query($data); + $opts = ['http' => ['method' => 'POST', 'header' => "Host: {$GLOBALS['host']}\r\nCookie: {$cookie}\r\nX-Requested-With: XMLHttpRequest\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: " . strlen($body) . "\r\n", 'content' => $body, 'timeout' => 20, 'ignore_errors' => true]]; + $res = @file_get_contents($url, false, stream_context_create($opts)); + return json_decode((string)$res, true) ?: ['code' => 0, 'msg' => substr((string)$res, 0, 120)]; +} + +$modules = [ + 'cycle' => '周期档案', 'cycle_log' => '每日症状记录', 'symptom_tag' => '症状标签', 'reminder' => '智能提醒', + 'mood_log' => '情绪日记', 'content' => '内容库', 'content_category' => '内容分类', + 'bbt_record' => '基础体温', 'lh_test' => '试纸识别', 'task' => '任务管理', + 'family_bind' => '家人绑定', 'care_message' => '沟通留言', 'point_log' => '爱心积分', + 'memory' => '纪念日', 'course_order' => '课程订单', + 'baby' => '宝宝档案', 'growth_record' => '成长记录', 'vaccine' => '疫苗与儿保', + 'counselor' => '咨询师', 'consult_order' => '咨询订单', 'assessment' => '量表管理', 'assessment_record' => '测评记录', + 'member_plan' => '会员套餐', 'member_order' => '会员订单', 'user_setting' => '隐私与提醒', 'user_backup' => '数据备份', +]; + +$pass = 0; +$fail = []; + +// 1. 登录页品牌 +list($code, $html) = http_get($base . '/index/login', ''); +$ok = $code === 200 && strpos($html, '登录运营后台') !== false && strpos($html, '花期') !== false; +$ok ? $pass++ : $fail[] = '登录页品牌渲染'; +echo ($ok ? ' ✅' : ' ❌') . " 登录页(暖色调品牌版)\n"; + +// 2. 品牌与菜单 +list($code, $html) = http_get($base . '/index/index', $cookie); +$ok = $code === 200 && strpos($html, '花期运营') !== false && strpos($html, '周期档案') !== false; +$ok ? $pass++ : $fail[] = '后台框架与菜单'; +echo ($ok ? ' ✅' : ' ❌') . " 后台框架 + 花期运营菜单\n"; + +// 3. 26 个模块页面与列表接口 +foreach ($modules as $module => $title) { + list($code, $html) = http_get($base . '/' . $module . '/index', $cookie); + list($acode, $json) = http_get($base . '/' . $module . '/index?limit=2&offset=0', $cookie, true); + $data = json_decode($json, true); + $ok = $code === 200 && strpos($html, 'hq-admin') !== false && is_array($data) && isset($data['total']); + if ($ok) { + $pass++; + printf(" ✅ %-18s 页面200 · 数据 %s 条\n", $title, $data['total']); + } else { + $fail[] = $module; + printf(" ❌ %-18s http=%s ajax=%s\n", $title, $code, substr($json, 0, 80)); + } +} + +// 4. 数据同步:后台改内容标题 → 数据库 → 前台接口 → 还原 +$row = $pdo->query("SELECT id,title FROM {$prefix}content WHERE type='advice' ORDER BY id LIMIT 1")->fetch(PDO::FETCH_ASSOC); +if ($row) { + $newTitle = '同步自检' . date('His'); + + // 4.1 后台编辑写库 + $res = http_post($base . '/content/edit?ids=' . $row['id'], $cookie, ['title' => $newTitle]); + $dbTitle = $pdo->query("SELECT title FROM {$prefix}content WHERE id=" . (int)$row['id'])->fetchColumn(); + $okWrite = (($res['code'] ?? 0) == 1) && $dbTitle === $newTitle; + $okWrite ? $pass++ : $fail[] = '后台编辑写库'; + echo ($okWrite ? ' ✅' : ' ❌') . " 后台编辑写入数据库\n"; + + // 4.2 前台接口读取(会员 token) + $token = ''; + $loginCtx = stream_context_create(['http' => [ + 'method' => 'POST', + 'header' => "Host: {$host}\r\nContent-Type: application/x-www-form-urlencoded\r\n", + 'content' => http_build_query(['account' => 'demo', 'password' => 'demo123456']), + 'timeout' => 15, 'ignore_errors' => true, + ]]); + $login = json_decode((string)@file_get_contents($apiBase . '/api/user/login', false, $loginCtx), true); + $token = $login['data']['userinfo']['token'] ?? ''; + + $apiTitle = ''; + if ($token) { + $ctx = stream_context_create(['http' => [ + 'method' => 'GET', + 'header' => "Host: {$host}\r\ntoken: {$token}\r\n", + 'timeout' => 15, 'ignore_errors' => true, + ]]); + $api = json_decode((string)@file_get_contents($apiBase . '/api/content/detail?id=' . $row['id'], false, $ctx), true); + $apiTitle = $api['data']['title'] ?? ''; + } + $okSync = $apiTitle === $newTitle; + $okSync ? $pass++ : $fail[] = '前台接口同步'; + echo ($okSync ? ' ✅' : ' ❌') . " 后台改动 → 前台 API 同步(读取到:{$apiTitle})\n"; + + // 4.3 还原 + http_post($base . '/content/edit?ids=' . $row['id'], $cookie, ['title' => $row['title']]); + $restored = $pdo->query("SELECT title FROM {$prefix}content WHERE id=" . (int)$row['id'])->fetchColumn(); + $okRestore = $restored === $row['title']; + $okRestore ? $pass++ : $fail[] = '内容还原'; + echo ($okRestore ? ' ✅' : ' ❌') . " 数据还原({$restored})\n"; +} + +echo "=" . str_repeat('=', 60) . "\n"; +echo "通过 {$pass} 项,失败 " . count($fail) . " 项\n"; +@unlink($sessFile); +if ($fail) { + echo "失败项:" . implode('、', $fail) . "\n"; + exit(1); +} +echo "🎉 运营后台全链路自检通过\n"; diff --git a/server/application/admin/controller/Assessment.php b/server/application/admin/controller/Assessment.php new file mode 100644 index 0000000..e56d3b2 --- /dev/null +++ b/server/application/admin/controller/Assessment.php @@ -0,0 +1,13 @@ +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; + } +} diff --git a/server/application/admin/controller/MemberOrder.php b/server/application/admin/controller/MemberOrder.php new file mode 100644 index 0000000..05ec035 --- /dev/null +++ b/server/application/admin/controller/MemberOrder.php @@ -0,0 +1,13 @@ + def) + * @param array $row 当前数据 + * @param array $users 用户下拉数据 + * @return string + */ + public static function render($fields, $row = [], $users = []) + { + $html = ''; + foreach ($fields as $name => $def) { + if (isset($def['readonly']) && $def['readonly']) { + continue; + } + if (isset($def['form']) && $def['form'] === false) { + continue; + } + $html .= self::group($name, $def, $row, $users); + } + return $html; + } + + protected static function group($name, $def, $row, $users) + { + $label = htmlspecialchars($def['label'] ?? $name); + $type = $def['type'] ?? 'text'; + $value = array_key_exists($name, $row) ? $row[$name] : ''; + $control = self::control($name, $type, $def, $value, $users); + $hint = isset($def['hint']) ? '' . htmlspecialchars($def['hint']) . '' : ''; + + return '
' + . '' + . '
' . $control . $hint . '
' + . '
'; + } + + protected static function control($name, $type, $def, $value, $users) + { + $n = htmlspecialchars($name); + $v = is_string($value) ? htmlspecialchars($value) : $value; + + switch ($type) { + case 'textarea': + return ''; + + case 'select': + $options = $def['options'] ?? []; + if (is_string($options)) { + $options = ManageConfig::$enums[$options] ?? []; + } + $html = ''; + + case 'bool': + $checked = $value ? ' checked' : ''; + return '
' + . '
'; + + case 'number': + return ''; + + case 'decimal': + return ''; + + case 'date': + return ''; + + case 'datetime': + return ''; + + case 'user': + $html = ''; + + default: + return ''; + } + } +} diff --git a/server/application/admin/library/ManageConfig.php b/server/application/admin/library/ManageConfig.php new file mode 100644 index 0000000..4bf7e9a --- /dev/null +++ b/server/application/admin/library/ManageConfig.php @@ -0,0 +1,496 @@ + ['normal' => '正常', 'hidden' => '隐藏'], + 'access' => ['free' => '免费', 'member' => '会员', 'pay' => '付费'], + 'verify' => ['pending' => '待审核', 'passed' => '已通过', 'rejected' => '已驳回'], + 'pay_status' => ['unpaid' => '待支付', 'paid' => '已支付', 'refunded' => '已退款'], + 'scope' => ['prepare' => '备孕任务', 'family' => '家庭共育', 'relation' => '双人任务', 'parenting' => '育儿任务'], + 'mood' => ['happy' => '开心', 'calm' => '平静', 'anxious' => '焦虑', 'low' => '低落', 'angry' => '烦躁'], + 'mode' => ['cycle' => '经期模式', 'prepare' => '备孕模式', 'pregnant' => '孕期模式'], + 'source' => ['user' => '用户记录', 'algorithm' => '算法生成'], + 'result' => ['negative' => '阴性', 'weak' => '弱阳', 'positive' => '阳性', 'strong' => '强阳', 'unknown' => '未识别'], + 'level' => ['basic' => '基础版', 'pro' => '专业版'], + 'repeat_rule' => ['daily' => '每天', 'once' => '单次', 'weekly' => '每周'], + 'app_lock' => ['none' => '关闭', 'password' => '数字密码', 'biometric' => '生物识别'], + 'cert_verify' => ['pending' => '待审核', 'passed' => '已认证', 'expired' => '已过期'], + 'consult_status' => ['pending' => '待开始', 'ongoing' => '进行中', 'finished' => '已完成', 'canceled' => '已取消'], + 'onoff' => ['on' => '上架', 'off' => '下架'], + 'relation' => ['partner' => '伴侣', 'parent' => '父母', 'friend' => '朋友'], + 'test_type' => ['lh' => '排卵试纸', 'hcg' => '早孕试纸'], + 'type_content' => [ + 'jijing' => '每日心语', 'mood' => '情绪科普', 'knowledge' => '知识库', 'advice' => '健康建议', + 'meditation' => '正念冥想', 'care' => '暖心话术', 'card' => '沟通引导卡', 'conflict' => '冲突工具', + 'firstaid' => '情绪急救包', 'guide' => '同房指导', 'course' => '系列课程', 'activity' => '亲子活动', + 'emotion' => '育儿情绪', + ], + 'category_type' => [ + 'jijing' => '心语', 'mood' => '情绪科普', 'knowledge' => '知识库', 'advice' => '健康建议', + 'meditation' => '冥想', 'care' => '话术', 'card' => '引导卡', 'conflict' => '冲突工具', + 'firstaid' => '急救包', 'guide' => '同房指导', 'course' => '课程', 'activity' => '亲子活动', 'emotion' => '育儿情绪', + ], + 'action' => ['care_daily' => '每日关怀', 'prepare_task' => '备孕任务', 'exchange' => '积分兑换'], + 'category' => ['check' => '检查', 'folic' => '叶酸', 'nutrition' => '营养', 'partner' => '伴侣', 'daily' => '日常', 'care' => '照护'], + 'memory_type' => ['anniversary' => '纪念日', 'milestone' => '里程碑', 'daily' => '日常记录'], + 'vaccine_type' => ['vaccine' => '疫苗', 'checkup' => '儿保体检'], + 'gender' => ['0' => '未知', '1' => '男', '2' => '女'], + 'advice_key' => [], + ]; + + /** + * 所有受管业务表 + */ + public static function all() + { + return [ + /* ================= M1 周期管理 ================= */ + 'fa_cycle' => [ + 'title' => '周期档案', + 'group' => '周期管理', + 'sort' => 'start_date desc, id desc', + 'fields' => [ + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'start_date' => ['label' => '经期开始日', 'type' => 'date', 'search' => true], + 'end_date' => ['label' => '经期结束日', 'type' => 'date'], + 'period_length' => ['label' => '经期天数', 'type' => 'number'], + 'cycle_length' => ['label' => '周期天数', 'type' => 'number'], + 'ovulation_date' => ['label' => '排卵日', 'type' => 'date'], + 'mode' => ['label' => '模式', 'type' => 'select', 'options' => 'mode'], + 'source' => ['label' => '来源', 'type' => 'select', 'options' => 'source'], + ], + ], + 'fa_cycle_log' => [ + 'title' => '每日症状记录', + 'group' => '周期管理', + 'sort' => 'log_date desc, id desc', + 'fields' => [ + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'log_date' => ['label' => '记录日期', 'type' => 'date', 'search' => true], + 'flow' => ['label' => '经血量', 'type' => 'select', 'options' => ['0' => '无', '1' => '少', '2' => '中', '3' => '多']], + 'pain_level' => ['label' => '痛经程度', 'type' => 'select', 'options' => ['0' => '无', '1' => '轻微', '2' => '可忍', '3' => '明显', '4' => '较重', '5' => '难忍']], + 'symptom_ids' => ['label' => '症状标签ID', 'type' => 'text'], + 'mood' => ['label' => '情绪分值', 'type' => 'number'], + 'sleep_hours' => ['label' => '睡眠(小时)', 'type' => 'decimal'], + 'sex' => ['label' => '性生活', 'type' => 'select', 'options' => ['0' => '无', '1' => '有保护', '2' => '无保护']], + 'note' => ['label' => '备注', 'type' => 'textarea'], + ], + ], + 'fa_symptom_tag' => [ + 'title' => '症状标签', + 'group' => '周期管理', + 'sort' => 'weigh desc, id asc', + 'fields' => [ + 'name' => ['label' => '标签名', 'type' => 'text', 'search' => true], + 'icon' => ['label' => '图标', 'type' => 'text'], + 'category' => ['label' => '分类', 'type' => 'select', 'options' => ['body' => '身体', 'mood' => '情绪', 'flow' => '经血']], + 'is_preset' => ['label' => '预设标签', 'type' => 'bool'], + 'user_id' => ['label' => '归属用户', 'type' => 'user'], + 'weigh' => ['label' => '权重', 'type' => 'number'], + 'status' => ['label' => '状态', 'type' => 'select', 'options' => 'status'], + ], + ], + 'fa_reminder' => [ + 'title' => '智能提醒', + 'group' => '周期管理', + 'sort' => 'id desc', + 'fields' => [ + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'title' => ['label' => '提醒内容', 'type' => 'text', 'search' => true], + 'type' => ['label' => '类型', 'type' => 'select', 'options' => ['period' => '经期', 'ovulation' => '排卵', 'record' => '记录', 'medication' => '用药', 'custom' => '自定义']], + 'remind_time' => ['label' => '提醒时间', 'type' => 'text'], + 'repeat_rule' => ['label' => '重复', 'type' => 'select', 'options' => 'repeat_rule'], + 'remind_date' => ['label' => '提醒日期', 'type' => 'date'], + 'enabled' => ['label' => '启用', 'type' => 'bool'], + ], + ], + + /* ================= M2 情绪疗愈 ================= */ + 'fa_mood_log' => [ + 'title' => '情绪日记', + 'group' => '情绪疗愈', + 'sort' => 'log_date desc, id desc', + 'fields' => [ + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'log_date' => ['label' => '记录日期', 'type' => 'date', 'search' => true], + 'mood' => ['label' => '情绪', 'type' => 'select', 'options' => 'mood'], + 'intensity' => ['label' => '强度', 'type' => 'number'], + 'triggers' => ['label' => '触发事件', 'type' => 'text'], + 'content' => ['label' => '内容', 'type' => 'textarea'], + ], + ], + + /* ================= 内容中心 ================= */ + 'fa_content' => [ + 'title' => '内容库', + 'group' => '内容中心', + 'sort' => 'weigh desc, id desc', + 'fields' => [ + 'type' => ['label' => '内容类型', 'type' => 'select', 'options' => 'type_content', 'search' => true], + 'category_id' => ['label' => '分类ID', 'type' => 'number'], + 'title' => ['label' => '标题', 'type' => 'text', 'search' => true], + 'summary' => ['label' => '摘要', 'type' => 'text'], + 'body' => ['label' => '正文', 'type' => 'textarea'], + 'cover_emoji' => ['label' => '图标字符', 'type' => 'text'], + 'media_type' => ['label' => '媒体类型', 'type' => 'select', 'options' => ['text' => '图文', 'audio' => '音频', 'video' => '视频']], + 'duration' => ['label' => '时长(秒)', 'type' => 'number'], + 'phase' => ['label' => '适用阶段', 'type' => 'text'], + 'mood_tag' => ['label' => '适用情绪', 'type' => 'text'], + 'extra' => ['label' => '扩展信息', 'type' => 'textarea'], + 'access' => ['label' => '访问权限', 'type' => 'select', 'options' => 'access', 'search' => true], + 'verify' => ['label' => '审核状态', 'type' => 'select', 'options' => 'verify', 'search' => true], + 'weigh' => ['label' => '权重', 'type' => 'number'], + 'status' => ['label' => '状态', 'type' => 'select', 'options' => 'status', 'search' => true], + ], + ], + 'fa_content_category' => [ + 'title' => '内容分类', + 'group' => '内容中心', + 'sort' => 'weigh desc, id asc', + 'fields' => [ + 'pid' => ['label' => '父级ID', 'type' => 'number'], + 'name' => ['label' => '分类名', 'type' => 'text', 'search' => true], + 'type' => ['label' => '类型', 'type' => 'select', 'options' => 'category_type', 'search' => true], + 'weigh' => ['label' => '权重', 'type' => 'number'], + 'status' => ['label' => '状态', 'type' => 'select', 'options' => 'status'], + ], + ], + + /* ================= M3 科学备孕 ================= */ + 'fa_bbt_record' => [ + 'title' => '基础体温', + 'group' => '科学备孕', + 'sort' => 'log_date desc, id desc', + 'fields' => [ + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'log_date' => ['label' => '日期', 'type' => 'date', 'search' => true], + 'temperature' => ['label' => '体温(℃)', 'type' => 'decimal'], + 'measure_time' => ['label' => '测量时间', 'type' => 'text'], + 'device' => ['label' => '设备来源', 'type' => 'select', 'options' => ['manual' => '手动', 'bluetooth' => '蓝牙']], + ], + ], + 'fa_lh_test' => [ + 'title' => '试纸识别记录', + 'group' => '科学备孕', + 'sort' => 'log_date desc, id desc', + 'fields' => [ + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'test_type' => ['label' => '试纸类型', 'type' => 'select', 'options' => 'test_type'], + 'log_date' => ['label' => '日期', 'type' => 'date', 'search' => true], + 'result' => ['label' => '识别结果', 'type' => 'select', 'options' => 'result'], + 'confidence' => ['label' => '置信度', 'type' => 'decimal'], + 'algo' => ['label' => '算法', 'type' => 'select', 'options' => ['rule' => '规则引擎', 'cnn' => 'CNN 模型']], + 'image' => ['label' => '试纸图片', 'type' => 'text'], + ], + ], + 'fa_task' => [ + 'title' => '任务(备孕/双人/共育)', + 'group' => '科学备孕', + 'sort' => 'id desc', + 'fields' => [ + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'scope' => ['label' => '归属模块', 'type' => 'select', 'options' => 'scope', 'search' => true], + 'title' => ['label' => '任务内容', 'type' => 'text', 'search' => true], + 'category' => ['label' => '分类', 'type' => 'select', 'options' => 'category'], + 'due_date' => ['label' => '截止日期', 'type' => 'date'], + 'done' => ['label' => '已完成', 'type' => 'bool'], + 'share_partner' => ['label' => '同步伴侣', 'type' => 'bool'], + ], + ], + + /* ================= M4 家人共护 ================= */ + 'fa_family_bind' => [ + 'title' => '家人绑定', + 'group' => '家人共护', + 'sort' => 'id desc', + 'fields' => [ + 'user_id' => ['label' => '主账号', 'type' => 'user', 'search' => true], + 'member_user_id' => ['label' => '家人账号', 'type' => 'user'], + 'member_name' => ['label' => '家人称呼', 'type' => 'text', 'search' => true], + 'relation' => ['label' => '关系', 'type' => 'select', 'options' => 'relation'], + 'scope' => ['label' => '可见范围', 'type' => 'text'], + 'invite_code' => ['label' => '邀请码', 'type' => 'text', 'search' => true], + 'status' => ['label' => '状态', 'type' => 'select', 'options' => ['pending' => '待接受', 'normal' => '已绑定', 'removed' => '已解绑'], 'search' => true], + ], + ], + 'fa_care_message' => [ + 'title' => '沟通空间留言', + 'group' => '家人共护', + 'sort' => 'id desc', + 'fields' => [ + 'bind_id' => ['label' => '绑定ID', 'type' => 'number', 'search' => true], + 'user_id' => ['label' => '发送人', 'type' => 'user', 'search' => true], + 'type' => ['label' => '类型', 'type' => 'select', 'options' => ['text' => '文字', 'voice' => '语音', 'gift' => '礼物']], + 'content' => ['label' => '留言内容', 'type' => 'textarea'], + ], + ], + 'fa_point_log' => [ + 'title' => '爱心积分流水', + 'group' => '家人共护', + 'sort' => 'id desc', + 'fields' => [ + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'action' => ['label' => '积分动作', 'type' => 'select', 'options' => 'action', 'search' => true], + 'point' => ['label' => '积分', 'type' => 'number'], + 'ref_id' => ['label' => '关联ID', 'type' => 'number'], + 'remark' => ['label' => '备注', 'type' => 'text'], + ], + ], + + /* ================= M7 两性关系 ================= */ + 'fa_memory' => [ + 'title' => '纪念日与回忆', + 'group' => '两性关系', + 'sort' => 'memory_date desc, id desc', + 'fields' => [ + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'title' => ['label' => '标题', 'type' => 'text', 'search' => true], + 'memory_date' => ['label' => '日期', 'type' => 'date', 'search' => true], + 'type' => ['label' => '类型', 'type' => 'select', 'options' => 'memory_type'], + 'content' => ['label' => '内容', 'type' => 'textarea'], + 'remind' => ['label' => '提醒', 'type' => 'bool'], + ], + ], + 'fa_course_order' => [ + 'title' => '课程订单', + 'group' => '两性关系', + 'sort' => 'id desc', + 'fields' => [ + 'order_no' => ['label' => '订单号', 'type' => 'text', 'search' => true], + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'content_id' => ['label' => '课程ID', 'type' => 'number'], + 'amount' => ['label' => '金额', 'type' => 'decimal'], + 'pay_status' => ['label' => '支付状态', 'type' => 'select', 'options' => 'pay_status', 'search' => true], + ], + ], + + /* ================= M8 亲子教育 ================= */ + 'fa_baby' => [ + 'title' => '宝宝档案', + 'group' => '亲子教育', + 'sort' => 'id desc', + 'fields' => [ + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'nickname' => ['label' => '小名', 'type' => 'text', 'search' => true], + 'gender' => ['label' => '性别', 'type' => 'select', 'options' => ['0' => '未知', '1' => '男', '2' => '女']], + 'birthday' => ['label' => '生日', 'type' => 'date'], + 'due_date' => ['label' => '预产期', 'type' => 'date'], + 'status' => ['label' => '状态', 'type' => 'select', 'options' => 'status'], + ], + ], + 'fa_growth_record' => [ + 'title' => '成长记录', + 'group' => '亲子教育', + 'sort' => 'record_date desc, id desc', + 'fields' => [ + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'baby_id' => ['label' => '宝宝ID', 'type' => 'number'], + 'record_date' => ['label' => '记录日期', 'type' => 'date', 'search' => true], + 'height' => ['label' => '身高(cm)', 'type' => 'decimal'], + 'weight' => ['label' => '体重(kg)', 'type' => 'decimal'], + 'milestone' => ['label' => '里程碑', 'type' => 'text'], + 'note' => ['label' => '备注', 'type' => 'textarea'], + ], + ], + 'fa_vaccine' => [ + 'title' => '疫苗与儿保', + 'group' => '亲子教育', + 'sort' => 'weigh desc, id asc', + 'fields' => [ + 'name' => ['label' => '项目名称', 'type' => 'text', 'search' => true], + 'type' => ['label' => '类型', 'type' => 'select', 'options' => 'vaccine_type', 'search' => true], + 'month_from' => ['label' => '起始月龄', 'type' => 'number'], + 'month_to' => ['label' => '结束月龄', 'type' => 'number'], + 'note' => ['label' => '说明', 'type' => 'text'], + 'weigh' => ['label' => '权重', 'type' => 'number'], + ], + ], + + /* ================= M9 心理服务 ================= */ + 'fa_counselor' => [ + 'title' => '咨询师', + 'group' => '心理服务', + 'sort' => 'weigh desc, id asc', + 'fields' => [ + 'name' => ['label' => '姓名', 'type' => 'text', 'search' => true], + 'avatar_emoji' => ['label' => '头像图标', 'type' => 'text'], + 'title' => ['label' => '职称', 'type' => 'text'], + 'cert_no' => ['label' => '资质编号', 'type' => 'text', 'search' => true], + 'cert_verify' => ['label' => '资质审核', 'type' => 'select', 'options' => 'cert_verify', 'search' => true], + 'price_text' => ['label' => '文字咨询价', 'type' => 'decimal'], + 'price_voice' => ['label' => '语音咨询价', 'type' => 'decimal'], + 'price_video' => ['label' => '视频咨询价', 'type' => 'decimal'], + 'rate' => ['label' => '平台分成', 'type' => 'decimal'], + 'intro' => ['label' => '简介', 'type' => 'textarea'], + 'tags' => ['label' => '擅长标签', 'type' => 'text'], + 'status' => ['label' => '状态', 'type' => 'select', 'options' => 'onoff', 'search' => true], + 'weigh' => ['label' => '权重', 'type' => 'number'], + ], + ], + 'fa_consult_order' => [ + 'title' => '咨询订单', + 'group' => '心理服务', + 'sort' => 'id desc', + 'fields' => [ + 'order_no' => ['label' => '订单号', 'type' => 'text', 'search' => true], + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'counselor_id' => ['label' => '咨询师ID', 'type' => 'number'], + 'mode' => ['label' => '方式', 'type' => 'select', 'options' => ['text' => '文字', 'voice' => '语音', 'video' => '视频']], + 'start_time' => ['label' => '预约时间', 'type' => 'datetime'], + 'duration' => ['label' => '时长(分钟)', 'type' => 'number'], + 'amount' => ['label' => '金额', 'type' => 'decimal'], + 'pay_status' => ['label' => '支付状态', 'type' => 'select', 'options' => 'pay_status', 'search' => true], + 'consult_status' => ['label' => '咨询状态', 'type' => 'select', 'options' => 'consult_status', 'search' => true], + 'rate_amount' => ['label' => '分成金额', 'type' => 'decimal'], + ], + ], + 'fa_assessment' => [ + 'title' => '量表管理', + 'group' => '心理服务', + 'sort' => 'weigh desc, id asc', + 'fields' => [ + 'code' => ['label' => '量表编码', 'type' => 'text', 'search' => true], + 'title' => ['label' => '量表名称', 'type' => 'text', 'search' => true], + 'intro' => ['label' => '引导语', 'type' => 'textarea'], + 'questions' => ['label' => '题目(JSON数组)', 'type' => 'textarea'], + 'scoring' => ['label' => '分级规则(JSON)', 'type' => 'textarea'], + 'level' => ['label' => '版本', 'type' => 'select', 'options' => 'level'], + 'scope' => ['label' => '归属', 'type' => 'select', 'options' => ['mood' => '情绪', 'relation' => '关系', 'parenting' => '育儿']], + 'access' => ['label' => '访问权限', 'type' => 'select', 'options' => 'access'], + 'status' => ['label' => '状态', 'type' => 'select', 'options' => 'status'], + 'weigh' => ['label' => '权重', 'type' => 'number'], + ], + ], + 'fa_assessment_record' => [ + 'title' => '测评记录', + 'group' => '心理服务', + 'sort' => 'id desc', + 'fields' => [ + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'assessment_id' => ['label' => '量表ID', 'type' => 'number', 'search' => true], + 'score' => ['label' => '得分', 'type' => 'decimal'], + 'result' => ['label' => '结果分级', 'type' => 'text'], + 'risk_flag' => ['label' => '风险标记', 'type' => 'bool'], + 'answers' => ['label' => '作答(JSON)', 'type' => 'textarea', 'readonly' => true], + 'report' => ['label' => '解读', 'type' => 'textarea', 'readonly' => true], + ], + ], + + /* ================= M6 会员与用户 ================= */ + 'fa_member_plan' => [ + 'title' => '会员套餐', + 'group' => '会员中心', + 'sort' => 'weigh desc, id asc', + 'fields' => [ + 'name' => ['label' => '套餐名', 'type' => 'text', 'search' => true], + 'code' => ['label' => '编码', 'type' => 'text', 'search' => true], + 'subtitle' => ['label' => '副标题', 'type' => 'text'], + 'price' => ['label' => '售价', 'type' => 'decimal'], + 'origin_price' => ['label' => '原价', 'type' => 'decimal'], + 'duration_days' => ['label' => '有效天数', 'type' => 'number'], + 'benefits' => ['label' => '权益(JSON)', 'type' => 'textarea'], + 'badge' => ['label' => '角标', 'type' => 'text'], + 'weigh' => ['label' => '权重', 'type' => 'number'], + 'status' => ['label' => '状态', 'type' => 'select', 'options' => 'status'], + ], + ], + 'fa_member_order' => [ + 'title' => '会员订单', + 'group' => '会员中心', + 'sort' => 'id desc', + 'fields' => [ + 'order_no' => ['label' => '订单号', 'type' => 'text', 'search' => true], + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'plan_code' => ['label' => '套餐编码', 'type' => 'text'], + 'amount' => ['label' => '金额', 'type' => 'decimal'], + 'pay_type' => ['label' => '支付方式', 'type' => 'select', 'options' => ['wechat' => '微信', 'alipay' => '支付宝']], + 'pay_status' => ['label' => '支付状态', 'type' => 'select', 'options' => 'pay_status', 'search' => true], + 'trade_no' => ['label' => '第三方流水', 'type' => 'text'], + 'start_time' => ['label' => '生效时间', 'type' => 'datetime'], + 'expire_time' => ['label' => '到期时间', 'type' => 'datetime'], + ], + ], + 'fa_user_setting' => [ + 'title' => '隐私与提醒设置', + 'group' => '会员中心', + 'sort' => 'id desc', + 'fields' => [ + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'app_lock' => ['label' => '应用锁', 'type' => 'select', 'options' => 'app_lock'], + 'local_only' => ['label' => '仅本地存储', 'type' => 'bool'], + 'remind_json' => ['label' => '提醒配置(JSON)', 'type' => 'textarea'], + ], + ], + 'fa_user_backup' => [ + 'title' => '数据备份', + 'group' => '会员中心', + 'sort' => 'id desc', + 'fields' => [ + 'user_id' => ['label' => '用户', 'type' => 'user', 'search' => true], + 'name' => ['label' => '备份名称', 'type' => 'text'], + 'size' => ['label' => '大小(字节)', 'type' => 'number', 'readonly' => true], + 'payload' => ['label' => '备份内容', 'type' => 'textarea', 'readonly' => true], + ], + ], + 'fa_user' => [ + 'title' => '会员用户', + 'group' => '会员中心', + 'sort' => 'id desc', + 'readonly_table' => true, + 'fields' => [ + 'username' => ['label' => '账号', 'type' => 'text', 'search' => true], + 'nickname' => ['label' => '昵称', 'type' => 'text', 'search' => true], + 'mobile' => ['label' => '手机号', 'type' => 'text', 'search' => true], + 'gender' => ['label' => '性别', 'type' => 'select', 'options' => ['0' => '未知', '1' => '男', '2' => '女']], + 'birthday' => ['label' => '生日', 'type' => 'date'], + 'bio' => ['label' => '签名', 'type' => 'text'], + 'score' => ['label' => '积分', 'type' => 'number'], + 'status' => ['label' => '状态', 'type' => 'select', 'options' => ['normal' => '正常', 'hidden' => '禁用']], + ], + ], + ]; + } + + /** + * 取单个表的配置,并补齐通用字段 + */ + public static function get($table) + { + $all = self::all(); + if (!isset($all[$table])) { + return null; + } + $config = $all[$table]; + if (!isset($config['fields']['createtime'])) { + $config['fields']['createtime'] = ['label' => '创建时间', 'type' => 'datetime', 'readonly' => true]; + } + if (!isset($config['fields']['updatetime'])) { + $config['fields']['updatetime'] = ['label' => '更新时间', 'type' => 'datetime', 'readonly' => true]; + } + if (!isset($config['fields']['deletetime'])) { + $config['fields']['deletetime'] = ['label' => '删除时间', 'type' => 'datetime', 'readonly' => true, 'form' => false]; + } + return $config; + } + + /** 分组(用于菜单生成与文档) */ + public static function groups() + { + $groups = []; + foreach (self::all() as $table => $config) { + $groups[$config['group']][] = ['table' => $table, 'title' => $config['title']]; + } + return $groups; + } +} diff --git a/server/application/admin/view/common/header.html b/server/application/admin/view/common/header.html index 5344058..68c0371 100755 --- a/server/application/admin/view/common/header.html +++ b/server/application/admin/view/common/header.html @@ -1,9 +1,24 @@ - + + diff --git a/server/application/admin/view/index/login.html b/server/application/admin/view/index/login.html index a21d420..d3e2588 100755 --- a/server/application/admin/view/index/login.html +++ b/server/application/admin/view/index/login.html @@ -1,142 +1,376 @@ - + {include file="common/meta" /} - - - {if $background} - - {/if} - -
-