安装向导:首次部署浏览器引导建库建表(批 26)
- 新增 install.php 独立安装向导:环境检查 → 表单(站点标题/家庭名/数据库/ 高德/管理员)→ 全量建表 → 基本种子数据 → 生成 config.php → 自动自删。 已安装判定以 config.php 为准,重复访问只显示「已安装」。 - 新增 sql/schema.sql 全量建表脚本(15 张表,按外键依赖排序,全部 IF NOT EXISTS),由向导执行;tools/migrate.php 继续负责增量迁移。 - index.php 加未安装检测:config.php 缺失或无效时跳转安装页。 - layout.php 标题回退值接通 app.name,安装时填的网站标题在页面上生效。 - 种子数据仅基本款:节点类型 5、角色标签 4、出行方式 9(不含需绑定 车辆的自驾两类);管理员账号按既有加密方式写入。 - README 部署章节改为向导优先,手动方式降为备选。 - 验证:本地 60 文件 php -l 全过;渲染检查 386 项全过; 安装流程端到端 46 项全过(临时副本 + 独立 MariaDB:跳转、表单、 建表、种子、加密可逆、自删、已安装拒绝、错误路径)。
This commit is contained in:
@@ -68,52 +68,61 @@ uploads/ 用户上传内容(不入库)
|
||||
|
||||
### 1. 环境要求
|
||||
|
||||
- PHP **8.x**(开发与线上均在 8.1 与 7.4 双版本下做过语法检查与冒烟)
|
||||
- MySQL 5.7+ / 8.0,字符集 `utf8mb4`
|
||||
- PHP 扩展:`pdo_mysql`、`curl`、`gd`、`mbstring`、`json`
|
||||
- PHP **7.4+**(推荐 8.x;开发与线上均在 8.1 与 7.4 双版本下做过语法检查与冒烟)
|
||||
- MySQL 5.7+ / 8.0(MariaDB 亦可),字符集 `utf8mb4`
|
||||
- PHP 扩展:`pdo_mysql`、`curl`、`gd`、`mbstring`、`json`、`openssl`
|
||||
- 站点根目录对 Web 进程**可写**(安装时生成 `config.php` 用;`uploads/` 始终需要可写)
|
||||
|
||||
### 2. 取代码与配置
|
||||
### 2. 安装向导(推荐)
|
||||
|
||||
1. 把代码放入站点目录,确保站点根对 Web 进程可写;
|
||||
2. 在 MySQL 里创建一个**空的**数据库(表结构由安装向导自动生成):
|
||||
|
||||
```sql
|
||||
CREATE DATABASE trip DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
-- 再创建专用账号并授予 trip 库全部权限(推荐,不要用 root)
|
||||
```
|
||||
|
||||
3. 浏览器访问站点根 —— 未安装时 `index.php` 会自动跳转到 `install.php` 安装向导;
|
||||
4. 按表单填写:网站标题、家庭名称、数据库连接、高德地图 Key(可留空稍后补)、
|
||||
管理员账号(手机号 + 密码);
|
||||
5. 提交后向导会:全量建表(`sql/schema.sql`)→ 执行增量迁移(`src/Migrate.php`)→
|
||||
写入基本数据(你的家庭、管理员账号、节点类型、角色标签、常用出行方式)→
|
||||
生成 `config.php` → **自动删除 `install.php`**。
|
||||
|
||||
安装完成后即可用管理员账号登录。「已安装」的判定以 `config.php` 为准:
|
||||
即使 `install.php` 没有被成功删除,再次访问也只会看到「已安装」提示,无法重新安装。
|
||||
|
||||
> 种子的出行方式不含「自驾」两种 —— 自驾方式必须关联具体车辆,
|
||||
> 请先在「车辆管理」里添加车辆,再自行添加自驾出行方式。
|
||||
|
||||
### 3. 手动安装(备选)
|
||||
|
||||
不想用向导时,也可以完全手动:
|
||||
|
||||
```bash
|
||||
git clone <repo-url> triplog
|
||||
cd triplog
|
||||
cp config.example.php config.php
|
||||
cp config.example.php config.php # 手工填写数据库与高德参数
|
||||
php tools/migrate.php # 不够:首次还需要全量建表,见下
|
||||
```
|
||||
|
||||
编辑 `config.php` 填数据库与高德参数。**`config.php` 不入库**(含口令),只提交模板。
|
||||
全量建表:把 `sql/schema.sql` 导入空库(`mysql trip < sql/schema.sql`),
|
||||
再执行 `php tools/migrate.php` 补齐增量迁移。管理员账号目前需自行向 `families`、
|
||||
`users` 插入(密码用 `password_hash()`,手机号存 `phone_hash`(SHA-256)与
|
||||
`phone_encrypted`(AES-256-CBC)),**推荐直接使用安装向导**。
|
||||
|
||||
所有配置项都支持环境变量覆盖(`DB_HOST` / `DB_NAME` / `AMAP_NAV_SRC` 等),
|
||||
便于在不同机器上跑同一份代码。
|
||||
|
||||
### 3. 数据库
|
||||
|
||||
创建库与账号(示例):
|
||||
|
||||
```sql
|
||||
CREATE DATABASE trip DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
CREATE USER 'trip'@'localhost' IDENTIFIED BY 'your-password';
|
||||
GRANT ALL PRIVILEGES ON trip.* TO 'trip'@'localhost';
|
||||
```
|
||||
|
||||
执行结构迁移(**幂等,可反复跑**):
|
||||
|
||||
```bash
|
||||
php tools/migrate.php # 执行迁移
|
||||
php tools/migrate.php --status # 只看当前版本,不改动
|
||||
```
|
||||
|
||||
该入口**只在命令行可用**,被 HTTP 访问时直接返回 404。
|
||||
`config.php` 不入库(含口令),只提交模板 `config.example.php`。
|
||||
所有配置项都支持环境变量覆盖(`DB_HOST` / `DB_NAME` / `AMAP_NAV_SRC` 等)。
|
||||
|
||||
### 4. Web 服务器
|
||||
|
||||
把站点根指向项目根目录。Apache 需允许 `.htaccess`(`uploads/` 下有一条禁止执行脚本的规则,
|
||||
不可删除);Nginx 请自行配置等价规则,并注意 `uploads/` 内不得解析 PHP。
|
||||
|
||||
确保 `uploads/` 对 Web 进程可写。
|
||||
|
||||
### 5. 高德地图
|
||||
|
||||
在 `config.php` 的 `amap` 段填写:
|
||||
在 `config.php` 的 `amap` 段填写(安装向导里填过的可跳过):
|
||||
|
||||
- `key` —— 服务端接口用
|
||||
- `security_js` —— 前端 JS API 2.0 的安全密钥
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* 未安装检测(放在所有框架加载之前):
|
||||
* config.php 不存在、或缺少 db.name 时,说明站点尚未安装,跳转安装向导。
|
||||
* 安装完成后 install.php 会自删(删除失败也无妨——config.php 本身就是锁,
|
||||
* 本检测会让未安装状态永远到不了业务路由)。
|
||||
*/
|
||||
$tripInstalled = false;
|
||||
if (is_file(__DIR__ . '/config.php')) {
|
||||
$tripCfg = require __DIR__ . '/config.php';
|
||||
$tripInstalled = is_array($tripCfg) && !empty($tripCfg['db']['name'] ?? null);
|
||||
unset($tripCfg);
|
||||
}
|
||||
if (!$tripInstalled) {
|
||||
header('Location: install.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
require __DIR__ . '/src/Database.php';
|
||||
require __DIR__ . '/src/Auth.php';
|
||||
require __DIR__ . '/src/helpers.php';
|
||||
|
||||
+529
@@ -0,0 +1,529 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* TripLog Web 安装向导(独立入口,不加载框架)
|
||||
* ============================================================================
|
||||
*
|
||||
* 流程:
|
||||
* 1) 已安装检测 —— config.php 存在且 db.name 非空 → 拒绝再装(config.php 本身就是锁);
|
||||
* 2) 环境检查 —— PHP 版本、扩展、目录可写;
|
||||
* 3) 表单 —— 网站标题 / 家庭名称 / 数据库 / 高德 / 管理员;
|
||||
* 4) 执行 —— 测库连接 → 跑 sql/schema.sql 全量建表 → src/Migrate.php 幂等迁移
|
||||
* → 写入基本种子数据(家庭、管理员、节点类型、角色标签、出行方式);
|
||||
* 5) 生成 config.php(临时文件 include 验证后 rename)→ 自动删除本文件。
|
||||
*
|
||||
* 安全设计:
|
||||
* * 「已安装」以 config.php 为准,安装器重复访问无害;
|
||||
* * 自删失败只提示手动删除,不阻塞使用;
|
||||
* * 所有输出经 e() 转义;CSRF token;密码不明文回显;
|
||||
* * config.php 写入前先写临时文件并 include 验证,失败即删,绝不留下半残配置。
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 0) 已安装检测(最先做,不做任何环境假设)
|
||||
// ---------------------------------------------------------------------------
|
||||
$ROOT = __DIR__;
|
||||
$CFG_FILE = $ROOT . '/config.php';
|
||||
$installed = false;
|
||||
if (is_file($CFG_FILE)) {
|
||||
$cfgProbe = @include $CFG_FILE;
|
||||
$installed = is_array($cfgProbe) && !empty($cfgProbe['db']['name'] ?? null);
|
||||
unset($cfgProbe);
|
||||
}
|
||||
unset($CFG_FILE);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1) 会话与工具
|
||||
// ---------------------------------------------------------------------------
|
||||
session_start();
|
||||
if (empty($_SESSION['install_csrf'])) {
|
||||
$_SESSION['install_csrf'] = bin2hex(random_bytes(16));
|
||||
}
|
||||
|
||||
/** HTML 转义 */
|
||||
function e(string $s): string
|
||||
{
|
||||
return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/** 手机号加密 —— 必须与 src/Controllers/UserController::encrypt() 保持一致 */
|
||||
function install_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);
|
||||
}
|
||||
|
||||
/** 环境检查:返回 [项名, 是否通过, 说明] 列表 */
|
||||
function env_checks(string $root): array
|
||||
{
|
||||
$exts = ['pdo_mysql', 'openssl', 'gd', 'curl', 'mbstring', 'json', 'session'];
|
||||
$rows = [];
|
||||
$rows[] = ['PHP 版本 ≥ 7.4', version_compare(PHP_VERSION, '7.4.0', '>='), '当前 ' . PHP_VERSION];
|
||||
foreach ($exts as $ext) {
|
||||
$rows[] = ["扩展 {$ext}", extension_loaded($ext), extension_loaded($ext) ? '已加载' : '未安装'];
|
||||
}
|
||||
$rows[] = ['站点根目录可写(用于生成 config.php)', is_writable($root), is_writable($root) ? '可写' : '不可写'];
|
||||
$rows[] = ['uploads/ 目录可写(用于存放照片)', is_writable($root . '/uploads'), is_writable($root . '/uploads') ? '可写' : '不可写'];
|
||||
$rows[] = ['sql/schema.sql 存在', is_file($root . '/sql/schema.sql'), is_file($root . '/sql/schema.sql') ? '存在' : '缺失'];
|
||||
return $rows;
|
||||
}
|
||||
|
||||
/** 把 schema.sql 拆成可逐条执行的 SQL(剥离注释行、过滤空段) */
|
||||
function schema_statements(string $root): array
|
||||
{
|
||||
$raw = (string)file_get_contents($root . '/sql/schema.sql');
|
||||
$parts = preg_split('/;\s*[\r\n]+/', $raw) ?: [];
|
||||
$out = [];
|
||||
foreach ($parts as $part) {
|
||||
// 去掉每段里以 -- 开头的注释行
|
||||
$lines = preg_grep('/^\s*--/', explode("\n", $part), PREG_GREP_INVERT) ?: [];
|
||||
$stmt = trim(implode("\n", $lines));
|
||||
if ($stmt !== '' && stripos($stmt, 'CREATE TABLE') === 0) {
|
||||
$out[] = $stmt . ';';
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2) 已安装:直接拒绝
|
||||
// ---------------------------------------------------------------------------
|
||||
if ($installed) {
|
||||
?><!DOCTYPE html><html lang="zh-CN"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>TripLog 安装</title></head>
|
||||
<body style="font-family:system-ui,-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;background:#f4f6fa;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;">
|
||||
<div style="background:#fff;border-radius:14px;padding:40px 44px;max-width:460px;box-shadow:0 8px 30px rgba(30,50,90,.10);text-align:center;">
|
||||
<div style="font-size:44px;">✅</div>
|
||||
<h1 style="font-size:20px;margin:12px 0 8px;">TripLog 已安装</h1>
|
||||
<p style="color:#6b7686;font-size:14px;line-height:1.7;margin:0 0 20px;">
|
||||
检测到站点已完成安装(config.php 已存在)。<br>
|
||||
为安全起见,安装向导已停用。<br>
|
||||
如果 <code>install.php</code> 仍在站点目录中,请手动删除它。
|
||||
</p>
|
||||
<a href="index.php" style="display:inline-block;background:#3d6bf5;color:#fff;text-decoration:none;border-radius:8px;padding:10px 28px;font-size:14px;">进入首页</a>
|
||||
</div></body></html><?php
|
||||
exit;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3) 安装执行(POST)
|
||||
// ---------------------------------------------------------------------------
|
||||
$errors = [];
|
||||
$values = [
|
||||
'site_name' => '',
|
||||
'family_name' => '我的家庭',
|
||||
'db_host' => '127.0.0.1',
|
||||
'db_port' => '3306',
|
||||
'db_name' => '',
|
||||
'db_user' => '',
|
||||
'db_pass' => '',
|
||||
'amap_key' => '',
|
||||
'amap_sec' => '',
|
||||
'amap_nav_src' => 'trip.example.com',
|
||||
'admin_name' => '',
|
||||
'admin_phone' => '',
|
||||
];
|
||||
|
||||
$done = false;
|
||||
$removed = false; // install.php 是否自删成功
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||
$token = (string)($_POST['_csrf'] ?? '');
|
||||
if (!hash_equals($_SESSION['install_csrf'], $token)) {
|
||||
$errors[] = '页面已过期,请刷新后重试。';
|
||||
} else {
|
||||
// ---- 收集(密码类不回填,单独处理)----
|
||||
foreach ($values as $k => $_) {
|
||||
if (isset($_POST[$k]) && is_string($_POST[$k])) {
|
||||
$values[$k] = trim($_POST[$k]);
|
||||
}
|
||||
}
|
||||
$adminPass = (string)($_POST['admin_pass'] ?? '');
|
||||
$adminPass2 = (string)($_POST['admin_pass2'] ?? '');
|
||||
$dbPass = $values['db_pass'];
|
||||
|
||||
// ---- 校验 ----
|
||||
if ($values['site_name'] === '') {
|
||||
$errors[] = '网站标题不能为空';
|
||||
} elseif (mb_strlen($values['site_name']) > 50) {
|
||||
$errors[] = '网站标题不能超过 50 个字符';
|
||||
}
|
||||
if ($values['family_name'] === '') {
|
||||
$values['family_name'] = '我的家庭';
|
||||
} elseif (mb_strlen($values['family_name']) > 30) {
|
||||
$errors[] = '家庭名称不能超过 30 个字符';
|
||||
}
|
||||
if (!preg_match('/^\d{1,5}$/', $values['db_port']) || (int)$values['db_port'] < 1 || (int)$values['db_port'] > 65535) {
|
||||
$errors[] = '数据库端口不正确';
|
||||
}
|
||||
if (!preg_match('/^[A-Za-z0-9_]+$/', $values['db_name'])) {
|
||||
$errors[] = '数据库名只能包含字母、数字和下划线';
|
||||
}
|
||||
if ($values['db_user'] === '') {
|
||||
$errors[] = '数据库用户名不能为空';
|
||||
}
|
||||
if ($values['admin_name'] === '') {
|
||||
$errors[] = '管理员姓名不能为空';
|
||||
}
|
||||
if (!preg_match('/^1[3-9]\d{9}$/', $values['admin_phone'])) {
|
||||
$errors[] = '管理员手机号格式不正确(11 位,1 开头)';
|
||||
}
|
||||
if (strlen($adminPass) < 6) {
|
||||
$errors[] = '管理员密码至少 6 位';
|
||||
}
|
||||
if ($adminPass !== $adminPass2) {
|
||||
$errors[] = '两次输入的管理员密码不一致';
|
||||
}
|
||||
if ($values['amap_nav_src'] === '') {
|
||||
$values['amap_nav_src'] = 'trip.example.com';
|
||||
}
|
||||
|
||||
// ---- 执行安装 ----
|
||||
if (!$errors) {
|
||||
$pdo = null;
|
||||
try {
|
||||
$dsn = sprintf(
|
||||
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
|
||||
$values['db_host'], (int)$values['db_port'], $values['db_name']
|
||||
);
|
||||
$pdo = new PDO($dsn, $values['db_user'], $dbPass, [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
]);
|
||||
} catch (PDOException $ex) {
|
||||
$code = (int)$ex->getCode();
|
||||
$msg = $ex->getMessage();
|
||||
if (strpos($msg, '1049') !== false || $code === 1049
|
||||
|| strpos($msg, '1044') !== false || $code === 1044) {
|
||||
// MySQL 8 对不存在的库报 1049,MariaDB 报 1044(Access denied)——
|
||||
// 对用户来说两种情况都是「库没建好或账号不对」,合并成一条可操作的提示
|
||||
$errors[] = '数据库不存在,或该用户无权访问「' . $values['db_name'] . '」:请先在 MySQL 里创建名为「'
|
||||
. $values['db_name'] . '」的空数据库(CREATE DATABASE ' . $values['db_name']
|
||||
. ' DEFAULT CHARACTER SET utf8mb4;)并把它的全部权限授予刚才填写的用户,再回到本页重试。';
|
||||
} elseif (strpos($msg, '1045') !== false || $code === 1045) {
|
||||
$errors[] = '无法连接数据库主机「' . $values['db_host'] . '」,请确认主机地址与端口。';
|
||||
} else {
|
||||
$errors[] = '数据库连接失败:' . $msg;
|
||||
}
|
||||
}
|
||||
|
||||
if ($pdo) {
|
||||
try {
|
||||
// ---- 建表(schema.sql 全量 + Migrate 增量,均幂等)----
|
||||
$stmts = schema_statements($ROOT);
|
||||
if (count($stmts) < 10) {
|
||||
throw new RuntimeException('sql/schema.sql 内容异常(仅解析出 ' . count($stmts) . ' 条建表语句)');
|
||||
}
|
||||
foreach ($stmts as $stmt) {
|
||||
$pdo->exec($stmt);
|
||||
}
|
||||
require $ROOT . '/src/Migrate.php';
|
||||
\App\Migrate::run($pdo);
|
||||
|
||||
// ---- 种子数据(一个事务;均只在空表时插入,重复执行不重复)----
|
||||
$pdo->beginTransaction();
|
||||
$familyName = $values['family_name'];
|
||||
$pdo->prepare('INSERT INTO families (name) VALUES (?)')->execute([$familyName]);
|
||||
$familyId = (int)$pdo->lastInsertId();
|
||||
|
||||
$pdo->prepare('INSERT INTO users
|
||||
(family_id, name, phone_encrypted, phone_hash, password_hash, role, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)')
|
||||
->execute([
|
||||
$familyId,
|
||||
$values['admin_name'],
|
||||
install_encrypt($values['admin_phone']),
|
||||
hash('sha256', $values['admin_phone']),
|
||||
password_hash($adminPass, PASSWORD_DEFAULT),
|
||||
'admin',
|
||||
'active',
|
||||
]);
|
||||
|
||||
// 节点类型(内置,不可删除)
|
||||
$insType = $pdo->prepare('INSERT IGNORE INTO node_types (family_id, name, icon, is_system) VALUES (?, ?, ?, 1)');
|
||||
foreach ([
|
||||
['游玩', '🎡'], ['用餐', '🍜'], ['住宿', '🏨'], ['赶路', '🚄'], ['休息', '☕'],
|
||||
] as $t) {
|
||||
$insType->execute([$familyId, $t[0], $t[1]]);
|
||||
}
|
||||
|
||||
// 角色标签
|
||||
$insTag = $pdo->prepare('INSERT IGNORE INTO role_tags (family_id, name, icon) VALUES (?, ?, ?)');
|
||||
foreach ([
|
||||
['司机', '🚗'], ['摄影担当', '📷'], ['向导', '🗺️'], ['财务', '💰'],
|
||||
] as $t) {
|
||||
$insTag->execute([$familyId, $t[0], $t[1]]);
|
||||
}
|
||||
|
||||
// 出行方式:不含自驾两种(自驾需先在「车辆管理」里添加车辆,
|
||||
// travel_modes.vehicle_id 外键指向车辆,空挂没有意义)
|
||||
$insMode = $pdo->prepare('INSERT IGNORE INTO travel_modes (family_id, mode_type) VALUES (?, ?)');
|
||||
foreach (['train', 'plane', 'bus', 'subway', 'taxi', 'bike', 'walk', 'ship', 'other'] as $m) {
|
||||
$insMode->execute([$familyId, $m]);
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
|
||||
// ---- 生成 config.php(临时文件 → include 验证 → rename)----
|
||||
$configArr = [
|
||||
'db' => [
|
||||
'host' => $values['db_host'],
|
||||
'port' => (int)$values['db_port'],
|
||||
'name' => $values['db_name'],
|
||||
'user' => $values['db_user'],
|
||||
'pass' => $dbPass,
|
||||
'charset' => 'utf8mb4',
|
||||
],
|
||||
'app' => [
|
||||
'name' => $values['site_name'],
|
||||
'timezone' => 'Asia/Shanghai',
|
||||
],
|
||||
'amap' => [
|
||||
'key' => $values['amap_key'],
|
||||
'security_js' => $values['amap_sec'],
|
||||
'nav_src' => $values['amap_nav_src'],
|
||||
],
|
||||
];
|
||||
$php = "<?php\n"
|
||||
. "/**\n"
|
||||
. " * 由 TripLog 安装向导生成于 " . date('Y-m-d H:i:s') . "\n"
|
||||
. " * 结构与 config.example.php 一致;手工调整后注意保持键完整。\n"
|
||||
. " */\n"
|
||||
. 'return ' . var_export($configArr, true) . ";\n";
|
||||
$tmp = $ROOT . '/config.install-tmp.php';
|
||||
if (@file_put_contents($tmp, $php) === false) {
|
||||
throw new RuntimeException('无法写入配置文件(站点根目录不可写)。请把 Web 运行账号对该目录授予写权限后重试。');
|
||||
}
|
||||
$check = @include $tmp;
|
||||
if (!is_array($check) || empty($check['db']['name']) || empty($check['app']['name'])) {
|
||||
@unlink($tmp);
|
||||
throw new RuntimeException('生成的配置文件校验未通过,已中止。请重试安装。');
|
||||
}
|
||||
if (!@rename($tmp, $ROOT . '/config.php')) {
|
||||
@unlink($tmp);
|
||||
throw new RuntimeException('配置文件落位失败(无法重命名为 config.php)。请检查站点根目录写权限后重试。');
|
||||
}
|
||||
|
||||
// ---- 自删 install.php(失败不阻塞,config.php 已是锁)----
|
||||
$removed = @unlink(__FILE__);
|
||||
|
||||
$done = true;
|
||||
} catch (Throwable $ex) {
|
||||
if ($pdo && $pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
$errors[] = $ex instanceof PDOException
|
||||
? '安装执行失败:' . $ex->getMessage()
|
||||
: $ex->getMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4) 结果页 / 表单页
|
||||
// ---------------------------------------------------------------------------
|
||||
$checks = env_checks($ROOT);
|
||||
$envOk = true;
|
||||
foreach ($checks as $c) {
|
||||
if (!$c[1]) { $envOk = false; break; }
|
||||
}
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex,nofollow">
|
||||
<title>TripLog 安装向导</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family:system-ui,-apple-system,'PingFang SC','Microsoft YaHei',sans-serif;
|
||||
background:#f4f6fa; color:#243041; font-size:14px; }
|
||||
.wrap { max-width:640px; margin:0 auto; padding:36px 16px 60px; }
|
||||
.head { text-align:center; margin-bottom:26px; }
|
||||
.head .logo { font-size:40px; }
|
||||
.head h1 { font-size:22px; margin:8px 0 4px; }
|
||||
.head p { color:#6b7686; margin:0; }
|
||||
.card { background:#fff; border-radius:14px; padding:24px 26px; margin-bottom:18px;
|
||||
box-shadow:0 8px 30px rgba(30,50,90,.10); }
|
||||
.card h2 { font-size:15px; margin:0 0 14px; padding-bottom:10px; border-bottom:1px solid #eef1f6; }
|
||||
.check-row { display:flex; align-items:center; gap:10px; padding:5px 0; }
|
||||
.check-ok { color:#178a50; }
|
||||
.check-bad { color:#c6362f; }
|
||||
.check-note { color:#98a2b3; font-size:12px; margin-left:auto; }
|
||||
.field { margin-bottom:14px; }
|
||||
.field label { display:block; font-weight:600; margin-bottom:5px; }
|
||||
.field label .req { color:#c6362f; }
|
||||
.field input { width:100%; border:1px solid #d8dfe9; border-radius:8px; padding:9px 12px;
|
||||
font-size:14px; background:#fbfcfe; }
|
||||
.field input:focus { outline:none; border-color:#3d6bf5; background:#fff; }
|
||||
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:0 14px; }
|
||||
.hint { color:#8a94a6; font-size:12px; margin-top:4px; line-height:1.6; }
|
||||
.btn { display:block; width:100%; background:#3d6bf5; color:#fff; border:0; border-radius:9px;
|
||||
padding:12px; font-size:15px; font-weight:600; cursor:pointer; }
|
||||
.btn:disabled { background:#b9c6dd; cursor:not-allowed; }
|
||||
.btn-go { display:inline-block; background:#3d6bf5; color:#fff; text-decoration:none;
|
||||
border-radius:8px; padding:11px 34px; font-size:15px; font-weight:600; }
|
||||
.errors { background:#fdf1f0; border:1px solid #f3c2be; color:#a1322b; border-radius:9px;
|
||||
padding:12px 16px; margin-bottom:18px; line-height:1.8; }
|
||||
.okbox { text-align:center; padding:14px 0 4px; }
|
||||
.okbox .big { font-size:48px; }
|
||||
.okbox h1 { font-size:21px; margin:10px 0 6px; }
|
||||
.okbox p { color:#6b7686; line-height:1.8; margin:0 0 22px; }
|
||||
code { background:#eef2f8; border-radius:4px; padding:1px 6px; font-size:12.5px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="head">
|
||||
<div class="logo">🧭</div>
|
||||
<h1>TripLog 安装向导</h1>
|
||||
<p>家庭旅行日记 · 首次部署配置</p>
|
||||
</div>
|
||||
|
||||
<?php if ($done): ?>
|
||||
<div class="card">
|
||||
<div class="okbox">
|
||||
<div class="big">🎉</div>
|
||||
<h1>安装完成!</h1>
|
||||
<p>
|
||||
数据表已创建,基本数据已就绪(节点类型、角色标签、出行方式)。<br>
|
||||
请使用刚才设置的管理员手机号和密码登录。<br>
|
||||
<?php if (!$removed): ?>
|
||||
<span style="color:#c6362f;">⚠️ 安装文件 install.php 未能自动删除,请手动删除它。</span><br>
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<a class="btn-go" href="index.php">开始使用</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php else: ?>
|
||||
|
||||
<?php if ($errors): ?>
|
||||
<div class="errors">
|
||||
<strong>安装未完成,请修正以下问题:</strong><br>
|
||||
<?php foreach ($errors as $err): ?>· <?= e($err) ?><br><?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card">
|
||||
<h2>环境检查</h2>
|
||||
<?php foreach ($checks as [$label, $ok, $note]): ?>
|
||||
<div class="check-row">
|
||||
<span class="<?= $ok ? 'check-ok' : 'check-bad' ?>"><?= $ok ? '✓' : '✗' ?></span>
|
||||
<span><?= e($label) ?></span>
|
||||
<span class="check-note"><?= e($note) ?></span>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<form method="post" autocomplete="off">
|
||||
<input type="hidden" name="_csrf" value="<?= e($_SESSION['install_csrf']) ?>">
|
||||
|
||||
<div class="card">
|
||||
<h2>站点信息</h2>
|
||||
<div class="field">
|
||||
<label>网站标题 <span class="req">*</span></label>
|
||||
<input type="text" name="site_name" maxlength="50" required
|
||||
placeholder="例如:我们一家的旅行日记"
|
||||
value="<?= e($values['site_name']) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>家庭名称</label>
|
||||
<input type="text" name="family_name" maxlength="30"
|
||||
placeholder="我的家庭" value="<?= e($values['family_name']) ?>">
|
||||
<div class="hint">所有成员、行程、花销都归属这个家庭;之后还可以在后台调整。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>数据库(MySQL)</h2>
|
||||
<div class="hint" style="margin:0 0 12px;">
|
||||
请先在 MySQL 中创建一个<strong>空的</strong>数据库,表结构由安装向导自动生成。
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field">
|
||||
<label>主机 <span class="req">*</span></label>
|
||||
<input type="text" name="db_host" required value="<?= e($values['db_host']) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>端口 <span class="req">*</span></label>
|
||||
<input type="text" name="db_port" required value="<?= e($values['db_port']) ?>">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>数据库名 <span class="req">*</span></label>
|
||||
<input type="text" name="db_name" required placeholder="trip"
|
||||
value="<?= e($values['db_name']) ?>">
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field">
|
||||
<label>用户名 <span class="req">*</span></label>
|
||||
<input type="text" name="db_user" required value="<?= e($values['db_user']) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>密码 <span class="req">*</span></label>
|
||||
<input type="password" name="db_pass" autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>高德开放平台(可稍后在 config.php 中补充)</h2>
|
||||
<div class="hint" style="margin:0 0 12px;">
|
||||
用于地图展示与地点搜索。<a href="https://lbs.amap.com/" target="_blank" rel="noopener">lbs.amap.com</a>
|
||||
→ 控制台 → 应用管理 → 创建应用(Web端(JS API) + Web服务)后填写。
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Key(Web端 JS API)</label>
|
||||
<input type="text" name="amap_key" value="<?= e($values['amap_key']) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>安全密钥(securityJsCode)</label>
|
||||
<input type="text" name="amap_sec" value="<?= e($values['amap_sec']) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>导航来源标识(src)</label>
|
||||
<input type="text" name="amap_nav_src" value="<?= e($values['amap_nav_src']) ?>">
|
||||
<div class="hint">仅用于高德侧的调用来源统计,填任意英数字串均可,通常填你的域名。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>管理员账号</h2>
|
||||
<div class="field">
|
||||
<label>姓名 <span class="req">*</span></label>
|
||||
<input type="text" name="admin_name" maxlength="50" required
|
||||
value="<?= e($values['admin_name']) ?>">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>手机号 <span class="req">*</span></label>
|
||||
<input type="text" name="admin_phone" maxlength="11" required placeholder="11 位手机号(用于登录)"
|
||||
value="<?= e($values['admin_phone']) ?>">
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="field">
|
||||
<label>密码 <span class="req">*</span></label>
|
||||
<input type="password" name="admin_pass" minlength="6" required autocomplete="new-password">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>确认密码 <span class="req">*</span></label>
|
||||
<input type="password" name="admin_pass2" minlength="6" required autocomplete="new-password">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn" type="submit" <?= $envOk ? '' : 'disabled' ?>>
|
||||
<?= $envOk ? '开始安装' : '环境检查未通过,无法安装' ?>
|
||||
</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
-- ============================================================================
|
||||
-- TripLog 全量建表脚本(安装向导使用)
|
||||
--
|
||||
-- 来源:2026-09-28 从生产库 SHOW CREATE TABLE 导出后整理:
|
||||
-- * 去掉导出文件里的 AUTO_INCREMENT=N 当前值;
|
||||
-- * 全部 CREATE TABLE 加 IF NOT EXISTS(重复执行不破坏已有库);
|
||||
-- * 按「外键依赖」排好建表顺序:families → users → 字典表 → 业务表。
|
||||
--
|
||||
-- 由 install.php(Web 安装向导)在安装时执行;tools/migrate.php 只负责
|
||||
-- 之后的**增量**迁移(见 src/Migrate.php),两者幂等、互不冲突。
|
||||
-- ============================================================================
|
||||
|
||||
-- ---- 家庭空间(一切数据的根:users / 字典表都挂在 family 下)----
|
||||
CREATE TABLE IF NOT EXISTS `families` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '家庭名称',
|
||||
`default_home_location` json DEFAULT NULL COMMENT '默认家地址 {name, address, lat, lng}',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='家庭空间';
|
||||
|
||||
-- ---- 用户表 ----
|
||||
CREATE TABLE IF NOT EXISTS `users` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`family_id` bigint NOT NULL COMMENT '所属家庭',
|
||||
`name` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '姓名',
|
||||
`phone_encrypted` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'AES加密手机号',
|
||||
`phone_hash` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '手机号SHA-256哈希,用于登录查找',
|
||||
`id_card_encrypted` varchar(300) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'AES加密身份证号',
|
||||
`password_hash` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '密码哈希,仅admin/user有',
|
||||
`role` enum('admin','user','guest') COLLATE utf8mb4_unicode_ci DEFAULT 'guest' COMMENT '角色',
|
||||
`avatar` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '头像URL',
|
||||
`status` enum('active','inactive') COLLATE utf8mb4_unicode_ci DEFAULT 'active' COMMENT '状态',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_family_role` (`family_id`,`role`),
|
||||
KEY `idx_phone_hash` (`phone_hash`),
|
||||
CONSTRAINT `fk_users_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
|
||||
|
||||
-- ---- 节点类型库 ----
|
||||
CREATE TABLE IF NOT EXISTS `node_types` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`family_id` bigint NOT NULL,
|
||||
`name` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '游玩/用餐/赶路/休息/突发',
|
||||
`icon` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '图标/表情',
|
||||
`is_system` tinyint(1) DEFAULT '0' COMMENT '内置类型不可删除',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_family_name` (`family_id`,`name`),
|
||||
CONSTRAINT `fk_node_types_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='节点类型库';
|
||||
|
||||
-- ---- 角色标签库 ----
|
||||
CREATE TABLE IF NOT EXISTS `role_tags` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`family_id` bigint NOT NULL,
|
||||
`name` varchar(30) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '角色名:司机/摄影担当',
|
||||
`icon` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '图标/表情',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_family_name` (`family_id`,`name`),
|
||||
CONSTRAINT `fk_role_tags_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色标签库';
|
||||
|
||||
-- ---- 车辆库 ----
|
||||
CREATE TABLE IF NOT EXISTS `vehicles` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`family_id` bigint NOT NULL,
|
||||
`plate_number` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车牌号',
|
||||
`model_name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车型名称',
|
||||
`vehicle_type` enum('own','rental') COLLATE utf8mb4_unicode_ci DEFAULT 'own' COMMENT '自有/租车',
|
||||
`rental_company` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '租车公司',
|
||||
`rental_order_no` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '租车订单号',
|
||||
`is_default` tinyint(1) DEFAULT '0' COMMENT '是否为默认自有车',
|
||||
`notes` text COLLATE utf8mb4_unicode_ci COMMENT '备注',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_family_type` (`family_id`,`vehicle_type`),
|
||||
CONSTRAINT `fk_vehicles_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆库';
|
||||
|
||||
-- ---- 出行方式 ----
|
||||
CREATE TABLE IF NOT EXISTS `travel_modes` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`family_id` bigint NOT NULL,
|
||||
`mode_type` enum('drive_own','drive_rental','train','plane','bus','subway','taxi','bike','walk','ship','other') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '交通类型',
|
||||
`vehicle_id` bigint DEFAULT NULL COMMENT '仅drive_own/drive_rental时关联vehicles',
|
||||
`notes` text COLLATE utf8mb4_unicode_ci COMMENT '备注(延误等)',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_family_type` (`family_id`,`mode_type`),
|
||||
KEY `fk_travel_modes_vehicle` (`vehicle_id`),
|
||||
CONSTRAINT `fk_travel_modes_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_travel_modes_vehicle` FOREIGN KEY (`vehicle_id`) REFERENCES `vehicles` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出行方式';
|
||||
|
||||
-- ---- 活动容器 ----
|
||||
CREATE TABLE IF NOT EXISTS `activities` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`family_id` bigint NOT NULL,
|
||||
`title` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '活动主题',
|
||||
`description` text COLLATE utf8mb4_unicode_ci COMMENT '活动简介/前言',
|
||||
`cover_image` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '封面图URL',
|
||||
`created_by` bigint NOT NULL COMMENT '创建者user_id',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_family_created` (`family_id`,`created_at`),
|
||||
KEY `fk_activities_creator` (`created_by`),
|
||||
CONSTRAINT `fk_activities_creator` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`),
|
||||
CONSTRAINT `fk_activities_family` FOREIGN KEY (`family_id`) REFERENCES `families` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='活动容器';
|
||||
|
||||
-- ---- 活动级人员名单 ----
|
||||
CREATE TABLE IF NOT EXISTS `activity_members` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`activity_id` bigint NOT NULL,
|
||||
`user_id` bigint NOT NULL,
|
||||
`permission` enum('owner','editor','viewer') COLLATE utf8mb4_unicode_ci DEFAULT 'viewer' COMMENT '权限',
|
||||
`joined_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间',
|
||||
`left_at` datetime DEFAULT NULL COMMENT '离开时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_activity_user` (`activity_id`,`user_id`),
|
||||
KEY `idx_activity` (`activity_id`),
|
||||
KEY `fk_activity_members_user` (`user_id`),
|
||||
CONSTRAINT `fk_activity_members_activity` FOREIGN KEY (`activity_id`) REFERENCES `activities` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_activity_members_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='活动级人员名单';
|
||||
|
||||
-- ---- 行程节点(计划/执行双轨)----
|
||||
CREATE TABLE IF NOT EXISTS `trip_nodes` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`activity_id` bigint NOT NULL,
|
||||
`node_type_id` bigint NOT NULL COMMENT '关联node_types',
|
||||
`node_category` enum('plan','reality') COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'plan=计划, reality=执行',
|
||||
`original_plan_node_id` bigint DEFAULT NULL COMMENT '执行节点克隆自哪个计划节点(空=临时新增)',
|
||||
`start_time` datetime NOT NULL COMMENT '开始时间',
|
||||
`end_time` datetime NOT NULL COMMENT '结束时间',
|
||||
`duration_minutes` int DEFAULT NULL COMMENT '持续分钟数',
|
||||
`start_location` json DEFAULT NULL COMMENT '位置信息',
|
||||
`end_location` json DEFAULT NULL COMMENT '位置信息',
|
||||
`title` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '节点标题',
|
||||
`description` text COLLATE utf8mb4_unicode_ci COMMENT '详细文字描述',
|
||||
`vehicle_id` bigint DEFAULT NULL COMMENT '关联vehicles(仅自驾)',
|
||||
`travel_mode_id` bigint DEFAULT NULL COMMENT '关联travel_modes(赶路节点必填)',
|
||||
`status` enum('planned','ongoing','completed','cancelled') COLLATE utf8mb4_unicode_ci DEFAULT 'planned',
|
||||
`cancel_reason` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '取消原因',
|
||||
`sort_order` int DEFAULT '0' COMMENT '手动排序',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
`line_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '线路名称(如京沪高铁、地铁10号线)',
|
||||
`carrier` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '承运公司(如中国铁路、滴滴)',
|
||||
`trip_number` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车次/航班号/线路号',
|
||||
`seat_info` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '座位号/车厢',
|
||||
`gate` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '登机口/站台',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_activity_category` (`activity_id`,`node_category`,`start_time`),
|
||||
KEY `idx_original` (`original_plan_node_id`),
|
||||
KEY `idx_travel_mode` (`travel_mode_id`),
|
||||
KEY `fk_trip_nodes_type` (`node_type_id`),
|
||||
KEY `fk_trip_nodes_vehicle` (`vehicle_id`),
|
||||
CONSTRAINT `fk_trip_nodes_activity` FOREIGN KEY (`activity_id`) REFERENCES `activities` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_trip_nodes_original` FOREIGN KEY (`original_plan_node_id`) REFERENCES `trip_nodes` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_trip_nodes_travel` FOREIGN KEY (`travel_mode_id`) REFERENCES `travel_modes` (`id`) ON DELETE SET NULL,
|
||||
CONSTRAINT `fk_trip_nodes_type` FOREIGN KEY (`node_type_id`) REFERENCES `node_types` (`id`),
|
||||
CONSTRAINT `fk_trip_nodes_vehicle` FOREIGN KEY (`vehicle_id`) REFERENCES `vehicles` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行程节点(计划/执行双轨)';
|
||||
|
||||
-- ---- 节点级参与人员 ----
|
||||
CREATE TABLE IF NOT EXISTS `node_members` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`trip_node_id` bigint NOT NULL,
|
||||
`user_id` bigint NOT NULL,
|
||||
`role_tag_ids` json DEFAULT NULL COMMENT '[12, 34] 角色ID数组',
|
||||
`emotion` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '情绪表情(仅执行节点)',
|
||||
`check_in_time` datetime DEFAULT NULL COMMENT '签到时间',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_node_user` (`trip_node_id`,`user_id`),
|
||||
KEY `idx_node` (`trip_node_id`),
|
||||
KEY `fk_node_members_user` (`user_id`),
|
||||
CONSTRAINT `fk_node_members_node` FOREIGN KEY (`trip_node_id`) REFERENCES `trip_nodes` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_node_members_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='节点级参与人员';
|
||||
|
||||
-- ---- 花销记录 ----
|
||||
CREATE TABLE IF NOT EXISTS `expenses` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`trip_node_id` bigint NOT NULL,
|
||||
`payer_user_id` bigint NOT NULL COMMENT '付款人',
|
||||
`currency` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT 'CNY' COMMENT '币种',
|
||||
`total_amount` decimal(12,2) NOT NULL COMMENT '总金额',
|
||||
`category` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '餐饮/门票/交通/购物/其他',
|
||||
`description` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '消费描述',
|
||||
`is_budget` tinyint(1) DEFAULT '0' COMMENT 'TRUE=预算, FALSE=实际',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_node_budget` (`trip_node_id`,`is_budget`),
|
||||
KEY `fk_expenses_payer` (`payer_user_id`),
|
||||
CONSTRAINT `fk_expenses_node` FOREIGN KEY (`trip_node_id`) REFERENCES `trip_nodes` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_expenses_payer` FOREIGN KEY (`payer_user_id`) REFERENCES `users` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='花销记录';
|
||||
|
||||
-- ---- 花销分摊明细 ----
|
||||
CREATE TABLE IF NOT EXISTS `expense_splits` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`expense_id` bigint NOT NULL,
|
||||
`user_id` bigint NOT NULL COMMENT '分摊对象(为谁花的)',
|
||||
`share_amount` decimal(12,2) NOT NULL COMMENT '承担金额',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_expense` (`expense_id`),
|
||||
KEY `fk_expense_splits_user` (`user_id`),
|
||||
CONSTRAINT `fk_expense_splits_expense` FOREIGN KEY (`expense_id`) REFERENCES `expenses` (`id`) ON DELETE CASCADE,
|
||||
CONSTRAINT `fk_expense_splits_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='花销分摊明细';
|
||||
|
||||
-- ---- 评论表 ----
|
||||
CREATE TABLE IF NOT EXISTS `comments` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`author_id` bigint NOT NULL,
|
||||
`target_type` enum('activity','trip_node') COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`target_id` bigint NOT NULL,
|
||||
`parent_id` bigint DEFAULT NULL COMMENT '父评论ID(楼中楼)',
|
||||
`content` text COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_target` (`target_type`,`target_id`,`created_at`),
|
||||
KEY `fk_comments_author` (`author_id`),
|
||||
KEY `fk_comments_parent` (`parent_id`),
|
||||
CONSTRAINT `fk_comments_author` FOREIGN KEY (`author_id`) REFERENCES `users` (`id`),
|
||||
CONSTRAINT `fk_comments_parent` FOREIGN KEY (`parent_id`) REFERENCES `comments` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评论表';
|
||||
|
||||
-- ---- 统一附件表 ----
|
||||
CREATE TABLE IF NOT EXISTS `attachments` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT,
|
||||
`target_type` enum('activity','trip_node','comment') COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`target_id` bigint NOT NULL COMMENT '对应表的ID',
|
||||
`uploader_id` bigint NOT NULL,
|
||||
`file_url` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`file_name` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
`sort_order` int DEFAULT '0',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP,
|
||||
`storage` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'local' COMMENT 'local|oss',
|
||||
`object_key` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'OSS 对象键,或本地相对路径',
|
||||
`mime_type` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||
`size_bytes` bigint DEFAULT NULL,
|
||||
`width` int DEFAULT NULL COMMENT '图片宽(非图片为空)',
|
||||
`height` int DEFAULT NULL COMMENT '图片高(非图片为空)',
|
||||
`is_image` tinyint(1) NOT NULL DEFAULT '0',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_target` (`target_type`,`target_id`),
|
||||
KEY `fk_attachments_uploader` (`uploader_id`),
|
||||
KEY `idx_storage` (`storage`),
|
||||
CONSTRAINT `fk_attachments_uploader` FOREIGN KEY (`uploader_id`) REFERENCES `users` (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='统一附件表';
|
||||
|
||||
-- ---- 站点级键值设置 ----
|
||||
CREATE TABLE IF NOT EXISTS `app_settings` (
|
||||
`setting_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
`setting_value` text COLLATE utf8mb4_unicode_ci,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`setting_key`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点级键值设置';
|
||||
@@ -14,7 +14,7 @@ $__initial = $__name !== '' ? mb_substr($__name, 0, 1) : '·';
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title><?= e($pageTitle ?? 'TripLog 家庭旅行日记') ?></title>
|
||||
<title><?= e($pageTitle ?? ((string)(app_config()['app']['name'] ?? '') ?: 'TripLog 家庭旅行日记')) ?></title>
|
||||
<?php /* 站点图标:一本翻开的日记本 —— 左页「家」、右页「路线 + 图钉」。
|
||||
ICO 放站点根(浏览器没写地址也会去 /favicon.ico 找),其余走 asset() 带版本号。 */ ?>
|
||||
<link rel="icon" href="<?= e(asset('favicon.ico')) ?>" sizes="any">
|
||||
|
||||
Reference in New Issue
Block a user