Преглед изворни кода

feat: P2 Twig 模板引擎与首页占位(布局/静态资源/转义)

caesar пре 1 месец
родитељ
комит
b3b982bf79

+ 10 - 18
app/controllers/HomeController.php

@@ -6,41 +6,33 @@ namespace Glacier\Controllers;
 
 use Glacier\Core\Logger;
 use Glacier\Core\Response;
+use Glacier\Core\View;
 
 /**
  * 首页控制器(本期"开发中"占位;后期公司新闻 + 功能导航)。
- * P1 暂用内联 HTML,P2 切换为 Twig 模板渲染。
+ * 脚本只产出数据,渲染交给 Twig 模板(见 docs/architecture.md §3.3)。
  */
 final class HomeController
 {
     public function index(array $params = []): never
     {
-        Response::html($this->page('Glacier · 开发中', '<h1>Glacier 公司管理系统</h1><p>开发中,敬请期待。</p>'));
+        Response::html(View::render('home/index.html.twig'));
     }
 
     /** 参数路由演示(P1 验证用,后续移除)。 */
     public function demo(array $params = []): never
     {
-        $name = $params['name'] ?? '';
-        Logger::info('demo route hit', ['name' => $name]);
-        Response::html($this->page('Demo · ' . $name, '<h1>参数路由演示</h1><p>参数 <code>name</code> = ' . e($name) . '</p>'));
+        Logger::info('demo route hit', ['name' => $params['name'] ?? '']);
+        Response::html(View::render('home/demo.html.twig', [
+            'name' => $params['name'] ?? '',
+        ]));
     }
 
     /** 可选段演示(P1 验证用,后续移除)。 */
     public function demoPage(array $params = []): never
     {
-        $page = $params['page'] ?? '1';
-        Response::html($this->page('DemoPage · ' . $page, '<h1>可选段演示</h1><p>当前页 = ' . e($page) . '</p>'));
-    }
-
-    private function page(string $title, string $body): string
-    {
-        return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8">'
-            . '<meta name="viewport" content="width=device-width, initial-scale=1">'
-            . '<title>' . e($title) . '</title>'
-            . '<style>body{font-family:system-ui,sans-serif;max-width:800px;margin:40px auto;padding:0 16px;color:#333}'
-            . 'h1{color:#0b7a5f;border-bottom:2px solid #0b7a5f;padding-bottom:8px}'
-            . 'code{background:#f0f0f0;padding:2px 6px;border-radius:4px}</style>'
-            . '</head><body>' . $body . '</body></html>';
+        Response::html(View::render('home/demo_page.html.twig', [
+            'page' => $params['page'] ?? '1',
+        ]));
     }
 }

+ 3 - 1
app/core/Router.php

@@ -79,7 +79,9 @@ final class Router
                 $params = [];
                 foreach ($matches as $key => $value) {
                     if (is_string($key)) {
-                        $params[$key] = $value;
+                        // 路径参数做百分号解码(rawurldecode 不把 + 当空格);
+                        // 输出安全由模板默认转义兜底。
+                        $params[$key] = rawurldecode($value);
                     }
                 }
                 return [

+ 47 - 0
app/core/View.php

@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Glacier\Core;
+
+use Twig\Environment;
+use Twig\Loader\FilesystemLoader;
+
+/**
+ * Twig 渲染封装。
+ * - 模板目录:app/views(模块模板后续追加 app/modules/<name>/views)
+ * - 默认自动转义(安全基线:XSS)
+ * - 开发环境关闭编译缓存,生产开启(storage/cache/twig)
+ * - 全局变量:app_name / base_url(模板生成链接,杜绝物理路径)
+ */
+final class View
+{
+    private static ?Environment $twig = null;
+
+    public static function render(string $template, array $data = []): string
+    {
+        return self::twig()->render($template, $data);
+    }
+
+    public static function twig(): Environment
+    {
+        if (self::$twig === null) {
+            $cacheDir = Config::get('storage.cache', APP_ROOT . '/storage/cache') . '/twig';
+            $debug = (bool) Config::get('app.debug', false);
+
+            $loader = new FilesystemLoader([
+                APP_ROOT . '/app/views',
+            ]);
+            self::$twig = new Environment($loader, [
+                'cache' => $debug ? false : $cacheDir,
+                'autoescape' => 'html',
+                'charset' => 'UTF-8',
+                'strict_variables' => false,
+                'debug' => $debug,
+            ]);
+            self::$twig->addGlobal('app_name', Config::get('app.name', 'Glacier'));
+            self::$twig->addGlobal('base_url', (string) Config::get('app.base_url', ''));
+        }
+        return self::$twig;
+    }
+}

+ 9 - 0
app/views/home/demo.html.twig

@@ -0,0 +1,9 @@
+{% extends 'layouts/base.html.twig' %}
+
+{% block title %}参数路由演示{% endblock %}
+
+{% block content %}
+<h1>参数路由演示</h1>
+<p>参数 <code>name</code> = {{ name }}</p>
+<p><a href="{{ base_url }}/">返回首页</a></p>
+{% endblock %}

+ 9 - 0
app/views/home/demo_page.html.twig

@@ -0,0 +1,9 @@
+{% extends 'layouts/base.html.twig' %}
+
+{% block title %}可选段演示{% endblock %}
+
+{% block content %}
+<h1>可选段演示</h1>
+<p>当前页 = {{ page }}</p>
+<p><a href="{{ base_url }}/">返回首页</a></p>
+{% endblock %}

+ 11 - 0
app/views/home/index.html.twig

@@ -0,0 +1,11 @@
+{% extends 'layouts/base.html.twig' %}
+
+{% block title %}{{ app_name }} · 开发中{% endblock %}
+
+{% block content %}
+<section class="hero">
+    <h1>{{ app_name }} 公司管理系统</h1>
+    <p class="muted">开发中,敬请期待。</p>
+    <p class="muted">后期将提供公司新闻与功能导航。</p>
+</section>
+{% endblock %}

+ 24 - 0
app/views/layouts/base.html.twig

@@ -0,0 +1,24 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>{% block title %}{{ app_name }} 管理系统{% endblock %}</title>
+    <link rel="stylesheet" href="{{ base_url }}/assets/app.css">
+</head>
+<body>
+    <header class="site-header">
+        <div class="container">
+            <a class="brand" href="{{ base_url }}/">{{ app_name }} 管理系统</a>
+        </div>
+    </header>
+
+    <main class="container">
+        {% block content %}{% endblock %}
+    </main>
+
+    <footer class="site-footer">
+        <div class="container">&copy; {{ 'now'|date('Y') }} {{ app_name }} · 内部系统</div>
+    </footer>
+</body>
+</html>

+ 0 - 0
public/assets/.gitkeep


+ 48 - 0
public/assets/app.css

@@ -0,0 +1,48 @@
+/* Glacier 基础样式(P2) */
+:root {
+    --brand: #0b7a5f;
+    --text: #333;
+    --muted: #777;
+    --bg: #f7f7f5;
+    --border: #e2e2dd;
+}
+
+* { box-sizing: border-box; }
+
+body {
+    margin: 0;
+    font-family: system-ui, "Microsoft YaHei", "PingFang SC", sans-serif;
+    color: var(--text);
+    background: var(--bg);
+    line-height: 1.6;
+}
+
+.container { max-width: 900px; margin: 0 auto; padding: 0 16px; }
+
+.site-header {
+    background: #fff;
+    border-bottom: 1px solid var(--border);
+    padding: 14px 0;
+}
+
+.brand {
+    font-size: 18px;
+    font-weight: 700;
+    color: var(--brand);
+    text-decoration: none;
+}
+
+.site-footer {
+    margin-top: 48px;
+    padding: 16px 0;
+    border-top: 1px solid var(--border);
+    color: var(--muted);
+    font-size: 13px;
+}
+
+.hero { padding: 48px 0; }
+.hero h1 { color: var(--brand); border-bottom: 2px solid var(--brand); padding-bottom: 12px; }
+.muted { color: var(--muted); }
+
+code { background: #eee; padding: 2px 6px; border-radius: 4px; }
+a { color: var(--brand); }

+ 31 - 0
public/index.php

@@ -30,6 +30,37 @@ try {
     }
     $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
 
+    // 开发辅助:静态资源(/assets/*)直接服务。
+    // 生产环境由 Web 服务器完成(Caddy/ Apache 的"真实文件直接返回"语义)。
+    // 仅限 public/assets 目录内,realpath 校验防路径穿越;URL 不映射业务物理路径。
+    if (str_starts_with($path, '/assets/')) {
+        $assetFile = APP_ROOT . '/public' . $path;
+        $real = realpath($assetFile);
+        $assetRoot = realpath(APP_ROOT . '/public/assets');
+        if ($real !== false && $assetRoot !== false && str_starts_with($real, $assetRoot)) {
+            // 常见类型映射(Windows fileinfo 对 css 等识别不稳定)
+            $ext = strtolower(pathinfo($real, PATHINFO_EXTENSION));
+            $mimeMap = [
+                'css' => 'text/css',
+                'js' => 'application/javascript',
+                'json' => 'application/json',
+                'png' => 'image/png',
+                'jpg' => 'image/jpeg',
+                'jpeg' => 'image/jpeg',
+                'gif' => 'image/gif',
+                'svg' => 'image/svg+xml',
+                'webp' => 'image/webp',
+                'ico' => 'image/x-icon',
+                'woff' => 'font/woff',
+                'woff2' => 'font/woff2',
+            ];
+            $mime = $mimeMap[$ext] ?? (function_exists('mime_content_type') ? mime_content_type($real) : false) ?: 'application/octet-stream';
+            header('Content-Type: ' . $mime);
+            readfile($real);
+            exit;
+        }
+    }
+
     Logger::info('request', ['method' => $method, 'path' => $path]);
 
     // 注册路由(声明式路由表)