feat: 前台404页与通配路由; 积木拖拽排序(vuedraggable); nginx SPA说明
Made-with: Cursor
This commit is contained in:
21
admin/package-lock.json
generated
21
admin/package-lock.json
generated
@@ -12,7 +12,8 @@
|
|||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"element-plus": "^2.4.4",
|
"element-plus": "^2.4.4",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.2.5"
|
"vue-router": "^4.2.5",
|
||||||
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^4.5.2",
|
"@vitejs/plugin-vue": "^4.5.2",
|
||||||
@@ -1585,6 +1586,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sortablejs": {
|
||||||
|
"version": "1.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz",
|
||||||
|
"integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -1689,6 +1696,18 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"vue": "^3.5.0"
|
"vue": "^3.5.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vuedraggable": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sortablejs": "1.14.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.0.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,12 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"element-plus": "^2.4.4",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.2.5",
|
"vue-router": "^4.2.5",
|
||||||
"element-plus": "^2.4.4",
|
"vuedraggable": "^4.1.0"
|
||||||
"@element-plus/icons-vue": "^2.3.1",
|
|
||||||
"axios": "^1.6.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^4.5.2",
|
"@vitejs/plugin-vue": "^4.5.2",
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-builder-blocks">
|
<div class="page-builder-blocks">
|
||||||
<div v-for="(block, idx) in modelBlocks" :key="block.id || idx" class="block-card">
|
<Draggable
|
||||||
<div class="block-head">
|
v-model="blocksWritable"
|
||||||
<span class="block-type">{{ typeLabel(block.type) }}</span>
|
item-key="id"
|
||||||
<div class="block-actions">
|
handle=".drag-handle"
|
||||||
<el-button link type="primary" :disabled="idx === 0" @click="move(idx, -1)">上移</el-button>
|
:animation="220"
|
||||||
<el-button link type="primary" :disabled="idx >= modelBlocks.length - 1" @click="move(idx, 1)">下移</el-button>
|
ghost-class="block-ghost"
|
||||||
<el-button link type="danger" @click="remove(idx)">删除</el-button>
|
chosen-class="block-chosen"
|
||||||
</div>
|
drag-class="block-dragging"
|
||||||
</div>
|
class="blocks-drag-list"
|
||||||
|
>
|
||||||
|
<template #item="{ element: block, index: idx }">
|
||||||
|
<div class="block-card">
|
||||||
|
<div class="block-head">
|
||||||
|
<span class="block-head-left">
|
||||||
|
<span class="drag-handle" title="按住拖拽排序">
|
||||||
|
<el-icon><Rank /></el-icon>
|
||||||
|
</span>
|
||||||
|
<span class="block-type">{{ typeLabel(block.type) }}</span>
|
||||||
|
</span>
|
||||||
|
<div class="block-actions">
|
||||||
|
<el-button link type="danger" @click="remove(idx)">删除</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<template v-if="block.type === 'heading'">
|
<template v-if="block.type === 'heading'">
|
||||||
<el-form label-width="88px" size="small">
|
<el-form label-width="88px" size="small">
|
||||||
@@ -118,7 +132,9 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<span class="muted">未知类型 {{ block.type }}</span>
|
<span class="muted">未知类型 {{ block.type }}</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</Draggable>
|
||||||
|
|
||||||
<div class="add-bar">
|
<div class="add-bar">
|
||||||
<el-dropdown trigger="click" @command="addBlock">
|
<el-dropdown trigger="click" @command="addBlock">
|
||||||
@@ -146,7 +162,8 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { ArrowDown } from '@element-plus/icons-vue'
|
import { ArrowDown, Rank } from '@element-plus/icons-vue'
|
||||||
|
import Draggable from 'vuedraggable'
|
||||||
import LinkPickerDialog from './LinkPickerDialog.vue'
|
import LinkPickerDialog from './LinkPickerDialog.vue'
|
||||||
import PageBuilderAnimFields from './PageBuilderAnimFields.vue'
|
import PageBuilderAnimFields from './PageBuilderAnimFields.vue'
|
||||||
|
|
||||||
@@ -158,7 +175,14 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
const emit = defineEmits(['update:blocks'])
|
const emit = defineEmits(['update:blocks'])
|
||||||
|
|
||||||
const modelBlocks = computed(() => props.blocks)
|
const blocksWritable = computed({
|
||||||
|
get() {
|
||||||
|
return props.blocks
|
||||||
|
},
|
||||||
|
set(val) {
|
||||||
|
emit('update:blocks', val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function typeLabel(t) {
|
function typeLabel(t) {
|
||||||
const m = {
|
const m = {
|
||||||
@@ -225,16 +249,6 @@ function remove(idx) {
|
|||||||
emit('update:blocks', list)
|
emit('update:blocks', list)
|
||||||
}
|
}
|
||||||
|
|
||||||
function move(idx, delta) {
|
|
||||||
const list = [...props.blocks]
|
|
||||||
const j = idx + delta
|
|
||||||
if (j < 0 || j >= list.length) return
|
|
||||||
const t = list[idx]
|
|
||||||
list[idx] = list[j]
|
|
||||||
list[j] = t
|
|
||||||
emit('update:blocks', list)
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickerVisible = ref(false)
|
const pickerVisible = ref(false)
|
||||||
const pickTarget = ref(null)
|
const pickTarget = ref(null)
|
||||||
|
|
||||||
@@ -317,4 +331,41 @@ function patchSectionChildren(idx, children) {
|
|||||||
color: #909399;
|
color: #909399;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
.blocks-drag-list {
|
||||||
|
min-height: 4px;
|
||||||
|
}
|
||||||
|
.block-head-left {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.drag-handle {
|
||||||
|
cursor: grab;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 6px;
|
||||||
|
margin-right: 4px;
|
||||||
|
color: #909399;
|
||||||
|
border-radius: 4px;
|
||||||
|
user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.drag-handle:hover {
|
||||||
|
color: #409eff;
|
||||||
|
background: #ecf5ff;
|
||||||
|
}
|
||||||
|
.drag-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
:deep(.block-ghost) {
|
||||||
|
opacity: 0.55;
|
||||||
|
background: #ecf5ff !important;
|
||||||
|
border: 1px dashed #409eff !important;
|
||||||
|
}
|
||||||
|
:deep(.block-chosen) {
|
||||||
|
box-shadow: 0 2px 12px rgba(64, 158, 255, 0.25);
|
||||||
|
}
|
||||||
|
:deep(.block-dragging) {
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ const blocks = ref([])
|
|||||||
const jsonDraft = ref('')
|
const jsonDraft = ref('')
|
||||||
let syncingFromParent = false
|
let syncingFromParent = false
|
||||||
|
|
||||||
|
function newBlockId() {
|
||||||
|
return 'b-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10)
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeBlock(b) {
|
function normalizeBlock(b) {
|
||||||
|
if (!b.id) b.id = newBlockId()
|
||||||
if (!b.props) b.props = {}
|
if (!b.props) b.props = {}
|
||||||
if (!b.animation) b.animation = { enter: 'fadeIn', delay_ms: 0, duration_ms: 600 }
|
if (!b.animation) b.animation = { enter: 'fadeIn', delay_ms: 0, duration_ms: 600 }
|
||||||
if (b.type === 'link_list' && !Array.isArray(b.props.items)) b.props.items = []
|
if (b.type === 'link_list' && !Array.isArray(b.props.items)) b.props.items = []
|
||||||
@@ -86,6 +91,7 @@ function applyJsonDraft() {
|
|||||||
}
|
}
|
||||||
syncingFromParent = true
|
syncingFromParent = true
|
||||||
blocks.value = JSON.parse(JSON.stringify(j.blocks))
|
blocks.value = JSON.parse(JSON.stringify(j.blocks))
|
||||||
|
blocks.value.forEach(normalizeBlock)
|
||||||
syncingFromParent = false
|
syncingFromParent = false
|
||||||
emit('update:modelValue', stringify())
|
emit('update:modelValue', stringify())
|
||||||
ElMessage.success('已应用')
|
ElMessage.success('已应用')
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
- 在 **网页管理** 中创建页面,设置 **前台路径**(`route_path` 或默认 `/{slug}`)、**发布**、**内容模式**。
|
- 在 **网页管理** 中创建页面,设置 **前台路径**(`route_path` 或默认 `/{slug}`)、**发布**、**内容模式**。
|
||||||
- **HTML**:`content` 为 HTML 字符串,前台用 `v-html` 渲染(仅信任后台内容)。
|
- **HTML**:`content` 为 HTML 字符串,前台用 `v-html` 渲染(仅信任后台内容)。
|
||||||
- **积木(builder)**:后台使用 **可视化编辑器**(添加模块、拖拽顺序、配置动画);链接可在弹窗中选择 **站内页面**、**可下载文件** 或 **自定义地址**。亦可展开「高级」直接改 JSON。
|
- **积木(builder)**:后台使用 **可视化编辑器**(添加模块、**按住左侧手柄拖拽**调整顺序、配置动画);链接可在弹窗中选择 **站内页面**、**可下载文件** 或 **自定义地址**。亦可展开「高级」直接改 JSON。
|
||||||
- 存储仍为 JSON,结构如下,前台按模块渲染并支持入场动画。
|
- 存储仍为 JSON,结构如下,前台按模块渲染并支持入场动画。
|
||||||
|
|
||||||
## 动态路由
|
## 动态路由
|
||||||
|
|||||||
@@ -72,3 +72,10 @@ sudo systemctl start nginx
|
|||||||
```
|
```
|
||||||
|
|
||||||
然后再按上面步骤创建证书目录、放入证书、复制 conf 并重载。
|
然后再按上面步骤创建证书目录、放入证书、复制 conf 并重载。
|
||||||
|
|
||||||
|
## 5. 前台动态路由与 404(SPA)
|
||||||
|
|
||||||
|
- **现象**:浏览器直接打开 `https://你的域名/some-page` 出现 **nginx** 的 `404 Not Found`(页脚带 `nginx/x.x.x`),而不是网站自己的页面。
|
||||||
|
- **原因**:提供静态文件的 `server` 未把「不存在的路径」交给 `index.html`,Nginx 在磁盘上找不到 `some-page` 文件就返回 404。
|
||||||
|
- **要求**:托管 **web 前台** 的站点必须使用 **`try_files $uri $uri/ /index.html;`**(见仓库 `nginx/web.conf` 与 `web/Dockerfile` 内嵌配置)。若你自建 Nginx,请对照修改后再 `nginx -t` 并重载。
|
||||||
|
- **应用内 404**:在 SPA 已正确回退的前提下,未在后台发布的路径会由前端路由进入 **「页面不存在」** 页(`NotFound.vue`),与上述 nginx 404 不同。
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ server {
|
|||||||
location = / {
|
location = / {
|
||||||
try_files /index.html =404;
|
try_files /index.html =404;
|
||||||
}
|
}
|
||||||
|
# 前台为 Vue SPA:任意路径须回退到 index.html,否则直接访问 /xxx 会得到 nginx 404
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ RUN printf '%s\n' \
|
|||||||
' location = / {' \
|
' location = / {' \
|
||||||
' try_files /index.html =404;' \
|
' try_files /index.html =404;' \
|
||||||
' }' \
|
' }' \
|
||||||
|
' # SPA: 避免直接访问 /path 时 nginx 404' \
|
||||||
' location / {' \
|
' location / {' \
|
||||||
' try_files $uri $uri/ /index.html;' \
|
' try_files $uri $uri/ /index.html;' \
|
||||||
' }' \
|
' }' \
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ async function bootstrap() {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
/* 动态路由失败时仍展示首页 */
|
/* 动态路由失败时仍展示首页 */
|
||||||
}
|
}
|
||||||
|
// 通配路由须最后注册,避免盖住已发布的动态页面
|
||||||
|
router.addRoute({
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'NotFound',
|
||||||
|
meta: { title: '页面未找到' },
|
||||||
|
component: () => import('./views/NotFound.vue')
|
||||||
|
})
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
76
web/src/views/NotFound.vue
Normal file
76
web/src/views/NotFound.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<div class="not-found">
|
||||||
|
<div class="not-found-inner">
|
||||||
|
<p class="code">404</p>
|
||||||
|
<h1>页面不存在</h1>
|
||||||
|
<p class="desc">该地址没有对应页面,或页面已下线。请从首页重新进入。</p>
|
||||||
|
<div class="actions">
|
||||||
|
<router-link to="/" class="btn-primary">返回首页</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.title = '页面未找到'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.not-found {
|
||||||
|
min-height: 60vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 16px;
|
||||||
|
}
|
||||||
|
.not-found-inner {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
.code {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 8px 0 16px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #e8e8ef;
|
||||||
|
}
|
||||||
|
.desc {
|
||||||
|
margin: 0 0 28px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #0a0a12;
|
||||||
|
background: linear-gradient(135deg, #7eb8ff 0%, #4d8fff 100%);
|
||||||
|
transition: opacity 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
opacity: 0.92;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user