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

数据库里早就建好了 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
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App;
/**
* 归属校验:所有「按 id 取资源」的地方都从这里走,
* 保证「不是自己家庭的数据一律当作不存在(404)」
*/
class Access
{
public static function familyId(): int
{
return (int)(Auth::familyId() ?? 0);
}
/** 取当前家庭的活动,否则 404 */
public static function activity(int $id): array
{
$row = self::activityOrNull($id);
if ($row === null) {
http_response_code(404);
exit('活动不存在');
}
return $row;
}
public static function activityOrNull(int $id): ?array
{
if ($id <= 0) return null;
$stmt = Database::get()->prepare('SELECT * FROM activities WHERE id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
return ($row && (int)$row['family_id'] === self::familyId()) ? $row : null;
}
/** 取当前家庭的行程节点(连同所属活动),否则 404 */
public static function node(int $id): array
{
if ($id <= 0) {
http_response_code(404);
exit('节点不存在');
}
$stmt = Database::get()->prepare('
SELECT tn.*, a.family_id, a.title AS activity_title, a.id AS activity_id
FROM trip_nodes tn
JOIN activities a ON a.id = tn.activity_id
WHERE tn.id = ?');
$stmt->execute([$id]);
$row = $stmt->fetch();
if (!$row || (int)$row['family_id'] !== self::familyId()) {
http_response_code(404);
exit('节点不存在');
}
return $row;
}
/**
* 校验「评论/附件」这类软关联目标确实属于当前家庭
* @return array{ok: bool, error?: string}
*/
public static function target(string $targetType, int $targetId): array
{
if ($targetType === Comments::TARGET_ACTIVITY) {
return self::activityOrNull($targetId) !== null
? ['ok' => true]
: ['ok' => false, 'error' => '活动不存在'];
}
if ($targetType === Comments::TARGET_NODE) {
$stmt = Database::get()->prepare('
SELECT a.family_id FROM trip_nodes tn
JOIN activities a ON a.id = tn.activity_id WHERE tn.id = ?');
$stmt->execute([$targetId]);
$familyId = $stmt->fetchColumn();
return ($familyId !== false && (int)$familyId === self::familyId())
? ['ok' => true]
: ['ok' => false, 'error' => '节点不存在'];
}
return ['ok' => false, 'error' => '不支持的目标类型'];
}
/** 仅管理员可进入的页面 */
public static function requireAdmin(): void
{
if (!Auth::isAdmin()) {
http_response_code(403);
exit('仅管理员可访问此页面');
}
}
}
+82
View File
@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
namespace App;
class Auth
{
private static ?array $cachedUser = null;
public static function check(): bool
{
return !empty($_SESSION['user_id']);
}
public static function id(): ?int
{
return isset($_SESSION['user_id']) ? (int)$_SESSION['user_id'] : null;
}
public static function familyId(): ?int
{
return isset($_SESSION['family_id']) ? (int)$_SESSION['family_id'] : null;
}
public static function isAdmin(): bool
{
return ($_SESSION['user_role'] ?? '') === 'admin';
}
public static function user(): ?array
{
if (!self::check()) return null;
if (self::$cachedUser !== null) return self::$cachedUser;
$stmt = Database::get()->prepare('SELECT id, family_id, name, role, avatar FROM users WHERE id = ?');
$stmt->execute([self::id()]);
$u = $stmt->fetch();
self::$cachedUser = $u ?: null;
return self::$cachedUser;
}
public static function login(array $user): void
{
session_regenerate_id(true);
$_SESSION['user_id'] = (int)$user['id'];
$_SESSION['family_id'] = (int)$user['family_id'];
$_SESSION['user_name'] = $user['name'];
$_SESSION['user_role'] = $user['role'];
}
public static function logout(): void
{
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$p = session_get_cookie_params();
setcookie(session_name(), '', time() - 42000,
$p['path'], $p['domain'], $p['secure'], $p['httponly']);
}
session_destroy();
}
/**
* 通过手机号 + 密码尝试登录
* @return array|null 成功返回用户数组,失败返回 null
*/
public static function attempt(string $phone, string $password): ?array
{
$phone = trim($phone);
if ($phone === '' || $password === '') return null;
$phoneHash = hash('sha256', $phone);
$stmt = Database::get()->prepare(
'SELECT * FROM users WHERE phone_hash = ? AND role IN ("admin","user") AND status = "active" LIMIT 1'
);
$stmt->execute([$phoneHash]);
$user = $stmt->fetch();
if (!$user) return null;
if (!password_verify($password, (string)($user['password_hash'] ?? ''))) return null;
return $user;
}
}
+151
View File
@@ -0,0 +1,151 @@
<?php
declare(strict_types=1);
namespace App;
/**
* 评论与讨论
*
* 采用「两级平铺」而不是递归嵌套:parent_id 只允许指向顶层评论,
* 写「回复的回复」时规范化回顶层。这样库里最多两层,UI 永远只缩进一次,
* 来回多少轮都不会越缩越窄,也不必递归查询。
*/
class Comments
{
public const TARGET_ACTIVITY = 'activity';
public const TARGET_NODE = 'trip_node';
public const MAX_LENGTH = 2000;
public static function targetTypes(): array
{
return [self::TARGET_ACTIVITY, self::TARGET_NODE];
}
/**
* 取某目标的评论,两级平铺
* @return array{top: array, replies: array<int, array>, total: int}
*/
public static function tree(string $targetType, int $targetId): array
{
$stmt = Database::get()->prepare('
SELECT c.*, u.name AS author_name, u.avatar AS author_avatar, u.role AS author_role
FROM comments c
LEFT JOIN users u ON u.id = c.author_id
WHERE c.target_type = ? AND c.target_id = ?
ORDER BY c.created_at ASC, c.id ASC');
$stmt->execute([$targetType, $targetId]);
$rows = $stmt->fetchAll();
$top = [];
$replies = [];
foreach ($rows as $row) {
if ($row['parent_id'] === null) {
$top[] = $row;
} else {
$replies[(int)$row['parent_id']][] = $row;
}
}
// 回复挂到一个不存在的父评论上(父被删过)时,把它们提到顶层,避免评论消失
$topIdSet = [];
foreach ($top as $t) {
$topIdSet[(int)$t['id']] = true;
}
foreach ($replies as $pid => $list) {
if (!isset($topIdSet[(int)$pid])) {
foreach ($list as $orphan) {
$orphan['parent_id'] = null;
$top[] = $orphan;
}
unset($replies[$pid]);
}
}
usort($top, static fn($a, $b) => [$a['created_at'], $a['id']] <=> [$b['created_at'], $b['id']]);
return [
'top' => $top,
'replies' => $replies,
'total' => count($rows),
];
}
/** 各目标下的评论数(一次查完,避免 N+1) */
public static function countFor(string $targetType, array $targetIds): array
{
if (empty($targetIds)) return [];
$ph = implode(',', array_fill(0, count($targetIds), '?'));
$stmt = Database::get()->prepare("SELECT target_id, COUNT(*) AS cnt
FROM comments WHERE target_type = ? AND target_id IN ($ph) GROUP BY target_id");
$stmt->execute(array_merge([$targetType], array_values($targetIds)));
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[(int)$row['target_id']] = (int)$row['cnt'];
}
return $out;
}
/**
* 发表评论
* @return array{ok: bool, error?: string, id?: int}
*/
public static function create(string $targetType, int $targetId, int $authorId, string $content, ?int $parentId = null): array
{
if (!in_array($targetType, self::targetTypes(), true)) {
return ['ok' => false, 'error' => '评论目标无效'];
}
$content = trim($content);
if ($content === '') {
return ['ok' => false, 'error' => '评论内容不能为空'];
}
if (mb_strlen($content) > self::MAX_LENGTH) {
return ['ok' => false, 'error' => '评论不能超过 ' . self::MAX_LENGTH . ' 个字'];
}
// 父评论必须属于同一个目标;若是「回复的回复」,规范化回顶层
$normalizedParent = null;
if ($parentId !== null && $parentId > 0) {
$stmt = Database::get()->prepare('SELECT id, parent_id FROM comments
WHERE id = ? AND target_type = ? AND target_id = ?');
$stmt->execute([$parentId, $targetType, $targetId]);
$parent = $stmt->fetch();
if ($parent) {
$normalizedParent = $parent['parent_id'] === null
? (int)$parent['id']
: (int)$parent['parent_id'];
}
}
$stmt = Database::get()->prepare('INSERT INTO comments (author_id, target_type, target_id, parent_id, content)
VALUES (?, ?, ?, ?, ?)');
$stmt->execute([$authorId, $targetType, $targetId, $normalizedParent, $content]);
return ['ok' => true, 'id' => (int)Database::get()->lastInsertId()];
}
/** 删除评论;删顶层时把它的回复一起删掉(外键是 SET NULL,会留下孤儿) */
public static function delete(int $id, int $actorId, bool $isAdmin): bool
{
$db = Database::get();
$stmt = $db->prepare('SELECT * FROM comments WHERE id = ?');
$stmt->execute([$id]);
$comment = $stmt->fetch();
if (!$comment) return false;
if ((int)$comment['author_id'] !== $actorId && !$isAdmin) return false;
$stmt = $db->prepare('DELETE FROM comments WHERE parent_id = ?');
$stmt->execute([$id]);
$stmt = $db->prepare('DELETE FROM comments WHERE id = ?');
$stmt->execute([$id]);
return true;
}
/** 删除某个目标(活动/节点)下的全部评论,供删除活动、删除节点时调用 */
public static function purgeTarget(string $targetType, int $targetId, ?\PDO $db = null): int
{
$db = $db ?? Database::get();
$stmt = $db->prepare('DELETE FROM comments WHERE target_type = ? AND target_id = ?');
$stmt->execute([$targetType, $targetId]);
return $stmt->rowCount();
}
}
+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');
}
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App;
use PDO;
class Database
{
private static ?PDO $instance = null;
public static function get(): PDO
{
if (self::$instance instanceof PDO) {
return self::$instance;
}
$config = require dirname(__DIR__) . '/config.php';
$db = $config['db'];
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$db['host'], $db['port'], $db['name'], $db['charset']
);
self::$instance = new PDO($dsn, $db['user'], $db['pass'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
return self::$instance;
}
}
+213
View File
@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace App;
/**
* 花销与分摊
*
* 表结构:expenses(挂在 trip_nodes 上,is_budget 区分"预算"和"实际")
* + expense_splits(这笔钱按人分摊,谁承担多少)
*/
class Expenses
{
public const CATEGORIES = ['餐饮', '门票', '交通', '住宿', '购物', '娱乐', '其他'];
/** 展示用的货币符号(库里只存三字母代码) */
public const SYMBOLS = ['CNY' => '¥', 'JPY' => '¥', 'USD' => '$', 'EUR' => '€', 'HKD' => 'HK$'];
public const MAX_AMOUNT = 99999999.99;
public static function currencyLabels(): array
{
return ['CNY' => '人民币 ¥', 'JPY' => '日元 ¥', 'USD' => '美元 $', 'EUR' => '欧元 €', 'HKD' => '港币 HK$'];
}
/** 某个节点下的花销,带付款人和分摊明细 */
public static function forNode(int $nodeId): array
{
$db = Database::get();
$stmt = $db->prepare('
SELECT e.*, u.name AS payer_name, u.avatar AS payer_avatar
FROM expenses e
LEFT JOIN users u ON u.id = e.payer_user_id
WHERE e.trip_node_id = ?
ORDER BY e.is_budget ASC, e.created_at ASC, e.id ASC');
$stmt->execute([$nodeId]);
$rows = $stmt->fetchAll();
if (empty($rows)) return [];
$ids = array_column($rows, 'id');
$ph = implode(',', array_fill(0, count($ids), '?'));
$s = $db->prepare("SELECT es.*, u.name AS user_name
FROM expense_splits es
LEFT JOIN users u ON u.id = es.user_id
WHERE es.expense_id IN ($ph)
ORDER BY es.id ASC");
$s->execute($ids);
$splits = [];
foreach ($s->fetchAll() as $row) {
$splits[(int)$row['expense_id']][] = $row;
}
foreach ($rows as &$row) {
$row['splits'] = $splits[(int)$row['id']] ?? [];
}
unset($row);
return $rows;
}
/** 活动下的花销合计(预算 / 实际),以及各节点的小计 */
public static function summaryForActivity(int $activityId): array
{
$db = Database::get();
$stmt = $db->prepare('
SELECT tn.id AS node_id, e.is_budget, SUM(e.total_amount) AS amount, COUNT(*) AS cnt
FROM expenses e
JOIN trip_nodes tn ON tn.id = e.trip_node_id
WHERE tn.activity_id = ?
GROUP BY tn.id, e.is_budget');
$stmt->execute([$activityId]);
$byNode = [];
$budgetTotal = 0.0;
$actualTotal = 0.0;
$count = 0;
foreach ($stmt->fetchAll() as $row) {
$nodeId = (int)$row['node_id'];
$isBudget = (int)$row['is_budget'] === 1;
$amount = (float)$row['amount'];
$byNode[$nodeId][$isBudget ? 'budget' : 'actual'] = $amount;
if ($isBudget) {
$budgetTotal += $amount;
} else {
$actualTotal += $amount;
}
$count += (int)$row['cnt'];
}
return [
'budget' => $budgetTotal,
'actual' => $actualTotal,
'count' => $count,
'by_node' => $byNode,
'diff' => $actualTotal - $budgetTotal,
];
}
/**
* 新增一笔花销,并把金额按人平均分摊(尾差归最后一人,保证分摊之和等于总额)
* @return array{ok: bool, error?: string, id?: int}
*/
public static function create(int $nodeId, int $payerId, string $amountRaw, string $currency, string $category, string $description, bool $isBudget, array $splitUserIds): array
{
$db = Database::get();
$amountRaw = str_replace([',', ',', ' '], '', trim($amountRaw));
if ($amountRaw === '' || !is_numeric($amountRaw)) {
return ['ok' => false, 'error' => '金额必须是数字'];
}
$amount = round((float)$amountRaw, 2);
if ($amount <= 0) {
return ['ok' => false, 'error' => '金额必须大于 0'];
}
if ($amount > self::MAX_AMOUNT) {
return ['ok' => false, 'error' => '金额过大'];
}
if (!isset(self::currencyLabels()[$currency])) {
$currency = 'CNY';
}
if ($category === '' || !in_array($category, self::CATEGORIES, true)) {
$category = '其他';
}
$description = trim($description);
if (mb_strlen($description) > 200) {
return ['ok' => false, 'error' => '描述不能超过 200 个字'];
}
// 付款人和分摊人都必须是这个节点所属家庭的成员
$stmt = $db->prepare('SELECT a.family_id FROM trip_nodes tn JOIN activities a ON a.id = tn.activity_id WHERE tn.id = ?');
$stmt->execute([$nodeId]);
$familyId = $stmt->fetchColumn();
if ($familyId === false) {
return ['ok' => false, 'error' => '节点不存在'];
}
$familyId = (int)$familyId;
$memberIds = array_column(Members::familyUsers($familyId), 'id');
$memberIds = array_map('intval', $memberIds);
if (!in_array($payerId, $memberIds, true)) {
return ['ok' => false, 'error' => '付款人必须是本家庭成员'];
}
$splitUserIds = array_values(array_unique(array_intersect(array_map('intval', $splitUserIds), $memberIds)));
if (empty($splitUserIds)) {
// 没选分摊人时默认由付款人自己承担
$splitUserIds = [$payerId];
}
$db->beginTransaction();
try {
$stmt = $db->prepare('INSERT INTO expenses
(trip_node_id, payer_user_id, currency, total_amount, category, description, is_budget)
VALUES (?, ?, ?, ?, ?, ?, ?)');
$stmt->execute([
$nodeId, $payerId, $currency, number_format($amount, 2, '.', ''),
$category, $description !== '' ? $description : null, $isBudget ? 1 : 0,
]);
$expenseId = (int)$db->lastInsertId();
// 以「分」为单位平均分,把除不尽的尾差加到最后一个人身上
$totalCents = (int)round($amount * 100);
$n = count($splitUserIds);
$base = intdiv($totalCents, $n);
$rest = $totalCents - $base * $n;
$stmt = $db->prepare('INSERT INTO expense_splits (expense_id, user_id, share_amount) VALUES (?, ?, ?)');
foreach ($splitUserIds as $i => $uid) {
$cents = $base + ($i === $n - 1 ? $rest : 0);
$stmt->execute([$expenseId, $uid, number_format($cents / 100, 2, '.', '')]);
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
return ['ok' => false, 'error' => '保存失败:' . $e->getMessage()];
}
return ['ok' => true, 'id' => $expenseId];
}
public static function delete(int $id): bool
{
$db = Database::get();
$stmt = $db->prepare('SELECT id FROM expenses WHERE id = ?');
$stmt->execute([$id]);
if (!$stmt->fetch()) return false;
// expense_splits 有 ON DELETE CASCADE,这里显式删一遍更直观
$db->prepare('DELETE FROM expense_splits WHERE expense_id = ?')->execute([$id]);
$db->prepare('DELETE FROM expenses WHERE id = ?')->execute([$id]);
return true;
}
/** 某个节点的花销合计 */
public static function summaryForNode(int $nodeId): array
{
$stmt = Database::get()->prepare('SELECT is_budget, SUM(total_amount) AS amount, COUNT(*) AS cnt
FROM expenses WHERE trip_node_id = ? GROUP BY is_budget');
$stmt->execute([$nodeId]);
$budget = 0.0;
$actual = 0.0;
$count = 0;
foreach ($stmt->fetchAll() as $row) {
if ((int)$row['is_budget'] === 1) {
$budget = (float)$row['amount'];
} else {
$actual = (float)$row['amount'];
}
$count += (int)$row['cnt'];
}
return ['budget' => $budget, 'actual' => $actual, 'count' => $count, 'diff' => $actual - $budget];
}
}
+182
View File
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace App;
/**
* 节点参与人:谁参加了这个节点、当时担任什么角色、心情如何、有没有签到
* 对应 trip_nodes 的从表 node_members(trip_node_id + user_id 唯一)
*/
class Members
{
/**
* 情绪目录:emoji 只在这里出现,库里存的就是 emoji 字符本身(列宽 varchar(10) 只够 1 个字符)
* 全部选用 Emoji 11.0(2018-06)及以前的码位 —— 更新的码位在旧系统 emoji 字体里没有字形,
* 会显示成空白/方框。
*/
public const EMOTIONS = [
'😄' => '开心',
'🥰' => '温暖',
'😍' => '喜欢',
'🤩' => '兴奋',
'🤗' => '满足',
'😎' => '得意',
'🤔' => '沉思',
'😌' => '平静',
'😋' => '好吃',
'😅' => '无奈',
'😴' => '累了',
'😵' => '晕了',
'😢' => '难过',
'😭' => '大哭',
'😱' => '惊吓',
'😠' => '生气',
'🤒' => '不舒服',
];
public static function isValidEmotion(string $emoji): bool
{
return $emoji !== '' && isset(self::EMOTIONS[$emoji]);
}
/** 某个节点的参与人列表(带姓名、头像、角色标签名) */
public static function forNode(int $nodeId): array
{
$stmt = Database::get()->prepare('
SELECT nm.*, u.name, u.avatar, u.role
FROM node_members nm
LEFT JOIN users u ON u.id = nm.user_id
WHERE nm.trip_node_id = ?
ORDER BY nm.id ASC');
$stmt->execute([$nodeId]);
$rows = $stmt->fetchAll();
$tagNames = self::roleTagMap();
foreach ($rows as &$r) {
$ids = json_decode((string)($r['role_tag_ids'] ?? '[]'), true);
$ids = is_array($ids) ? array_map('intval', $ids) : [];
$r['role_tag_ids'] = $ids;
$r['role_tag_names'] = array_values(array_filter(array_map(
static fn($id) => $tagNames[$id] ?? null,
$ids
)));
}
unset($r);
return $rows;
}
/** 一次查完多个节点的参与人数与情绪,供列表/卡片使用 */
public static function summaryForNodes(array $nodeIds): array
{
if (empty($nodeIds)) return [];
$ph = implode(',', array_fill(0, count($nodeIds), '?'));
$stmt = Database::get()->prepare("SELECT trip_node_id, COUNT(*) AS cnt,
GROUP_CONCAT(emotion ORDER BY id SEPARATOR '') AS emotions
FROM node_members WHERE trip_node_id IN ($ph) GROUP BY trip_node_id");
$stmt->execute(array_values($nodeIds));
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[(int)$row['trip_node_id']] = [
'count' => (int)$row['cnt'],
'emotions' => (string)($row['emotions'] ?? ''),
];
}
return $out;
}
/**
* 新增或更新一个参与人(同一节点同一人只留一条)
* @return array{ok: bool, error?: string}
*/
public static function save(int $nodeId, int $userId, array $roleTagIds, string $emotion, ?string $checkIn): array
{
$db = Database::get();
if ($userId <= 0) {
return ['ok' => false, 'error' => '请选择参与人'];
}
// 只能选本家庭的成员(注意:必须同时比对 users.family_id,
// 否则「节点存在 + 用户存在」就放行,别家用户也能被塞进来)
$stmt = $db->prepare('SELECT u.id, a.family_id
FROM trip_nodes tn
JOIN activities a ON a.id = tn.activity_id
JOIN users u ON u.id = ? AND u.family_id = a.family_id
WHERE tn.id = ?');
$stmt->execute([$userId, $nodeId]);
$row = $stmt->fetch();
if (!$row) {
return ['ok' => false, 'error' => '该成员不属于这个活动所在家庭'];
}
// 角色标签也要属于同一个家庭
$validTags = array_keys(self::roleTagMap((int)$row['family_id']));
$roleTagIds = array_values(array_intersect(array_map('intval', $roleTagIds), $validTags));
if ($emotion !== '' && !self::isValidEmotion($emotion)) {
return ['ok' => false, 'error' => '情绪表情不在可选范围内'];
}
$checkIn = $checkIn !== null && trim($checkIn) !== '' ? trim($checkIn) : null;
if ($checkIn !== null) {
$ts = strtotime(str_replace('T', ' ', $checkIn));
$checkIn = $ts !== false ? date('Y-m-d H:i:s', $ts) : null;
}
$stmt = $db->prepare('INSERT INTO node_members (trip_node_id, user_id, role_tag_ids, emotion, check_in_time)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE role_tag_ids = VALUES(role_tag_ids),
emotion = VALUES(emotion), check_in_time = VALUES(check_in_time)');
$stmt->execute([
$nodeId,
$userId,
$roleTagIds ? json_encode($roleTagIds, JSON_UNESCAPED_UNICODE) : null,
$emotion !== '' ? $emotion : null,
$checkIn,
]);
return ['ok' => true];
}
public static function remove(int $nodeId, int $userId): void
{
$stmt = Database::get()->prepare('DELETE FROM node_members WHERE trip_node_id = ? AND user_id = ?');
$stmt->execute([$nodeId, $userId]);
}
/** 一次把一批人加进节点(用于「把活动成员一键全部加入」) */
public static function addMany(int $nodeId, array $userIds, array $roleTagIds = []): int
{
$n = 0;
foreach (array_unique(array_map('intval', $userIds)) as $uid) {
$r = self::save($nodeId, $uid, $roleTagIds, '', null);
if ($r['ok']) $n++;
}
return $n;
}
/** 家庭内的角色标签 [id => name] */
public static function roleTagMap(?int $familyId = null): array
{
$db = Database::get();
if ($familyId === null) {
$stmt = $db->query('SELECT id, name, family_id FROM role_tags');
} else {
$stmt = $db->prepare('SELECT id, name, family_id FROM role_tags WHERE family_id = ?');
$stmt->execute([$familyId]);
}
$out = [];
foreach ($stmt->fetchAll() as $row) {
$out[(int)$row['id']] = $row['name'];
}
return $out;
}
/** 家庭内可作为参与人的用户(guest 也算,记录出行时常常是"待激活"的亲戚) */
public static function familyUsers(int $familyId): array
{
$stmt = Database::get()->prepare('SELECT id, name, avatar, role FROM users
WHERE family_id = ? ORDER BY FIELD(role, "admin","user","guest"), id ASC');
$stmt->execute([$familyId]);
return $stmt->fetchAll();
}
}
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
function e($value): string
{
return htmlspecialchars((string)($value ?? ''), ENT_QUOTES, 'UTF-8');
}
/**
* 读取 config.php(每个请求只读一次)
* 页面里的第三方 Key 一律从这里取,避免同一个值散落在多个文件里
*/
function app_config(): array
{
static $config = null;
if ($config === null) {
$config = require dirname(__DIR__) . '/config.php';
}
return $config;
}
function url(string $route, array $params = []): string
{
$params = array_merge(['r' => $route], $params);
return '/index.php?' . http_build_query($params);
}
function redirect(string $route, array $params = [], string $anchor = ''): void
{
$location = url($route, $params);
if ($anchor !== '') {
$location .= '#' . ltrim($anchor, '#');
}
header('Location: ' . $location);
exit;
}
function view(string $template, array $data = []): void
{
extract($data, EXTR_SKIP);
$basePath = dirname(__DIR__);
$content = $basePath . '/templates/' . $template . '.php';
if (!file_exists($content)) {
throw new \RuntimeException("模板不存在: $template");
}
require $basePath . '/templates/layout.php';
}
function flash(string $key, ?string $value = null): ?string
{
if ($value !== null) {
$_SESSION['_flash'][$key] = $value;
return null;
}
$v = $_SESSION['_flash'][$key] ?? null;
unset($_SESSION['_flash'][$key]);
return $v;
}
function csrf_token(): string
{
if (empty($_SESSION['_csrf'])) {
$_SESSION['_csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['_csrf'];
}
function csrf_check(): void
{
$token = $_POST['_csrf'] ?? '';
if (!hash_equals($_SESSION['_csrf'] ?? '', $token)) {
http_response_code(419);
exit('CSRF token 校验失败');
}
}
function json_decode_field($json, $default = [])
{
if (empty($json)) return $default;
if (is_array($json)) return $json;
$decoded = json_decode($json, true);
return $decoded ?? $default;
}