补齐未接入代码的功能域:参与人 / 评论 / 花销与分摊
数据库里早就建好了 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:
@@ -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('仅管理员可访问此页面');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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('仅管理员可访问此页面');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user