feat(web): 手机端排版适配 — 媒体查询 + 抽屉式侧栏 + 过滤区 wrap

This commit is contained in:
Mavis
2026-06-11 09:28:14 +08:00
parent 2e0e5ea80c
commit d90c5955f5
4 changed files with 209 additions and 25 deletions

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>