Browse Source

feat: docs with ai content generation

M1Screw 2 years ago
parent
commit
a82c99ea40

+ 16 - 10
app/routes.php

@@ -28,9 +28,8 @@ return static function (Slim\App $app): void {
         // 公告
         $group->get('/announcement', App\Controllers\UserController::class . ':announcement');
         // 文档
-        $group->get('/docs', App\Controllers\UserController::class . ':docs');
-        // 流媒体解锁
-        $group->get('/media', App\Controllers\UserController::class . ':media');
+        $group->get('/docs', App\Controllers\User\DocsController::class . ':index');
+        $group->get('/docs/{id}/view', App\Controllers\User\DocsController::class . ':detail');
         // 个人资料
         $group->get('/profile', App\Controllers\UserController::class . ':profile');
         $group->get('/invite', App\Controllers\UserController::class . ':invite');
@@ -130,7 +129,7 @@ return static function (Slim\App $app): void {
     $app->group('/admin', static function (RouteCollectorProxy $group): void {
         $group->get('', App\Controllers\AdminController::class . ':index');
         $group->get('/', App\Controllers\AdminController::class . ':index');
-        // Node Mange
+        // Node
         $group->get('/node', App\Controllers\Admin\NodeController::class . ':index');
         $group->get('/node/create', App\Controllers\Admin\NodeController::class . ':create');
         $group->post('/node', App\Controllers\Admin\NodeController::class . ':add');
@@ -140,7 +139,7 @@ return static function (Slim\App $app): void {
         $group->put('/node/{id}', App\Controllers\Admin\NodeController::class . ':update');
         $group->delete('/node/{id}', App\Controllers\Admin\NodeController::class . ':delete');
         $group->post('/node/ajax', App\Controllers\Admin\NodeController::class . ':ajax');
-        // Ticket Mange
+        // Ticket
         $group->get('/ticket', App\Controllers\Admin\TicketController::class . ':index');
         $group->post('/ticket', App\Controllers\Admin\TicketController::class . ':add');
         $group->get('/ticket/{id}/view', App\Controllers\Admin\TicketController::class . ':ticketView');
@@ -149,7 +148,7 @@ return static function (Slim\App $app): void {
         $group->put('/ticket/{id}/ai', App\Controllers\Admin\TicketController::class . ':updateAI');
         $group->delete('/ticket/{id}', App\Controllers\Admin\TicketController::class . ':delete');
         $group->post('/ticket/ajax', App\Controllers\Admin\TicketController::class . ':ajax');
-        // Ann Mange
+        // Ann
         $group->get('/announcement', App\Controllers\Admin\AnnController::class . ':index');
         $group->get('/announcement/create', App\Controllers\Admin\AnnController::class . ':create');
         $group->post('/announcement', App\Controllers\Admin\AnnController::class . ':add');
@@ -157,6 +156,15 @@ return static function (Slim\App $app): void {
         $group->put('/announcement/{id}', App\Controllers\Admin\AnnController::class . ':update');
         $group->delete('/announcement/{id}', App\Controllers\Admin\AnnController::class . ':delete');
         $group->post('/announcement/ajax', App\Controllers\Admin\AnnController::class . ':ajax');
+        // Docs
+        $group->get('/docs', App\Controllers\Admin\DocsController::class . ':index');
+        $group->get('/docs/create', App\Controllers\Admin\DocsController::class . ':create');
+        $group->post('/docs', App\Controllers\Admin\DocsController::class . ':add');
+        $group->post('/docs/generate', App\Controllers\Admin\DocsController::class . ':generate');
+        $group->get('/docs/{id}/edit', App\Controllers\Admin\DocsController::class . ':edit');
+        $group->put('/docs/{id}', App\Controllers\Admin\DocsController::class . ':update');
+        $group->delete('/docs/{id}', App\Controllers\Admin\DocsController::class . ':delete');
+        $group->post('/docs/ajax', App\Controllers\Admin\DocsController::class . ':ajax');
         // 审计
         $group->get('/detect', App\Controllers\Admin\DetectController::class . ':detect');
         $group->get('/detect/create', App\Controllers\Admin\DetectController::class . ':create');
@@ -167,14 +175,14 @@ return static function (Slim\App $app): void {
         $group->post('/detect/log/ajax', App\Controllers\Admin\DetectController::class . ':ajaxLog');
         $group->get('/detect/ban', App\Controllers\Admin\DetectController::class . ':ban');
         $group->post('/detect/ban/ajax', App\Controllers\Admin\DetectController::class . ':ajaxBan');
-        // User Mange
+        // User
         $group->get('/user', App\Controllers\Admin\UserController::class . ':index');
         $group->get('/user/{id}/edit', App\Controllers\Admin\UserController::class . ':edit');
         $group->put('/user/{id}', App\Controllers\Admin\UserController::class . ':update');
         $group->post('/user/create', App\Controllers\Admin\UserController::class . ':createNewUser');
         $group->delete('/user/{id}', App\Controllers\Admin\UserController::class . ':delete');
         $group->post('/user/ajax', App\Controllers\Admin\UserController::class . ':ajax');
-        // Coupon Mange
+        // Coupon
         $group->get('/coupon', App\Controllers\Admin\CouponController::class . ':index');
         $group->post('/coupon', App\Controllers\Admin\CouponController::class . ':add');
         $group->post('/coupon/ajax', App\Controllers\Admin\CouponController::class . ':ajax');
@@ -262,8 +270,6 @@ return static function (Slim\App $app): void {
 
     // WebAPI
     $app->group('/mod_mu', static function (RouteCollectorProxy $group): void {
-        // 流媒体检测
-        $group->post('/media/save_report', App\Controllers\WebAPI\NodeController::class . ':saveReport');
         // 节点
         $group->get('/nodes/{id}/info', App\Controllers\WebAPI\NodeController::class . ':getInfo');
         // 用户

+ 10 - 0
config/settings.json

@@ -1379,6 +1379,16 @@
         "default": "1",
         "mark": "显示用户审计记录"
     },
+    {
+        "id": null,
+        "item": "display_docs",
+        "value": "1",
+        "class": "feature",
+        "is_public": 1,
+        "type": "bool",
+        "default": "1",
+        "mark": "显示文档"
+    },
     {
         "id": null,
         "item": "enable_forced_replacement",

+ 0 - 8
db/migrations/2023020100-init.php

@@ -234,14 +234,6 @@ return new class() implements MigrationInterface {
                 KEY `status` (`status`)
             ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
 
-            CREATE TABLE `stream_media` (
-                `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '记录ID',
-                `node_id` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT '节点ID',
-                `result` text NOT NULL DEFAULT '' COMMENT '检测结果',
-                `created_at` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
-                PRIMARY KEY (`id`)
-            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-
             CREATE TABLE `ticket` (
                 `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '工单ID',
                 `title` varchar(255) NOT NULL DEFAULT '' COMMENT '工单标题',

+ 0 - 4
db/migrations/2023061800-update_new_shop_data_type.php

@@ -139,10 +139,6 @@ return new class() implements MigrationInterface {
         ALTER TABLE product MODIFY COLUMN `sale_count` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '累计销售数';
         ALTER TABLE product MODIFY COLUMN `stock` int(11) NOT NULL DEFAULT -1 COMMENT '库存';
         ALTER TABLE product ADD KEY IF NOT EXISTS `status` (`status`);
-        ALTER TABLE stream_media MODIFY COLUMN `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '记录ID';
-        ALTER TABLE stream_media MODIFY COLUMN `node_id` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT '节点ID';
-        ALTER TABLE stream_media MODIFY COLUMN `result` text NOT NULL DEFAULT '' COMMENT '检测结果';
-        ALTER TABLE stream_media MODIFY COLUMN `created_at` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间';
         ALTER TABLE ticket MODIFY COLUMN `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '工单ID';
         ALTER TABLE ticket MODIFY COLUMN `title` varchar(255) NOT NULL DEFAULT '' COMMENT '工单标题';
         ALTER TABLE ticket MODIFY COLUMN `content` longtext NOT NULL DEFAULT '{}' COMMENT '工单内容' CHECK (json_valid(`content`));

+ 32 - 0
db/migrations/2023071600-drop_stream_media.php

@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types=1);
+
+use App\Interfaces\MigrationInterface;
+use App\Services\DB;
+
+return new class() implements MigrationInterface {
+    public function up(): int
+    {
+        DB::getPdo()->exec('
+            DROP TABLE IF EXISTS `stream_media`;
+        ');
+
+        return 2023071600;
+    }
+
+    public function down(): int
+    {
+        DB::getPdo()->exec(
+            "CREATE TABLE IF NOT EXISTS `stream_media` (
+                `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '记录ID',
+                `node_id` bigint(20) unsigned NOT NULL DEFAULT 0 COMMENT '节点ID',
+                `result` text NOT NULL DEFAULT '' COMMENT '检测结果',
+                `created_at` int(11) unsigned NOT NULL DEFAULT 0 COMMENT '创建时间',
+                PRIMARY KEY (`id`)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;"
+        );
+
+        return 2023071000;
+    }
+};

+ 4 - 5
resources/views/tabler/admin/announcement/create.tpl

@@ -1,7 +1,6 @@
 {include file='admin/header.tpl'}
 
-<link rel="stylesheet" type="text/css" href="//cdn.jsdelivr.net/npm/@tabler/core@latest/dist/libs/tinymce/skins/ui/oxide/skin.min.css">
-<script src="//cdn.jsdelivr.net/npm/@tabler/core@latest/dist/libs/tinymce/tinymce.min.js"></script>
+<script src="//cdnjs.cloudflare.com/ajax/libs/tinymce/6.6.0/tinymce.min.js"></script>
 
 <div class="page-wrapper">
     <div class="container-xl">
@@ -17,10 +16,10 @@
                 </div>
                 <div class="col-auto ms-auto d-print-none">
                     <div class="btn-list">
-                        <a id="create-ann" href="#" class="btn btn-primary">
+                        <button id="create-ann" href="#" class="btn btn-primary">
                             <i class="icon ti ti-device-floppy"></i>
                             保存
-                        </a>
+                        </button>
                     </div>
                 </div>
             </div>
@@ -72,7 +71,7 @@
             plugins:
               'advlist autolink lists link image charmap preview anchor ' +
               'searchreplace visualblocks code fullscreen ' +
-              'insertdatetime media table code help wordcount',
+              'insertdatetime media table wordcount',
             toolbar: 'undo redo | formatselect | ' +
               'bold italic backcolor link | blocks | alignleft aligncenter ' +
               'alignright alignjustify | bullist numlist outdent indent | ' +

+ 6 - 7
resources/views/tabler/admin/announcement/edit.tpl

@@ -1,7 +1,6 @@
 {include file='admin/header.tpl'}
 
-<link rel="stylesheet" type="text/css" href="//cdn.jsdelivr.net/npm/@tabler/core@latest/dist/libs/tinymce/skins/ui/oxide/skin.min.css">
-<script src="//cdn.jsdelivr.net/npm/@tabler/core@latest/dist/libs/tinymce/tinymce.min.js"></script>
+<script src="//cdnjs.cloudflare.com/ajax/libs/tinymce/6.6.0/tinymce.min.js"></script>
 
 <div class="page-wrapper">
     <div class="container-xl">
@@ -9,7 +8,7 @@
             <div class="row align-items-center">
                 <div class="col">
                     <h2 class="page-title">
-                        <span class="home-title">编辑公告</span>
+                        <span class="home-title">编辑公告 #{$ann->id}</span>
                     </h2>
                     <div class="page-pretitle my-3">
                         <span class="home-subtitle">编辑站点公告</span>
@@ -17,10 +16,10 @@
                 </div>
                 <div class="col-auto ms-auto d-print-none">
                     <div class="btn-list">
-                        <a id="save-ann" href="#" class="btn btn-primary">
+                        <button id="save-ann" href="#" class="btn btn-primary">
                             <i class="icon ti ti-device-floppy"></i>
                             保存
-                        </a>
+                        </button>
                     </div>
                 </div>
             </div>
@@ -49,9 +48,9 @@
             menubar: false,
             statusbar: false,
             plugins:
-              'advlist autolink lists link image charmap print preview anchor ' +
+              'advlist autolink lists link image charmap preview anchor ' +
               'searchreplace visualblocks code fullscreen ' +
-              'insertdatetime media table paste code help wordcount',
+              'insertdatetime media table wordcount',
             toolbar: 'undo redo | formatselect | ' +
               'bold italic backcolor link | blocks | alignleft aligncenter ' +
               'alignright alignjustify | bullist numlist outdent indent | ' +

+ 1 - 1
resources/views/tabler/admin/announcement/index.tpl

@@ -46,7 +46,7 @@
     </div>
 
     <script>
-        var table = $('#data_table').DataTable({
+        let table = $('#data_table').DataTable({
             ajax: {
                 url: '/admin/announcement/ajax',
                 type: 'POST',

+ 142 - 0
resources/views/tabler/admin/docs/create.tpl

@@ -0,0 +1,142 @@
+{include file='admin/header.tpl'}
+
+<script src="//cdnjs.cloudflare.com/ajax/libs/tinymce/6.6.0/tinymce.min.js"></script>
+
+<div class="page-wrapper">
+    <div class="container-xl">
+        <div class="page-header d-print-none text-white">
+            <div class="row align-items-center">
+                <div class="col">
+                    <h2 class="page-title">
+                        <span class="home-title">创建文档</span>
+                    </h2>
+                    <div class="page-pretitle my-3">
+                        <span class="home-subtitle">创建站点文档</span>
+                    </div>
+                </div>
+                <div class="col-auto ms-auto d-print-none">
+                    <div class="btn-list">
+                        <button href="#" class="btn btn-primary" data-bs-toggle="modal"
+                            data-bs-target="#generate-ai-content">
+                            <i class="icon ti ti-robot"></i>
+                            AI 文档生成
+                        </button>
+                        <button id="create-doc" href="#" class="btn btn-primary">
+                            <i class="icon ti ti-device-floppy"></i>
+                            保存
+                        </button>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="page-body">
+        <div class="container-xl">
+            <div class="card">
+                <div class="card-body">
+                    <div class="mb-3">
+                        <label class="form-label col-3 col-form-label">文档标题</label>
+                        <div class="col">
+                            <input id="title" type="text" class="form-control" value="">
+                        </div>
+                    </div>
+                    <div class="mb-3">
+                        <form method="post">
+                            <textarea id="tinymce"></textarea>
+                        </form>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div class="modal modal-blur fade" id="generate-ai-content" tabindex="-1" role="dialog" aria-hidden="true">
+        <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable" role="document">
+            <div class="modal-content">
+                <div class="modal-header">
+                    <h5 class="modal-title">使用 LLM 自动生成文档</h5>
+                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
+                </div>
+                <div class="modal-body">
+                    <div class="mb-3">
+                        <input id="question" class="form-control" rows="12" placeholder="请输入文档生成问题"></input>
+                    </div>
+                </div>
+                <div class="modal-footer">
+                    <button type="button" class="btn me-auto" data-bs-dismiss="modal">取消</button>
+                    <button id="generate" type="button" class="btn btn-primary" data-bs-dismiss="modal">生成</button>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script>
+    document.addEventListener("DOMContentLoaded", function () {
+        let options = {
+            selector: '#tinymce',
+            height: 300,
+            menubar: false,
+            statusbar: false,
+            plugins:
+              'advlist autolink lists link image charmap preview anchor ' +
+              'searchreplace visualblocks code fullscreen ' +
+              'insertdatetime media table wordcount',
+            toolbar: 'undo redo | formatselect | ' +
+              'bold italic backcolor link | blocks | alignleft aligncenter ' +
+              'alignright alignjustify | bullist numlist outdent indent | ' +
+              'removeformat',
+            content_style: 'body { font-family: -apple-system, BlinkMacSystemFont, San Francisco, Segoe UI, Roboto, Helvetica Neue, sans-serif;font-size:   14px; -webkit-font-smoothing: antialiased; }',
+            {if $user->is_dark_mode}
+            skin: 'oxide-dark',
+            content_css: 'dark',
+            {/if}
+        }
+        tinyMCE.init(options);
+    })
+
+    $("#generate").click(function() {
+        $.ajax({
+            url: "/admin/docs/generate",
+            type: 'POST',
+            dataType: "json",
+            data: {
+                question: $("#question").val(),
+            },
+            success: function(data) {
+                if (data.ret === 1) {
+                    $('#success-noreload-message').text(data.msg);
+                    $('#success-noreload-dialog').modal('show');
+                    tinyMCE.activeEditor.setContent(data.content);
+                } else {
+                    $('#fail-message').text(data.msg);
+                    $('#fail-dialog').modal('show');
+                }
+            }
+        })
+    });
+
+    $("#create-doc").click(function() {
+        $.ajax({
+            url: '/admin/docs',
+            type: 'POST',
+            dataType: "json",
+            data: {
+                title: $("#title").val(),
+                content: tinyMCE.get('tinymce').getContent(),
+            },
+            success: function(data) {
+                if (data.ret === 1) {
+                    $('#success-message').text(data.msg);
+                    $('#success-dialog').modal('show');
+                    window.setTimeout("location.href=top.document.referrer", {$config['jump_delay']});
+                } else {
+                    $('#fail-message').text(data.msg);
+                    $('#fail-dialog').modal('show');
+                }
+            }
+        })
+    });
+</script>
+
+{include file='admin/footer.tpl'}

+ 96 - 0
resources/views/tabler/admin/docs/edit.tpl

@@ -0,0 +1,96 @@
+{include file='admin/header.tpl'}
+
+<script src="//cdnjs.cloudflare.com/ajax/libs/tinymce/6.6.0/tinymce.min.js"></script>
+
+<div class="page-wrapper">
+    <div class="container-xl">
+        <div class="page-header d-print-none text-white">
+            <div class="row align-items-center">
+                <div class="col">
+                    <h2 class="page-title">
+                        <span class="home-title">编辑文档 #{$doc->id}</span>
+                    </h2>
+                    <div class="page-pretitle my-3">
+                        <span class="home-subtitle">编辑站点文档</span>
+                    </div>
+                </div>
+                <div class="col-auto ms-auto d-print-none">
+                    <div class="btn-list">
+                        <button id="save-doc" href="#" class="btn btn-primary">
+                            <i class="icon ti ti-device-floppy"></i>
+                            保存
+                        </button>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="page-body">
+        <div class="container-xl">
+            <div class="card">
+                <div class="card-body">
+                    <div class="mb-3">
+                        <label class="form-label col-3 col-form-label">文档标题</label>
+                        <div class="col">
+                            <input id="title" type="text" class="form-control" value="{$doc->title}">
+                        </div>
+                    </div>
+                    <div class="mb-3">
+                        <form method="post">
+                            <textarea id="tinymce">{$doc->content}</textarea>
+                        </form>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+
+<script>
+    document.addEventListener("DOMContentLoaded", function () {
+        let options = {
+            selector: '#tinymce',
+            height: 300,
+            menubar: false,
+            statusbar: false,
+            plugins:
+              'advlist autolink lists link image charmap preview anchor ' +
+              'searchreplace visualblocks code fullscreen ' +
+              'insertdatetime media table wordcount',
+            toolbar: 'undo redo | formatselect | ' +
+              'bold italic backcolor link | blocks | alignleft aligncenter ' +
+              'alignright alignjustify | bullist numlist outdent indent | ' +
+              'removeformat',
+            content_style: 'body { font-family: -apple-system, BlinkMacSystemFont, San Francisco, Segoe UI, Roboto, Helvetica Neue, sans-serif;   font-size:   14px; -webkit-font-smoothing: antialiased; }',
+            {if $user->is_dark_mode}
+            skin: 'oxide-dark',
+            content_css: 'dark',
+            {/if}
+        }
+        tinyMCE.init(options);
+    })
+
+    $("#save-doc").click(function() {
+        $.ajax({
+            url: '/admin/docs/' + {$doc->id},
+            type: 'PUT',
+            dataType: "json",
+            data: {
+                title: $("#title").val(),
+                content: tinyMCE.activeEditor.getContent(),
+            },
+            success: function(data) {
+                if (data.ret === 1) {
+                    $('#success-message').text(data.msg);
+                    $('#success-dialog').modal('show');
+                    window.setTimeout("location.href=top.document.referrer", {$config['jump_delay']});
+                } else {
+                    $('#fail-message').text(data.msg);
+                    $('#fail-dialog').modal('show');
+                }
+            }
+        })
+    });
+</script>
+
+{include file='admin/footer.tpl'}

+ 131 - 0
resources/views/tabler/admin/docs/index.tpl

@@ -0,0 +1,131 @@
+{include file='admin/header.tpl'}
+
+<div class="page-wrapper">
+    <div class="container-xl">
+        <div class="page-header d-print-none text-white">
+            <div class="row align-items-center">
+                <div class="col">
+                    <h2 class="page-title">
+                        <span class="home-title">文档管理</span>
+                    </h2>
+                    <div class="page-pretitle my-3">
+                        <span class="home-subtitle">查看并管理站点中的文档</span>
+                    </div>
+                </div>
+                <div class="col-auto ms-auto d-print-none">
+                    <div class="btn-list">
+                        <a href="/admin/docs/create" class="btn btn-primary">
+                            <i class="icon ti ti-plus"></i>
+                            创建
+                        </a>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="page-body">
+        <div class="container-xl">
+            <div class="row row-deck row-cards">
+                <div class="col-12">
+                    <div class="card">
+                        <div class="table-responsive">
+                            <table id="data_table" class="table card-table table-vcenter text-nowrap datatable">
+                                <thead>
+                                    <tr>
+                                        {foreach $details['field'] as $key => $value}
+                                            <th>{$value}</th>
+                                        {/foreach}
+                                    </tr>
+                                </thead>
+                            </table>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <script>
+        let table = $('#data_table').DataTable({
+            ajax: {
+                url: '/admin/docs/ajax',
+                type: 'POST',
+                dataSrc: 'docs'
+            },
+            "autoWidth":false,
+            'iDisplayLength': 10,
+            'scrollX': true,
+            'order': [
+                [1, 'asc']
+            ],
+            columns: [
+                {foreach $details['field'] as $key => $value}
+                { data: '{$key}' },
+                {/foreach}
+            ],
+            "columnDefs":[
+                { targets:[0],orderable:false }
+            ],
+            "dom": "<'row px-3 py-3'<'col-sm-12 col-md-6'l><'col-sm-12 col-md-6'f>>" +
+                "<'row'<'col-sm-12'tr>>" +
+                "<'row card-footer d-flex d-flexalign-items-center'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",
+            language: {
+                "sProcessing": "处理中...",
+                "sLengthMenu": "显示 _MENU_ 条",
+                "sZeroRecords": "没有匹配结果",
+                "sInfo": "第 _START_ 至 _END_ 项结果,共 _TOTAL_项",
+                "sInfoEmpty": "第 0 至 0 项结果,共 0 项",
+                "sInfoFiltered": "(在 _MAX_ 项中查找)",
+                "sInfoPostFix": "",
+                "sSearch": "<i class=\"ti ti-search\"></i> ",
+                "sUrl": "",
+                "sEmptyTable": "表中数据为空",
+                "sLoadingRecords": "载入中...",
+                "sInfoThousands": ",",
+                "oPaginate": {
+                    "sFirst": "首页",
+                    "sPrevious": "<i class=\"titi-arrow-left\"></i>",
+                    "sNext": "<i class=\"ti ti-arrow-right\"><i>",
+                    "sLast": "末页"
+                },
+                "oAria": {
+                    "sSortAscending": ": 以升序排列此列",
+                    "sSortDescending": ": 以降序排列此列"
+                }
+            },
+        });
+
+        function loadTable() {
+            table;
+        }
+
+        function deleteDoc(doc_id) {
+            $('#notice-message').text('确定删除此文档?');
+            $('#notice-dialog').modal('show');
+            $('#notice-confirm').off('click').on('click', function() {
+                $.ajax({
+                    url: "/admin/docs/" + doc_id,
+                    type: 'DELETE',
+                    dataType: "json",
+                    success: function(data) {
+                        if (data.ret === 1) {
+                            $('#success-noreload-message').text(data.msg);
+                            $('#success-noreload-dialog').modal('show');
+                            reloadTableAjax();
+                        } else {
+                            $('#fail-message').text(data.msg);
+                            $('#fail-dialog').modal('show');
+                        }
+                    }
+                })
+            });
+        }
+
+        function reloadTableAjax() {
+            table.ajax.reload(null, false);
+        }
+
+        loadTable();
+    </script>
+
+{include file='admin/footer.tpl'}

+ 4 - 0
resources/views/tabler/admin/header.tpl

@@ -158,6 +158,10 @@
                                         <i class="ti ti-messages"></i>&nbsp;
                                         工单
                                     </a>
+                                    <a class="dropdown-item" href="/admin/docs">
+                                        <i class="ti ti-notes"></i>&nbsp;
+                                        文档
+                                    </a>
                                 </div>
                             </li>
                             <li class="nav-item dropdown">

+ 9 - 0
resources/views/tabler/admin/setting/feature.tpl

@@ -66,6 +66,15 @@
                                         </select>
                                     </div>
                                 </div>
+                                <div class="form-group mb-3 row">
+                                    <label class="form-label col-3 col-form-label">显示文档</label>
+                                    <div class="col">
+                                        <select id="display_docs" class="col form-select" value="{$settings['display_docs']}">
+                                            <option value="0" {if $settings['display_docs'] === false}selected{/if}>关闭</option>
+                                            <option value="1" {if $settings['display_docs']}selected{/if}>开启</option>
+                                        </select>
+                                    </div>
+                                </div>
                             </div>
                         </div>
                     </div>

+ 0 - 49
resources/views/tabler/user/docs.tpl

@@ -1,49 +0,0 @@
-{include file='user/header.tpl'}
-
-<div class="page-wrapper">
-    <div class="container-xl">
-        <div class="page-header d-print-none text-white">
-            <div class="row align-items-center">
-                <div class="col">
-                    <h2 class="page-title">
-                        <span class="home-title">文档中心</span>
-                    </h2>
-                    <div class="page-pretitle my-3">
-                        <span class="home-subtitle">在这里查看安装和使用教程</span>
-                    </div>
-                </div>
-            </div>
-        </div>
-    </div>
-    <div class="page-body">
-        <div class="container-xl">
-            <div class="row row-deck row-cards">
-                <div class="col-12">
-                    <div class="card">
-                        <div class="table-responsive">
-                            <table class="table table-vcenter card-table">
-                                <thead>
-                                    <tr>
-                                        <th>ID</th>
-                                        <th>文档标题</th>
-                                        <th>文档内容</th>
-                                    </tr>
-                                </thead>
-                                <tbody>
-                                    {foreach $docs as $doc}
-                                        <tr>
-                                            <td>{$doc->id}</td>
-                                            <td>{$doc->title}</td>
-                                            <td>{$doc->content}</td>
-                                        </tr>
-                                    {/foreach}
-                                </tbody>
-                            </table>
-                        </div>
-                    </div>
-                </div>
-            </div>
-        </div>
-    </div>
-
-{include file='user/footer.tpl'}

+ 51 - 0
resources/views/tabler/user/docs/index.tpl

@@ -0,0 +1,51 @@
+{include file='user/header.tpl'}
+
+<div class="page-wrapper">
+    <div class="container-xl">
+        <div class="page-header d-print-none text-white">
+            <div class="row align-items-center">
+                <div class="col">
+                    <h2 class="page-title">
+                        <span class="home-title">文档中心</span>
+                    </h2>
+                    <div class="page-pretitle my-3">
+                        <span class="home-subtitle">在这里查看安装和使用教程</span>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="page-body">
+        <div class="container-xl">
+            <div class="row row-deck row-cards">
+                <div class="col-12">
+                    <div class="card">
+                        <div class="card-header">
+                            <h3 class="card-title">文档列表</h3>
+                        </div>
+                        <div class="list-group list-group-flush list-group-hoverable">
+                            {foreach $docs as $doc}
+                            <div class="list-group-item">
+                                <div class="row align-items-center">
+                                    <div class="col text-truncate">
+                                        <div class="text-reset d-block">{$doc->title}</div>
+                                        <div class="d-block text-secondary text-truncate mt-n1">
+                                            {$doc->date}
+                                        </div>
+                                    </div>
+                                    <div class="col-auto">
+                                        <a class="btn btn-blue" href="/user/docs/{$doc->id}/view">
+                                            查看
+                                        </a>
+                                    </div>
+                                </div>
+                            </div>
+                            {/foreach}
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+{include file='user/footer.tpl'}

+ 27 - 0
resources/views/tabler/user/docs/view.tpl

@@ -0,0 +1,27 @@
+{include file='user/header.tpl'}
+
+<div class="page-wrapper">
+    <div class="container-xl">
+        <div class="page-header d-print-none text-white">
+            <div class="row align-items-center">
+                <div class="col">
+                    <h2 class="page-title">
+                        {$doc->title}
+                    </h2>
+                </div>
+            </div>
+        </div>
+    </div>
+    <div class="page-body">
+        <div class="container-xl">
+            <div class="card card-lg">
+                <div class="card-body ">
+                    <div class="row g-4">
+                        {$doc->content}
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+
+{include file='user/footer.tpl'}

+ 6 - 6
resources/views/tabler/user/header.tpl

@@ -125,12 +125,6 @@
                                         <i class="ti ti-server"></i>&nbsp;
                                         节点
                                     </a>
-                                    {if $public_setting['display_media']}
-                                    <a class="dropdown-item" href="/user/media">
-                                        <i class="ti ti-key"></i>&nbsp;
-                                        流媒体解锁
-                                    </a>
-                                    {/if}
                                 </div>
                             </li>
                             <li class="nav-item dropdown">
@@ -154,6 +148,12 @@
                                         工单
                                     </a>
                                     {/if}
+                                    {if $public_setting['display_docs']}
+                                    <a class="dropdown-item" href="/user/docs">
+                                        <i class="ti ti-notes"></i>&nbsp;
+                                        文档
+                                    </a>
+                                    {/if}
                                 </div>
                             </li>
                             <li class="nav-item dropdown">

+ 0 - 66
resources/views/tabler/user/media.tpl

@@ -1,66 +0,0 @@
-{include file='user/header.tpl'}
-
-<div class="page-wrapper">
-    <div class="container-xl">
-        <div class="page-header d-print-none text-white">
-            <div class="row align-items-center">
-                <div class="col">
-                    <h2 class="page-title">
-                        <span class="home-title">流媒体解锁</span>
-                    </h2>
-                    <div class="page-pretitle my-3">
-                        <span class="home-subtitle">你可以在这里查看节点的流媒体解锁情况</span>
-                    </div>
-                </div>
-            </div>
-        </div>
-    </div>
-    <div class="page-body">
-        <div class="container-xl">
-            <div class="row row-deck row-cards">
-                <div class="col-12">
-                    <div class="card">
-                        <div class="table-responsive">
-                            <table class="table card-table table-vcenter text-nowrap datatable">
-                                <thead>
-                                    <tr>
-                                        <th>节点</th>
-                                        {foreach $results['0']['unlock_item'] as $key => $value}
-                                            {if $key !== 'BilibiliChinaMainland'}
-                                                {if $key === 'BilibiliHKMCTW'}
-                                                    <th>Bilibili(港澳台)</th>
-                                                {elseif $key === 'BilibiliTW'}
-                                                    <th>Bilibili(台湾)</th>
-                                                {else}
-                                                    <th>{$key}</th>
-                                                {/if}
-                                            {/if}
-                                        {/foreach}
-                                        <th>更新时间</th>
-                                    </tr>
-                                </thead>
-                                <tbody>
-                                    {foreach $results as $result}
-                                        <tr>
-                                            <td>{$result['node_name']}</td>
-                                            <td>{$result['unlock_item']['YouTube']}</td>
-                                            <td>{$result['unlock_item']['Netflix']}</td>
-                                            <td>{$result['unlock_item']['DisneyPlus']}</td>
-                                            <td>{$result['unlock_item']['BilibiliHKMCTW']}</td>
-                                            <td>{$result['unlock_item']['BilibiliTW']}</td>
-                                            <td>{$result['unlock_item']['MyTVSuper']}
-                                            <td>{$result['unlock_item']['BBC']}</td>
-                                            <td>{$result['unlock_item']['Abema']}</td>
-                                            <td>{date('Y-m-d H:i:s', $result['created_at'])}</td>
-                                        </tr>
-                                    {/foreach}
-                                </tbody>
-                            </table>
-                        </div>
-                    </div>
-                </div>
-            </div>
-        </div>
-    </div>
-    
-{include file='user/footer.tpl'}

+ 194 - 0
src/Controllers/Admin/DocsController.php

@@ -0,0 +1,194 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers\Admin;
+
+use App\Controllers\BaseController;
+use App\Models\Docs;
+use App\Services\ChatGPT;
+use App\Services\PaLM;
+use Exception;
+use Psr\Http\Message\ResponseInterface;
+use Slim\Http\Response;
+use Slim\Http\ServerRequest;
+use Telegram\Bot\Exceptions\TelegramSDKException;
+use function date;
+
+final class DocsController extends BaseController
+{
+    public static array $details =
+        [
+            'field' => [
+                'op' => '操作',
+                'id' => 'ID',
+                'date' => '日期',
+                'title' => '标题',
+            ],
+        ];
+
+    /**
+     * 后台文档页面
+     *
+     * @throws Exception
+     */
+    public function index(ServerRequest $request, Response $response, array $args): Response|ResponseInterface
+    {
+        return $response->write(
+            $this->view()
+                ->assign('details', self::$details)
+                ->fetch('admin/docs/index.tpl')
+        );
+    }
+
+    /**
+     * 后台文档创建页面
+     *
+     * @throws Exception
+     */
+    public function create(ServerRequest $request, Response $response, array $args): Response|ResponseInterface
+    {
+        return $response->write(
+            $this->view()
+                ->fetch('admin/docs/create.tpl')
+        );
+    }
+
+    /**
+     * 后台添加文档
+     */
+    public function add(ServerRequest $request, Response $response, array $args): Response|ResponseInterface
+    {
+        $title = $request->getParam('title');
+        $content = $request->getParam('content');
+
+        if ($content === '' || $title === '') {
+            return $response->withJson([
+                'ret' => 0,
+                'msg' => '文档标题或内容不能为空',
+            ]);
+        }
+
+        $doc = new Docs();
+        $doc->date = date('Y-m-d H:i:s');
+        $doc->title = $title;
+        $doc->content = $content;
+
+        if (! $doc->save()) {
+            return $response->withJson([
+                'ret' => 0,
+                'msg' => '文档添加失败',
+            ]);
+        }
+
+        return $response->withJson([
+            'ret' => 1,
+            'msg' => '文档添加成功',
+        ]);
+    }
+
+    /**
+     * 使用LLM生成文档
+     */
+    public function generate(ServerRequest $request, Response $response, array $args): Response|ResponseInterface
+    {
+        $question = $request->getParam('question');
+
+        // 这里可能要等4-5秒
+        if ($_ENV['llm_backend'] === 'openai') {
+            $content = ChatGPT::askOnce($question);
+        } elseif ($_ENV['llm_backend'] === 'palm') {
+            $content = PaLM::textPrompt($question);
+        } else {
+            return $response->withJson([
+                'ret' => 0,
+                'msg' => 'LLM 后端配置错误',
+            ]);
+        }
+
+        return $response->withJson([
+            'ret' => 1,
+            'msg' => '文档生成成功',
+            'content' => $content,
+        ]);
+    }
+
+    /**
+     * 文档编辑页面
+     *
+     * @throws Exception
+     */
+    public function edit(ServerRequest $request, Response $response, array $args): Response|ResponseInterface
+    {
+        $doc = Docs::find($args['id']);
+
+        return $response->write(
+            $this->view()
+                ->assign('doc', $doc)
+                ->fetch('admin/docs/edit.tpl')
+        );
+    }
+
+    /**
+     * 后台编辑文档提交
+     *
+     * @throws TelegramSDKException
+     */
+    public function update(ServerRequest $request, Response $response, array $args): Response|ResponseInterface
+    {
+        $doc = Docs::find($args['id']);
+        $doc->title = $request->getParam('title');
+        $doc->content = $request->getParam('content');
+        $doc->date = date('Y-m-d H:i:s');
+
+        if (! $doc->save()) {
+            return $response->withJson([
+                'ret' => 0,
+                'msg' => '文档更新失败',
+            ]);
+        }
+
+        return $response->withJson([
+            'ret' => 1,
+            'msg' => '文档更新成功',
+        ]);
+    }
+
+    /**
+     * 后台删除文档
+     */
+    public function delete(ServerRequest $request, Response $response, array $args): Response|ResponseInterface
+    {
+        $doc = Docs::find($args['id']);
+
+        if (! $doc->delete()) {
+            return $response->withJson([
+                'ret' => 0,
+                'msg' => '删除失败',
+            ]);
+        }
+
+        return $response->withJson([
+            'ret' => 1,
+            'msg' => '删除成功',
+        ]);
+    }
+
+    /**
+     * 后台文档页面 AJAX
+     */
+    public function ajax(ServerRequest $request, Response $response, array $args): Response|ResponseInterface
+    {
+        $docs = Docs::orderBy('id', 'asc')->get();
+
+        foreach ($docs as $doc) {
+            $doc->op = '<button type="button" class="btn btn-red" id="delete-doc-' . $doc->id . '" 
+            onclick="deleteDoc(' . $doc->id . ')">删除</button>
+            <a class="btn btn-blue" href="/admin/docs/' . $doc->id . '/edit">编辑</a>';
+        }
+
+        return $response->withJson([
+            'docs' => $docs,
+        ]);
+    }
+}

+ 1 - 0
src/Controllers/Admin/Setting/FeatureController.php

@@ -15,6 +15,7 @@ final class FeatureController extends BaseController
         'display_media',
         'display_subscribe_log',
         'display_detect_log',
+        'display_docs',
     ];
 
     /**

+ 44 - 0
src/Controllers/User/DocsController.php

@@ -0,0 +1,44 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Controllers\User;
+
+use App\Controllers\BaseController;
+use App\Models\Docs;
+use Exception;
+use Psr\Http\Message\ResponseInterface;
+use Slim\Http\Response;
+use Slim\Http\ServerRequest;
+
+final class DocsController extends BaseController
+{
+    /**
+     * @throws Exception
+     */
+    public function index(ServerRequest $request, Response $response, array $args): Response|ResponseInterface
+    {
+        $docs = Docs::orderBy('id', 'desc')->get();
+
+        return $response->write(
+            $this->view()
+                ->assign('docs', $docs)
+                ->fetch('user/docs/index.tpl')
+        );
+    }
+
+    /**
+     * @throws Exception
+     */
+    public function detail(ServerRequest $request, Response $response, array $args): Response|ResponseInterface
+    {
+        $id = $args['id'];
+        $doc = Docs::find($id);
+
+        return $response->write(
+            $this->view()
+                ->assign('doc', $doc)
+                ->fetch('user/docs/view.tpl')
+        );
+    }
+}

+ 0 - 64
src/Controllers/UserController.php

@@ -5,20 +5,17 @@ declare(strict_types=1);
 namespace App\Controllers;
 
 use App\Models\Ann;
-use App\Models\Docs;
 use App\Models\InviteCode;
 use App\Models\LoginIp;
 use App\Models\Node;
 use App\Models\OnlineLog;
 use App\Models\Payback;
 use App\Models\Setting;
-use App\Models\StreamMedia;
 use App\Models\User;
 use App\Services\Auth;
 use App\Services\Cache;
 use App\Services\Captcha;
 use App\Services\Config;
-use App\Services\DB;
 use App\Services\MFA;
 use App\Utils\Hash;
 use App\Utils\ResponseHelper;
@@ -31,17 +28,13 @@ use RedisException;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
 use voku\helper\AntiXSS;
-use function array_column;
-use function array_multisort;
 use function in_array;
-use function json_decode;
 use function str_replace;
 use function strlen;
 use function strtolower;
 use function strtotime;
 use function time;
 use const BASE_PATH;
-use const SORT_ASC;
 
 /**
  *  HomeController
@@ -114,63 +107,6 @@ final class UserController extends BaseController
         );
     }
 
-    /**
-     * @throws Exception
-     */
-    public function docs(ServerRequest $request, Response $response, array $args): Response|ResponseInterface
-    {
-        $docs = Docs::orderBy('id', 'desc')->get();
-
-        return $response->write(
-            $this->view()
-                ->assign('docs', $docs)
-                ->fetch('user/docs.tpl')
-        );
-    }
-
-    /**
-     * @throws Exception
-     */
-    public function media(ServerRequest $request, Response $response, array $args): Response|ResponseInterface
-    {
-        $results = [];
-        $pdo = DB::getPdo();
-        $nodes = $pdo->query('SELECT DISTINCT node_id FROM stream_media');
-
-        foreach ($nodes as $node_id) {
-            $node = Node::where('id', $node_id)->first();
-
-            $unlock = StreamMedia::where('node_id', $node_id)
-                ->orderBy('id', 'desc')
-                ->where('created_at', '>', time() - 86400) // 只获取最近一天内上报的数据
-                ->first();
-
-            if ($unlock !== null && $node !== null) {
-                $details = json_decode($unlock->result, true);
-                $details = str_replace('Originals Only', '仅限自制', $details);
-                $details = str_replace('Oversea Only', '仅限海外', $details);
-                $info = [];
-
-                foreach ($details as $key => $value) {
-                    $info = [
-                        'node_name' => $node->name,
-                        'created_at' => $unlock->created_at,
-                        'unlock_item' => $details,
-                    ];
-                }
-
-                $results[] = $info;
-            }
-        }
-
-        $node_names = array_column($results, 'node_name');
-        array_multisort($node_names, SORT_ASC, $results);
-
-        return $response->write($this->view()
-            ->assign('results', $results)
-            ->fetch('user/media.tpl'));
-    }
-
     /**
      * @throws Exception
      */

+ 0 - 20
src/Controllers/WebAPI/NodeController.php

@@ -6,35 +6,15 @@ namespace App\Controllers\WebAPI;
 
 use App\Controllers\BaseController;
 use App\Models\Node;
-use App\Models\StreamMedia;
 use App\Utils\ResponseHelper;
 use Psr\Http\Message\ResponseInterface;
 use Slim\Http\Response;
 use Slim\Http\ServerRequest;
 use function json_decode;
-use function json_encode;
-use function time;
 use const VERSION;
 
 final class NodeController extends BaseController
 {
-    public function saveReport(ServerRequest $request, Response $response, array $args): ResponseInterface
-    {
-        $node_id = $request->getParam('node_id');
-        $content = $request->getParam('content');
-        $result = json_decode(base64_decode($content), true);
-        $report = new StreamMedia();
-        $report->node_id = $node_id;
-        $report->result = json_encode($result);
-        $report->created_at = time();
-        $report->save();
-
-        return $response->withJson([
-            'ret' => 1,
-            'data' => 'ok',
-        ]);
-    }
-
     public function getInfo(ServerRequest $request, Response $response, array $args): ResponseInterface
     {
         $node_id = $args['id'];

+ 0 - 11
src/Models/StreamMedia.php

@@ -1,11 +0,0 @@
-<?php
-
-declare(strict_types=1);
-
-namespace App\Models;
-
-final class StreamMedia extends Model
-{
-    protected $connection = 'default';
-    protected $table = 'stream_media';
-}