rebuild(stage-4): deliver shared shell and account interfaces

This commit is contained in:
leefer
2026-07-30 02:08:20 +08:00
parent d1c658d8ef
commit 3b75bf2d63
47 changed files with 3042 additions and 99 deletions
+42
View File
@@ -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>
+17
View File
@@ -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>
+96
View File
@@ -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>