rebuild(stage-4): deliver shared shell and account interfaces
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted } from "vue";
|
||||
|
||||
import DialogHost from "../../shared/components/DialogHost.vue";
|
||||
import ToastHost from "../../shared/components/ToastHost.vue";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import DesktopSidebar from "./DesktopSidebar.vue";
|
||||
import MarketStrip from "./MarketStrip.vue";
|
||||
import MobileNav from "./MobileNav.vue";
|
||||
import StatusBar from "./StatusBar.vue";
|
||||
import TopBar from "./TopBar.vue";
|
||||
|
||||
const ui = useUiStore();
|
||||
|
||||
function globalShortcut(event: KeyboardEvent): void {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "k") return;
|
||||
if (event.defaultPrevented) {
|
||||
ui.showToast("Ctrl+K 已被其他功能占用");
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
ui.openDialog("search");
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener("keydown", globalShortcut));
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", globalShortcut));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<DesktopSidebar />
|
||||
<div class="shell-main">
|
||||
<TopBar />
|
||||
<MarketStrip />
|
||||
<RouterView />
|
||||
</div>
|
||||
<StatusBar />
|
||||
<MobileNav />
|
||||
<DialogHost />
|
||||
<ToastHost />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { workspaceGroups, workspaces } from "../workspaceRegistry";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<span class="brand-mark">复</span>
|
||||
<span class="brand-name">小白复盘</span>
|
||||
</div>
|
||||
<nav class="sidebar-nav" aria-label="主导航">
|
||||
<template v-for="group in workspaceGroups" :key="group">
|
||||
<div class="nav-group-label">{{ group }}</div>
|
||||
<RouterLink
|
||||
v-for="workspace in workspaces.filter((item) => item.group === group)"
|
||||
:key="workspace.key"
|
||||
class="nav-item"
|
||||
:to="`/workspace/${workspace.key}`"
|
||||
>
|
||||
<span class="nav-mark" aria-hidden="true">{{ workspace.mark }}</span>
|
||||
<span>{{ workspace.title }}</span>
|
||||
</RouterLink>
|
||||
</template>
|
||||
</nav>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
|
||||
const expanded = ref(false);
|
||||
const cells = ["市场情绪", "涨停", "跌停", "炸板", "封板率", "两市成交", "数据日期"];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="market-strip" aria-label="市场摘要">
|
||||
<div class="market-strip-row">
|
||||
<span class="market-emotion"><span class="emotion-dot" aria-hidden="true"></span>市场情绪</span>
|
||||
<span>涨停</span>
|
||||
<span>跌停</span>
|
||||
<span>炸板</span>
|
||||
<span>封板率</span>
|
||||
<span>两市成交</span>
|
||||
<span class="faint">等待最新行情</span>
|
||||
<button class="market-toggle" type="button" :aria-expanded="expanded" @click="expanded = !expanded">
|
||||
{{ expanded ? "收起详情" : "展开详情" }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="expanded" class="market-details">
|
||||
<div v-for="cell in cells" :key="cell" class="market-cell">
|
||||
<div class="market-cell-label">{{ cell }}</div>
|
||||
<div class="market-cell-value"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<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>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import { findWorkspace } from "../workspaceRegistry";
|
||||
|
||||
const route = useRoute();
|
||||
const title = computed(() => findWorkspace(String(route.params.workspace ?? ""))?.title ?? "系统管理");
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer class="statusbar">
|
||||
<span>{{ title }}</span>
|
||||
<span class="statusbar-center">股市有风险,投资需谨慎</span>
|
||||
<span class="statusbar-right">等待行情数据</span>
|
||||
</footer>
|
||||
</template>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import { useMarketStore } from "../../shared/stores/market";
|
||||
import { useSessionStore } from "../../shared/stores/session";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
import { findWorkspace } from "../workspaceRegistry";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const market = useMarketStore();
|
||||
const session = useSessionStore();
|
||||
const ui = useUiStore();
|
||||
const menuOpen = ref(false);
|
||||
const menuRoot = ref<HTMLElement | null>(null);
|
||||
|
||||
const title = computed(() => {
|
||||
if (route.name === "system") return "系统管理";
|
||||
return findWorkspace(String(route.params.workspace ?? "emotion"))?.title ?? "小白复盘";
|
||||
});
|
||||
|
||||
function closeOnOutside(event: MouseEvent): void {
|
||||
if (!menuRoot.value?.contains(event.target as Node)) menuOpen.value = false;
|
||||
}
|
||||
|
||||
function openAccountDialog(name: "profile" | "membership" | "password"): void {
|
||||
menuOpen.value = false;
|
||||
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
|
||||
) {
|
||||
market.selectedDate = value;
|
||||
return;
|
||||
}
|
||||
input.value = market.selectedDate;
|
||||
ui.showToast("请输入 YYYY-MM-DD 格式的日期");
|
||||
}
|
||||
|
||||
async function endSession(action: "logout" | "switch-account"): Promise<void> {
|
||||
menuOpen.value = false;
|
||||
try {
|
||||
await session.endSession(action);
|
||||
await router.replace("/workspace/emotion");
|
||||
} catch (error) {
|
||||
ui.showToast(error instanceof Error ? error.message : "操作失败,请稍后重试。");
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener("mousedown", closeOnOutside));
|
||||
onBeforeUnmount(() => document.removeEventListener("mousedown", closeOnOutside));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<button class="icon-button" type="button" aria-label="全局搜索" title="全局搜索(Ctrl+K)" @click="ui.openDialog('search')">⌕</button>
|
||||
<button class="icon-button desktop-only" type="button" aria-label="提醒中心" title="提醒中心" @click="ui.showToast('暂无新提醒')">!</button>
|
||||
<button class="btn btn-small" type="button" :title="ui.theme === 'light' ? '切换夜间模式' : '切换日间模式'" @click="ui.toggleTheme">
|
||||
{{ ui.theme === "light" ? "夜间" : "日间" }}
|
||||
</button>
|
||||
<button v-if="session.isAdmin" class="btn btn-small desktop-only" type="button" @click="ui.showToast('行情刷新任务暂不可用')">后台刷新</button>
|
||||
<button v-if="session.isAdmin" class="btn btn-small desktop-only" type="button" @click="router.push('/system')">系统管理</button>
|
||||
<div class="identity-cluster">
|
||||
<button v-if="session.isMember" class="tag tag-vip" type="button" title="查看会员状态" @click="ui.openDialog('membership')">V 会员</button>
|
||||
<span v-if="session.isAdmin" class="tag tag-admin">管理员</span>
|
||||
<button v-if="!session.isMember" class="tag desktop-only" type="button" @click="ui.openDialog('membership')">普通用户</button>
|
||||
</div>
|
||||
<div ref="menuRoot" class="account-menu-wrap">
|
||||
<button class="btn btn-small account-trigger" type="button" :aria-expanded="menuOpen" @click="menuOpen = !menuOpen">
|
||||
<span class="account-name">{{ session.account?.username }}</span><span class="mobile-account-label">账户</span><span aria-hidden="true">⌄</span>
|
||||
</button>
|
||||
<div v-if="menuOpen" class="account-menu">
|
||||
<button type="button" @click="openAccountDialog('profile')">个人资料</button>
|
||||
<button type="button" @click="openAccountDialog('membership')">会员状态</button>
|
||||
<button type="button" @click="openAccountDialog('password')">修改密码</button>
|
||||
<button type="button" @click="endSession('switch-account')">切换账号</button>
|
||||
<button type="button" @click="endSession('logout')">退出</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
Reference in New Issue
Block a user