feat(web): 手机端排版适配 — 媒体查询 + 抽屉式侧栏 + 过滤区 wrap
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, h, ref } from 'vue'
|
||||
import { computed, h, ref, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter, RouterView, RouterLink } from 'vue-router'
|
||||
import {
|
||||
NLayout, NLayoutHeader, NLayoutContent, NLayoutSider,
|
||||
NMenu, NIcon, NSpace, NButton, NText, NTag, NAvatar, NDropdown, useMessage,
|
||||
NMenu, NIcon, NSpace, NButton, NText, NTag, NAvatar, NDropdown, NDrawer, NDrawerContent, useMessage,
|
||||
} from 'naive-ui'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { meApi } from '@/api/articles'
|
||||
import { onMounted } from 'vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
@@ -15,6 +14,22 @@ const router = useRouter()
|
||||
const message = useMessage()
|
||||
|
||||
const usage = ref<any>(null)
|
||||
const isMobile = ref(false)
|
||||
const drawerOpen = ref(false)
|
||||
|
||||
// 监听屏幕宽度,断点 768px
|
||||
function syncBreakpoint() {
|
||||
isMobile.value = window.innerWidth <= 768
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
syncBreakpoint()
|
||||
window.addEventListener('resize', syncBreakpoint)
|
||||
try { usage.value = await meApi.usage() } catch { /* noop */ }
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', syncBreakpoint)
|
||||
})
|
||||
|
||||
const menu = computed(() => [
|
||||
{ key: '/', label: '24h 列表', icon: () => '📰' },
|
||||
@@ -30,11 +45,6 @@ const userMenu = computed(() => [
|
||||
{ key: 'logout', label: '退出登录' },
|
||||
])
|
||||
|
||||
async function fetchUsage() {
|
||||
try { usage.value = await meApi.usage() } catch { /* noop */ }
|
||||
}
|
||||
onMounted(fetchUsage)
|
||||
|
||||
function onUserMenu(key: string) {
|
||||
if (key === 'logout') {
|
||||
auth.logout()
|
||||
@@ -45,30 +55,52 @@ function onUserMenu(key: string) {
|
||||
|
||||
function onMenu(key: string) {
|
||||
router.push(key)
|
||||
// 手机端:点完菜单自动关闭抽屉
|
||||
if (isMobile.value) drawerOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NLayout style="min-height: 100vh">
|
||||
<NLayoutHeader bordered style="padding: 12px 24px">
|
||||
<NSpace align="center" justify="space-between">
|
||||
<NSpace align="center">
|
||||
<NSpace align="center" justify="space-between" :wrap="true" :size="[8, 8]">
|
||||
<NSpace align="center" :size="8" :wrap="false">
|
||||
<NButton
|
||||
v-if="isMobile"
|
||||
quaternary
|
||||
circle
|
||||
class="mobile-menu-btn"
|
||||
aria-label="菜单"
|
||||
@click="drawerOpen = true"
|
||||
>
|
||||
☰
|
||||
</NButton>
|
||||
<span style="font-size: 20px; font-weight: 700">📚 Diary News</span>
|
||||
<NTag v-if="usage" size="small" :type="usage.pct_used > 80 ? 'warning' : 'default'">
|
||||
<NTag v-if="usage && !isMobile" size="small" :type="usage.pct_used > 80 ? 'warning' : 'default'">
|
||||
翻译: {{ usage.used_chars.toLocaleString() }} / {{ usage.quota_chars.toLocaleString() }}
|
||||
({{ usage.pct_used.toFixed(1) }}%)
|
||||
</NTag>
|
||||
</NSpace>
|
||||
<NSpace>
|
||||
<NText v-if="auth.user">{{ auth.user.username }} ({{ auth.user.role }})</NText>
|
||||
<NSpace :size="8" :wrap="false">
|
||||
<NText v-if="auth.user" style="font-size: 13px" class="mobile-hide">
|
||||
{{ auth.user.username }} ({{ auth.user.role }})
|
||||
</NText>
|
||||
<NDropdown :options="userMenu" trigger="click" @select="onUserMenu">
|
||||
<NButton quaternary>账号 ▾</NButton>
|
||||
</NDropdown>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
<!-- 手机端:配额 tag 放到第二行,避免挤掉菜单按钮 -->
|
||||
<div v-if="usage && isMobile" style="margin-top: 6px">
|
||||
<NTag size="small" :type="usage.pct_used > 80 ? 'warning' : 'default'">
|
||||
翻译: {{ usage.used_chars.toLocaleString() }} / {{ usage.quota_chars.toLocaleString() }}
|
||||
({{ usage.pct_used.toFixed(1) }}%)
|
||||
</NTag>
|
||||
</div>
|
||||
</NLayoutHeader>
|
||||
|
||||
<NLayout has-sider style="min-height: calc(100vh - 60px)">
|
||||
<!-- 桌面:固定侧栏 -->
|
||||
<NLayout v-if="!isMobile" has-sider style="min-height: calc(100vh - 60px)">
|
||||
<NLayoutSider bordered :width="220" :native-scrollbar="false">
|
||||
<NMenu
|
||||
:value="route.path"
|
||||
@@ -81,5 +113,28 @@ function onMenu(key: string) {
|
||||
<RouterView />
|
||||
</NLayoutContent>
|
||||
</NLayout>
|
||||
|
||||
<!-- 手机:无侧栏,菜单用抽屉;内容全宽 -->
|
||||
<NLayoutContent v-else style="padding: 0; width: 100%;">
|
||||
<RouterView />
|
||||
</NLayoutContent>
|
||||
|
||||
<!-- 手机端抽屉式侧栏 -->
|
||||
<NDrawer v-if="isMobile" v-model:show="drawerOpen" :width="260" placement="left">
|
||||
<NDrawerContent title="📚 Diary News" closable>
|
||||
<NMenu
|
||||
:value="route.path"
|
||||
:options="menu"
|
||||
@update:value="onMenu"
|
||||
/>
|
||||
</NDrawerContent>
|
||||
</NDrawer>
|
||||
</NLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mobile-menu-btn {
|
||||
font-size: 20px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -68,6 +68,88 @@ h1 { font-size: 28px; font-weight: 700; line-height: 1.3; }
|
||||
h2 { font-size: 22px; font-weight: 700; line-height: 1.35; }
|
||||
h3 { font-size: 18px; font-weight: 700; line-height: 1.4; }
|
||||
|
||||
/* ============================================================
|
||||
* 手机端适配
|
||||
*
|
||||
* 触发断点:
|
||||
* - 768px:pad / 小屏(侧栏改成抽屉)
|
||||
* - 480px:手机竖屏(进一步压缩 padding / 字号)
|
||||
*
|
||||
* 改动:
|
||||
* 1) 容器 padding 缩小,正文最大宽度限制放宽
|
||||
* 2) 卡片内边距 / 标题字号 / 评论钩子字号按档位缩
|
||||
* 3) 文章正文段间距、行高按手机阅读体验调
|
||||
* 4) 标签 / Tag 不再强制 nowrap(允许换行,避免溢出)
|
||||
* 5) 顶栏 NSpace 在小屏允许换行,过滤区也允许 wrap
|
||||
* ============================================================ */
|
||||
@media (max-width: 768px) {
|
||||
:root {
|
||||
--max-width: 100%;
|
||||
}
|
||||
|
||||
/* 顶栏/正文容器 padding 收紧 */
|
||||
.n-layout-header,
|
||||
.n-layout-header.n-layout-header--absolute-positioned {
|
||||
padding: 10px 14px !important;
|
||||
}
|
||||
.n-layout-content {
|
||||
padding: 14px !important;
|
||||
}
|
||||
|
||||
/* 卡片 padding 缩 25% */
|
||||
.n-card.article-card .n-card__content {
|
||||
padding: 12px 14px !important;
|
||||
}
|
||||
|
||||
/* 标题字号档位降 1 档 */
|
||||
h1 { font-size: 22px; line-height: 1.35; }
|
||||
h2 { font-size: 20px; line-height: 1.4; }
|
||||
h3 { font-size: 17px; line-height: 1.45; }
|
||||
|
||||
/* 文章正文:行高微调(屏幕窄,行高再松一点) */
|
||||
.article-body { font-size: 16px; line-height: 1.8; }
|
||||
.diary-para { font-size: 16px; line-height: 1.8; }
|
||||
|
||||
/* 评论钩子字号微调 */
|
||||
.commentary-box { font-size: 13px; padding: 10px 12px; }
|
||||
.commentary-text { font-size: 13px; }
|
||||
|
||||
/* 详情页字段 */
|
||||
.commentary-text-detail { font-size: 14.5px; line-height: 1.8; }
|
||||
.article-body-fallback { font-size: 15px; line-height: 1.8; }
|
||||
|
||||
/* 顶栏字号 / 配额 tag */
|
||||
.n-layout-header span[style*="font-size: 20px"] {
|
||||
font-size: 17px !important;
|
||||
}
|
||||
|
||||
/* 滚动条:手机端太窄,隐藏 */
|
||||
::-webkit-scrollbar { width: 0; height: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.n-card.article-card .n-card__content {
|
||||
padding: 10px 12px !important;
|
||||
}
|
||||
h1 { font-size: 20px; }
|
||||
.article-body { font-size: 15.5px; }
|
||||
.diary-para { font-size: 15.5px; }
|
||||
}
|
||||
|
||||
/* 工具类:手机端隐藏侧栏相关装饰 */
|
||||
@media (max-width: 768px) {
|
||||
.mobile-hide {
|
||||
display: none !important;
|
||||
}
|
||||
.mobile-stack {
|
||||
flex-direction: column !important;
|
||||
align-items: stretch !important;
|
||||
}
|
||||
.mobile-full-width {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 文章卡片 ===== */
|
||||
.n-card.article-card {
|
||||
margin-bottom: 16px;
|
||||
|
||||
@@ -114,7 +114,7 @@ onMounted(load)
|
||||
<NCard class="article-detail-card">
|
||||
<NSpace vertical :size="14">
|
||||
<!-- tag 行 -->
|
||||
<NSpace align="center" :size="6" :wrap="false" style="overflow: hidden">
|
||||
<NSpace align="center" :size="6" :wrap="true" style="row-gap: 6px">
|
||||
<NTag type="primary" :bordered="false" round>{{ article.source.name }}</NTag>
|
||||
<NTag v-if="article.lang_src" :bordered="false" round>{{ article.lang_src.toUpperCase() }}</NTag>
|
||||
<NTag v-if="article.translation_status !== 'ok'" size="small" type="warning" :bordered="false" round>
|
||||
@@ -126,7 +126,7 @@ onMounted(load)
|
||||
<NTag v-for="c in categories" :key="c" type="success" size="small" :bordered="false" round>
|
||||
{{ c }}
|
||||
</NTag>
|
||||
<NText :depth="3" style="font-size: 12px; margin-left: auto">
|
||||
<NText :depth="3" style="font-size: 12px; margin-left: auto" class="detail-time-label">
|
||||
{{ fmtTime(publishedAt) }}
|
||||
</NText>
|
||||
</NSpace>
|
||||
@@ -148,7 +148,7 @@ onMounted(load)
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮行 -->
|
||||
<NSpace :size="8" :wrap="false">
|
||||
<NSpace :size="8" :wrap="true" style="row-gap: 8px">
|
||||
<NButton
|
||||
:type="starred ? 'warning' : 'primary'"
|
||||
:ghost="!starred"
|
||||
@@ -304,4 +304,16 @@ onMounted(load)
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-variant);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.detail-time-label {
|
||||
margin-left: 0 !important;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
/* 详情页头部操作按钮在手机上等宽更整齐 */
|
||||
:deep(.n-card .n-space) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -116,22 +116,22 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<NSpace vertical :size="16">
|
||||
<NSpace align="center" justify="space-between">
|
||||
<NSpace :size="10">
|
||||
<NSpace align="center" justify="space-between" :wrap="true" :size="[10, 10]" class="feed-toolbar">
|
||||
<NSpace :size="10" :wrap="true" class="feed-toolbar-left">
|
||||
<NSelect
|
||||
v-model:value="sourceFilter"
|
||||
multiple
|
||||
clearable
|
||||
placeholder="按源筛选"
|
||||
:options="sourceOptions"
|
||||
style="min-width: 240px"
|
||||
class="feed-source-select"
|
||||
@update:value="resetToFirstPage"
|
||||
/>
|
||||
<NInput v-model:value="q" placeholder="关键词搜索" clearable style="width: 220px"
|
||||
<NInput v-model:value="q" placeholder="关键词搜索" clearable class="feed-search-input"
|
||||
@keyup.enter="resetToFirstPage" @clear="resetToFirstPage" />
|
||||
<NButton type="primary" @click="resetToFirstPage">刷新</NButton>
|
||||
<NButton type="primary" @click="resetToFirstPage" round>刷新</NButton>
|
||||
</NSpace>
|
||||
<NText :depth="3" style="font-size: 13px">{{ itemsLabel }}</NText>
|
||||
<NText :depth="3" style="font-size: 13px" class="feed-count-label">{{ itemsLabel }}</NText>
|
||||
</NSpace>
|
||||
|
||||
<NSpin :show="loading && items.length === 0">
|
||||
@@ -147,7 +147,7 @@ onMounted(async () => {
|
||||
>
|
||||
<NSpace vertical :size="10">
|
||||
<!-- 顶行:源 / 语言 / 分类 tag / 时间 -->
|
||||
<NSpace align="center" :size="6" :wrap="false" style="overflow: hidden">
|
||||
<NSpace align="center" :size="6" :wrap="true" style="row-gap: 6px">
|
||||
<NTag size="small" type="primary" :bordered="false" round>
|
||||
{{ a.source.name }}
|
||||
</NTag>
|
||||
@@ -167,7 +167,7 @@ onMounted(async () => {
|
||||
>
|
||||
{{ c }}
|
||||
</NTag>
|
||||
<NText :depth="3" style="font-size: 12px; margin-left: auto">
|
||||
<NText :depth="3" style="font-size: 12px; margin-left: auto" class="feed-time-label">
|
||||
{{ fmtTime(a.published_at || a.fetched_at) }}
|
||||
</NText>
|
||||
</NSpace>
|
||||
@@ -282,4 +282,39 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* ===== 桌面端默认宽度 ===== */
|
||||
.feed-source-select {
|
||||
min-width: 240px;
|
||||
}
|
||||
.feed-search-input {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
/* ===== 移动端(<= 768px):过滤条全宽,允许换行 ===== */
|
||||
@media (max-width: 768px) {
|
||||
.feed-source-select {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.feed-search-input {
|
||||
width: 100%;
|
||||
}
|
||||
.feed-toolbar-left > * {
|
||||
width: 100%;
|
||||
}
|
||||
.feed-count-label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.feed-toolbar {
|
||||
align-items: stretch !important;
|
||||
}
|
||||
.feed-time-label {
|
||||
margin-left: 0 !important;
|
||||
width: 100%;
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user