ui: 手机端布局重设计——底部带文字标签栏(5个主入口)+更多弹层、全屏搜索、打卡日历底部抽屉、弹窗贴底、记账卡片行、密码库横滑表格

This commit is contained in:
root
2026-10-16 16:30:45 +08:00
parent e5f99d46d8
commit cfc8e3c734
12 changed files with 390 additions and 71 deletions
+261 -30
View File
@@ -42,6 +42,8 @@ async function onLogin(s: Session) {
}
}
async function doLogout() {
mobileSearch.value = false
moreOpen.value = false
stopRealtime()
if (syncTimer !== null) { window.clearInterval(syncTimer); syncTimer = null }
await logoutUser()
@@ -126,6 +128,22 @@ window.addEventListener('hashchange', () => { route.value = readRoute() })
function nav(key: string) { location.hash = '#/' + key }
const current = computed(() => VIEWS.find(v => v.key === route.value) || VIEWS[0])
// 手机端底部标签页:4 个主功能 + 「更多」弹层(统计/总结/密码库/设置/退出)
const TAB_KEYS = ['calendar', 'today', 'ledger', 'memo']
const TABS = VIEWS.filter(v => TAB_KEYS.includes(v.key))
const MORE_VIEWS = VIEWS.filter(v => !TAB_KEYS.includes(v.key))
const isMoreActive = computed(() => MORE_VIEWS.some(v => v.key === route.value))
const moreOpen = ref(false)
function navFromSheet(key: string) { moreOpen.value = false; nav(key) }
// 手机端全屏搜索
const mobileSearch = ref(false)
const msInput = ref<HTMLInputElement | null>(null)
function openMobileSearch() { mobileSearch.value = true; window.setTimeout(() => msInput.value?.focus(), 60) }
function pickLog(item: LogItem & { date: string }) { openLog(item); mobileSearch.value = false }
function pickMemo(m: Memo) { openMemo(m); mobileSearch.value = false }
// ---- 工具栏打卡 ----
const today = ref(todayStr())
const att = ref<Attendance | undefined>(undefined)
@@ -212,6 +230,9 @@ onMounted(async () => {
<div class="main">
<header class="topbar">
<span class="title">{{ current.name }}</span>
<button class="mob-search-btn" title="搜索" @click="openMobileSearch">
<Icon name="search" :size="16" />
</button>
<div class="gsearch">
<Icon name="search" :size="14" class="gs-icon" />
<input
@@ -242,8 +263,8 @@ onMounted(async () => {
<span class="dot" style="background: var(--warn)"></span>
填写本周周记
</button>
<span class="user-chip num">{{ session?.username }}</span>
<button class="btn btn-sm" @click="doLogout">退出</button>
<span class="user-chip num mobile-hide">{{ session?.username }}</span>
<button class="btn btn-sm mobile-hide" @click="doLogout">退出</button>
<button class="btn btn-primary btn-sm" :disabled="clockDone" @click="doClock">
<Icon :name="clockDone ? 'check-circle' : 'clock'" :size="14" />
<span class="num">{{ clockLabel }}</span>
@@ -262,6 +283,72 @@ onMounted(async () => {
<SettingsView v-else />
</div>
</main>
<!-- 手机端底部标签栏 -->
<nav class="tabbar">
<button
v-for="v in TABS" :key="v.key"
class="tab-btn" :class="{ active: route === v.key }"
@click="nav(v.key)"
>
<Icon :name="v.icon" :size="20" />
<span>{{ v.name }}</span>
</button>
<button class="tab-btn" :class="{ active: isMoreActive }" @click="moreOpen = true">
<Icon name="more" :size="20" />
<span>更多</span>
</button>
</nav>
<!-- 手机端「更多」底部弹层 -->
<div v-if="moreOpen" class="sheet-mask" @click="moreOpen = false">
<div class="more-sheet" @click.stop>
<div class="sheet-user num">{{ session?.username }}</div>
<button v-for="v in MORE_VIEWS" :key="v.key" class="sheet-row" @click="navFromSheet(v.key)">
<Icon :name="v.icon" :size="18" />
<span class="sheet-name">{{ v.name }}</span>
<Icon name="chevron-right" :size="14" class="sheet-go" />
</button>
<div class="sheet-divider"></div>
<button class="sheet-row sheet-danger" @click="moreOpen = false; doLogout()">
<Icon name="close" :size="18" />
<span class="sheet-name">退出登录</span>
</button>
</div>
</div>
<!-- 手机端全屏搜索 -->
<div v-if="mobileSearch" class="msw-mask" @click.self="mobileSearch = false">
<div class="msw">
<div class="msw-bar">
<Icon name="search" :size="16" class="msw-icon" />
<input
ref="msInput" class="msw-input" v-model="searchQ"
placeholder="搜索工作记录与备忘录"
@input="onSearchInput" @keydown.esc="mobileSearch = false"
/>
<button class="icon-btn" @click="clearSearch(); mobileSearch = false">
<Icon name="close" :size="16" />
</button>
</div>
<div class="msw-results">
<template v-if="hasResults">
<div v-if="results.logs.length" class="gs-group muted">工作记录</div>
<div v-for="r in results.logs" :key="'l' + r.id" class="msw-item" @mousedown.prevent="pickLog(r)">
<span class="tag">{{ categoryLabel(r.category) }}</span>
<span class="gs-text">{{ r.content }}</span>
<span class="muted num gs-date">{{ r.date }}</span>
</div>
<div v-if="results.memos.length" class="gs-group muted">备忘录</div>
<div v-for="m in results.memos" :key="'m' + m.id" class="msw-item" @mousedown.prevent="pickMemo(m)">
<span class="gs-text">{{ m.title || '无标题' }}</span>
<span class="muted gs-sub">{{ m.content }}</span>
</div>
</template>
<div v-else-if="searchQ.trim()" class="gs-empty muted">无匹配结果</div>
</div>
</div>
</div>
</div>
<div class="toast-wrap">
<transition-group name="toast">
@@ -419,38 +506,182 @@ onMounted(async () => {
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translateY(-4px); }
.toast-enter-active, .toast-leave-active { transition: all 0.15s ease; }
/* ---- 新增的手机端组件样式(桌面端隐藏) ---- */
.mob-search-btn {
display: none;
width: 30px;
height: 30px;
border: none;
background: transparent;
border-radius: var(--radius-sm);
color: var(--text-2);
cursor: pointer;
align-items: center;
justify-content: center;
}
.mobile-hide { display: initial; }
.tabbar {
display: none;
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 30;
background: var(--bg-page);
border-top: 1px solid var(--border);
padding-bottom: env(safe-area-inset-bottom);
}
.tab-btn {
flex: 1;
height: calc(52px - 6px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
border: none;
background: transparent;
color: var(--text-3);
cursor: pointer;
font-size: 11px;
}
.tab-btn.active { color: var(--accent); }
.sheet-mask {
position: fixed;
inset: 0;
z-index: 70;
background: rgba(29, 33, 41, 0.35);
}
.more-sheet {
position: absolute;
left: 0;
right: 0;
bottom: 0;
background: var(--bg-page);
border-radius: 14px 14px 0 0;
padding: 10px 14px calc(14px + env(safe-area-inset-bottom));
display: flex;
flex-direction: column;
}
.sheet-user {
font-size: 12px;
color: var(--text-3);
padding: 4px 6px 10px;
border-bottom: 1px solid var(--border);
margin-bottom: 4px;
}
.sheet-row {
display: flex;
align-items: center;
gap: 12px;
height: 46px;
border: none;
background: transparent;
color: var(--text-1);
font-size: 14px;
cursor: pointer;
border-radius: var(--radius-sm);
text-align: left;
padding: 0 6px;
}
.sheet-row:hover, .sheet-row:active { background: var(--bg-hover); }
.sheet-name { flex: 1; }
.sheet-go { color: var(--text-3); }
.sheet-divider { height: 1px; background: var(--border); margin: 6px 0; }
.sheet-danger { color: var(--danger); }
.msw-mask {
position: fixed;
inset: 0;
z-index: 70;
background: var(--bg-page);
}
.msw {
height: 100%;
display: flex;
flex-direction: column;
}
.msw-bar {
height: 52px;
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 0 12px;
border-bottom: 1px solid var(--border);
}
.msw-icon { color: var(--text-3); }
.msw-input {
flex: 1;
height: 34px;
border: none;
outline: none;
font-size: 14px;
background: transparent;
color: var(--text-1);
}
.msw-results {
flex: 1;
overflow: auto;
padding: 10px 4px calc(20px + env(safe-area-inset-bottom));
}
.msw-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 10px;
border-radius: var(--radius-sm);
cursor: pointer;
}
.msw-item:hover, .msw-item:active { background: var(--bg-hover); }
@media (max-width: 720px) {
.app { flex-direction: column; height: 100dvh; }
.side {
order: 2;
width: 100%;
height: calc(54px + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
flex-direction: row;
justify-content: space-around;
border-right: none;
border-top: 1px solid var(--border);
padding-top: 0;
}
.main { order: 1; flex: 1; min-height: 0; }
.nav-btn { width: 44px; height: 42px; }
.nav-btn::after { display: none; }
/* 布局骨架:顶栏一行 + 内容 + 底部标签栏 */
.app { display: block; height: 100dvh; }
.side { display: none; }
.main { display: flex; flex-direction: column; height: 100dvh; min-width: 0; }
.topbar {
flex-wrap: wrap;
height: auto;
min-height: 52px;
padding: 8px 12px;
row-gap: 8px;
height: 50px;
min-height: 50px;
padding: 0 10px;
gap: 6px;
}
.topbar > .flex1 { display: none; }
.gsearch { order: 10; width: 100%; max-width: 100%; }
.gs-input { height: 34px; }
.gs-panel { top: 100%; }
.topbar-actions { margin-left: auto; }
.user-chip { max-width: 72px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.title { font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.gsearch { display: none; }
.gsearch .gs-panel { display: none; }
.mob-search-btn { display: flex; }
.topbar-actions { margin-left: auto; gap: 6px; }
.mobile-hide { display: none !important; }
.week-tip { height: 28px; max-width: 118px; padding: 0 8px; white-space: nowrap; overflow: hidden; }
.topbar-actions .btn { padding: 0 10px; }
.topbar-actions .btn .num { font-size: 12px; }
.content-inner { padding: 12px; }
.toast-wrap { top: 8px; right: 12px; left: 12px; align-items: flex-end; }
.tabbar { display: flex; }
.content { flex: 1; min-height: 0; }
.content-inner {
max-width: none;
padding: 12px 12px calc(66px + env(safe-area-inset-bottom));
}
/* 卡片与表单:统一左右贴边、内距收紧 */
.card { border-radius: var(--radius-md); }
input.input, select.select, textarea.textarea { width: 100%; box-sizing: border-box; }
.btn { touch-action: manipulation; }
/* 打卡按钮更醒目 */
.topbar-actions .btn-primary {
height: 32px;
}
/* 提示信息对齐左上,避免遮挡底部标签栏 */
.toast-wrap {
top: 6px;
right: 10px;
left: 10px;
align-items: flex-start;
}
}
</style>
+2 -1
View File
@@ -22,5 +22,6 @@ export const ICONS: Record<string, string> = {
settings: svg('<circle cx="12" cy="12" r="3"/><path d="M12 4.5v2.2M12 17.3v2.2M4.5 12h2.2M17.3 12h2.2M6.8 6.8l1.6 1.6M15.6 15.6l1.6 1.6M17.2 6.8l-1.6 1.6M8.4 15.6l-1.6 1.6"/>'),
memo: svg('<path d="M6.5 3.5h7.5L18.5 8v11a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1v-15a1 1 0 0 1 1-1z"/><path d="M14 3.5V8h4.5M9.5 12h5M9.5 15.5h5"/>'),
lock: svg('<rect x="5.5" y="10.5" width="13" height="9.5" rx="1.5"/><path d="M8.5 10.5V8a3.5 3.5 0 0 1 7 0v2.5"/>'),
wallet: svg('<rect x="3.5" y="6" width="17" height="12.5" rx="2"/><path d="M3.5 10h17"/><circle cx="16.4" cy="14.2" r="1.1"/>')
wallet: svg('<rect x="3.5" y="6" width="17" height="12.5" rx="2"/><path d="M3.5 10h17"/><circle cx="16.4" cy="14.2" r="1.1"/>'),
more: svg('<circle cx="5.4" cy="12" r="1.7"/><circle cx="12" cy="12" r="1.7"/><circle cx="18.6" cy="12" r="1.7"/>')
}
+1
View File
@@ -1,6 +1,7 @@
import { createApp } from 'vue'
import App from './App.vue'
import './styles/tokens.css'
import './styles/mobile.css'
import { showFatalError } from './lib/errOverlay'
import { bootstrapSession, openUserDatabase } from './db/auth'
+86
View File
@@ -0,0 +1,86 @@
/* ============================================================
手机端(<=720px)全局适配:作用于各视图与弹窗组件
(App.vue 的壳层样式是 scoped,跨组件规则统一放这里,便于集中管理)
============================================================ */
/* 通用的白底卡片在窄屏贴边 */
@media (max-width: 720px) {
.card { border-radius: 12px; }
input.input, select.select, textarea.textarea {
width: 100%;
box-sizing: border-box;
}
.btn { touch-action: manipulation; }
/* ---------- 弹窗:窄屏一体布局,覆盖组件内固定宽度 ---------- */
.dialog {
width: calc(100vw - 20px) !important;
max-width: calc(100vw - 20px) !important;
max-height: 86vh;
max-height: 86dvh;
overflow: auto;
}
.dialog-body { padding: 14px !important; }
.form-grid { grid-template-columns: 1fr !important; }
.dialog-btns { gap: 8px; }
.dialog-btns .btn { flex: 1; }
/* ---------- 日历当日抽屉:改为底部抽屉 ---------- */
.drawer {
top: auto !important;
left: 0 !important;
width: 100% !important;
height: min(86vh, 86dvh) !important;
border-left: none !important;
border-top: 1px solid var(--border);
border-radius: 14px 14px 0 0 !important;
}
.drawer-enter-active, .drawer-leave-active { transition: transform 0.2s ease, opacity 0.2s ease; }
.drawer-enter-from, .drawer-leave-to { transform: translateY(28px); opacity: 0; }
.drawer-head { border-radius: 14px 14px 0 0; }
.drawer-body { padding: 12px !important; }
/* ---------- 记账表格:卡片行(带行内标签),手机上不横滚 ---------- */
.tx-table { min-width: 0 !important; }
.tx-table thead { display: none !important; }
.tx-table tr { display: block; padding: 10px 12px; border-bottom: 1px solid var(--border); background: transparent; }
.tx-table tbody tr:last-child { border-bottom: none; }
.tx-table td {
display: flex !important;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 3px 0 !important;
border: none !important;
white-space: normal !important;
}
.tx-table td::before {
content: attr(data-label);
color: var(--text-3);
font-size: 12px;
flex: none;
}
.tx-table td.ops-cell::before { content: none; }
.tx-table td.ops-cell { justify-content: flex-end; }
.tx-table td.empty { justify-content: center; }
.tx-table td.empty::before { content: none !important; }
/* ---------- 密码库等其余宽表:横向滚动,不再挤压折行 ---------- */
.table-card, .tx-card { overflow-x: auto; }
.cred-table { min-width: 560px !important; }
.th-ops { width: auto !important; }
.vault-toolbar { flex-wrap: wrap; row-gap: 8px; }
.vault-toolbar .search-wrap { width: 100%; max-width: none !important; }
.vault-toolbar .flex1 { display: none; }
.vault-toolbar .btn { flex: none; }
/* ---------- 日历格子收窄 ---------- */
.cal-toolbar { margin-bottom: 8px; }
.cal-cell { min-height: 54px !important; padding: 4px 5px !important; }
.cal-marks { margin-top: 3px !important; height: 12px !important; gap: 3px !important; }
/* ---------- 各种桌面习惯型小间距收敛 ---------- */
.section, .sec { padding: 12px 12px !important; }
.rule-grid { grid-template-columns: 1fr !important; }
}
+6 -6
View File
@@ -293,16 +293,16 @@ async function confirmDelete() {
</thead>
<tbody>
<tr v-for="r in filtered" :key="r.id">
<td class="num">{{ r.date }}</td>
<td><span class="tag">{{ r.category }}</span></td>
<td class="num" :class="r.type === 'income' ? 'amt-income' : 'amt-expense'">{{ amountText(r) }}</td>
<td class="td-note">{{ r.note || '--' }}</td>
<td>
<td class="num" data-label="日期">{{ r.date }}</td>
<td data-label="分类"><span class="tag">{{ r.category }}</span></td>
<td class="num" data-label="金额" :class="r.type === 'income' ? 'amt-income' : 'amt-expense'">{{ amountText(r) }}</td>
<td class="td-note" data-label="备注">{{ r.note || '--' }}</td>
<td data-label="报销">
<span v-if="r.type !== 'expense' || r.reimbursable !== 1" class="muted">--</span>
<span v-else-if="r.reimbursed === 1" class="tx-done">已报销</span>
<span v-else class="tx-pending">待报销</span>
</td>
<td>
<td class="ops-cell">
<div class="ops">
<button
v-if="r.type === 'expense' && r.reimbursable === 1 && r.reimbursed !== 1"
+2 -2
View File
@@ -271,7 +271,7 @@ async function doChangePwd() {
</div>
</td>
<td class="td-remark">{{ row.remark || '--' }}</td>
<td>
<td class="td-ops">
<div class="ops">
<button class="btn btn-xs" @click="openEdit(row)">编辑</button>
<button class="btn btn-xs" @click="deleteTarget = row">删除</button>
@@ -284,7 +284,7 @@ async function doChangePwd() {
</tbody>
</table>
</div>
<div class="muted vault-note num">共 {{ rows.length }} 条账号记录 · 密码以 AES-256-GCM 加密存储于本机</div>
<div class="muted vault-note num">共 {{ rows.length }} 条账号记录 · 密码以 AES-256-GCM 加密,服务器仅存密文</div>
</template>
<CredentialDialog