docs: 开发规范/教程/demo 全套 + 修复插件运行期自动加载缺口
- demo 插件 plugins/demo.hello-world:演示短代码过滤器、comment.created 钩子、路由+命名空间视图、迁移+模型、后台设置页、filament.post_form 表单注入 - demo 主题 themes/demo:最小可运行主题(覆盖 partials + 绿色系样式) - 教程 docs/tutorial-plugin.md / docs/tutorial-theme.md(10 分钟上手);规范 docs/development.md(代码/插件/主题/队列/测试/提交) - README 增加开发者文档导航与上手示例 - 修复关键缺口:插件类原来靠 composer.json 硬编码加载,第三方 ZIP 安装的插件无法加载;现改为启动时扫描 plugins/*/src 运行期注册 PSR-4(register 阶段,保证 Filament 面板解析插件页面前就绪),composer.json 移除硬编码 - 测试:demo 插件 3 个用例 + demo 主题渲染;全量 89 通过
This commit is contained in:
@@ -120,9 +120,26 @@ php artisan workerman:serve stop
|
|||||||
## 测试
|
## 测试
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php artisan test # 25 个用例:迁移/老路由 301/主题/插件/会员支付流/AI 审核
|
php artisan test # 84 个用例:迁移/老路由 301/主题/插件/会员支付流/AI 审核/商城购买
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 开发者文档
|
||||||
|
|
||||||
|
| 文档 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| [docs/development.md](docs/development.md) | 开发规范(代码/插件/主题/队列/测试/提交) |
|
||||||
|
| [docs/tutorial-plugin.md](docs/tutorial-plugin.md) | 插件开发教程(10 分钟上手,配套 demo) |
|
||||||
|
| [docs/tutorial-theme.md](docs/tutorial-theme.md) | 主题开发教程(10 分钟上手,配套 demo) |
|
||||||
|
| [docs/plugins.md](docs/plugins.md) | 插件 API 详解(钩子/依赖/商品化/市场契约) |
|
||||||
|
| [docs/themes.md](docs/themes.md) | 主题 API 详解(视图解析/注入点/变量) |
|
||||||
|
| [docs/architecture.md](docs/architecture.md) | 架构与设计决策 |
|
||||||
|
| [docs/api.md](docs/api.md) | 对外 API(小程序/第三方) |
|
||||||
|
| [docs/deploy.md](docs/deploy.md) | 部署(nginx/systemd/workerman/cron) |
|
||||||
|
|
||||||
|
**上手示例**:
|
||||||
|
- 插件:`plugins/demo.hello-world/`(短代码 / 钩子 / 路由 / 后台页 / 迁移 / 表单注入,全演示)
|
||||||
|
- 主题:`themes/demo/`(最小可运行主题,绿色系,覆盖 partials 即可换肤)
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -130,9 +147,9 @@ app/
|
|||||||
Blog/ # 前台域模块(Controllers/Jobs/Services/Support/View)
|
Blog/ # 前台域模块(Controllers/Jobs/Services/Support/View)
|
||||||
Filament/ # 后台资源与页面
|
Filament/ # 后台资源与页面
|
||||||
Console/Commands # sablog:import / attachments:sync-s3 / workerman:serve / content:convert
|
Console/Commands # sablog:import / attachments:sync-s3 / workerman:serve / content:convert
|
||||||
themes/ # 主题(sablog / modern)
|
themes/ # 主题(sablog / modern / demo 示例)
|
||||||
plugins/ # 内置插件(blog-seo / ai-moderation / payment / membership)
|
plugins/ # 内置插件(blog-seo / ai-moderation / payment / membership)+ demo.hello-world 示例
|
||||||
docs/deploy.md # 部署指南
|
docs/ # 全部文档(见上文表格)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 许可
|
## 许可
|
||||||
|
|||||||
@@ -6,23 +6,32 @@ declare(strict_types=1);
|
|||||||
namespace App\Blog\Providers;
|
namespace App\Blog\Providers;
|
||||||
|
|
||||||
use App\Blog\Support\PluginManager;
|
use App\Blog\Support\PluginManager;
|
||||||
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class PluginManagerServiceProvider extends ServiceProvider
|
class PluginManagerServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
public function register(): void
|
public function register(): void
|
||||||
{
|
{
|
||||||
$this->app->singleton(PluginManager::class);
|
$this->app->singleton(PluginManager::class);
|
||||||
|
|
||||||
|
// 运行期自动加载:扫描 plugins/*/src,按 Plugins\{Vendor}\{Name} 注册 PSR-4。
|
||||||
|
// 必须在 register() 阶段完成:Filament 面板 boot 时要解析插件页面类,
|
||||||
|
// 第三方 ZIP 安装的插件无需修改 composer.json。
|
||||||
|
if (is_dir(config('plugins.path'))) {
|
||||||
|
$this->registerPluginAutoload();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
$manager = $this->app->make(PluginManager::class);
|
|
||||||
|
|
||||||
if (! is_dir(config('plugins.path'))) {
|
if (! is_dir(config('plugins.path'))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$manager = $this->app->make(PluginManager::class);
|
||||||
|
|
||||||
foreach ($manager->all() as $key => $plugin) {
|
foreach ($manager->all() as $key => $plugin) {
|
||||||
if (! $plugin['enabled']) {
|
if (! $plugin['enabled']) {
|
||||||
continue;
|
continue;
|
||||||
@@ -40,4 +49,21 @@ class PluginManagerServiceProvider extends ServiceProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function registerPluginAutoload(): void
|
||||||
|
{
|
||||||
|
$loaders = \Composer\Autoload\ClassLoader::getRegisteredLoaders();
|
||||||
|
$loader = reset($loaders);
|
||||||
|
|
||||||
|
if (! $loader) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (File::directories(config('plugins.path')) as $dir) {
|
||||||
|
$basename = basename($dir);
|
||||||
|
[$vendor, $name] = array_pad(explode('.', $basename), 2, $basename);
|
||||||
|
|
||||||
|
$loader->addPsr4('Plugins\\'.Str::studly($vendor).'\\'.Str::studly($name).'\\', $dir.'/src/');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-5
@@ -38,11 +38,7 @@
|
|||||||
"psr-4": {
|
"psr-4": {
|
||||||
"App\\": "app/",
|
"App\\": "app/",
|
||||||
"Database\\Factories\\": "database/factories/",
|
"Database\\Factories\\": "database/factories/",
|
||||||
"Database\\Seeders\\": "database/seeders/",
|
"Database\\Seeders\\": "database/seeders/"
|
||||||
"Plugins\\Neatstudio\\BlogSeo\\": "plugins/neatstudio.blog-seo/src/",
|
|
||||||
"Plugins\\Neatstudio\\AiModeration\\": "plugins/neatstudio.ai-moderation/src/",
|
|
||||||
"Plugins\\Neatstudio\\Payment\\": "plugins/neatstudio.payment/src/",
|
|
||||||
"Plugins\\Neatstudio\\Membership\\": "plugins/neatstudio.membership/src/"
|
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"app/Support/helpers.php"
|
"app/Support/helpers.php"
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# 开发规范
|
||||||
|
|
||||||
|
面向所有为 LaraLog 写代码的人(核心 / 插件 / 主题作者)。教程见 `tutorial-plugin.md`、`tutorial-theme.md`,配套示例见 `plugins/demo.hello-world/`、`themes/demo/`。
|
||||||
|
|
||||||
|
## 1. 代码规范
|
||||||
|
|
||||||
|
- **PHP 8.2+**,所有文件以 `declare(strict_types=1);` 开头
|
||||||
|
- 命名空间:核心 `App\...`;插件 `Plugins\{Vendor}\{Name}\...`(Vendor/Name 用 `Str::studly` 规则)
|
||||||
|
- 文件命名:类文件与类名一致(PSR-4 自动加载)
|
||||||
|
- 方法/属性/变量:`camelCase`;常量:`UPPER_SNAKE`;数据库表:`snake_case`
|
||||||
|
- 中文文案直接写在代码/语言包中(本项目默认中文,不硬编码英文 UI)
|
||||||
|
- 不写无意义注释;只在「为什么」不明显处加一行注释
|
||||||
|
- **安全红线**:不提交 `.env`、密钥、证书;用户输入一律 `e()` 转义或框架验证;数据库操作用 Eloquent/查询构造器(防注入)
|
||||||
|
|
||||||
|
## 2. 目录约定
|
||||||
|
|
||||||
|
```
|
||||||
|
app/Blog/ # 前台域模块:Controllers / Jobs / Services / Support / View
|
||||||
|
app/Filament/ # 后台资源与页面
|
||||||
|
plugins/ # 插件,目录名 vendor.name
|
||||||
|
themes/ # 主题,目录名即主题名
|
||||||
|
routes/web.php # 前台规范路由;routes/legacy.php 老 URL 301(新增老入口兼容放这里)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 插件规范(详见 docs/plugins.md)
|
||||||
|
|
||||||
|
- 目录 `plugins/{vendor}.{name}/`,**目录名是插件唯一标识**
|
||||||
|
- 类自动加载:系统启动时扫描 `plugins/*/src` 注册 PSR-4(`Plugins\{Vendor}\{Name}`),**无需改 composer.json**
|
||||||
|
- `plugin.json` 必填:`title/version/description/author/type/provider`;可选 `usage/requires/filament_pages/filament_resources`
|
||||||
|
- 入口类 `src/ServiceProvider.php` 继承 `App\Blog\Support\PluginServiceProvider`,**不要命名为 PluginServiceProvider**
|
||||||
|
- 依赖声明在 `requires`(`vendor.name`),系统强制校验:依赖未启用不能启用本插件;停用/卸载被依赖者阻止
|
||||||
|
- 后台扩展**一律走钩子注入**(`filament.post_form` / `filament.post_table`),禁止改核心资源文件
|
||||||
|
- 可购买实体约定:实现 `payableLabel(): string` 与 `payableUrl(): ?string` 即接入订单展示(支付插件零改动)
|
||||||
|
- 插件视图放插件目录并用 `loadViews()` 注册命名空间;前台页面用 `@include('partials.header')` 复用主题公共片段
|
||||||
|
|
||||||
|
### 钩子清单
|
||||||
|
|
||||||
|
| Hook | 类型 | 参数 | 用途 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `post.rendered` | filter | `(string $html, Post): string` | 文章渲染后处理 |
|
||||||
|
| `comment.created` | action | `Comment` | 新评论创建 |
|
||||||
|
| `payment.paid` | action | `Payment` | 支付成功分发 |
|
||||||
|
| `filament.post_form` | action | `Schema` | 文章表单注入 |
|
||||||
|
| `filament.post_table` | action | `Table` | 文章表格注入 |
|
||||||
|
| `seo.structured_data` | filter | `(array): array` | JSON-LD 扩展 |
|
||||||
|
| `theme.inject.{point}` | filter | `(string): string` | 注入点追加 HTML |
|
||||||
|
|
||||||
|
> `Schema::components()` / `Table::columns()` 是**替换**语义:表单用 `[...$schema->getComponents(), Section::make(...)]` 合并,表格用 `pushColumns()`。
|
||||||
|
|
||||||
|
## 4. 主题规范(详见 docs/themes.md)
|
||||||
|
|
||||||
|
- 目录 `themes/{name}/`,`theme.json` 必填;views 前置到全局视图路径,同名覆盖、缺失回退
|
||||||
|
- 语义化 HTML(header/nav/main/aside/footer/article/time)是 SEO 要求,不是可选项
|
||||||
|
- 保留注入点 `@stack('theme:xxx')`,并尽量在 theme.json 声明 `inject_points`
|
||||||
|
- 必须有移动端断点(≤768px 两栏改单栏)
|
||||||
|
- 资产开发期流式返回、生产 `theme:publish`
|
||||||
|
|
||||||
|
## 5. 队列任务规范
|
||||||
|
|
||||||
|
- 实现 `App\Blog\Jobs\AiJob` 接口(`handle(LlmClient)` 由容器注入)+ `ShouldQueue`
|
||||||
|
- 入队 `dispatch(new XxxJob($id))->onQueue('ai')`
|
||||||
|
- 消费者二选一:`php artisan workerman:serve`(常驻+WS)或 `php artisan queue:work --queue=default,ai`(等价,见 docs/deploy.md)
|
||||||
|
- LLM 一律走 `LlmClient`(OpenAI 兼容,base_url/key/model 可配),不要直连第三方 SDK
|
||||||
|
|
||||||
|
## 6. 测试规范
|
||||||
|
|
||||||
|
- 新增功能必须配 Feature 测试(`tests/Feature/`),跑 `php artisan test` 全绿再提交
|
||||||
|
- 示例参照:`PluginDependencyTest`(依赖校验)、`PostFormInjectionTest`(表单注入)、`MarketPurchaseTest`(商城购买流)、`MembershipFlowTest`(会员/支付流)
|
||||||
|
- 测试用 RefreshDatabase + 沙箱,**不触碰真实支付/真实 LLM 凭据**
|
||||||
|
|
||||||
|
## 7. 提交规范
|
||||||
|
|
||||||
|
- 一次提交一件事,信息用中文描述「做了什么 + 为什么」
|
||||||
|
- 不提交:`.env`、密钥/证书、`storage/media-library/temp/`、构建产物
|
||||||
|
- 完成后更新对应 `docs/` 文档
|
||||||
|
|
||||||
|
## 8. 常用命令速查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
php artisan test # 全量测试
|
||||||
|
php artisan dev:watch # 开发热重载(改插件/核心代码自动重启 workerman)
|
||||||
|
php artisan migrate # 插件迁移自动加载
|
||||||
|
php artisan workerman:serve start # 队列消费者 + WebSocket
|
||||||
|
php artisan theme:publish # 主题资产发布到 public
|
||||||
|
```
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# 插件开发教程(10 分钟上手)
|
||||||
|
|
||||||
|
配套示例:**`plugins/demo.hello-world/`**。本文带你从零写一个同样功能的插件,每个文件都有对照。
|
||||||
|
|
||||||
|
## 1. 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
plugins/{vendor}.{name}/ # 目录名必须 vendor.name 格式(如 demo.hello-world)
|
||||||
|
├── plugin.json # 插件清单(必填)
|
||||||
|
├── src/
|
||||||
|
│ ├── ServiceProvider.php # 入口类(必填,继承基类)
|
||||||
|
│ ├── Http/ # 前台控制器
|
||||||
|
│ ├── Models/ # 模型
|
||||||
|
│ └── Filament/Pages/ # 后台页面
|
||||||
|
├── routes/web.php # 可选:前台路由(boot 时自动加载)
|
||||||
|
├── database/migrations/ # 可选:迁移(migrate 自动执行)
|
||||||
|
└── views/ # 可选:视图(注册命名空间后引用)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. plugin.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Hello World 示例插件",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "一句话描述",
|
||||||
|
"usage": "使用说明(多行文本,后台插件卡片可折叠查看)",
|
||||||
|
"author": "你的名字",
|
||||||
|
"type": "demo",
|
||||||
|
"provider": "Plugins\\Demo\\HelloWorld\\ServiceProvider",
|
||||||
|
"requires": ["neatstudio.payment"], // 可选:依赖的插件
|
||||||
|
"filament_pages": ["Plugins\\Demo\\HelloWorld\\Filament\\Pages\\HelloWorldSettings"],
|
||||||
|
"filament_resources": ["...\\Resources\\XxxResource"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段说明见 `docs/plugins.md`。`provider` 指向入口类;缺省时系统会自动推断 `src/ServiceProvider.php`。
|
||||||
|
|
||||||
|
## 3. 入口类 ServiceProvider
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Plugins\Demo\HelloWorld;
|
||||||
|
|
||||||
|
use App\Blog\Support\PluginManager;
|
||||||
|
use App\Blog\Support\PluginServiceProvider;
|
||||||
|
|
||||||
|
class ServiceProvider extends PluginServiceProvider
|
||||||
|
{
|
||||||
|
protected function boot(PluginManager $manager): void
|
||||||
|
{
|
||||||
|
$this->loadRoutes(__DIR__.'/../routes/web.php'); // 加载路由
|
||||||
|
$this->loadViews(__DIR__.'/../views', 'demo-hello'); // 注册视图命名空间 demo-hello::
|
||||||
|
|
||||||
|
// 过滤器:文章渲染后处理(返回值传给下一个过滤器)
|
||||||
|
$manager->addFilter('post.rendered', function (string $html, \App\Models\Post $post) {
|
||||||
|
return $html;
|
||||||
|
}, 20);
|
||||||
|
|
||||||
|
// 动作:监听事件(无返回值)
|
||||||
|
$manager->addAction('comment.created', function (\App\Models\Comment $comment) {
|
||||||
|
// 做点什么
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 入口类**不要**命名为 `PluginServiceProvider`(与基类短名冲突),统一用 `ServiceProvider`。
|
||||||
|
|
||||||
|
> **类自动加载**:系统启动时自动扫描 `plugins/*/src` 并按 `Plugins\{Vendor}\{Name}` 注册 PSR-4——**无需修改 composer.json**,第三方 ZIP 安装的插件同样生效。
|
||||||
|
|
||||||
|
### 常用钩子
|
||||||
|
|
||||||
|
| Hook | 类型 | 参数 | 用途 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `post.rendered` | filter | `(string $html, Post) : string` | 文章渲染后处理(短代码/付费过滤) |
|
||||||
|
| `comment.created` | action | `Comment` | 新评论创建 |
|
||||||
|
| `payment.paid` | action | `Payment` | 支付成功(按 `$payment->payable` 分发) |
|
||||||
|
| `filament.post_form` | action | `Schema` | 文章编辑表单追加字段 |
|
||||||
|
| `filament.post_table` | action | `Table` | 文章列表追加列 |
|
||||||
|
| `seo.structured_data` | filter | `(array $data) : array` | 扩展 JSON-LD |
|
||||||
|
|
||||||
|
完整列表见 `docs/plugins.md`。
|
||||||
|
|
||||||
|
## 4. 短代码示例(过滤器)
|
||||||
|
|
||||||
|
```php
|
||||||
|
$manager->addFilter('post.rendered', function (string $html, Post $post) {
|
||||||
|
$greeting = (string) \App\Models\Setting::get('hello_world_greeting', '你好');
|
||||||
|
|
||||||
|
return preg_replace_callback(
|
||||||
|
'/\[hello\s+([^\]]+)\]/',
|
||||||
|
fn (array $m) => '<span class="hello-world">'.$greeting.','.e($m[1]).'!</span>',
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
}, 20);
|
||||||
|
```
|
||||||
|
|
||||||
|
文章里写 `[hello 世界]`,渲染时变成「你好,世界!」。`e()` 转义用户输入防 XSS。
|
||||||
|
|
||||||
|
## 5. 路由 + 视图
|
||||||
|
|
||||||
|
`routes/web.php`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Route::get('/hello-world', [HelloWorldController::class, 'index'])->name('hello-world.index');
|
||||||
|
```
|
||||||
|
|
||||||
|
控制器返回插件视图(命名空间 `demo-hello::`):
|
||||||
|
|
||||||
|
```php
|
||||||
|
return view('demo-hello::hello', compact('logs'));
|
||||||
|
```
|
||||||
|
|
||||||
|
视图里可以 `@include('partials.header')` 复用当前主题的公共片段。
|
||||||
|
|
||||||
|
## 6. 迁移 + 模型
|
||||||
|
|
||||||
|
迁移放在 `database/migrations/`,**无需手动 --path**,`php artisan migrate` 自动执行(AppServiceProvider 已注册 `plugins/*/database/migrations`)。
|
||||||
|
|
||||||
|
模型照常写,命名空间 `Plugins\Demo\HelloWorld\Models`。
|
||||||
|
|
||||||
|
## 7. 后台页面
|
||||||
|
|
||||||
|
`src/Filament/Pages/HelloWorldSettings.php`(继承 `Filament\Pages\Page` + `InteractsWithForms`),在 `plugin.json` 的 `filament_pages` 注册后自动出现在「插件」导航分组。可复用默认表单页视图 `filament.pages.plugin-settings`(见示例)。
|
||||||
|
|
||||||
|
## 8. 打包与安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd plugins/demo.hello-world && zip -r ../demo.hello-world.zip . -x ".*"
|
||||||
|
```
|
||||||
|
|
||||||
|
后台「插件管理」→ 上传 ZIP 安装 → 启用。目录名即包键,重复安装会提示已存在。
|
||||||
|
|
||||||
|
## 9. 测试你的插件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 快速验证短代码
|
||||||
|
php artisan tinker --execute="
|
||||||
|
\Plugins\Demo\HelloWorld\ServiceProvider::bootPlugin(app(\App\Blog\Support\PluginManager::class));
|
||||||
|
\$html = '正文 [hello 世界]';
|
||||||
|
echo app(\App\Blog\Support\PluginManager::class)->applyFilters('post.rendered', \$html, \App\Models\Post::first());
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
建议为插件写 Feature 测试(参照 `tests/Feature/` 现有用例)。
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
- **改了代码不生效**:后台启停一次,或重启队列消费者(workerman/dev:watch)
|
||||||
|
- **依赖其他插件**:在 `requires` 声明,系统会强制校验(依赖未启用无法启用)
|
||||||
|
- **想让商品出现在订单页**:实体实现 `payableLabel()` / `payableUrl()` 两个方法(见 `docs/plugins.md`「订单商品化」)
|
||||||
|
- **不想污染核心**:后台表单/表格一律走 `filament.post_form` / `filament.post_table` 钩子注入,核心零改动
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# 主题开发教程(10 分钟上手)
|
||||||
|
|
||||||
|
配套示例:**`themes/demo/`**。本文用最小结构带你做一个能用的主题。
|
||||||
|
|
||||||
|
## 1. 最小主题结构
|
||||||
|
|
||||||
|
```
|
||||||
|
themes/demo/
|
||||||
|
├── theme.json # 主题清单(必填)
|
||||||
|
├── views/
|
||||||
|
│ └── partials/ # 覆盖公共片段
|
||||||
|
│ ├── head.blade.php
|
||||||
|
│ ├── header.blade.php
|
||||||
|
│ └── footer.blade.php
|
||||||
|
└── assets/
|
||||||
|
└── style.css # 主题样式(/themes/demo/assets/style.css 访问)
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心机制**:激活主题的 `views/` 目录被前置到全局视图路径。页面视图(`index`/`show`/`list`…)按名字解析——主题有同名视图就用主题的,**没有就自动回退默认视图**(`resources/views/`)。
|
||||||
|
|
||||||
|
所以最小主题只需要覆盖 `partials/`(head/header/sidebar/footer),页面样式自己定,其他全部继承默认。
|
||||||
|
|
||||||
|
## 2. theme.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Demo 演示主题",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "一句话描述",
|
||||||
|
"author": "你的名字",
|
||||||
|
"screenshot": "screenshot.png",
|
||||||
|
"inject_points": ["head", "header", "footer"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `screenshot`:`assets/` 下的预览图,后台主题卡片展示(可选)
|
||||||
|
- `inject_points`:声明实现了哪些注入点(后台会展示);缺省视为全部支持
|
||||||
|
|
||||||
|
## 3. 覆盖 partials
|
||||||
|
|
||||||
|
`views/partials/head.blade.php` 需要包含:`<title>`、meta、CSS 引用。**必须保留注入点** `@stack('theme:head')`(后台「主题注入」的内容会渲染到这里):
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<link rel="stylesheet" href="{{ theme('asset', 'style.css') }}">
|
||||||
|
@stack('head')
|
||||||
|
@stack('theme:head')
|
||||||
|
```
|
||||||
|
|
||||||
|
`header.blade.php` / `footer.blade.php` 同理,尾部保留 `@stack('theme:footer')` / `@stack('theme:body_end')`。
|
||||||
|
|
||||||
|
## 4. 视图解析规则
|
||||||
|
|
||||||
|
| 场景 | 结果 |
|
||||||
|
|------|------|
|
||||||
|
| 主题有 `index.blade.php` | 覆盖默认首页 |
|
||||||
|
| 主题没有 | 用 `resources/views/index.blade.php` |
|
||||||
|
| 主题只有 partials | 页面用默认,公共片段用主题的 |
|
||||||
|
|
||||||
|
想完全自定义某类页面,就在主题 views 下放同名文件(如 `show.blade.php`、`tag.blade.php`),控制器传的变量清单见 `docs/themes.md`。
|
||||||
|
|
||||||
|
## 5. 主题变量(可后台编辑)
|
||||||
|
|
||||||
|
`theme.json` 之外,可以在后台「主题注入 → 主题变量」里给主题加任意键值,视图用:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
{{ theme('var', 'footer_about', '默认文本') }}
|
||||||
|
```
|
||||||
|
|
||||||
|
适合放广告位 HTML、自定义文案等,不用改代码。
|
||||||
|
|
||||||
|
## 6. 资产与发布
|
||||||
|
|
||||||
|
- **开发期**:`GET /themes/{name}/assets/{path}` 流式返回(带缓存头),改 CSS 刷新即生效
|
||||||
|
- **生产**:`php artisan theme:publish` 拷贝到 `public/themes/`,由 Web 服务器托管
|
||||||
|
|
||||||
|
## 7. 打包与安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd themes/demo && zip -r ../demo.zip . -x ".*"
|
||||||
|
```
|
||||||
|
|
||||||
|
后台「主题管理」→ 上传 ZIP 安装 → 启用。**不能删除当前激活的主题**。
|
||||||
|
|
||||||
|
## 8. 好主题的检查清单
|
||||||
|
|
||||||
|
- [ ] 移动端适配:`@media (max-width: 768px)` 两栏改单栏(参照 sablog/modern)
|
||||||
|
- [ ] 语义化 HTML:`<header>/<nav>/<main>/<aside>/<footer>/<article>/<time>`(SEO 关键)
|
||||||
|
- [ ] 保留全部注入点 `@stack('theme:xxx')`
|
||||||
|
- [ ] 深色模式可选(modern 有示例)
|
||||||
|
- [ ] 文章 TOC:`show` 视图接收 `$toc` 变量,样式化 `.toc` / `.toc-level-N`(可选)
|
||||||
|
- [ ] 截图 `screenshot.png`(无头浏览器截首页生成)
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
- **页面还是旧的**:确认主题已启用、文件在 `views/` 下、缓存已清(后台「缓存管理」或 `php artisan view:clear`)
|
||||||
|
- **CSS 不生效**:检查 `theme('asset', 'style.css')` 路径,生产环境要先 `theme:publish`
|
||||||
|
- **想参考完整实现**:`themes/sablog/`(经典两栏)、`themes/modern/`(简约 + 深色模式)
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('hello_world_logs', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('comment_id')->nullable()->constrained()->nullOnDelete();
|
||||||
|
$table->string('note', 500)->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('hello_world_logs');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"title": "Hello World 示例插件",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "插件开发教程配套示例:演示短代码过滤器、钩子监听、后台页面、前台路由、迁移的完整写法",
|
||||||
|
"usage": "启用后:\n1. 文章正文写 [hello 世界] 会渲染为问候语\n2. 访问 /hello-world 查看前台演示页\n3. 后台「插件 → Hello World 设置」可配置问候语\n4. 新评论会写入一条 hello_world_logs 记录(可在 tinker 查看)\n\n对照 docs/tutorial-plugin.md 学习每个文件的写法",
|
||||||
|
"author": "LaraLog",
|
||||||
|
"type": "demo",
|
||||||
|
"provider": "Plugins\\Demo\\HelloWorld\\ServiceProvider",
|
||||||
|
"filament_pages": [
|
||||||
|
"Plugins\\Demo\\HelloWorld\\Filament\\Pages\\HelloWorldSettings"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Plugins\Demo\HelloWorld\Http\HelloWorldController;
|
||||||
|
|
||||||
|
Route::get('/hello-world', [HelloWorldController::class, 'index'])->name('hello-world.index');
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Plugins\Demo\HelloWorld\Filament\Pages;
|
||||||
|
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Concerns\InteractsWithForms;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class HelloWorldSettings extends Page
|
||||||
|
{
|
||||||
|
use InteractsWithForms;
|
||||||
|
|
||||||
|
protected static \UnitEnum|string|null $navigationGroup = '插件';
|
||||||
|
|
||||||
|
protected static string|\BackedEnum|null $navigationIcon = 'heroicon-o-face-smile';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Hello World 设置';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Hello World 设置';
|
||||||
|
|
||||||
|
protected string $view = 'filament.pages.plugin-settings';
|
||||||
|
|
||||||
|
public array $data = [];
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->form->fill([
|
||||||
|
'hello_world_greeting' => Setting::get('hello_world_greeting', '你好'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
\Filament\Schemas\Components\Section::make('问候语')
|
||||||
|
->schema([
|
||||||
|
TextInput::make('hello_world_greeting')->label('默认问候语')->required(),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
->statePath('data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(): void
|
||||||
|
{
|
||||||
|
foreach ($this->form->getState() as $key => $value) {
|
||||||
|
Setting::set($key, (string) $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
Notification::make()->title('已保存')->success()->send();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function getFormActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Action::make('save')->label('保存')->submit('save'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Plugins\Demo\HelloWorld\Http;
|
||||||
|
|
||||||
|
use Plugins\Demo\HelloWorld\Models\HelloWorldLog;
|
||||||
|
|
||||||
|
class HelloWorldController
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 前台演示页:展示插件渲染能力(视图走插件命名空间 demo-hello::)。
|
||||||
|
* 主题的 SidebarComposer 只覆盖核心前台视图,插件页面需手动调用它
|
||||||
|
* 注入共享数据(siteName/siteDescription/sidebar 等),才能安全复用 partials。
|
||||||
|
*/
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$logs = HelloWorldLog::query()->with('comment')->latest()->limit(10)->get();
|
||||||
|
|
||||||
|
$view = view('demo-hello::hello', ['logs' => $logs]);
|
||||||
|
app(\App\Blog\View\Composers\SidebarComposer::class)->compose($view);
|
||||||
|
|
||||||
|
return $view;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Plugins\Demo\HelloWorld\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 演示模型:插件自带迁移创建的表。
|
||||||
|
*/
|
||||||
|
class HelloWorldLog extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['comment_id', 'note'];
|
||||||
|
|
||||||
|
public function comment(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(\App\Models\Comment::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Plugins\Demo\HelloWorld;
|
||||||
|
|
||||||
|
use App\Blog\Support\PluginManager;
|
||||||
|
use App\Blog\Support\PluginServiceProvider;
|
||||||
|
use App\Models\Comment;
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Plugins\Demo\HelloWorld\Models\HelloWorldLog;
|
||||||
|
|
||||||
|
class ServiceProvider extends PluginServiceProvider
|
||||||
|
{
|
||||||
|
protected function boot(PluginManager $manager): void
|
||||||
|
{
|
||||||
|
// 1. 加载前台路由(可选)
|
||||||
|
$this->loadRoutes(__DIR__.'/../routes/web.php');
|
||||||
|
|
||||||
|
// 2. 注册视图命名空间(可选):views/ -> demo-hello::xxx
|
||||||
|
$this->loadViews(__DIR__.'/../views', 'demo-hello');
|
||||||
|
|
||||||
|
// 3. 过滤器示例:文章渲染时把 [hello xxx] 短代码替换为问候语
|
||||||
|
$manager->addFilter('post.rendered', function (string $html, Post $post) {
|
||||||
|
$greeting = (string) Setting::get('hello_world_greeting', '你好');
|
||||||
|
|
||||||
|
return preg_replace_callback(
|
||||||
|
'/\[hello\s+([^\]]+)\]/',
|
||||||
|
fn (array $m) => '<span class="hello-world">'.$greeting.','.e($m[1]).'!</span>',
|
||||||
|
$html
|
||||||
|
);
|
||||||
|
}, 20);
|
||||||
|
|
||||||
|
// 4. 动作示例:新评论创建时写日志(演示数据关联 + 钩子监听)
|
||||||
|
$manager->addAction('comment.created', function (Comment $comment) {
|
||||||
|
HelloWorldLog::create([
|
||||||
|
'comment_id' => $comment->id,
|
||||||
|
'note' => '捕获新评论:'.mb_substr($comment->content, 0, 30),
|
||||||
|
]);
|
||||||
|
Log::info('HelloWorld: 捕获新评论 #'.$comment->id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 后台表单注入示例:给文章编辑页加一个演示字段(meta.demo_note)
|
||||||
|
$manager->addAction('filament.post_form', function (Schema $schema) {
|
||||||
|
$schema->components([
|
||||||
|
...$schema->getComponents(),
|
||||||
|
Section::make('Hello World 演示')
|
||||||
|
->collapsible()
|
||||||
|
->schema([
|
||||||
|
TextInput::make('meta.demo_note')
|
||||||
|
->label('演示字段(存到文章 meta)')
|
||||||
|
->helperText('这是插件注入的字段,停用插件后消失'),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Hello World 演示 - {{ blog_setting('site_name', config('blog.name')) }}</title>
|
||||||
|
<link rel="stylesheet" href="{{ theme('asset', 'style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
@include('partials.header')
|
||||||
|
<div class="container main-layout">
|
||||||
|
<main class="content">
|
||||||
|
<article class="post-full">
|
||||||
|
<h1 class="post-title">Hello World 演示页</h1>
|
||||||
|
<p>这个页面由插件路由渲染,视图来自插件目录 <code>views/hello.blade.php</code>(命名空间 <code>demo-hello::</code>)。</p>
|
||||||
|
<p>在文章正文里写 <code>[hello 世界]</code>,渲染时会变成:<span class="hello-world">你好,世界!</span></p>
|
||||||
|
<p>最近捕获的评论(<code>hello_world_logs</code> 表):</p>
|
||||||
|
<ul>
|
||||||
|
@forelse($logs as $log)
|
||||||
|
<li>#{{ $log->comment_id }}:{{ $log->note }}</li>
|
||||||
|
@empty
|
||||||
|
<li>暂无记录,去发一条评论试试</li>
|
||||||
|
@endforelse
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
@include('partials.sidebar')
|
||||||
|
</div>
|
||||||
|
@include('partials.footer')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Blog\Support\PluginManager;
|
||||||
|
use App\Models\Comment;
|
||||||
|
use App\Models\Post;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Plugins\Demo\HelloWorld\ServiceProvider;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class DemoPluginTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// demo 插件默认未启用(不在 config/plugins.php enabled),测试中手动 boot 验证其代码
|
||||||
|
ServiceProvider::bootPlugin(app(PluginManager::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makePost(): Post
|
||||||
|
{
|
||||||
|
return Post::create([
|
||||||
|
'title' => '演示',
|
||||||
|
'slug' => 'demo-'.uniqid(),
|
||||||
|
'content' => '内容',
|
||||||
|
'content_format' => 'markdown',
|
||||||
|
'status' => 'published',
|
||||||
|
'published_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_hello_shortcode_filter_renders_greeting(): void
|
||||||
|
{
|
||||||
|
Setting::set('hello_world_greeting', '嗨');
|
||||||
|
|
||||||
|
$html = app(PluginManager::class)->applyFilters('post.rendered', '正文 [hello 世界]', $this->makePost());
|
||||||
|
|
||||||
|
$this->assertStringContainsString('嗨,世界!', $html);
|
||||||
|
$this->assertStringNotContainsString('[hello', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_comment_created_action_writes_log(): void
|
||||||
|
{
|
||||||
|
$post = $this->makePost();
|
||||||
|
$comment = Comment::create([
|
||||||
|
'post_id' => $post->id,
|
||||||
|
'author_name' => '游客',
|
||||||
|
'content' => '演示评论内容',
|
||||||
|
'status' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
app(PluginManager::class)->doAction('comment.created', $comment);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('hello_world_logs', ['comment_id' => $comment->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_hello_world_route_renders_plugin_view(): void
|
||||||
|
{
|
||||||
|
$this->get('/hello-world')
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('Hello World 演示页');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,12 @@ class ThemeTest extends TestCase
|
|||||||
$this->get('/')->assertOk();
|
$this->get('/')->assertOk();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_demo_theme_renders(): void
|
||||||
|
{
|
||||||
|
app(\App\Blog\Support\ThemeManager::class)->activate('demo');
|
||||||
|
$this->get('/')->assertOk()->assertSee('Demo 主题');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_theme_asset_url_respects_explicit_theme(): void
|
public function test_theme_asset_url_respects_explicit_theme(): void
|
||||||
{
|
{
|
||||||
// 回归:theme('asset', path, name) 必须用指定主题,而不是激活主题(否则主题管理页所有卡片显示同一张图)
|
// 回归:theme('asset', path, name) 必须用指定主题,而不是激活主题(否则主题管理页所有卡片显示同一张图)
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/* Demo 主题:绿色系,仅演示核心布局。新主题请参照 sablog/modern 补全移动端断点 */
|
||||||
|
:root {
|
||||||
|
--d-accent: #059669;
|
||||||
|
--d-accent-dark: #047857;
|
||||||
|
--d-bg: #f6f8f7;
|
||||||
|
--d-text: #1f2937;
|
||||||
|
--d-muted: #6b7280;
|
||||||
|
--d-border: #e2e8e4;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
background: var(--d-bg);
|
||||||
|
color: var(--d-text);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: var(--d-accent); text-decoration: none; }
|
||||||
|
a:hover { color: var(--d-accent-dark); }
|
||||||
|
|
||||||
|
.d-container { max-width: 960px; margin: 0 auto; padding: 0 16px; }
|
||||||
|
|
||||||
|
/* 头部 */
|
||||||
|
.d-header { background: #fff; border-bottom: 3px solid var(--d-accent); }
|
||||||
|
.d-header .d-container { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; padding-top: 14px; padding-bottom: 14px; }
|
||||||
|
.d-logo { font-size: 20px; font-weight: 800; color: var(--d-accent); }
|
||||||
|
.d-nav { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; font-size: 14px; }
|
||||||
|
.d-nav a.active { color: var(--d-accent-dark); font-weight: 700; }
|
||||||
|
.d-search input { border: 1px solid var(--d-border); border-radius: 6px; padding: 6px 10px; font-size: 13px; }
|
||||||
|
.d-link-btn { background: none; border: none; color: var(--d-accent); cursor: pointer; font-size: 14px; }
|
||||||
|
|
||||||
|
/* 主体两栏 */
|
||||||
|
.main-layout { display: grid; grid-template-columns: 1fr 280px; gap: 20px; padding: 24px 16px; }
|
||||||
|
.main-layout .content { min-width: 0; }
|
||||||
|
|
||||||
|
/* 内容卡片(默认视图自带 .post-card/.post-full/.list-title 等类) */
|
||||||
|
.post-card, .post-full, .comments-section, .alert, .toc, .paywall {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid var(--d-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 侧边栏 */
|
||||||
|
.widget { background: #fff; border: 1px solid var(--d-border); border-radius: 10px; padding: 14px 16px; margin-bottom: 14px; }
|
||||||
|
.widget h3 { margin: 0 0 10px; font-size: 14px; color: var(--d-accent-dark); }
|
||||||
|
|
||||||
|
/* 页脚 */
|
||||||
|
.d-footer { background: #fff; border-top: 3px solid var(--d-accent); text-align: center; font-size: 13px; color: var(--d-muted); padding: 18px 0; margin-top: 24px; }
|
||||||
|
.d-footer p { margin: 4px 0; }
|
||||||
|
|
||||||
|
/* 按钮 */
|
||||||
|
.btn { display: inline-block; background: var(--d-accent); color: #fff; border: none; padding: 8px 18px; border-radius: 6px; font-size: 14px; cursor: pointer; }
|
||||||
|
.btn:hover { background: var(--d-accent-dark); color: #fff; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.main-layout { grid-template-columns: 1fr; }
|
||||||
|
.d-header .d-container { flex-direction: column; align-items: flex-start; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"title": "Demo 演示主题",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "主题开发教程配套示例:只覆盖 partials(head/header/footer)+ 自定义配色,其余页面回退默认视图",
|
||||||
|
"author": "LaraLog",
|
||||||
|
"inject_points": [
|
||||||
|
"head",
|
||||||
|
"header",
|
||||||
|
"footer"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<footer class="d-footer">
|
||||||
|
<div class="d-container">
|
||||||
|
<p>© {{ date('Y') }} {{ $siteName }}@if($siteIcp) · <a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">{{ $siteIcp }}</a>@endif</p>
|
||||||
|
<p>由 LaraLog 驱动 · Demo 主题</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
@stack('theme:footer')
|
||||||
|
@stack('theme:body_end')
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ $pageTitle ?? $siteName }}</title>
|
||||||
|
<meta name="description" content="{{ $pageDescription ?? $siteDescription }}">
|
||||||
|
@if(($pageKeywords ?? '') !== '')
|
||||||
|
<meta name="keywords" content="{{ $pageKeywords }}">
|
||||||
|
@endif
|
||||||
|
<meta name="generator" content="LaraLog">
|
||||||
|
<link rel="canonical" href="{{ url()->current() }}">
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="{{ $siteName }}" href="{{ route('feed.rss') }}">
|
||||||
|
{{-- 主题资产:/themes/demo/assets/style.css --}}
|
||||||
|
<link rel="stylesheet" href="{{ theme('asset', 'style.css') }}">
|
||||||
|
@stack('head')
|
||||||
|
@stack('theme:head')
|
||||||
|
</head>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<header class="d-header">
|
||||||
|
<div class="d-container">
|
||||||
|
<a href="{{ route('home') }}" class="d-logo">{{ $siteName }}</a>
|
||||||
|
<nav class="d-nav">
|
||||||
|
<a href="{{ route('home') }}" class="{{ request()->routeIs('home') ? 'active' : '' }}">首页</a>
|
||||||
|
<a href="{{ route('archives') }}" class="{{ request()->routeIs('archives*') ? 'active' : '' }}">归档</a>
|
||||||
|
<a href="{{ route('tags') }}" class="{{ request()->routeIs('tags*') ? 'active' : '' }}">标签</a>
|
||||||
|
<a href="{{ route('links') }}" class="{{ request()->routeIs('links') ? 'active' : '' }}">友链</a>
|
||||||
|
<form class="d-search" action="{{ route('search') }}" method="get">
|
||||||
|
<input type="search" name="q" placeholder="搜索..." value="{{ request('q', request('keyword', '')) }}">
|
||||||
|
</form>
|
||||||
|
@auth
|
||||||
|
<a href="{{ route('profile') }}">{{ auth()->user()->name }}</a>
|
||||||
|
<form action="{{ route('logout') }}" method="post" class="inline-form">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="d-link-btn">退出</button>
|
||||||
|
</form>
|
||||||
|
@else
|
||||||
|
<a href="{{ route('login') }}">登录</a>
|
||||||
|
<a href="{{ route('register') }}">注册</a>
|
||||||
|
@endauth
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
@stack('theme:header')
|
||||||
Reference in New Issue
Block a user