优化功能
Some checks failed
Tests / PHP 8.2 (push) Has been cancelled
Tests / PHP 8.3 (push) Has been cancelled
Tests / PHP 8.4 (push) Has been cancelled

This commit is contained in:
jiangdong.cheng
2026-02-12 17:10:36 +08:00
parent 56c685b579
commit a795b2c896
29 changed files with 2155 additions and 884 deletions

View File

@@ -0,0 +1,65 @@
@php
$routeName = (string) (request()->route()?->getName() ?? '');
$parts = explode('.', $routeName);
$moduleKey = $parts[1] ?? 'dashboard';
$actionKey = $parts[2] ?? 'index';
$moduleMeta = [
'dashboard' => ['label' => '控制台', 'index' => 'admin.dashboard', 'subtitle' => '查看系统概览与关键运营数据。'],
'tools' => ['label' => 'AI 工具', 'index' => 'admin.tools.index', 'subtitle' => '维护工具信息、状态与展示内容。'],
'models' => ['label' => 'AI 模型', 'index' => 'admin.models.index', 'subtitle' => '管理模型参数、评分与发布状态。'],
'articles' => ['label' => 'AI 资讯', 'index' => 'admin.articles.index', 'subtitle' => '维护资讯内容、来源与发布质量。'],
'guides' => ['label' => 'AI 教程', 'index' => 'admin.guides.index', 'subtitle' => '维护教程内容与学习难度分层。'],
'categories' => ['label' => '分类管理', 'index' => 'admin.categories.index', 'subtitle' => '统一管理分类体系与启用状态。'],
'sources' => ['label' => '来源管理', 'index' => 'admin.sources.index', 'subtitle' => '维护可信来源白名单与抓取策略。'],
'settings' => ['label' => '首页配置', 'index' => 'admin.settings.index', 'subtitle' => '配置首页模块、条目与展示顺序。'],
'feedback' => ['label' => '反馈管理', 'index' => 'admin.feedback.index', 'subtitle' => '跟进用户反馈并及时更新处理状态。'],
][$moduleKey] ?? ['label' => '管理后台', 'index' => 'admin.dashboard', 'subtitle' => '维护站点内容与配置。'];
$actionLabel = [
'index' => '列表',
'create' => '新建',
'edit' => '编辑',
][$actionKey] ?? '详情';
$defaultTitle = $moduleMeta['label'];
$pageTitle = trim((string) $__env->yieldContent('title'));
$pageTitle = $pageTitle !== '' ? $pageTitle : $defaultTitle;
$pageSubtitle = trim((string) $__env->yieldContent('page_subtitle'));
if ($pageSubtitle === '') {
$pageSubtitle = $actionKey === 'index'
? $moduleMeta['subtitle']
: '当前为'.$actionLabel.'页面,请按提示完成必填信息并保存。';
}
@endphp
<div class="admin-page-head">
<nav class="admin-breadcrumb" aria-label="breadcrumb">
<ol class="breadcrumb mb-2">
<li class="breadcrumb-item"><a href="{{ route('admin.dashboard') }}">控制台</a></li>
@if($moduleKey !== 'dashboard')
@if($actionKey === 'index')
<li class="breadcrumb-item active" aria-current="page">{{ $moduleMeta['label'] }}</li>
@else
<li class="breadcrumb-item"><a href="{{ route($moduleMeta['index']) }}">{{ $moduleMeta['label'] }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ $actionLabel }}</li>
@endif
@endif
</ol>
</nav>
<div class="d-flex align-items-start justify-content-between gap-3 flex-wrap">
<div>
<h2 class="page-title mb-1">{{ $pageTitle }}</h2>
<div class="admin-page-subtitle">{{ $pageSubtitle }}</div>
</div>
@if(trim((string) $__env->yieldContent('page_actions')) !== '')
<div class="admin-page-actions">
@yield('page_actions')
</div>
@endif
</div>
</div>

View File

@@ -0,0 +1,48 @@
@php
$inputName = $field ?? 'body';
$previewElementId = $previewId ?? 'preview-'.str_replace(['[', ']', '.'], '-', $inputName);
$inputValue = old($inputName, $value ?? '');
$isRequired = (bool) ($required ?? false);
$editorRows = (int) ($rows ?? 14);
@endphp
<div class="editor-shell">
<div class="editor-card">
<div class="editor-card-head">
<span>
{{ $label ?? '正文内容' }}
@if($isRequired)
<span class="required-star">*</span>
@endif
</span>
<button class="btn btn-sm btn-outline-primary js-md-upload-advanced-btn" type="button" data-editor-target="{{ $inputName }}">
<i class="bi bi-image me-1"></i>上传图片
</button>
</div>
<div class="p-2">
<textarea
class="form-control js-md-editor-modern"
name="{{ $inputName }}"
rows="{{ $editorRows }}"
data-preview-target="#{{ $previewElementId }}"
placeholder="{{ $placeholder ?? '支持 Markdown 语法,建议使用标题和列表组织内容。' }}"
@if($isRequired) required @endif
@if(!empty($minlength)) minlength="{{ (int) $minlength }}" @endif
>{{ $inputValue }}</textarea>
@if(!empty($hint))
<div class="form-hint">{{ $hint }}</div>
@endif
</div>
</div>
<div class="preview-card">
<div class="preview-card-head">
<span>实时预览</span>
<span class="text-muted">自动渲染</span>
</div>
<div class="preview-content js-md-preview" id="{{ $previewElementId }}">
<div class="preview-placeholder">在左侧输入 Markdown 内容,这里会实时显示预览。</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,89 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.css">
<style>
.required-star { color: #ef4444; margin-left: .2rem; }
.required-tip { font-size: .86rem; color: #64748b; margin-bottom: .9rem; }
.modern-form-card { border: 1px solid #d7e0ef; border-radius: .9rem; box-shadow: 0 10px 20px rgba(41, 61, 118, .08); }
.form-section { border: 1px solid #e2e8f0; border-radius: .8rem; background: #fff; padding: 1rem; }
.form-section + .form-section { margin-top: 1rem; }
.form-section-title { margin: 0 0 .25rem; font-size: 1.02rem; font-weight: 700; color: #1e293b; }
.form-section-subtitle { margin: 0 0 .9rem; color: #64748b; font-size: .85rem; }
.form-hint { margin-top: .3rem; color: #64748b; font-size: .8rem; }
.field-required .form-label { font-weight: 600; }
.editor-shell {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: .85rem;
align-items: start;
}
.editor-card,
.preview-card { border: 1px solid #d8e2f2; border-radius: .75rem; background: #fff; overflow: hidden; }
.editor-card-head,
.preview-card-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: .55rem .7rem;
border-bottom: 1px solid #e2e8f0;
background: #f8fafc;
color: #334155;
font-size: .82rem;
font-weight: 600;
}
.preview-content {
min-height: 360px;
max-height: 700px;
overflow: auto;
padding: .9rem .95rem;
color: #1e293b;
line-height: 1.68;
}
.preview-placeholder { color: #94a3b8; font-size: .86rem; }
.advanced-panel {
border: 1px dashed #cbd5e1;
border-radius: .75rem;
padding: .8rem .9rem;
background: #fcfdff;
}
.advanced-panel > summary {
cursor: pointer;
list-style: none;
font-weight: 600;
color: #334155;
}
.advanced-panel > summary::-webkit-details-marker { display: none; }
.editor-sticky-actions {
position: sticky;
bottom: 0;
z-index: 30;
margin-top: .9rem;
border: 1px solid #d7e3f5;
border-radius: .75rem;
background: rgba(255, 255, 255, .95);
backdrop-filter: blur(8px);
padding: .7rem .8rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: .65rem;
}
.EasyMDEContainer .CodeMirror {
min-height: 340px;
border: 0;
font-size: .9rem;
}
.EasyMDEContainer .editor-toolbar {
border: 0;
border-bottom: 1px solid #e2e8f0;
opacity: 1;
}
@media (max-width: 991px) {
.editor-shell { grid-template-columns: 1fr; }
.preview-content { min-height: 240px; }
}
</style>

View File

@@ -0,0 +1,222 @@
<script src="https://cdn.jsdelivr.net/npm/easymde/dist/easymde.min.js"></script>
<script>
(function () {
const previewEndpoint = '{{ route('admin.markdown.preview') }}';
const uploadEndpoint = '{{ route('admin.uploads.markdown-image') }}';
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
const instances = new Map();
const slugify = (value) => {
return String(value || '')
.normalize('NFKD')
.toLowerCase()
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\u4e00-\u9fa5\s-]/g, '')
.trim()
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
};
const debounce = (callback, wait = 350) => {
let timer = null;
return (...args) => {
window.clearTimeout(timer);
timer = window.setTimeout(() => callback(...args), wait);
};
};
const renderPreview = debounce(async (editor, previewElement) => {
if (!(previewElement instanceof HTMLElement)) {
return;
}
const markdown = editor.value();
if (!markdown || markdown.trim() === '') {
previewElement.innerHTML = '<div class="preview-placeholder">在左侧输入 Markdown 内容,这里会实时显示预览。</div>';
return;
}
previewElement.innerHTML = '<div class="text-muted">预览生成中...</div>';
try {
const response = await fetch(previewEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: JSON.stringify({ markdown }),
});
if (!response.ok) {
throw new Error('preview-failed');
}
const payload = await response.json();
if (!payload || payload.success !== true) {
throw new Error('preview-failed');
}
previewElement.innerHTML = payload.html && payload.html.trim() !== ''
? payload.html
: '<div class="preview-placeholder">暂无可预览内容。</div>';
} catch (_) {
previewElement.innerHTML = '<div class="text-danger">预览失败,请稍后重试。</div>';
}
}, 320);
const uploadImage = async (file) => {
const formData = new FormData();
formData.append('image', file);
const response = await fetch(uploadEndpoint, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken,
'X-Requested-With': 'XMLHttpRequest',
},
body: formData,
});
if (!response.ok) {
throw new Error('upload-failed');
}
const payload = await response.json();
if (!payload || payload.success !== true || !payload.url) {
throw new Error('upload-failed');
}
return payload;
};
const markdownForImage = (payload, fileName = 'image') => {
if (typeof payload.markdown === 'string' && payload.markdown.trim() !== '') {
return `\n${payload.markdown.trim()}\n`;
}
const alt = fileName.replace(/\.[^.]+$/, '');
return `\n![${alt}](${payload.url})\n`;
};
const initEditor = (textarea) => {
if (!(textarea instanceof HTMLTextAreaElement) || instances.has(textarea.name)) {
return;
}
const easyMde = new EasyMDE({
element: textarea,
spellChecker: false,
autoDownloadFontAwesome: false,
forceSync: true,
status: ['lines', 'words'],
placeholder: textarea.placeholder || '请输入 Markdown 内容',
toolbar: [
'bold',
'italic',
'heading',
'|',
'quote',
'unordered-list',
'ordered-list',
'|',
'link',
'image',
'table',
'|',
'preview',
'side-by-side',
'fullscreen',
'|',
'guide',
],
});
const previewSelector = textarea.getAttribute('data-preview-target');
const previewElement = previewSelector ? document.querySelector(previewSelector) : null;
easyMde.codemirror.on('change', () => {
renderPreview(easyMde, previewElement);
});
renderPreview(easyMde, previewElement);
instances.set(textarea.name, easyMde);
};
const connectUploadButtons = () => {
document.querySelectorAll('.js-md-upload-advanced-btn').forEach((button) => {
button.addEventListener('click', () => {
const fieldName = button.getAttribute('data-editor-target');
const editor = fieldName ? instances.get(fieldName) : null;
if (!editor) {
return;
}
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = async () => {
const file = input.files?.[0];
if (!file) {
return;
}
const originHtml = button.innerHTML;
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>上传中...';
try {
const payload = await uploadImage(file);
editor.codemirror.replaceSelection(markdownForImage(payload, file.name || 'image'));
} catch (_) {
alert('图片上传失败,请稍后重试');
} finally {
button.disabled = false;
button.innerHTML = originHtml;
}
};
input.click();
});
});
};
const initSlugAutoFill = () => {
const pairs = [
['input[name="name"]', 'input[name="slug"]'],
['input[name="title"]', 'input[name="slug"]'],
];
for (const [sourceSelector, slugSelector] of pairs) {
const sourceInput = document.querySelector(sourceSelector);
const slugInput = document.querySelector(slugSelector);
if (!(sourceInput instanceof HTMLInputElement) || !(slugInput instanceof HTMLInputElement)) {
continue;
}
let manualEdited = slugInput.value.trim() !== '';
slugInput.addEventListener('input', () => {
manualEdited = slugInput.value.trim() !== '';
});
sourceInput.addEventListener('input', () => {
if (manualEdited && slugInput.value.trim() !== '') {
return;
}
const suggested = slugify(sourceInput.value);
if (suggested !== '') {
slugInput.value = suggested;
}
});
}
};
document.querySelectorAll('textarea.js-md-editor-modern').forEach(initEditor);
connectUploadButtons();
initSlugAutoFill();
}());
</script>

View File

@@ -0,0 +1,57 @@
<style>
.modern-index-card {
border: 1px solid #d7e0ef;
border-radius: .9rem;
box-shadow: 0 10px 20px rgba(41, 61, 118, .06);
}
.modern-index-toolbar {
border: 1px solid #e2e8f0;
border-radius: .85rem;
background: #ffffff;
}
.modern-index-toolbar .toolbar-meta {
color: #64748b;
font-size: .84rem;
}
.modern-index-table thead th {
color: #475569;
font-size: .78rem;
text-transform: uppercase;
letter-spacing: .05em;
border-bottom-width: 1px;
background: #f8fafc;
}
.modern-index-title {
color: #0f172a;
font-weight: 700;
}
.modern-index-summary {
color: #64748b;
font-size: .82rem;
margin-top: .15rem;
max-width: 45rem;
}
.status-badge {
display: inline-flex;
align-items: center;
padding: .22rem .58rem;
border-radius: 999px;
font-size: .75rem;
font-weight: 600;
line-height: 1.2;
}
.status-badge.status-draft { background: #e2e8f0; color: #334155; }
.status-badge.status-review { background: #fef3c7; color: #92400e; }
.status-badge.status-published { background: #dcfce7; color: #166534; }
.status-badge.status-stale { background: #fee2e2; color: #991b1b; }
.status-badge.status-archived { background: #ede9fe; color: #5b21b6; }
.status-badge.status-default { background: #e2e8f0; color: #334155; }
</style>

View File

@@ -0,0 +1,42 @@
<script>
(() => {
const slugInput = document.querySelector('input[name="slug"]');
if (!(slugInput instanceof HTMLInputElement)) {
return;
}
const sourceInput = document.querySelector('input[name="name"]') || document.querySelector('input[name="title"]');
if (!(sourceInput instanceof HTMLInputElement)) {
return;
}
const slugify = (value) => {
return String(value || '')
.normalize('NFKD')
.toLowerCase()
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\u4e00-\u9fa5\s-]/g, '')
.trim()
.replace(/[\s_]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
};
let manualEdited = slugInput.value.trim() !== '';
slugInput.addEventListener('input', () => {
manualEdited = slugInput.value.trim() !== '';
});
sourceInput.addEventListener('input', () => {
if (manualEdited && slugInput.value.trim() !== '') {
return;
}
const suggested = slugify(sourceInput.value);
if (suggested !== '') {
slugInput.value = suggested;
}
});
})();
</script>

View File

@@ -0,0 +1,10 @@
@php
$statusValue = strtolower((string) ($status?->value ?? $status ?? 'default'));
$statusClass = match ($statusValue) {
'draft', 'review', 'published', 'stale', 'archived' => $statusValue,
default => 'default',
};
@endphp
<span class="status-badge status-{{ $statusClass }}">{{ $statusValue !== '' ? $statusValue : '-' }}</span>