rebuild(stage-13): redesign mobile information architecture
This commit is contained in:
@@ -8,6 +8,7 @@ import { useMarketStore } from "../../shared/stores/market";
|
||||
import DesktopSidebar from "./DesktopSidebar.vue";
|
||||
import MarketStrip from "./MarketStrip.vue";
|
||||
import MobileNav from "./MobileNav.vue";
|
||||
import MobileWorkspaceSwitch from "./MobileWorkspaceSwitch.vue";
|
||||
import StatusBar from "./StatusBar.vue";
|
||||
import TopBar from "./TopBar.vue";
|
||||
|
||||
@@ -33,11 +34,13 @@ onBeforeUnmount(() => window.removeEventListener("keydown", globalShortcut));
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<a class="skip-link" href="#main-content">跳到主要内容</a>
|
||||
<DesktopSidebar />
|
||||
<div class="shell-main">
|
||||
<TopBar />
|
||||
<MarketStrip />
|
||||
<RouterView />
|
||||
<MobileWorkspaceSwitch />
|
||||
<div id="main-content" class="shell-content" tabindex="-1"><RouterView /></div>
|
||||
</div>
|
||||
<StatusBar />
|
||||
<MobileNav />
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { findWorkspace } from "../workspaceRegistry";
|
||||
|
||||
const route = useRoute();
|
||||
const current = computed(() => String(route.params.workspace ?? ""));
|
||||
const marketActive = computed(() => findWorkspace(current.value)?.group === "市场复盘");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="mobile-nav" aria-label="移动端主导航">
|
||||
<RouterLink class="mobile-nav-item" to="/workspace/emotion">行情</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" to="/workspace/screener">选股</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" to="/workspace/mentor">问师</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" to="/workspace/heaven">问天</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" to="/workspace/review">复盘</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" :class="{ active: marketActive }" :aria-current="marketActive ? 'page' : undefined" to="/workspace/emotion">行情</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" :class="{ active: current === 'screener' }" to="/workspace/screener">选股</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" :class="{ active: current === 'mentor' }" to="/workspace/mentor">问师</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" :class="{ active: current === 'heaven' }" to="/workspace/heaven">问天</RouterLink>
|
||||
<RouterLink class="mobile-nav-item" :class="{ active: current === 'review' }" to="/workspace/review">复盘</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import { findWorkspace, workspaces } from "../workspaceRegistry";
|
||||
import TradingDateControl from "./TradingDateControl.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const current = computed(() => String(route.params.workspace ?? ""));
|
||||
const marketWorkspaces = workspaces.filter((item) => item.group === "市场复盘");
|
||||
const visible = computed(() => findWorkspace(current.value)?.group === "市场复盘");
|
||||
|
||||
function change(event: Event): void {
|
||||
void router.push(`/workspace/${(event.target as HTMLSelectElement).value}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mobile-context-bar">
|
||||
<label v-if="visible" class="mobile-workspace-switch">
|
||||
<span>行情页面</span>
|
||||
<select :value="current" aria-label="切换行情页面" @change="change">
|
||||
<option v-for="workspace in marketWorkspaces" :key="workspace.key" :value="workspace.key">
|
||||
{{ workspace.title }} · {{ workspace.description }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<TradingDateControl class="mobile-date-control" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -8,6 +8,7 @@ import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useSessionStore } from "../../shared/stores/session";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import { findWorkspace } from "../workspaceRegistry";
|
||||
import TradingDateControl from "./TradingDateControl.vue";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -32,22 +33,6 @@ function openAccountDialog(name: "profile" | "membership" | "password"): void {
|
||||
ui.openDialog(name);
|
||||
}
|
||||
|
||||
function changeDate(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
const parsed = new Date(`${value}T12:00:00+08:00`);
|
||||
if (
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
|
||||
!Number.isNaN(parsed.valueOf()) &&
|
||||
parsed.toISOString().slice(0, 10) === value
|
||||
) {
|
||||
void market.selectDate(value);
|
||||
return;
|
||||
}
|
||||
input.value = market.selectedDate;
|
||||
ui.showToast("请输入 YYYY-MM-DD 格式的日期");
|
||||
}
|
||||
|
||||
async function endSession(action: "logout" | "switch-account"): Promise<void> {
|
||||
menuOpen.value = false;
|
||||
try {
|
||||
@@ -84,11 +69,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside))
|
||||
<header class="topbar">
|
||||
<span class="topbar-title">{{ title }}</span>
|
||||
<div class="topbar-spacer"></div>
|
||||
<div class="date-control desktop-only">
|
||||
<button class="icon-button" type="button" aria-label="前一日" title="前一日" @click="market.moveDate(-1)">‹</button>
|
||||
<input :value="market.selectedDate" class="date-input" type="text" inputmode="numeric" pattern="\d{4}-\d{2}-\d{2}" maxlength="10" aria-label="交易日期" @change="changeDate" />
|
||||
<button class="icon-button" type="button" aria-label="后一日" title="后一日" @click="market.moveDate(1)">›</button>
|
||||
</div>
|
||||
<TradingDateControl class="desktop-only" />
|
||||
<button class="icon-button" type="button" aria-label="全局搜索" title="全局搜索(Ctrl+K)" @click="ui.openDialog('search')">⌕</button>
|
||||
<button class="icon-button topbar-alert desktop-only" type="button" aria-label="提醒中心" title="提醒中心" @click="ui.openDialog('alerts')">!<span v-if="ui.alertUnread" class="alert-badge">{{ ui.alertUnread > 99 ? '99+' : ui.alertUnread }}</span></button>
|
||||
<button class="icon-button desktop-only" type="button" aria-label="复盘助手" title="复盘助手" @click="ui.openDialog('assistant')">复</button>
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
|
||||
const market = useMarketStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
function change(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const value = input.value.trim();
|
||||
const parsed = new Date(`${value}T12:00:00+08:00`);
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value) {
|
||||
void market.selectDate(value);
|
||||
return;
|
||||
}
|
||||
input.value = market.selectedDate;
|
||||
ui.showToast("请输入 YYYY-MM-DD 格式的日期");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="date-control" aria-label="交易日期控制">
|
||||
<button class="icon-button" type="button" aria-label="前一日" title="前一日" @click="market.moveDate(-1)">‹</button>
|
||||
<input :value="market.selectedDate" class="date-input" type="text" inputmode="numeric" pattern="\d{4}-\d{2}-\d{2}" maxlength="10" aria-label="交易日期" @change="change" />
|
||||
<button class="icon-button" type="button" aria-label="后一日" title="后一日" @click="market.moveDate(1)">›</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -100,7 +100,11 @@ onMounted(load);
|
||||
<main class="page-frame review-page">
|
||||
<header class="page-header review-heading">
|
||||
<div><h1>我的复盘</h1><p class="page-subtitle">数据日期 {{ data?.trade_date ?? market.selectedDate }} · 当前账号私有</p></div>
|
||||
<button class="btn btn-primary" type="button" @click="watchDialog = true">添加自选</button>
|
||||
<div class="review-heading-actions">
|
||||
<button class="btn mobile-only" type="button" @click="ui.openDialog('alerts')">提醒中心</button>
|
||||
<button class="btn mobile-only" type="button" @click="ui.openDialog('assistant')">复盘助手</button>
|
||||
<button class="btn btn-primary" type="button" @click="watchDialog = true">添加自选</button>
|
||||
</div>
|
||||
</header>
|
||||
<div v-if="loading" class="card workspace-state">正在读取个人复盘记录</div>
|
||||
<EmptyState v-else-if="error" class="card" title="复盘记录暂不可用" :description="error" />
|
||||
|
||||
@@ -9,13 +9,19 @@ type TableColumn = {
|
||||
format?: (value: unknown, row: Record<string, unknown>) => string;
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
columns: TableColumn[];
|
||||
rows: Record<string, unknown>[];
|
||||
sortKey: string;
|
||||
sortDirection: "asc" | "desc";
|
||||
}>();
|
||||
const emit = defineEmits<{ sort: [key: string] }>();
|
||||
|
||||
function ariaSort(key: string, sortable?: boolean): "ascending" | "descending" | "none" | undefined {
|
||||
if (!sortable) return undefined;
|
||||
if (key !== props.sortKey) return "none";
|
||||
return props.sortDirection === "asc" ? "ascending" : "descending";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -23,8 +29,8 @@ const emit = defineEmits<{ sort: [key: string] }>();
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="index-column">序号</th>
|
||||
<th v-for="column in columns" :key="column.key" :class="{ numeric: column.numeric, 'wide-column': column.wide, 'code-column': column.code }">
|
||||
<th class="index-column" scope="col">序号</th>
|
||||
<th v-for="column in columns" :key="column.key" scope="col" :aria-sort="ariaSort(column.key, column.sortable)" :class="{ numeric: column.numeric, 'wide-column': column.wide, 'code-column': column.code }">
|
||||
<button v-if="column.sortable" type="button" @click="emit('sort', column.key)">
|
||||
{{ column.label }}<span v-if="sortKey === column.key">{{ sortDirection === "asc" ? "↑" : "↓" }}</span>
|
||||
</button>
|
||||
@@ -35,7 +41,7 @@ const emit = defineEmits<{ sort: [key: string] }>();
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in rows" :key="String(row.identifier ?? index)">
|
||||
<td class="index-column numeric">{{ index + 1 }}</td>
|
||||
<td v-for="column in columns" :key="column.key" :class="{ numeric: column.numeric, 'wide-column': column.wide, 'code-column': column.code, up: column.key.includes('change') && Number(row[column.key]) > 0, down: column.key.includes('change') && Number(row[column.key]) < 0 }">
|
||||
<td v-for="column in columns" :key="column.key" :data-label="column.label" :class="{ numeric: column.numeric, 'wide-column': column.wide, 'code-column': column.code, up: column.key.includes('change') && Number(row[column.key]) > 0, down: column.key.includes('change') && Number(row[column.key]) < 0 }">
|
||||
{{ column.format ? column.format(row[column.key], row) : (row[column.key] ?? "") }}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -87,13 +87,29 @@ p {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
top: var(--s-8);
|
||||
left: var(--s-8);
|
||||
z-index: var(--z-toast);
|
||||
padding: var(--s-8) var(--s-12);
|
||||
border-radius: var(--control-radius);
|
||||
color: var(--c-gray-050);
|
||||
background: var(--color-primary);
|
||||
transform: translateY(calc(-100% - var(--s-16)));
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
animation-duration: var(--s-1) !important;
|
||||
animation-duration: var(--duration-reduced) !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: var(--s-1) !important;
|
||||
transition-duration: var(--duration-reduced) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,18 @@
|
||||
.dialog,
|
||||
.dialog-wide {
|
||||
width: 100%;
|
||||
max-height: calc(100vh - var(--s-44));
|
||||
max-height: calc(100dvh - var(--s-44));
|
||||
border-radius: var(--card-radius) var(--card-radius) 0 0;
|
||||
}
|
||||
|
||||
button,
|
||||
.btn,
|
||||
.icon-button {
|
||||
.icon-button,
|
||||
.seg-control button,
|
||||
.segmented button,
|
||||
.mobile-nav-item,
|
||||
.mobile-workspace-switch select {
|
||||
min-width: var(--touch-height);
|
||||
min-height: var(--touch-height);
|
||||
}
|
||||
|
||||
@@ -27,7 +33,7 @@
|
||||
|
||||
.app-shell {
|
||||
padding-left: 0;
|
||||
padding-bottom: var(--shell-mobile-nav-height);
|
||||
padding-bottom: calc(var(--shell-mobile-nav-height) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.sidebar,
|
||||
@@ -47,8 +53,7 @@
|
||||
}
|
||||
|
||||
.identity-cluster .tag,
|
||||
.account-name,
|
||||
.date-control .icon-button {
|
||||
.account-name {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -69,10 +74,53 @@
|
||||
}
|
||||
|
||||
.page-frame {
|
||||
min-height: calc(100vh - var(--shell-topbar-height) - var(--shell-summary-height) - var(--shell-mobile-nav-height));
|
||||
min-height: calc(100dvh - var(--shell-topbar-height) - var(--shell-summary-height) - var(--shell-mobile-nav-height));
|
||||
padding: var(--s-12) var(--s-10);
|
||||
}
|
||||
|
||||
.mobile-context-bar {
|
||||
display: grid;
|
||||
gap: var(--s-10);
|
||||
padding: var(--s-6) var(--s-10);
|
||||
border-bottom: var(--s-1) solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.mobile-workspace-switch {
|
||||
min-height: var(--touch-height);
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--s-10);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-12);
|
||||
font-weight: var(--weight-600);
|
||||
}
|
||||
|
||||
.mobile-workspace-switch select {
|
||||
min-width: 0;
|
||||
padding: 0 var(--s-10);
|
||||
border: var(--s-1) solid var(--color-border);
|
||||
border-radius: var(--control-radius);
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.mobile-date-control {
|
||||
display: grid;
|
||||
grid-template-columns: var(--touch-height) minmax(0, 1fr) var(--touch-height);
|
||||
}
|
||||
|
||||
.mobile-date-control .date-input {
|
||||
width: 100%;
|
||||
height: var(--touch-height);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
align-items: flex-start;
|
||||
gap: var(--s-4);
|
||||
@@ -85,7 +133,7 @@
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: var(--z-mobile-nav);
|
||||
height: var(--shell-mobile-nav-height);
|
||||
min-height: calc(var(--shell-mobile-nav-height) + env(safe-area-inset-bottom));
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
@@ -101,7 +149,8 @@
|
||||
font-size: var(--font-11);
|
||||
}
|
||||
|
||||
.mobile-nav-item.router-link-active {
|
||||
.mobile-nav-item.router-link-active,
|
||||
.mobile-nav-item.active {
|
||||
color: var(--color-primary);
|
||||
font-weight: var(--weight-600);
|
||||
}
|
||||
@@ -246,8 +295,94 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 430px) {
|
||||
.data-table-wrap {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.data-table thead {
|
||||
position: absolute;
|
||||
width: var(--s-1);
|
||||
height: var(--s-1);
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
.data-table tbody {
|
||||
display: grid;
|
||||
gap: var(--s-8);
|
||||
padding: var(--s-8);
|
||||
}
|
||||
|
||||
.data-table tbody tr {
|
||||
display: grid;
|
||||
padding: var(--s-8) var(--s-10);
|
||||
border: var(--s-1) solid var(--color-border);
|
||||
border-radius: var(--control-radius);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.data-table tbody td,
|
||||
.data-table tbody td.numeric,
|
||||
.data-table tbody td.code-column,
|
||||
.data-table tbody td.wide-column {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(var(--s-96), 1fr) minmax(0, 2fr);
|
||||
gap: var(--s-8);
|
||||
padding: var(--s-6) 0;
|
||||
border-bottom: var(--s-1) solid var(--color-divider);
|
||||
text-align: right;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.data-table tbody td:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.data-table tbody td::before {
|
||||
content: attr(data-label);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-11);
|
||||
font-weight: var(--weight-600);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.data-table tbody td.index-column {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) and (orientation: landscape) and (max-height: 430px) {
|
||||
.page-header {
|
||||
align-items: baseline;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.pool-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.pool-filters {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.pool-search {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.review-main-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.review-heading-actions { width: 100%; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin-left: 0; }
|
||||
.trade-card, .daily-review-card { min-height: auto; }
|
||||
.review-watch-table { max-height: var(--s-320); }
|
||||
.trade-scroll { max-height: var(--s-320); }
|
||||
|
||||
@@ -17,6 +17,12 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.review-heading-actions {
|
||||
display: flex;
|
||||
gap: var(--s-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.review-watch-table {
|
||||
width: 100%;
|
||||
max-height: var(--s-260);
|
||||
|
||||
@@ -279,6 +279,15 @@
|
||||
padding: var(--page-padding-y) var(--page-padding-x);
|
||||
}
|
||||
|
||||
.shell-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mobile-context-bar,
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
|
||||
@@ -125,6 +125,7 @@
|
||||
--shadow-float: 0 12px 32px rgba(0, 0, 0, 0.18);
|
||||
--duration-fast: 160ms;
|
||||
--duration-normal: 220ms;
|
||||
--duration-reduced: 1ms;
|
||||
--ease-standard: ease;
|
||||
--opacity-disabled: 0.52;
|
||||
--opacity-muted: 0.72;
|
||||
|
||||
Reference in New Issue
Block a user