补齐未接入代码的功能域:参与人 / 评论 / 花销与分摊

数据库里早就建好了 comments、node_members + role_tags、expenses +
expense_splits 三组表,但一直没有代码接入。本次把这三块连上业务,
并新增「节点详情页」作为聚合入口。

新增
- src/Access.php          统一归属校验。此前每个控制器各写各的 family_id
                          条件,容易漏;现在 activity()/node()/target() 一处收敛,
                          requireAdmin() 统一管理员判断。
- src/Comments.php        两级平铺:parent_id 只允许指向顶层,写「回复的回复」
                          时规范化回顶层;删顶层会连同回复一起删。
- src/Members.php         参与人 + 角色标签 + 情绪 + 签到。情绪目录 17 项,
                          全部选用 Emoji 11.0(2018)及以前的码位,避免旧系统
                          字体缺字形显示成空白方块。
- src/Expenses.php        花销与分摊。分摊按「分」为单位 intdiv 平均,
                          余数归最后一人,保证 SUM(share_amount) = total_amount。
- templates/nodes/show.php 节点详情页:基本信息 + 参与人 + 花销 + 讨论 + 附件入口。
- 角色标签管理、评论区、参与人区、花销区各自的模板与控制器。

修复
- 越权:往别人活动的节点上插节点、把别家用户加成参与人,都只校验了
  「节点存在」,没校验「用户与节点同家庭」。现统一走 Access。
- 删活动时节点附件文件删不掉:附件在 /uploads/attachments/ 下,却调了
  只认 /uploads/covers/ 的删除函数,判定不通过 → 文件永久残留。改为
  统一的 deleteUploadFile(),两个目录都认。
- 删有引用记录的用户会 500(activities.created_by / attachments.uploader_id /
  comments.author_id / expenses.payer_user_id 这几处没有级联,也没做前置检查)。

其他
- 活动/节点列表补上附件、评论、参与人、花销四个计数(一次 GROUP BY 查完)。
- 首页布局新增一条迁移易踩的规避:新列上的索引必须排在 ALTER TABLE 之后。
- 新增 .gitignore:config.php(含数据库口令)与 uploads/ 下用户上传的
  头像封面不入库,只保留 uploads/.htaccess 那条禁止执行脚本的规则。
- 新增 config.example.php 作为新环境的配置模板。

本批不改数据库结构,不需要任何数据迁移。

本地与线上验证
- php -l 全绿(PHP 8.4 本地 / 线上 7.4.30 与 8.1.7 双版本)。
- 模板渲染检查 180 项全通过。
- 线上端到端 153 项全通过:登录、活动增删改查、节点流转、
  参与人/角色标签/情绪/签到、评论、花销与分摊、权限、级联清理、
  全站页面无 PHP 报错泄漏、跑完基线逐表核对且既有 id 集合逐字未变。
- 顺带写了 trip_sql_column_check.py:拿真实库表结构静态校验全项目 SQL 的
  列名引用。本次就是靠它之外的 e2e 抓到 travel_modes 并没有 line_name
  (该列属于 trip_nodes),一个列名写错导致节点详情页整页 Fatal。
This commit is contained in:
2026-09-22 22:03:08 +08:00
parent 6c1345e202
commit a79956758b
49 changed files with 7059 additions and 0 deletions
+424
View File
@@ -0,0 +1,424 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Auth;
use App\Comments;
use App\Database;
use App\Expenses;
class ActivityController
{
public function index(): void
{
$db = Database::get();
$familyId = Auth::familyId();
$stmt = $db->prepare('SELECT * FROM activities WHERE family_id = ? ORDER BY created_at DESC');
$stmt->execute([$familyId]);
$activities = $stmt->fetchAll();
// 每个活动的节点数:一次 GROUP BY 查完,避免逐个 COUNT 的 N+1
if (!empty($activities)) {
$ids = array_column($activities, 'id');
$ph = implode(',', array_fill(0, count($ids), '?'));
$s = $db->prepare("SELECT activity_id, COUNT(*) AS cnt
FROM trip_nodes WHERE activity_id IN ($ph) GROUP BY activity_id");
$s->execute($ids);
$counts = [];
foreach ($s->fetchAll() as $row) {
$counts[(int)$row['activity_id']] = (int)$row['cnt'];
}
foreach ($activities as &$a) {
$a['node_count'] = $counts[(int)$a['id']] ?? 0;
}
unset($a);
}
view('activities/index', [
'activities' => $activities,
'pageTitle' => '活动列表',
]);
}
public function create(): void
{
view('activities/form', [
'activity' => null,
'pageTitle' => '新建活动',
]);
}
public function store(): void
{
csrf_check();
$db = Database::get();
$familyId = Auth::familyId();
$userId = Auth::id();
$title = trim($_POST['title'] ?? '');
if ($title === '') {
flash('error', '标题不能为空');
redirect('activities/create');
}
if (mb_strlen($title) > 200) {
flash('error', '标题不能超过 200 个字符');
redirect('activities/create');
}
$cover = $this->normalizeCoverUrl($_POST['cover_image'] ?? '');
$stmt = $db->prepare('INSERT INTO activities (family_id, title, description, cover_image, created_by) VALUES (?, ?, ?, ?, ?)');
$stmt->execute([
$familyId,
$title,
trim($_POST['description'] ?? ''),
$cover,
$userId,
]);
$id = (int)$db->lastInsertId();
// 创建者自动成为 owner
$stmt = $db->prepare('INSERT INTO activity_members (activity_id, user_id, permission) VALUES (?, ?, "owner")');
$stmt->execute([$id, $userId]);
flash('error', '活动创建成功');
redirect('activities/show', ['id' => $id]);
}
public function edit(): void
{
$activity = $this->fetchOwnActivity((int)($_GET['id'] ?? 0));
view('activities/form', [
'activity' => $activity,
'pageTitle' => '编辑活动',
]);
}
public function update(): void
{
csrf_check();
$db = Database::get();
$activity = $this->fetchOwnActivity((int)($_POST['id'] ?? 0));
$id = (int)$activity['id'];
$title = trim($_POST['title'] ?? '');
if ($title === '') {
flash('error', '标题不能为空');
redirect('activities/edit', ['id' => $id]);
}
if (mb_strlen($title) > 200) {
flash('error', '标题不能超过 200 个字符');
redirect('activities/edit', ['id' => $id]);
}
$cover = $this->normalizeCoverUrl($_POST['cover_image'] ?? '');
$oldCover = (string)($activity['cover_image'] ?? '');
$stmt = $db->prepare('UPDATE activities SET title = ?, description = ?, cover_image = ? WHERE id = ?');
$stmt->execute([
$title,
trim($_POST['description'] ?? ''),
$cover,
$id,
]);
// 封面换过或移除后,删掉不再引用的旧文件
if ($oldCover !== '' && $oldCover !== $cover) {
$this->deleteCoverFile($oldCover);
}
flash('error', '保存成功');
redirect('activities/show', ['id' => $id]);
}
public function destroy(): void
{
csrf_check();
$db = Database::get();
$activity = $this->fetchOwnActivity((int)($_POST['id'] ?? 0));
$id = (int)$activity['id'];
// 附件表是软关联(target_type + target_id),没有外键级联,必须手工清
$files = [];
$stmt = $db->prepare('SELECT id, file_url FROM attachments
WHERE target_type = "activity" AND target_id = ?');
$stmt->execute([$id]);
foreach ($stmt->fetchAll() as $row) {
$files[] = $row['file_url'];
}
$stmt = $db->prepare('SELECT id FROM trip_nodes WHERE activity_id = ?');
$stmt->execute([$id]);
$nodeIds = array_map('intval', $stmt->fetchAll(\PDO::FETCH_COLUMN));
if (!empty($nodeIds)) {
$ph = implode(',', array_fill(0, count($nodeIds), '?'));
$stmt = $db->prepare("SELECT file_url FROM attachments
WHERE target_type = \"trip_node\" AND target_id IN ($ph)");
$stmt->execute($nodeIds);
foreach ($stmt->fetchAll() as $row) {
$files[] = $row['file_url'];
}
$stmt = $db->prepare("DELETE FROM attachments
WHERE target_type = \"trip_node\" AND target_id IN ($ph)");
$stmt->execute($nodeIds);
}
$stmt = $db->prepare('DELETE FROM attachments WHERE target_type = "activity" AND target_id = ?');
$stmt->execute([$id]);
// 评论同样是软关联
if (!empty($nodeIds)) {
$ph = implode(',', array_fill(0, count($nodeIds), '?'));
$stmt = $db->prepare("DELETE FROM comments WHERE target_type = \"trip_node\" AND target_id IN ($ph)");
$stmt->execute($nodeIds);
}
$stmt = $db->prepare('DELETE FROM comments WHERE target_type = "activity" AND target_id = ?');
$stmt->execute([$id]);
// 删活动:外键会把 trip_nodes / activity_members / node_members / expenses 一起带走
$stmt = $db->prepare('DELETE FROM activities WHERE id = ? AND family_id = ?');
$stmt->execute([$id, Auth::familyId()]);
// 数据库删干净了再删物理文件,避免删早了但事务回滚留下破损记录
// 注意:附件文件在 /uploads/attachments/ 下,不能拿「只认 covers」的
// deleteCoverFile() 去删 —— 那样判定不通过,文件会变成永远删不掉的孤儿
foreach ($files as $url) {
$this->deleteUploadFile((string)$url);
}
if (!empty($activity['cover_image'])) {
$this->deleteCoverFile((string)$activity['cover_image']);
}
flash('error', '活动已删除');
redirect('activities/index');
}
public function show(): void
{
$db = Database::get();
$id = (int)($_GET['id'] ?? 0);
$activity = $this->fetchOwnActivity($id, false);
if (!$activity) {
http_response_code(404);
exit('活动不存在');
}
$stmt = $db->prepare('SELECT tn.*, nt.name AS node_type_name, nt.icon AS node_type_icon
FROM trip_nodes tn
LEFT JOIN node_types nt ON nt.id = tn.node_type_id
WHERE tn.activity_id = ? AND tn.node_category = "plan"
ORDER BY tn.start_time ASC');
$stmt->execute([$id]);
$planNodes = $stmt->fetchAll();
$this->attachCounts($db, $planNodes);
$stmt = $db->prepare('SELECT tn.*, nt.name AS node_type_name, nt.icon AS node_type_icon
FROM trip_nodes tn
LEFT JOIN node_types nt ON nt.id = tn.node_type_id
WHERE tn.activity_id = ? AND tn.node_category = "reality"
ORDER BY tn.start_time ASC');
$stmt->execute([$id]);
$realityNodes = $stmt->fetchAll();
$this->attachCounts($db, $realityNodes);
// 计划节点 → 已生成执行记录的计划节点 id(用于在计划卡片上标记「已成行」)
$stmt = $db->prepare('SELECT original_plan_node_id FROM trip_nodes
WHERE activity_id = ? AND original_plan_node_id IS NOT NULL');
$stmt->execute([$id]);
$executedPlanIds = array_map('intval', $stmt->fetchAll(\PDO::FETCH_COLUMN));
view('activities/show', [
'activity' => $activity,
'planNodes' => $planNodes,
'realityNodes' => $realityNodes,
'executedPlanIds' => $executedPlanIds,
'expenseSummary' => Expenses::summaryForActivity($id),
'commentTree' => Comments::tree(Comments::TARGET_ACTIVITY, $id),
'pageTitle' => $activity['title'],
]);
}
/**
* 上传活动封面(接收前端裁剪后的 base64 图片)
*/
public function uploadCover(): void
{
header('Content-Type: application/json; charset=utf-8');
if (!Auth::check()) {
http_response_code(401);
echo json_encode(['error' => '未登录'], JSON_UNESCAPED_UNICODE);
exit;
}
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!hash_equals($_SESSION['_csrf'] ?? '', $token)) {
http_response_code(419);
echo json_encode(['error' => 'CSRF 校验失败'], JSON_UNESCAPED_UNICODE);
exit;
}
$body = file_get_contents('php://input');
$data = json_decode($body, true);
$base64 = $data['image'] ?? '';
if (!preg_match('/^data:image\/(png|jpeg|jpg|webp);base64,(.+)$/i', $base64, $m)) {
http_response_code(400);
echo json_encode(['error' => '无效的图片格式'], JSON_UNESCAPED_UNICODE);
exit;
}
$ext = strtolower($m[1]);
if ($ext === 'jpeg') $ext = 'jpg';
$raw = base64_decode($m[2], true);
if ($raw === false) {
http_response_code(400);
echo json_encode(['error' => '图片解码失败'], JSON_UNESCAPED_UNICODE);
exit;
}
// 封面体积上限 8MB(比头像宽松)
if (strlen($raw) > 8 * 1024 * 1024) {
http_response_code(413);
echo json_encode(['error' => '图片不能超过 8MB'], JSON_UNESCAPED_UNICODE);
exit;
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_buffer($finfo, $raw);
finfo_close($finfo);
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
http_response_code(400);
echo json_encode(['error' => '文件不是有效图片'], JSON_UNESCAPED_UNICODE);
exit;
}
$dir = dirname(__DIR__, 2) . '/uploads/covers';
if (!is_dir($dir)) mkdir($dir, 0755, true);
$filename = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
$savePath = $dir . '/' . $filename;
if (file_put_contents($savePath, $raw) === false) {
http_response_code(500);
echo json_encode(['error' => '保存文件失败'], JSON_UNESCAPED_UNICODE);
exit;
}
// 删除被替换掉的旧封面
$this->deleteCoverFile((string)($_POST['old'] ?? ''));
echo json_encode([
'url' => '/uploads/covers/' . $filename,
'size' => strlen($raw),
], JSON_UNESCAPED_UNICODE);
}
/**
* 取当前家庭下的活动;$allowMissing=true 时不存在返回 null 而不中断
*/
private function fetchOwnActivity(int $id, bool $allowMissing = false): ?array
{
if ($id <= 0) {
if ($allowMissing) return null;
http_response_code(404);
exit('活动不存在');
}
$stmt = Database::get()->prepare('SELECT * FROM activities WHERE id = ?');
$stmt->execute([$id]);
$activity = $stmt->fetch();
if (!$activity || (int)$activity['family_id'] !== Auth::familyId()) {
if ($allowMissing) return null;
http_response_code(404);
exit('活动不存在');
}
return $activity;
}
/** 只接受本站 covers 目录下的相对地址,其余一律视为未设置 */
private function normalizeCoverUrl(string $url): ?string
{
$url = trim($url);
if ($url === '') return null;
if (strpos($url, '/uploads/covers/') !== 0) return null;
if (strpos($url, '..') !== false) return null;
return $url;
}
/** 只删除本站上传目录下的文件(封面 / 附件),其余一律不碰 */
private function deleteUploadFile(string $url): void
{
$url = trim($url);
if ($url === '' || strpos($url, '..') !== false) return;
if (strpos($url, '/uploads/covers/') !== 0 && strpos($url, '/uploads/attachments/') !== 0) {
return;
}
$file = dirname(__DIR__, 2) . $url;
if (is_file($file)) @unlink($file);
}
private function deleteCoverFile(string $url): void
{
$url = trim($url);
if ($url === '' || strpos($url, '/uploads/covers/') !== 0 || strpos($url, '..') !== false) {
return;
}
$file = dirname(__DIR__, 2) . $url;
if (is_file($file)) @unlink($file);
}
/**
* 给一批节点补上「附件数 / 参与人数 / 评论数 / 花销笔数」
* 每个指标各一条 GROUP BY 查询,避免每张卡片单独查(N+1)
*/
private function attachCounts(\PDO $db, array &$nodes): void
{
if (empty($nodes)) return;
$ids = array_column($nodes, 'id');
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$counts = [
'attachment_count' => $this->groupCount($db,
"SELECT target_id, COUNT(*) AS cnt FROM attachments
WHERE target_type = 'trip_node' AND target_id IN ($placeholders)
GROUP BY target_id", $ids),
'comment_count' => $this->groupCount($db,
"SELECT target_id, COUNT(*) AS cnt FROM comments
WHERE target_type = 'trip_node' AND target_id IN ($placeholders)
GROUP BY target_id", $ids),
'member_count' => $this->groupCount($db,
"SELECT trip_node_id AS target_id, COUNT(*) AS cnt FROM node_members
WHERE trip_node_id IN ($placeholders) GROUP BY trip_node_id", $ids),
'expense_count' => $this->groupCount($db,
"SELECT trip_node_id AS target_id, COUNT(*) AS cnt FROM expenses
WHERE trip_node_id IN ($placeholders) GROUP BY trip_node_id", $ids),
];
foreach ($nodes as &$n) {
foreach ($counts as $key => $map) {
$n[$key] = $map[(int)$n['id']] ?? 0;
}
}
unset($n);
}
/** @return array<int, int> id => 计数 */
private function groupCount(\PDO $db, string $sql, array $params): array
{
$stmt = $db->prepare($sql);
$stmt->execute(array_values($params));
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[(int)$row['target_id']] = (int)$row['cnt'];
}
return $out;
}
}
+197
View File
@@ -0,0 +1,197 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Auth;
use App\Database;
class AttachmentController
{
private const ALLOWED_MIMES = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
'image/heic' => 'heic',
'application/pdf' => 'pdf',
'application/msword' => 'doc',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' => 'docx',
'application/vnd.ms-excel' => 'xls',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => 'xlsx',
'application/zip' => 'zip',
'video/mp4' => 'mp4',
'video/quicktime' => 'mov',
];
private const MAX_SIZE = 20 * 1024 * 1024;
public function node(): void
{
$this->requireLogin();
$nodeId = (int)($_GET['id'] ?? 0);
$node = $this->fetchNodeWithAccess($nodeId);
$db = Database::get();
$stmt = $db->prepare('SELECT a.*, u.name AS uploader_name
FROM attachments a
LEFT JOIN users u ON u.id = a.uploader_id
WHERE a.target_type = "trip_node" AND a.target_id = ?
ORDER BY a.created_at DESC');
$stmt->execute([$nodeId]);
$attachments = $stmt->fetchAll();
view('nodes/attachments', [
'node' => $node,
'attachments' => $attachments,
'pageTitle' => '节点附件:' . ($node['title'] ?: '无标题'),
]);
}
public function upload(): void
{
$this->requireLogin();
csrf_check();
$nodeId = (int)($_POST['node_id'] ?? 0);
$this->fetchNodeWithAccess($nodeId);
if (empty($_FILES['files']) || !is_array($_FILES['files']['name'])) {
flash('error', '未选择文件');
redirect('attachments/node', ['id' => $nodeId]);
}
$files = $this->restructureFiles($_FILES['files']);
$uploaded = 0;
$errors = [];
$db = Database::get();
$uploadDir = dirname(__DIR__, 2) . '/uploads/attachments';
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
foreach ($files as $file) {
if ($file['error'] !== UPLOAD_ERR_OK) {
$errors[] = $file['name'] . ': 上传错误';
continue;
}
if ($file['size'] > self::MAX_SIZE) {
$errors[] = $file['name'] . ': 超过 20MB';
continue;
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!isset(self::ALLOWED_MIMES[$mime])) {
$errors[] = $file['name'] . ': 不支持的类型';
continue;
}
$ext = self::ALLOWED_MIMES[$mime];
$filename = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
$dest = $uploadDir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $dest)) {
$errors[] = $file['name'] . ': 保存失败';
continue;
}
$stmt = $db->prepare('INSERT INTO attachments
(target_type, target_id, uploader_id, file_url, file_name)
VALUES ("trip_node", ?, ?, ?, ?)');
$stmt->execute([
$nodeId,
Auth::id(),
'/uploads/attachments/' . $filename,
$file['name'],
]);
$uploaded++;
}
$msg = "成功上传 {$uploaded} 个文件";
if (!empty($errors)) {
$msg .= ';失败:' . implode(',', $errors);
}
flash('error', $msg);
redirect('attachments/node', ['id' => $nodeId]);
}
public function destroy(): void
{
$this->requireLogin();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$db = Database::get();
$stmt = $db->prepare('SELECT * FROM attachments WHERE id = ? AND target_type = "trip_node"');
$stmt->execute([$id]);
$att = $stmt->fetch();
if (!$att) {
flash('error', '附件不存在');
redirect('activities/index');
}
$this->fetchNodeWithAccess((int)$att['target_id']);
$url = $att['file_url'];
if (strpos($url, '/uploads/attachments/') === 0) {
$file = dirname(__DIR__, 2) . $url;
if (is_file($file)) @unlink($file);
}
$stmt = $db->prepare('DELETE FROM attachments WHERE id = ?');
$stmt->execute([$id]);
flash('error', '附件已删除');
redirect('attachments/node', ['id' => (int)$att['target_id']]);
}
private function restructureFiles(array $files): array
{
$result = [];
$count = count($files['name']);
for ($i = 0; $i < $count; $i++) {
if (empty($files['name'][$i])) continue;
$result[] = [
'name' => $files['name'][$i],
'type' => $files['type'][$i],
'tmp_name' => $files['tmp_name'][$i],
'error' => $files['error'][$i],
'size' => $files['size'][$i],
];
}
return $result;
}
private function fetchNodeWithAccess(int $nodeId): array
{
$db = Database::get();
$stmt = $db->prepare('
SELECT tn.*, a.family_id, a.id AS activity_id, a.title AS activity_title
FROM trip_nodes tn
JOIN activities a ON a.id = tn.activity_id
WHERE tn.id = ?
');
$stmt->execute([$nodeId]);
$node = $stmt->fetch();
if (!$node) {
http_response_code(404);
exit('节点不存在');
}
if ((int)$node['family_id'] !== Auth::familyId()) {
http_response_code(403);
exit('无权访问此节点');
}
return $node;
}
private function requireLogin(): void
{
if (!Auth::check()) {
redirect('auth/login');
}
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Auth;
class AuthController
{
public function login(): void
{
if (Auth::check()) {
redirect('activities/index');
}
view('auth/login', ['pageTitle' => '登录']);
}
public function doLogin(): void
{
csrf_check();
$phone = trim($_POST['phone'] ?? '');
$password = (string)($_POST['password'] ?? '');
$user = Auth::attempt($phone, $password);
if (!$user) {
flash('error', '手机号或密码错误');
redirect('auth/login');
}
Auth::login($user);
redirect('activities/index');
}
public function logout(): void
{
Auth::logout();
redirect('auth/login');
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Access;
use App\Auth;
use App\Comments;
use App\Database;
/**
* 评论:活动级和节点级共用一套(靠 target_type 区分)
*/
class CommentController
{
public function store(): void
{
csrf_check();
$targetType = (string)($_POST['target_type'] ?? '');
$targetId = (int)($_POST['target_id'] ?? 0);
$check = Access::target($targetType, $targetId);
if (!$check['ok']) {
http_response_code(404);
exit($check['error']);
}
$parentId = (int)($_POST['parent_id'] ?? 0);
$result = Comments::create(
$targetType,
$targetId,
(int)Auth::id(),
(string)($_POST['content'] ?? ''),
$parentId > 0 ? $parentId : null
);
flash('error', $result['ok'] ? '评论已发布' : (string)$result['error']);
$this->backTo($targetType, $targetId);
}
public function destroy(): void
{
csrf_check();
$targetType = (string)($_POST['target_type'] ?? '');
$targetId = (int)($_POST['target_id'] ?? 0);
$check = Access::target($targetType, $targetId);
if (!$check['ok']) {
http_response_code(404);
exit($check['error']);
}
$id = (int)($_POST['id'] ?? 0);
// 评论必须真的挂在这个目标上,否则拿别人的 id 也能删
$stmt = Database::get()->prepare('SELECT id FROM comments
WHERE id = ? AND target_type = ? AND target_id = ?');
$stmt->execute([$id, $targetType, $targetId]);
if (!$stmt->fetch()) {
http_response_code(404);
exit('评论不存在');
}
if (Comments::delete($id, (int)Auth::id(), Auth::isAdmin())) {
flash('error', '评论已删除');
} else {
flash('error', '只能删除自己发表的评论');
}
$this->backTo($targetType, $targetId);
}
/** 回到评论所属的页面并定位到评论区 */
private function backTo(string $targetType, int $targetId): void
{
if ($targetType === Comments::TARGET_ACTIVITY) {
redirect('activities/show', ['id' => $targetId], 'comments');
}
redirect('nodes/show', ['id' => $targetId], 'comments');
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Access;
use App\Expenses;
use App\Database;
/**
* 花销记账:一笔花销挂在执行/计划节点上,按人分摊
*/
class ExpenseController
{
public function store(): void
{
csrf_check();
$nodeId = (int)($_POST['node_id'] ?? 0);
$node = Access::node($nodeId); // 归属校验,非本家庭一律 404
$splitIds = $_POST['split_user_ids'] ?? [];
if (!is_array($splitIds)) {
$splitIds = [$splitIds];
}
$splitIds = array_map('intval', $splitIds);
$result = Expenses::create(
$nodeId,
(int)($_POST['payer_user_id'] ?? 0),
(string)($_POST['total_amount'] ?? ''),
(string)($_POST['currency'] ?? 'CNY'),
(string)($_POST['category'] ?? '其他'),
(string)($_POST['description'] ?? ''),
(string)($_POST['is_budget'] ?? '0') === '1',
$splitIds
);
if ($result['ok']) {
$n = count($splitIds) > 0 ? count($splitIds) : 1;
flash('error', '已记一笔,按 ' . $n . ' 人分摊');
} else {
flash('error', (string)$result['error']);
}
redirect('nodes/show', ['id' => (int)$node['id']], 'expenses');
}
public function destroy(): void
{
csrf_check();
$id = (int)($_POST['id'] ?? 0);
$stmt = Database::get()->prepare('
SELECT e.id, tn.id AS node_id
FROM expenses e
JOIN trip_nodes tn ON tn.id = e.trip_node_id
JOIN activities a ON a.id = tn.activity_id
WHERE e.id = ? AND a.family_id = ?');
$stmt->execute([$id, Access::familyId()]);
$row = $stmt->fetch();
if (!$row) {
http_response_code(404);
exit('花销记录不存在');
}
Expenses::delete($id);
flash('error', '已删除这笔花销');
redirect('nodes/show', ['id' => (int)$row['node_id']], 'expenses');
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Access;
use App\Auth;
use App\Members;
/**
* 节点参与人:加人 / 改角色情绪 / 移出 / 一键把全家加进来
*/
class MemberController
{
public function store(): void
{
csrf_check();
$nodeId = (int)($_POST['node_id'] ?? 0);
Access::node($nodeId); // 归属校验,非本家庭一律 404
$roleTagIds = $_POST['role_tag_ids'] ?? [];
if (!is_array($roleTagIds)) {
$roleTagIds = [$roleTagIds];
}
$result = Members::save(
$nodeId,
(int)($_POST['user_id'] ?? 0),
array_map('intval', $roleTagIds),
trim((string)($_POST['emotion'] ?? '')),
(string)($_POST['check_in_time'] ?? '')
);
flash('error', $result['ok'] ? '已保存参与人' : $result['error']);
redirect('nodes/show', ['id' => $nodeId], 'members');
}
public function destroy(): void
{
csrf_check();
$nodeId = (int)($_POST['node_id'] ?? 0);
Access::node($nodeId);
Members::remove($nodeId, (int)($_POST['user_id'] ?? 0));
flash('error', '已移出该参与人');
redirect('nodes/show', ['id' => $nodeId], 'members');
}
/** 把家庭里的成员一次性全部加入这个节点(已存在的会被更新,不会重复) */
public function addAll(): void
{
csrf_check();
$nodeId = (int)($_POST['node_id'] ?? 0);
$node = Access::node($nodeId);
$users = Members::familyUsers((int)$node['family_id']);
$ids = array_map(static fn($u) => (int)$u['id'], $users);
Members::addMany($nodeId, $ids);
flash('error', '已把 ' . count($ids) . ' 位家庭成员加入本节点');
redirect('nodes/show', ['id' => $nodeId], 'members');
}
}
+513
View File
@@ -0,0 +1,513 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Access;
use App\Auth;
use App\Comments;
use App\Database;
use App\Expenses;
use App\Members;
class NodeController
{
/** 计划节点只可能有 planned / completed / cancelled */
private const PLAN_STATUSES = ['planned', 'completed', 'cancelled'];
/** 执行节点本身就是"已完成",额外允许 cancelled */
private const REALITY_STATUSES = ['completed', 'cancelled', 'ongoing', 'planned'];
public function create(): void
{
$db = Database::get();
$activityId = (int)($_GET['activity_id'] ?? 0);
$category = $_GET['category'] ?? 'plan';
$category = in_array($category, ['plan', 'reality'], true) ? $category : 'plan';
$activity = $this->fetchOwnActivity($activityId);
view('nodes/form', $this->formData($db, $activity, $category, null));
}
public function edit(): void
{
$db = Database::get();
$node = $this->fetchOwnNode((int)($_GET['id'] ?? 0));
$stmt = $db->prepare('SELECT * FROM activities WHERE id = ?');
$stmt->execute([$node['activity_id']]);
$activity = $stmt->fetch();
view('nodes/form', $this->formData($db, $activity, $node['node_category'], $node));
}
public function store(): void
{
csrf_check();
$db = Database::get();
$activity = $this->fetchOwnActivity((int)($_POST['activity_id'] ?? 0));
$category = $_POST['node_category'] ?? 'plan';
if (!in_array($category, ['plan', 'reality'], true)) {
exit('无效的节点分类');
}
$input = $this->collectInput($db, $activity, $category);
if ($input['error'] !== null) {
flash('error', $input['error']);
redirect('nodes/create', ['activity_id' => $activity['id'], 'category' => $category]);
}
$data = $input['data'];
$sql = 'INSERT INTO trip_nodes
(activity_id, node_type_id, node_category, original_plan_node_id,
start_time, end_time, duration_minutes,
start_location, end_location,
title, description, travel_mode_id, vehicle_id,
line_name, carrier, trip_number, seat_info, gate, status, cancel_reason)
VALUES
(:activity_id, :node_type_id, :node_category, :original_plan_node_id,
:start_time, :end_time, :duration_minutes,
:start_location, :end_location,
:title, :description, :travel_mode_id, :vehicle_id,
:line_name, :carrier, :trip_number, :seat_info, :gate, :status, :cancel_reason)';
$stmt = $db->prepare($sql);
$stmt->execute($data);
flash('error', '节点已保存');
redirect('activities/show', ['id' => (int)$activity['id']]);
}
public function update(): void
{
csrf_check();
$db = Database::get();
$node = $this->fetchOwnNode((int)($_POST['id'] ?? 0));
$category = $node['node_category'];
$stmt = $db->prepare('SELECT * FROM activities WHERE id = ?');
$stmt->execute([$node['activity_id']]);
$activity = $stmt->fetch();
$input = $this->collectInput($db, $activity, $category, $node);
if ($input['error'] !== null) {
flash('error', $input['error']);
redirect('nodes/edit', ['id' => (int)$node['id']]);
}
$data = $input['data'];
unset($data['activity_id'], $data['node_category'], $data['original_plan_node_id']);
$data['id'] = (int)$node['id'];
$sql = 'UPDATE trip_nodes SET
node_type_id = :node_type_id,
start_time = :start_time, end_time = :end_time, duration_minutes = :duration_minutes,
start_location = :start_location, end_location = :end_location,
title = :title, description = :description,
travel_mode_id = :travel_mode_id, vehicle_id = :vehicle_id,
line_name = :line_name, carrier = :carrier, trip_number = :trip_number,
seat_info = :seat_info, gate = :gate, status = :status, cancel_reason = :cancel_reason
WHERE id = :id';
$stmt = $db->prepare($sql);
$stmt->execute($data);
flash('error', '保存成功');
redirect('activities/show', ['id' => (int)$node['activity_id']]);
}
/**
* 卡片上的状态快捷切换(计划节点:标记完成 / 撤销完成)
*/
public function status(): void
{
csrf_check();
$db = Database::get();
$node = $this->fetchOwnNode((int)($_POST['id'] ?? 0));
$status = (string)($_POST['status'] ?? '');
$allowed = $node['node_category'] === 'plan' ? self::PLAN_STATUSES : self::REALITY_STATUSES;
if (!in_array($status, $allowed, true)) {
flash('error', '无效的状态');
redirect('activities/show', ['id' => (int)$node['activity_id']]);
}
$reason = trim($_POST['cancel_reason'] ?? '');
$stmt = $db->prepare('UPDATE trip_nodes SET status = ?, cancel_reason = ? WHERE id = ?');
$stmt->execute([
$status,
$status === 'cancelled' ? ($reason !== '' ? $reason : null) : null,
(int)$node['id'],
]);
$labels = ['planned' => '已恢复为待执行', 'completed' => '已标记为完成', 'cancelled' => '已取消', 'ongoing' => '已标记为进行中'];
flash('error', $labels[$status] ?? '状态已更新');
redirect('activities/show', ['id' => (int)$node['activity_id']]);
}
/**
* 用计划节点生成一条执行记录(复制字段,保留 original_plan_node_id 指向计划节点)
*/
public function fromPlan(): void
{
csrf_check();
$db = Database::get();
$plan = $this->fetchOwnNode((int)($_POST['plan_node_id'] ?? 0));
if ($plan['node_category'] !== 'plan') {
flash('error', '只有计划节点可以生成为执行记录');
redirect('activities/show', ['id' => (int)$plan['activity_id']]);
}
// 已经生成过就不重复生成
$chk = $db->prepare('SELECT id FROM trip_nodes WHERE original_plan_node_id = ? LIMIT 1');
$chk->execute([(int)$plan['id']]);
$exists = $chk->fetchColumn();
if ($exists) {
flash('error', '该计划节点已经有执行记录了');
redirect('activities/show', ['id' => (int)$plan['activity_id']]);
}
$stmt = $db->prepare('INSERT INTO trip_nodes
(activity_id, node_type_id, node_category, original_plan_node_id,
start_time, end_time, duration_minutes,
start_location, end_location,
title, description, travel_mode_id, vehicle_id,
line_name, carrier, trip_number, seat_info, gate, status)
VALUES (?, ?, "reality", ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, "completed")');
$stmt->execute([
(int)$plan['activity_id'],
(int)$plan['node_type_id'],
(int)$plan['id'],
$plan['start_time'],
$plan['end_time'],
$plan['duration_minutes'],
$plan['start_location'],
$plan['end_location'],
$plan['title'],
$plan['description'],
$plan['travel_mode_id'],
$plan['vehicle_id'],
$plan['line_name'],
$plan['carrier'],
$plan['trip_number'],
$plan['seat_info'],
$plan['gate'],
]);
flash('error', '已根据计划节点生成执行记录,可继续编辑补充实际情况');
redirect('activities/show', ['id' => (int)$plan['activity_id']]);
}
public function destroy(): void
{
csrf_check();
$db = Database::get();
$node = $this->fetchOwnNode((int)($_POST['id'] ?? 0));
// 软关联的附件先查出来,删完数据再删文件
$stmt = $db->prepare('SELECT file_url FROM attachments
WHERE target_type = "trip_node" AND target_id = ?');
$stmt->execute([(int)$node['id']]);
$files = $stmt->fetchAll(\PDO::FETCH_COLUMN);
$stmt = $db->prepare('DELETE FROM attachments WHERE target_type = "trip_node" AND target_id = ?');
$stmt->execute([(int)$node['id']]);
$stmt = $db->prepare('DELETE FROM comments WHERE target_type = "trip_node" AND target_id = ?');
$stmt->execute([(int)$node['id']]);
// 若有执行记录指向本计划节点,外键是 ON DELETE SET NULL,会自动置空
$stmt = $db->prepare('DELETE FROM trip_nodes WHERE id = ?');
$stmt->execute([(int)$node['id']]);
foreach ($files as $url) {
$this->deleteAttachmentFile((string)$url);
}
flash('error', '节点已删除');
redirect('activities/show', ['id' => (int)$node['activity_id']]);
}
/**
* 节点详情页:把挂在这个节点上的参与人、花销、讨论集中到一页
*/
public function show(): void
{
$db = Database::get();
$nodeId = (int)($_GET['id'] ?? 0);
$node = Access::node($nodeId); // 含 family_id / activity_id / activity_title
// 节点类型(本家庭)
$stmt = $db->prepare('SELECT name, icon FROM node_types WHERE id = ?');
$stmt->execute([(int)$node['node_type_id']]);
$type = $stmt->fetch();
if (!$type) $type = ['name' => '未分类', 'icon' => '·'];
// 出行方式(连同车辆信息拼成一行展示文本)
$travelModeLabel = '';
if (!empty($node['travel_mode_id'])) {
$stmt = $db->prepare('
SELECT tm.mode_type, v.plate_number, v.model_name
FROM travel_modes tm
LEFT JOIN vehicles v ON v.id = tm.vehicle_id
WHERE tm.id = ?');
$stmt->execute([(int)$node['travel_mode_id']]);
$tm = $stmt->fetch();
if ($tm) {
$label = TravelModeController::MODE_LABELS[$tm['mode_type']] ?? $tm['mode_type'];
if (!empty($tm['plate_number'])) {
$label .= ' · ' . $tm['plate_number'];
} elseif (!empty($tm['model_name'])) {
$label .= ' · ' . $tm['model_name'];
}
// 线路名属于节点自身(trip_nodes.line_name),不是出行方式表的列
if (!empty($node['line_name'])) {
$label .= ' · ' . $node['line_name'];
}
$travelModeLabel = $label;
}
}
$stmt = $db->prepare('SELECT COUNT(*) FROM attachments
WHERE target_type = "trip_node" AND target_id = ?');
$stmt->execute([$nodeId]);
$attachmentCount = (int)$stmt->fetchColumn();
$familyId = (int)$node['family_id'];
view('nodes/show', [
'node' => array_merge($node, [
'node_type_name' => (string)$type['name'],
'node_type_icon' => (string)$type['icon'],
'travel_mode_label' => $travelModeLabel,
]),
'attachmentCount' => $attachmentCount,
'members' => Members::forNode($nodeId),
'familyUsers' => Members::familyUsers($familyId),
'roleTags' => Members::roleTagMap($familyId),
'emotions' => Members::EMOTIONS,
'expenses' => Expenses::forNode($nodeId),
'expenseSummary' => Expenses::summaryForNode($nodeId),
'categories' => Expenses::CATEGORIES,
'currencies' => Expenses::currencyLabels(),
'expenseSymbols' => Expenses::SYMBOLS,
'currentUserId' => (int)Auth::id(),
'commentTree' => Comments::tree(Comments::TARGET_NODE, $nodeId),
'pageTitle' => $node['title'] !== null && $node['title'] !== ''
? (string)$node['title']
: '节点详情',
]);
}
// ------------------------------------------------------------------
// 内部辅助
// ------------------------------------------------------------------
/** 组装 nodes/form 模板所需数据 */
private function formData(\PDO $db, array $activity, string $category, ?array $node): array
{
$stmt = $db->prepare('SELECT * FROM node_types WHERE family_id = ? ORDER BY is_system DESC, id ASC');
$stmt->execute([$activity['family_id']]);
$nodeTypes = $stmt->fetchAll();
$stmt = $db->prepare('
SELECT tm.*, v.plate_number, v.model_name
FROM travel_modes tm
LEFT JOIN vehicles v ON v.id = tm.vehicle_id
WHERE tm.family_id = ?
ORDER BY FIELD(tm.mode_type, "drive_own","drive_rental","train","plane","bus","subway","taxi","bike","walk","ship","other"), tm.id ASC
');
$stmt->execute([$activity['family_id']]);
$travelModes = $stmt->fetchAll();
// 执行节点可以关联计划节点(编辑已有执行节点时不再改关联关系)
$planNodes = [];
if ($category === 'reality' && $node === null) {
$s = $db->prepare('SELECT id, title, start_time FROM trip_nodes WHERE activity_id = ? AND node_category = "plan" ORDER BY start_time');
$s->execute([$activity['id']]);
$planNodes = $s->fetchAll();
}
$isEdit = $node !== null;
return [
'modeLabels' => TravelModeController::MODE_LABELS,
'activity' => $activity,
'category' => $category,
'nodeTypes' => $nodeTypes,
'travelModes' => $travelModes,
'planNodes' => $planNodes,
'node' => $node,
'isEdit' => $isEdit,
'pageTitle' => ($isEdit ? '编辑' : '新建') . ($category === 'plan' ? '计划' : '执行') . '节点',
];
}
/**
* 校验并整理表单输入
* @return array{error: ?string, data: array}
*/
private function collectInput(\PDO $db, array $activity, string $category, ?array $node = null): array
{
$startTime = $this->normalizeDateTime((string)($_POST['start_time'] ?? ''));
$endTime = $this->normalizeDateTime((string)($_POST['end_time'] ?? ''));
if ($startTime === null || $endTime === null) {
return ['error' => '开始和结束时间必填', 'data' => []];
}
if (strtotime($endTime) < strtotime($startTime)) {
return ['error' => '结束时间不能早于开始时间', 'data' => []];
}
$nodeTypeId = (int)($_POST['node_type_id'] ?? 0);
$chk = $db->prepare('SELECT id FROM node_types WHERE id = ? AND family_id = ?');
$chk->execute([$nodeTypeId, $activity['family_id']]);
if (!$chk->fetch()) {
return ['error' => '节点类型不存在', 'data' => []];
}
$travelModeId = !empty($_POST['travel_mode_id']) ? (int)$_POST['travel_mode_id'] : null;
$vehicleId = null;
if ($travelModeId) {
$s = $db->prepare('SELECT vehicle_id FROM travel_modes WHERE id = ? AND family_id = ?');
$s->execute([$travelModeId, $activity['family_id']]);
$row = $s->fetch();
if (!$row) {
return ['error' => '出行方式不存在', 'data' => []];
}
$vehicleId = $row['vehicle_id'] !== null ? (int)$row['vehicle_id'] : null;
}
// 状态:计划节点默认待执行,执行节点默认已完成
$defaultStatus = $category === 'plan' ? 'planned' : 'completed';
$status = (string)($_POST['status'] ?? $defaultStatus);
$allowed = $category === 'plan' ? self::PLAN_STATUSES : self::REALITY_STATUSES;
if (!in_array($status, $allowed, true)) {
$status = $node['status'] ?? $defaultStatus;
}
$originalPlanId = null;
if ($category === 'reality' && $node === null && !empty($_POST['original_plan_node_id'])) {
$originalPlanId = (int)$_POST['original_plan_node_id'];
// 关联的计划节点必须属于同一个活动,否则视为未关联
$s = $db->prepare('SELECT id FROM trip_nodes WHERE id = ? AND activity_id = ? AND node_category = "plan"');
$s->execute([$originalPlanId, $activity['id']]);
if (!$s->fetch()) {
$originalPlanId = null;
}
}
$data = [
'activity_id' => (int)$activity['id'],
'node_type_id' => $nodeTypeId,
'node_category' => $category,
'original_plan_node_id' => $originalPlanId,
'start_time' => $startTime,
'end_time' => $endTime,
'duration_minutes' => $this->calcDuration($startTime, $endTime),
'start_location' => $this->buildLocation('start'),
'end_location' => $this->buildLocation('end'),
'title' => trim($_POST['title'] ?? ''),
'description' => trim($_POST['description'] ?? ''),
'travel_mode_id' => $travelModeId,
'vehicle_id' => $vehicleId,
'line_name' => $this->strOrNull('line_name'),
'carrier' => $this->strOrNull('carrier'),
'trip_number' => $this->strOrNull('trip_number'),
'seat_info' => $this->strOrNull('seat_info'),
'gate' => $this->strOrNull('gate'),
'status' => $status,
'cancel_reason' => $status === 'cancelled' ? $this->strOrNull('cancel_reason') : null,
];
return ['error' => null, 'data' => $data];
}
/** datetime-local 传上来是 2026-09-12T10:30,统一成 MySQL 认的 "Y-m-d H:i:s" */
private function normalizeDateTime(string $value): ?string
{
$value = trim($value);
if ($value === '') return null;
$value = str_replace('T', ' ', $value);
$ts = strtotime($value);
if ($ts === false) return null;
return date('Y-m-d H:i:s', $ts);
}
private function fetchOwnActivity(int $id): array
{
if ($id <= 0) {
http_response_code(404);
exit('活动不存在');
}
$stmt = Database::get()->prepare('SELECT * FROM activities WHERE id = ?');
$stmt->execute([$id]);
$activity = $stmt->fetch();
if (!$activity || (int)$activity['family_id'] !== Auth::familyId()) {
http_response_code(404);
exit('活动不存在');
}
return $activity;
}
private function fetchOwnNode(int $id): array
{
if ($id <= 0) {
http_response_code(404);
exit('节点不存在');
}
$stmt = Database::get()->prepare('
SELECT tn.*, a.family_id
FROM trip_nodes tn
JOIN activities a ON a.id = tn.activity_id
WHERE tn.id = ?');
$stmt->execute([$id]);
$node = $stmt->fetch();
if (!$node || (int)$node['family_id'] !== Auth::familyId()) {
http_response_code(404);
exit('节点不存在');
}
return $node;
}
private function deleteAttachmentFile(string $url): void
{
if (strpos($url, '/uploads/attachments/') !== 0 || strpos($url, '..') !== false) {
return;
}
$file = dirname(__DIR__, 2) . $url;
if (is_file($file)) @unlink($file);
}
private function calcDuration(string $start, string $end): int
{
$s = strtotime($start);
$e = strtotime($end);
return max(0, (int)round(($e - $s) / 60));
}
private function buildLocation(string $which): ?string
{
$name = trim($_POST["{$which}_location_name"] ?? '');
$address = trim($_POST["{$which}_location_address"] ?? '');
if ($name === '' && $address === '') return null;
$lat = $_POST["{$which}_location_lat"] ?? null;
$lng = $_POST["{$which}_location_lng"] ?? null;
return json_encode([
'name' => $name,
'address' => $address,
'lat' => ($lat === '' || $lat === null) ? null : $lat,
'lng' => ($lng === '' || $lng === null) ? null : $lng,
], JSON_UNESCAPED_UNICODE);
}
private function strOrNull(string $key): ?string
{
$v = trim($_POST[$key] ?? '');
return $v === '' ? null : $v;
}
}
+190
View File
@@ -0,0 +1,190 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Auth;
use App\Database;
class NodeTypeController
{
public function index(): void
{
$this->requireAdmin();
$db = Database::get();
$familyId = Auth::familyId();
$stmt = $db->prepare('SELECT * FROM node_types WHERE family_id = ? ORDER BY is_system DESC, id ASC');
$stmt->execute([$familyId]);
$types = $stmt->fetchAll();
// 统计每个类型被引用的节点数
foreach ($types as &$t) {
$s = $db->prepare('SELECT COUNT(*) FROM trip_nodes WHERE node_type_id = ?');
$s->execute([$t['id']]);
$t['node_count'] = (int)$s->fetchColumn();
}
unset($t);
view('node_types/index', [
'types' => $types,
'pageTitle' => '节点类型管理',
]);
}
public function create(): void
{
$this->requireAdmin();
view('node_types/form', [
'type' => null,
'pageTitle' => '新增节点类型',
]);
}
public function store(): void
{
$this->requireAdmin();
csrf_check();
$db = Database::get();
$familyId = Auth::familyId();
$name = trim($_POST['name'] ?? '');
$icon = trim($_POST['icon'] ?? '');
if ($name === '') {
flash('error', '名称不能为空');
redirect('node_types/create');
}
if (mb_strlen($name) > 30) {
flash('error', '名称不能超过 30 个字符');
redirect('node_types/create');
}
// 唯一性校验
$chk = $db->prepare('SELECT id FROM node_types WHERE family_id = ? AND name = ? LIMIT 1');
$chk->execute([$familyId, $name]);
if ($chk->fetch()) {
flash('error', '该类型名称已存在');
redirect('node_types/create');
}
$stmt = $db->prepare('INSERT INTO node_types (family_id, name, icon, is_system) VALUES (?, ?, ?, FALSE)');
$stmt->execute([$familyId, $name, $icon ?: null]);
flash('error', '创建成功');
redirect('node_types/index');
}
public function edit(): void
{
$this->requireAdmin();
$id = (int)($_GET['id'] ?? 0);
$db = Database::get();
$stmt = $db->prepare('SELECT * FROM node_types WHERE id = ? AND family_id = ?');
$stmt->execute([$id, Auth::familyId()]);
$type = $stmt->fetch();
if (!$type) {
http_response_code(404);
exit('节点类型不存在');
}
view('node_types/form', [
'type' => $type,
'pageTitle' => '编辑节点类型',
]);
}
public function update(): void
{
$this->requireAdmin();
csrf_check();
$db = Database::get();
$familyId = Auth::familyId();
$id = (int)($_POST['id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM node_types WHERE id = ? AND family_id = ?');
$stmt->execute([$id, $familyId]);
$type = $stmt->fetch();
if (!$type) {
http_response_code(404);
exit('节点类型不存在');
}
$name = trim($_POST['name'] ?? '');
$icon = trim($_POST['icon'] ?? '');
if ($name === '') {
flash('error', '名称不能为空');
redirect('node_types/edit', ['id' => $id]);
}
if (mb_strlen($name) > 30) {
flash('error', '名称不能超过 30 个字符');
redirect('node_types/edit', ['id' => $id]);
}
// 唯一性检查(排除自身)
$chk = $db->prepare('SELECT id FROM node_types WHERE family_id = ? AND name = ? AND id != ? LIMIT 1');
$chk->execute([$familyId, $name, $id]);
if ($chk->fetch()) {
flash('error', '该类型名称已被其他类型使用');
redirect('node_types/edit', ['id' => $id]);
}
$stmt = $db->prepare('UPDATE node_types SET name = ?, icon = ? WHERE id = ?');
$stmt->execute([$name, $icon ?: null, $id]);
flash('error', '保存成功');
redirect('node_types/index');
}
public function destroy(): void
{
$this->requireAdmin();
csrf_check();
$db = Database::get();
$id = (int)($_POST['id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM node_types WHERE id = ? AND family_id = ?');
$stmt->execute([$id, Auth::familyId()]);
$type = $stmt->fetch();
if (!$type) {
flash('error', '节点类型不存在');
redirect('node_types/index');
}
if ((int)$type['is_system'] === 1) {
flash('error', '系统内置类型不能删除');
redirect('node_types/index');
}
// 检查是否被引用
$cnt = $db->prepare('SELECT COUNT(*) FROM trip_nodes WHERE node_type_id = ?');
$cnt->execute([$id]);
$usedCount = (int)$cnt->fetchColumn();
if ($usedCount > 0) {
flash('error', "该类型已被 {$usedCount} 个节点使用,无法删除");
redirect('node_types/index');
}
$stmt = $db->prepare('DELETE FROM node_types WHERE id = ?');
$stmt->execute([$id]);
flash('error', '删除成功');
redirect('node_types/index');
}
private function requireAdmin(): void
{
if (!Auth::isAdmin()) {
http_response_code(403);
exit('仅管理员可访问此页面');
}
}
}
+182
View File
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Access;
use App\Auth;
use App\Database;
/**
* 角色标签库(司机 / 摄影担当 / 记账员 …)
* 节点参与人就是从这堆标签里挑
*/
class RoleTagController
{
public function index(): void
{
Access::requireAdmin();
$db = Database::get();
$familyId = Access::familyId();
$stmt = $db->prepare('SELECT * FROM role_tags WHERE family_id = ? ORDER BY id ASC');
$stmt->execute([$familyId]);
$tags = $stmt->fetchAll();
// 每个标签被多少个节点的参与人引用(role_tag_ids 是 JSON 数组)
foreach ($tags as &$t) {
$s = $db->prepare('SELECT COUNT(*) FROM node_members
WHERE role_tag_ids IS NOT NULL AND JSON_CONTAINS(role_tag_ids, ?)');
$s->execute([(string)(int)$t['id']]);
$t['used_count'] = (int)$s->fetchColumn();
}
unset($t);
view('role_tags/index', [
'tags' => $tags,
'pageTitle' => '角色标签管理',
]);
}
public function create(): void
{
Access::requireAdmin();
view('role_tags/form', ['tag' => null, 'pageTitle' => '新增角色标签']);
}
public function store(): void
{
Access::requireAdmin();
csrf_check();
$db = Database::get();
$familyId = Access::familyId();
$name = trim($_POST['name'] ?? '');
$icon = trim($_POST['icon'] ?? '');
if ($name === '') {
flash('error', '名称不能为空');
redirect('role_tags/create');
}
if (mb_strlen($name) > 30) {
flash('error', '名称不能超过 30 个字符');
redirect('role_tags/create');
}
if ($icon !== '' && mb_strlen($icon) > 4) {
flash('error', '图标最多放一个表情');
redirect('role_tags/create');
}
$chk = $db->prepare('SELECT id FROM role_tags WHERE family_id = ? AND name = ? LIMIT 1');
$chk->execute([$familyId, $name]);
if ($chk->fetch()) {
flash('error', '该角色名称已存在');
redirect('role_tags/create');
}
$stmt = $db->prepare('INSERT INTO role_tags (family_id, name, icon) VALUES (?, ?, ?)');
$stmt->execute([$familyId, $name, $icon ?: null]);
flash('error', '创建成功');
redirect('role_tags/index');
}
public function edit(): void
{
Access::requireAdmin();
view('role_tags/form', [
'tag' => $this->fetchTag((int)($_GET['id'] ?? 0)),
'pageTitle' => '编辑角色标签',
]);
}
public function update(): void
{
Access::requireAdmin();
csrf_check();
$db = Database::get();
$familyId = Access::familyId();
$tag = $this->fetchTag((int)($_POST['id'] ?? 0));
$id = (int)$tag['id'];
$name = trim($_POST['name'] ?? '');
$icon = trim($_POST['icon'] ?? '');
if ($name === '') {
flash('error', '名称不能为空');
redirect('role_tags/edit', ['id' => $id]);
}
if (mb_strlen($name) > 30) {
flash('error', '名称不能超过 30 个字符');
redirect('role_tags/edit', ['id' => $id]);
}
if ($icon !== '' && mb_strlen($icon) > 4) {
flash('error', '图标最多放一个表情');
redirect('role_tags/edit', ['id' => $id]);
}
$chk = $db->prepare('SELECT id FROM role_tags WHERE family_id = ? AND name = ? AND id != ? LIMIT 1');
$chk->execute([$familyId, $name, $id]);
if ($chk->fetch()) {
flash('error', '该角色名称已被其他角色使用');
redirect('role_tags/edit', ['id' => $id]);
}
$stmt = $db->prepare('UPDATE role_tags SET name = ?, icon = ? WHERE id = ?');
$stmt->execute([$name, $icon ?: null, $id]);
flash('error', '保存成功');
redirect('role_tags/index');
}
/**
* 删除角色标签:被引用时不阻止,而是先把它从各节点参与人身上摘掉
* (角色标签是轻量配置,删不掉会让人很憋屈;节点参与人本身不受影响)
*/
public function destroy(): void
{
Access::requireAdmin();
csrf_check();
$db = Database::get();
$tag = $this->fetchTag((int)($_POST['id'] ?? 0));
$id = (int)$tag['id'];
$stmt = $db->prepare('SELECT id, role_tag_ids FROM node_members
WHERE role_tag_ids IS NOT NULL AND JSON_CONTAINS(role_tag_ids, ?)');
$stmt->execute([(string)$id]);
$affected = $stmt->fetchAll();
$upd = $db->prepare('UPDATE node_members SET role_tag_ids = ? WHERE id = ?');
foreach ($affected as $row) {
$ids = json_decode((string)$row['role_tag_ids'], true);
$ids = is_array($ids) ? array_filter(array_map('intval', $ids), static fn($v) => $v !== $id) : [];
$upd->execute([$ids ? json_encode(array_values($ids)) : null, (int)$row['id']]);
}
$stmt = $db->prepare('DELETE FROM role_tags WHERE id = ? AND family_id = ?');
$stmt->execute([$id, Access::familyId()]);
$msg = '删除成功';
if ($affected) {
$msg .= ';已同时从 ' . count($affected) . ' 处参与人记录中移除该角色';
}
flash('error', $msg);
redirect('role_tags/index');
}
private function fetchTag(int $id): array
{
$stmt = Database::get()->prepare('SELECT * FROM role_tags WHERE id = ? AND family_id = ?');
$stmt->execute([$id, Access::familyId()]);
$tag = $stmt->fetch();
if (!$tag) {
http_response_code(404);
exit('角色标签不存在');
}
return $tag;
}
}
+231
View File
@@ -0,0 +1,231 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Auth;
use App\Database;
class TravelModeController
{
public const MODE_LABELS = [
'drive_own' => '自驾(自有车)',
'drive_rental' => '自驾(租车)',
'train' => '火车/高铁',
'plane' => '飞机',
'bus' => '大巴/公交',
'subway' => '地铁',
'taxi' => '出租车/网约车',
'bike' => '骑行',
'walk' => '步行',
'ship' => '轮渡/游船',
'other' => '其他',
];
public function index(): void
{
$this->requireLogin();
$db = Database::get();
$familyId = Auth::familyId();
$stmt = $db->prepare('
SELECT tm.*, v.plate_number, v.model_name
FROM travel_modes tm
LEFT JOIN vehicles v ON v.id = tm.vehicle_id
WHERE tm.family_id = ?
ORDER BY FIELD(tm.mode_type, "drive_own","drive_rental","train","plane","bus","subway","taxi","bike","walk","ship","other"), tm.id ASC
');
$stmt->execute([$familyId]);
$modes = $stmt->fetchAll();
foreach ($modes as &$m) {
$s = $db->prepare('SELECT COUNT(*) FROM trip_nodes WHERE travel_mode_id = ?');
$s->execute([$m['id']]);
$m['node_count'] = (int)$s->fetchColumn();
}
unset($m);
view('travel_modes/index', [
'modes' => $modes,
'modeLabels' => self::MODE_LABELS,
'pageTitle' => '出行方式管理',
]);
}
public function create(): void
{
$this->requireLogin();
view('travel_modes/form', [
'mode' => null,
'vehicles' => $this->fetchVehicles(),
'modeLabels' => self::MODE_LABELS,
'pageTitle' => '新增出行方式',
]);
}
public function store(): void
{
$this->requireLogin();
csrf_check();
$db = Database::get();
$familyId = Auth::familyId();
$modeType = $_POST['mode_type'] ?? '';
if (!isset(self::MODE_LABELS[$modeType])) {
flash('error', '无效的交通类型');
redirect('travel_modes/create');
}
$vehicleId = $this->validateVehicle($modeType, $db, $familyId, 'travel_modes/create');
$stmt = $db->prepare('INSERT INTO travel_modes (family_id, mode_type, vehicle_id, notes) VALUES (?, ?, ?, ?)');
$stmt->execute([
$familyId,
$modeType,
$vehicleId,
$this->strOrNull('notes'),
]);
flash('error', '创建成功');
redirect('travel_modes/index');
}
public function edit(): void
{
$this->requireLogin();
$id = (int)($_GET['id'] ?? 0);
$db = Database::get();
$stmt = $db->prepare('SELECT * FROM travel_modes WHERE id = ? AND family_id = ?');
$stmt->execute([$id, Auth::familyId()]);
$mode = $stmt->fetch();
if (!$mode) {
http_response_code(404);
exit('出行方式不存在');
}
view('travel_modes/form', [
'mode' => $mode,
'vehicles' => $this->fetchVehicles(),
'modeLabels' => self::MODE_LABELS,
'pageTitle' => '编辑出行方式',
]);
}
public function update(): void
{
$this->requireLogin();
csrf_check();
$db = Database::get();
$familyId = Auth::familyId();
$id = (int)($_POST['id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM travel_modes WHERE id = ? AND family_id = ?');
$stmt->execute([$id, $familyId]);
if (!$stmt->fetch()) {
http_response_code(404);
exit('出行方式不存在');
}
$modeType = $_POST['mode_type'] ?? '';
if (!isset(self::MODE_LABELS[$modeType])) {
flash('error', '无效的交通类型');
redirect('travel_modes/edit', ['id' => $id]);
}
$vehicleId = $this->validateVehicle($modeType, $db, $familyId, 'travel_modes/edit', ['id' => $id]);
$stmt = $db->prepare('UPDATE travel_modes SET mode_type = ?, vehicle_id = ?, notes = ? WHERE id = ?');
$stmt->execute([
$modeType,
$vehicleId,
$this->strOrNull('notes'),
$id,
]);
flash('error', '保存成功');
redirect('travel_modes/index');
}
public function destroy(): void
{
$this->requireLogin();
csrf_check();
$db = Database::get();
$id = (int)($_POST['id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM travel_modes WHERE id = ? AND family_id = ?');
$stmt->execute([$id, Auth::familyId()]);
if (!$stmt->fetch()) {
flash('error', '出行方式不存在');
redirect('travel_modes/index');
}
$cnt = $db->prepare('SELECT COUNT(*) FROM trip_nodes WHERE travel_mode_id = ?');
$cnt->execute([$id]);
$usedCount = (int)$cnt->fetchColumn();
if ($usedCount > 0) {
flash('error', "该出行方式已被 {$usedCount} 个节点使用,无法删除");
redirect('travel_modes/index');
}
$stmt = $db->prepare('DELETE FROM travel_modes WHERE id = ?');
$stmt->execute([$id]);
flash('error', '删除成功');
redirect('travel_modes/index');
}
private function validateVehicle(string $modeType, \PDO $db, int $familyId, string $route, array $params = []): ?int
{
if (!in_array($modeType, ['drive_own', 'drive_rental'], true)) {
return null;
}
$vehicleId = (int)($_POST['vehicle_id'] ?? 0);
if ($vehicleId <= 0) {
flash('error', '自驾类型必须选择车辆');
redirect($route, $params);
}
$chk = $db->prepare('SELECT id, vehicle_type FROM vehicles WHERE id = ? AND family_id = ?');
$chk->execute([$vehicleId, $familyId]);
$veh = $chk->fetch();
if (!$veh) {
flash('error', '车辆不存在');
redirect($route, $params);
}
if ($modeType === 'drive_own' && $veh['vehicle_type'] !== 'own') {
flash('error', '自有车类型必须选择自有车辆');
redirect($route, $params);
}
if ($modeType === 'drive_rental' && $veh['vehicle_type'] !== 'rental') {
flash('error', '租车类型必须选择租用车辆');
redirect($route, $params);
}
return $vehicleId;
}
private function fetchVehicles(): array
{
$stmt = Database::get()->prepare('SELECT * FROM vehicles WHERE family_id = ? ORDER BY vehicle_type ASC, is_default DESC, id ASC');
$stmt->execute([Auth::familyId()]);
return $stmt->fetchAll();
}
private function strOrNull(string $key): ?string
{
$v = trim($_POST[$key] ?? '');
return $v === '' ? null : $v;
}
private function requireLogin(): void
{
if (!Auth::check()) {
redirect('auth/login');
}
}
}
+404
View File
@@ -0,0 +1,404 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Auth;
use App\Database;
class UserController
{
public function index(): void
{
$this->requireAdmin();
$db = Database::get();
$familyId = Auth::familyId();
$stmt = $db->prepare('SELECT id, family_id, name, phone_encrypted, id_card_encrypted, role, avatar, status, created_at
FROM users WHERE family_id = ? ORDER BY
FIELD(role, "admin", "user", "guest"), id ASC');
$stmt->execute([$familyId]);
$users = $stmt->fetchAll();
view('users/index', [
'users' => $users,
'pageTitle' => '用户管理',
]);
}
public function create(): void
{
$this->requireAdmin();
view('users/form', [
'user' => null,
'isSelf' => false,
'pageTitle' => '新增用户',
]);
}
public function store(): void
{
$this->requireAdmin();
csrf_check();
$db = Database::get();
$familyId = Auth::familyId();
$name = trim($_POST['name'] ?? '');
$phone = trim($_POST['phone'] ?? '');
$password = (string)($_POST['password'] ?? '');
$role = $_POST['role'] ?? 'guest';
$idCard = trim($_POST['id_card'] ?? '');
$avatar = trim($_POST['avatar'] ?? '');
$status = $_POST['status'] ?? 'active';
// 校验
if ($name === '') {
flash('error', '姓名不能为空');
redirect('users/create');
}
if (!in_array($role, ['admin', 'user', 'guest'], true)) {
$role = 'guest';
}
// 普通用户 / 管理员必须有手机号和密码
if (in_array($role, ['admin', 'user'], true)) {
if ($phone === '' || $password === '') {
flash('error', '管理员/普通用户必须填写手机号和密码');
redirect('users/create');
}
if (!preg_match('/^1[3-9]\d{9}$/', $phone)) {
flash('error', '手机号格式不正确');
redirect('users/create');
}
// 手机号唯一性检查
$phoneHash = hash('sha256', $phone);
$chk = $db->prepare('SELECT id FROM users WHERE phone_hash = ? LIMIT 1');
$chk->execute([$phoneHash]);
if ($chk->fetch()) {
flash('error', '该手机号已被使用');
redirect('users/create');
}
}
$phoneEncrypted = $phone !== '' ? $this->encrypt($phone) : null;
$phoneHash = $phone !== '' ? hash('sha256', $phone) : null;
$idCardEncrypted = $idCard !== '' ? $this->encrypt($idCard) : null;
$passwordHash = $password !== '' ? password_hash($password, PASSWORD_DEFAULT) : null;
$stmt = $db->prepare('INSERT INTO users
(family_id, name, phone_encrypted, phone_hash, id_card_encrypted, password_hash, role, avatar, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)');
$stmt->execute([
$familyId, $name, $phoneEncrypted, $phoneHash, $idCardEncrypted,
$passwordHash, $role, $avatar ?: null, $status,
]);
flash('error', '用户创建成功'); // 复用 error flash 显示
redirect('users/index');
}
public function edit(): void
{
$id = (int)($_GET['id'] ?? 0);
$currentId = Auth::id();
// 只能编辑自己,或管理员编辑任何人
if (!Auth::isAdmin() && $id !== $currentId) {
http_response_code(403);
exit('无权编辑此用户');
}
$db = Database::get();
$stmt = $db->prepare('SELECT * FROM users WHERE id = ? AND family_id = ?');
$stmt->execute([$id, Auth::familyId()]);
$user = $stmt->fetch();
if (!$user) {
http_response_code(404);
exit('用户不存在');
}
view('users/form', [
'user' => $user,
'isSelf' => $id === $currentId,
'pageTitle' => '编辑用户:' . $user['name'],
]);
}
public function update(): void
{
csrf_check();
$db = Database::get();
$id = (int)($_POST['id'] ?? 0);
$currentId = Auth::id();
$isSelf = $id === $currentId;
if (!Auth::isAdmin() && !$isSelf) {
http_response_code(403);
exit('无权编辑此用户');
}
$stmt = $db->prepare('SELECT * FROM users WHERE id = ? AND family_id = ?');
$stmt->execute([$id, Auth::familyId()]);
$user = $stmt->fetch();
if (!$user) {
http_response_code(404);
exit('用户不存在');
}
$name = trim($_POST['name'] ?? '');
$phone = trim($_POST['phone'] ?? '');
$password = (string)($_POST['password'] ?? '');
$role = $_POST['role'] ?? $user['role'];
$idCard = trim($_POST['id_card'] ?? '');
$avatar = trim($_POST['avatar'] ?? '');
$status = $_POST['status'] ?? $user['status'];
if ($name === '') {
flash('error', '姓名不能为空');
redirect('users/edit', ['id' => $id]);
}
// 非管理员不能改自己的角色和状态
if (!Auth::isAdmin()) {
$role = $user['role'];
$status = $user['status'];
}
if (!in_array($role, ['admin', 'user', 'guest'], true)) {
$role = $user['role'];
}
if (!in_array($status, ['active', 'inactive'], true)) {
$status = $user['status'];
}
// 手机号只有在"非空且与原值不同"时才算修改
$phoneChanged = false;
if ($phone !== '') {
$phoneHash = hash('sha256', $phone);
if ($phoneHash !== ($user['phone_hash'] ?? '')) {
if (!preg_match('/^1[3-9]\d{9}$/', $phone)) {
flash('error', '手机号格式不正确');
redirect('users/edit', ['id' => $id]);
}
$chk = $db->prepare('SELECT id FROM users WHERE phone_hash = ? AND id != ? LIMIT 1');
$chk->execute([$phoneHash, $id]);
if ($chk->fetch()) {
flash('error', '该手机号已被其他用户使用');
redirect('users/edit', ['id' => $id]);
}
$phoneChanged = true;
}
}
// 关键修正:编辑时的必填校验
// 只有当"角色是 admin/user" 且 "当前用户既没有原手机号,也没有本次填的新手机号" 时才报错
$hasPhone = !empty($user['phone_hash']) || $phoneChanged;
if (in_array($role, ['admin', 'user'], true) && !$hasPhone) {
flash('error', '管理员/普通用户必须填写手机号');
redirect('users/edit', ['id' => $id]);
}
// 组装更新字段
$updates = [
'name = ?',
'role = ?',
'avatar = ?',
'status = ?',
];
$params = [$name, $role, $avatar ?: null, $status];
if ($phoneChanged) {
$updates[] = 'phone_encrypted = ?';
$updates[] = 'phone_hash = ?';
$params[] = $this->encrypt($phone);
$params[] = hash('sha256', $phone);
}
if ($idCard !== '') {
$updates[] = 'id_card_encrypted = ?';
$params[] = $this->encrypt($idCard);
}
if ($password !== '') {
$updates[] = 'password_hash = ?';
$params[] = password_hash($password, PASSWORD_DEFAULT);
}
$params[] = $id;
$sql = 'UPDATE users SET ' . implode(', ', $updates) . ' WHERE id = ?';
$stmt = $db->prepare($sql);
$stmt->execute($params);
// 如果编辑的是自己,同步 session
if ($isSelf) {
$_SESSION['user_name'] = $name;
$_SESSION['user_role'] = $role;
}
flash('error', '保存成功');
redirect('users/index');
}
public function destroy(): void
{
$this->requireAdmin();
csrf_check();
$id = (int)($_POST['id'] ?? 0);
if ($id === Auth::id()) {
flash('error', '不能删除自己');
redirect('users/index');
}
$db = Database::get();
$stmt = $db->prepare('SELECT role FROM users WHERE id = ? AND family_id = ?');
$stmt->execute([$id, Auth::familyId()]);
$user = $stmt->fetch();
if (!$user) {
flash('error', '用户不存在');
redirect('users/index');
}
// 如果是最后一个管理员,不允许删除
if ($user['role'] === 'admin') {
$cnt = $db->prepare('SELECT COUNT(*) FROM users WHERE family_id = ? AND role = "admin"');
$cnt->execute([Auth::familyId()]);
if ((int)$cnt->fetchColumn() <= 1) {
flash('error', '至少需要保留一位管理员');
redirect('users/index');
}
}
// 这些外键没有级联删除,被引用时直接 DELETE 会抛异常导致 500,先挡下来
$refs = [];
$checks = [
['SELECT COUNT(*) FROM activities WHERE created_by = ?', '创建的活动'],
['SELECT COUNT(*) FROM attachments WHERE uploader_id = ?', '上传的附件'],
['SELECT COUNT(*) FROM comments WHERE author_id = ?', '发表的评论'],
['SELECT COUNT(*) FROM expenses WHERE payer_user_id = ?', '记录的花销'],
];
foreach ($checks as [$sql, $label]) {
$s = $db->prepare($sql);
$s->execute([$id]);
$c = (int)$s->fetchColumn();
if ($c > 0) {
$refs[] = "{$label} {$c} 条";
}
}
if (!empty($refs)) {
flash('error', '该用户还有' . implode('、', $refs) . ',不能删除;可以改为「停用」。');
redirect('users/index');
}
$stmt = $db->prepare('DELETE FROM users WHERE id = ?');
$stmt->execute([$id]);
flash('error', '用户已删除');
redirect('users/index');
}
private function requireAdmin(): void
{
if (!Auth::isAdmin()) {
http_response_code(403);
exit('仅管理员可访问此页面');
}
}
/**
* 简易对称加密(密钥从 env 取,没有则用默认)
* 生产环境请改用环境变量存放密钥
*/
private function encrypt(string $plain): string
{
$key = getenv('APP_ENC_KEY') ?: 'family-trip-default-key-change-me';
$iv = random_bytes(16);
$cipher = openssl_encrypt($plain, 'AES-256-CBC', hash('sha256', $key, true), OPENSSL_RAW_DATA, $iv);
return base64_encode($iv . $cipher);
}
/**
* 上传头像(接收前端裁剪后的 base64 图片)
*/
public function uploadAvatar(): void
{
header('Content-Type: application/json; charset=utf-8');
if (!Auth::check()) {
http_response_code(401);
echo json_encode(['error' => '未登录'], JSON_UNESCAPED_UNICODE);
exit;
}
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
if (!hash_equals($_SESSION['_csrf'] ?? '', $token)) {
http_response_code(419);
echo json_encode(['error' => 'CSRF 校验失败'], JSON_UNESCAPED_UNICODE);
exit;
}
$body = file_get_contents('php://input');
$data = json_decode($body, true);
$base64 = $data['image'] ?? '';
if (!preg_match('/^data:image\/(png|jpeg|jpg|webp);base64,(.+)$/i', $base64, $m)) {
http_response_code(400);
echo json_encode(['error' => '无效的图片格式'], JSON_UNESCAPED_UNICODE);
exit;
}
$ext = strtolower($m[1]);
if ($ext === 'jpeg') $ext = 'jpg';
$raw = base64_decode($m[2], true);
if ($raw === false) {
http_response_code(400);
echo json_encode(['error' => '图片解码失败'], JSON_UNESCAPED_UNICODE);
exit;
}
if (strlen($raw) > 5 * 1024 * 1024) {
http_response_code(413);
echo json_encode(['error' => '图片不能超过 5MB'], JSON_UNESCAPED_UNICODE);
exit;
}
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mime = finfo_buffer($finfo, $raw);
finfo_close($finfo);
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
http_response_code(400);
echo json_encode(['error' => '文件不是有效图片'], JSON_UNESCAPED_UNICODE);
exit;
}
$dir = dirname(__DIR__, 2) . '/uploads/avatars';
if (!is_dir($dir)) mkdir($dir, 0755, true);
$filename = date('Ymd') . '_' . bin2hex(random_bytes(8)) . '.' . $ext;
$savePath = $dir . '/' . $filename;
if (file_put_contents($savePath, $raw) === false) {
http_response_code(500);
echo json_encode(['error' => '保存文件失败'], JSON_UNESCAPED_UNICODE);
exit;
}
$this->deleteLocalAvatar($_POST['old'] ?? '');
echo json_encode([
'url' => '/uploads/avatars/' . $filename,
'size' => strlen($raw),
], JSON_UNESCAPED_UNICODE);
}
private function deleteLocalAvatar(string $url): void
{
if ($url === '' || strpos($url, '/uploads/avatars/') !== 0) return;
$file = dirname(__DIR__, 2) . $url;
if (is_file($file)) @unlink($file);
}
}
+266
View File
@@ -0,0 +1,266 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Auth;
use App\Database;
class VehicleController
{
public const TYPE_LABELS = [
'own' => '自有车',
'rental' => '租车',
];
public function index(): void
{
$this->requireLogin();
$db = Database::get();
$familyId = Auth::familyId();
$stmt = $db->prepare('SELECT * FROM vehicles WHERE family_id = ?
ORDER BY vehicle_type ASC, is_default DESC, id ASC');
$stmt->execute([$familyId]);
$vehicles = $stmt->fetchAll();
// 统计每个车辆被出行方式引用次数
foreach ($vehicles as &$v) {
$s = $db->prepare('SELECT COUNT(*) FROM travel_modes WHERE vehicle_id = ?');
$s->execute([$v['id']]);
$v['mode_count'] = (int)$s->fetchColumn();
}
unset($v);
view('vehicles/index', [
'vehicles' => $vehicles,
'typeLabels' => self::TYPE_LABELS,
'pageTitle' => '车辆管理',
]);
}
public function create(): void
{
$this->requireLogin();
view('vehicles/form', [
'vehicle' => null,
'typeLabels' => self::TYPE_LABELS,
'pageTitle' => '新增车辆',
]);
}
public function store(): void
{
$this->requireLogin();
csrf_check();
$db = Database::get();
$familyId = Auth::familyId();
$data = $this->collectInput();
if ($data['error']) {
flash('error', $data['error']);
redirect('vehicles/create');
}
// 车牌唯一性
$chk = $db->prepare('SELECT id FROM vehicles WHERE family_id = ? AND plate_number = ? LIMIT 1');
$chk->execute([$familyId, $data['plate_number']]);
if ($chk->fetch()) {
flash('error', '该车牌号已存在');
redirect('vehicles/create');
}
// 如果是默认自有车,先把其他自有车的 is_default 清掉
if ($data['vehicle_type'] === 'own' && $data['is_default']) {
$db->prepare('UPDATE vehicles SET is_default = 0 WHERE family_id = ? AND vehicle_type = "own"')
->execute([$familyId]);
}
$stmt = $db->prepare('INSERT INTO vehicles
(family_id, plate_number, model_name, vehicle_type, rental_company, rental_order_no, is_default, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
$stmt->execute([
$familyId,
$data['plate_number'],
$data['model_name'],
$data['vehicle_type'],
$data['rental_company'],
$data['rental_order_no'],
$data['is_default'] ? 1 : 0,
$data['notes'],
]);
flash('error', '创建成功');
redirect('vehicles/index');
}
public function edit(): void
{
$this->requireLogin();
$id = (int)($_GET['id'] ?? 0);
$db = Database::get();
$stmt = $db->prepare('SELECT * FROM vehicles WHERE id = ? AND family_id = ?');
$stmt->execute([$id, Auth::familyId()]);
$vehicle = $stmt->fetch();
if (!$vehicle) {
http_response_code(404);
exit('车辆不存在');
}
view('vehicles/form', [
'vehicle' => $vehicle,
'typeLabels' => self::TYPE_LABELS,
'pageTitle' => '编辑车辆',
]);
}
public function update(): void
{
$this->requireLogin();
csrf_check();
$db = Database::get();
$familyId = Auth::familyId();
$id = (int)($_POST['id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM vehicles WHERE id = ? AND family_id = ?');
$stmt->execute([$id, $familyId]);
$old = $stmt->fetch();
if (!$old) {
http_response_code(404);
exit('车辆不存在');
}
$data = $this->collectInput();
if ($data['error']) {
flash('error', $data['error']);
redirect('vehicles/edit', ['id' => $id]);
}
// 车牌唯一性(排除自身)
$chk = $db->prepare('SELECT id FROM vehicles WHERE family_id = ? AND plate_number = ? AND id != ? LIMIT 1');
$chk->execute([$familyId, $data['plate_number'], $id]);
if ($chk->fetch()) {
flash('error', '该车牌号已被其他车辆使用');
redirect('vehicles/edit', ['id' => $id]);
}
// 如果改为默认自有车,清掉其他默认
if ($data['vehicle_type'] === 'own' && $data['is_default']) {
$db->prepare('UPDATE vehicles SET is_default = 0 WHERE family_id = ? AND vehicle_type = "own" AND id != ?')
->execute([$familyId, $id]);
}
$stmt = $db->prepare('UPDATE vehicles SET
plate_number = ?, model_name = ?, vehicle_type = ?,
rental_company = ?, rental_order_no = ?, is_default = ?, notes = ?
WHERE id = ?');
$stmt->execute([
$data['plate_number'],
$data['model_name'],
$data['vehicle_type'],
$data['rental_company'],
$data['rental_order_no'],
$data['is_default'] ? 1 : 0,
$data['notes'],
$id,
]);
flash('error', '保存成功');
redirect('vehicles/index');
}
public function destroy(): void
{
$this->requireLogin();
csrf_check();
$db = Database::get();
$id = (int)($_POST['id'] ?? 0);
$stmt = $db->prepare('SELECT * FROM vehicles WHERE id = ? AND family_id = ?');
$stmt->execute([$id, Auth::familyId()]);
$vehicle = $stmt->fetch();
if (!$vehicle) {
flash('error', '车辆不存在');
redirect('vehicles/index');
}
// 检查是否被出行方式引用
$cnt = $db->prepare('SELECT COUNT(*) FROM travel_modes WHERE vehicle_id = ?');
$cnt->execute([$id]);
$usedCount = (int)$cnt->fetchColumn();
if ($usedCount > 0) {
flash('error', "该车辆已被 {$usedCount} 条出行方式引用,无法删除");
redirect('vehicles/index');
}
// 检查是否被节点直接引用(vehicle_id 在 trip_nodes 上也可能存在)
$cnt2 = $db->prepare('SELECT COUNT(*) FROM trip_nodes WHERE vehicle_id = ?');
$cnt2->execute([$id]);
$nodeCount = (int)$cnt2->fetchColumn();
if ($nodeCount > 0) {
flash('error', "该车辆已被 {$nodeCount} 个节点使用,无法删除");
redirect('vehicles/index');
}
$stmt = $db->prepare('DELETE FROM vehicles WHERE id = ?');
$stmt->execute([$id]);
flash('error', '删除成功');
redirect('vehicles/index');
}
/**
* 从 $_POST 收集车辆数据并做基本校验
* @return array {error: string|null, plate_number, model_name, vehicle_type, rental_company, rental_order_no, is_default, notes}
*/
private function collectInput(): array
{
$plateNumber = strtoupper(trim($_POST['plate_number'] ?? ''));
$modelName = trim($_POST['model_name'] ?? '');
$vehicleType = $_POST['vehicle_type'] ?? 'own';
$rentalCompany = trim($_POST['rental_company'] ?? '');
$rentalOrderNo = trim($_POST['rental_order_no'] ?? '');
$isDefault = !empty($_POST['is_default']);
$notes = trim($_POST['notes'] ?? '');
if ($plateNumber === '') {
return ['error' => '车牌号不能为空'];
}
if (mb_strlen($plateNumber) > 20) {
return ['error' => '车牌号不能超过 20 个字符'];
}
if ($modelName === '') {
return ['error' => '车型名称不能为空'];
}
if (!in_array($vehicleType, ['own', 'rental'], true)) {
return ['error' => '无效的车辆类型'];
}
// 自有车不能是默认租车
if ($vehicleType === 'rental') {
$isDefault = false;
}
return [
'error' => null,
'plate_number' => $plateNumber,
'model_name' => $modelName,
'vehicle_type' => $vehicleType,
'rental_company' => $vehicleType === 'rental' ? ($rentalCompany ?: null) : null,
'rental_order_no' => $vehicleType === 'rental' ? ($rentalOrderNo ?: null) : null,
'is_default' => $isDefault,
'notes' => $notes ?: null,
];
}
private function requireLogin(): void
{
if (!Auth::check()) {
redirect('auth/login');
}
}
}