优化功能
This commit is contained in:
65
resources/views/admin/partials/admin-page-header.blade.php
Normal file
65
resources/views/admin/partials/admin-page-header.blade.php
Normal 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>
|
||||
|
||||
48
resources/views/admin/partials/markdown-editor.blade.php
Normal file
48
resources/views/admin/partials/markdown-editor.blade.php
Normal 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>
|
||||
89
resources/views/admin/partials/modern-form-head.blade.php
Normal file
89
resources/views/admin/partials/modern-form-head.blade.php
Normal 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>
|
||||
|
||||
222
resources/views/admin/partials/modern-form-scripts.blade.php
Normal file
222
resources/views/admin/partials/modern-form-scripts.blade.php
Normal 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\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>
|
||||
57
resources/views/admin/partials/modern-index-head.blade.php
Normal file
57
resources/views/admin/partials/modern-index-head.blade.php
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
10
resources/views/admin/partials/status-badge.blade.php
Normal file
10
resources/views/admin/partials/status-badge.blade.php
Normal 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>
|
||||
|
||||
Reference in New Issue
Block a user