rebuild(stage-4): deliver shared shell and account interfaces
This commit is contained in:
@@ -1,3 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
import { useSessionStore } from "../shared/stores/session";
|
||||
import AppShell from "./shell/AppShell.vue";
|
||||
import AuthView from "./views/AuthView.vue";
|
||||
|
||||
const session = useSessionStore();
|
||||
const restoreFailure = ref("");
|
||||
|
||||
async function restore(): Promise<void> {
|
||||
restoreFailure.value = "";
|
||||
try {
|
||||
await session.restore();
|
||||
} catch (error) {
|
||||
restoreFailure.value = error instanceof Error ? error.message : "服务连接失败";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(restore);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<main v-if="!session.initialized || restoreFailure" class="app-loading" aria-live="polite">
|
||||
<div class="loading-stack">
|
||||
<p>{{ restoreFailure || "正在载入复盘工作台" }}</p>
|
||||
<button v-if="restoreFailure" class="btn" type="button" @click="restore">重试</button>
|
||||
</div>
|
||||
</main>
|
||||
<AppShell v-else-if="session.account" />
|
||||
<AuthView v-else />
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
import BootstrapView from "./views/BootstrapView.vue";
|
||||
import SystemManagementView from "./views/SystemManagementView.vue";
|
||||
import WorkspaceView from "./views/WorkspaceView.vue";
|
||||
import { findWorkspace } from "./workspaceRegistry";
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [{ path: "/", name: "bootstrap", component: BootstrapView }],
|
||||
routes: [
|
||||
{ path: "/", redirect: "/workspace/emotion" },
|
||||
{
|
||||
path: "/workspace/:workspace",
|
||||
name: "workspace",
|
||||
component: WorkspaceView,
|
||||
beforeEnter: (to) => (findWorkspace(String(to.params.workspace)) ? true : "/workspace/emotion"),
|
||||
},
|
||||
{ path: "/system", name: "system", component: SystemManagementView },
|
||||
{ path: "/:pathMatch(.*)*", redirect: "/workspace/emotion" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../../shared/api/client";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
|
||||
type CredentialStatus = { name: string; configured: boolean; updated_at: string | null };
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
tushare_token: "Tushare Token",
|
||||
ifind_refresh_token: "iFinD Refresh Token",
|
||||
ifind_access_token: "iFinD Access Token",
|
||||
};
|
||||
const ui = useUiStore();
|
||||
const statuses = ref<CredentialStatus[]>([]);
|
||||
const values = reactive<Record<string, string>>({});
|
||||
const loading = ref(true);
|
||||
const errorMessage = ref("");
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
statuses.value = await api.get<CredentialStatus[]>("/admin/system/credentials");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "行情凭据读取失败。";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(name: string): Promise<void> {
|
||||
const value = values[name]?.trim();
|
||||
if (!value) {
|
||||
errorMessage.value = "请输入需要保存的凭据。";
|
||||
return;
|
||||
}
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await api.put(`/admin/system/credentials/${name}`, { value });
|
||||
values[name] = "";
|
||||
await load();
|
||||
ui.showToast("行情凭据已保存");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "保存失败,请稍后重试。";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="system-stack">
|
||||
<section class="card">
|
||||
<header class="card-header"><h2>平台行情凭据</h2><span class="faint">保存后不再返回明文</span></header>
|
||||
<div class="card-body credential-list">
|
||||
<p v-if="loading" class="muted">正在读取行情配置</p>
|
||||
<div v-for="status in statuses" v-else :key="status.name" class="credential-row">
|
||||
<div class="credential-meta">
|
||||
<strong>{{ labels[status.name] ?? status.name }}</strong>
|
||||
<span :class="status.configured ? 'up' : 'faint'">{{ status.configured ? "已配置" : "未配置" }}</span>
|
||||
</div>
|
||||
<input v-model="values[status.name]" class="input" type="password" autocomplete="off" placeholder="输入新值后保存" />
|
||||
<button class="btn" type="button" @click="save(status.name)">保存</button>
|
||||
</div>
|
||||
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<header class="card-header"><h2>历史数据回补</h2><span class="tag">暂不可用</span></header>
|
||||
<div class="card-body disabled-row">
|
||||
<p class="muted">历史数据回补将在行情数据迁移完成后开放。</p>
|
||||
<button class="btn" type="button" disabled>开始回补</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
import { api } from "../../shared/api/client";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
|
||||
type Membership = {
|
||||
status: string;
|
||||
active: boolean;
|
||||
is_permanent: boolean;
|
||||
expires_at: string | null;
|
||||
daily_limit: number;
|
||||
};
|
||||
type AccountMembership = { user_id: number; username: string; is_admin: boolean; membership: Membership };
|
||||
|
||||
const ui = useUiStore();
|
||||
const accounts = ref<AccountMembership[]>([]);
|
||||
const selectedId = ref<number | null>(null);
|
||||
const duration = ref("1_month");
|
||||
const dailyLimit = ref(50);
|
||||
const errorMessage = ref("");
|
||||
const confirmDisable = ref(false);
|
||||
const selected = computed(() => accounts.value.find((item) => item.user_id === selectedId.value) ?? null);
|
||||
const limitDescending = ref(false);
|
||||
const sortedAccounts = computed(() =>
|
||||
[...accounts.value].sort((left, right) => {
|
||||
const difference = left.membership.daily_limit - right.membership.daily_limit;
|
||||
return limitDescending.value ? -difference : difference;
|
||||
}),
|
||||
);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
accounts.value = await api.get<AccountMembership[]>("/admin/memberships");
|
||||
if (selectedId.value === null && accounts.value[0]) select(accounts.value[0]);
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "会员列表读取失败。";
|
||||
}
|
||||
}
|
||||
|
||||
function select(account: AccountMembership): void {
|
||||
selectedId.value = account.user_id;
|
||||
dailyLimit.value = account.membership.daily_limit;
|
||||
confirmDisable.value = false;
|
||||
}
|
||||
|
||||
async function disable(): Promise<void> {
|
||||
if (!confirmDisable.value) {
|
||||
confirmDisable.value = true;
|
||||
return;
|
||||
}
|
||||
await update("disable");
|
||||
confirmDisable.value = false;
|
||||
}
|
||||
|
||||
async function update(action: "activate" | "disable" | "set_limit"): Promise<void> {
|
||||
if (!selected.value) return;
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await api.patch(`/admin/memberships/${selected.value.user_id}`, {
|
||||
action,
|
||||
...(action === "activate" ? { duration: duration.value } : {}),
|
||||
daily_limit: dailyLimit.value,
|
||||
});
|
||||
await load();
|
||||
ui.showToast(action === "disable" ? "会员已停用" : "会员设置已保存");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "会员设置保存失败。";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="system-grid member-grid">
|
||||
<section class="card">
|
||||
<header class="card-header"><h2>账号会员状态</h2><span class="tag">{{ accounts.length }} 个账号</span></header>
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>账号</th><th>身份</th><th>会员状态</th><th>到期时间</th><th class="num" :aria-sort="limitDescending ? 'descending' : 'ascending'"><button class="sort-button" type="button" @click="limitDescending = !limitDescending">每日上限(次) <span aria-hidden="true">{{ limitDescending ? "↓" : "↑" }}</span></button></th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="account in sortedAccounts" :key="account.user_id" :class="{ selected: selectedId === account.user_id }" @click="select(account)">
|
||||
<td class="name-cell">{{ account.username }}</td><td>{{ account.is_admin ? "管理员" : "用户" }}</td><td>{{ account.membership.active ? "有效" : account.membership.status === "disabled" ? "停用" : "未开通" }}</td><td class="numeric">{{ account.membership.is_permanent ? "永久" : account.membership.expires_at?.slice(0, 10) || "" }}</td><td class="num numeric">{{ account.membership.daily_limit }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card member-editor">
|
||||
<header class="card-header"><h2>会员设置</h2></header>
|
||||
<div v-if="selected" class="card-body form-grid">
|
||||
<div class="selected-account"><strong>{{ selected.username }}</strong><span class="tag">{{ selected.membership.active ? "有效" : "未开通" }}</span></div>
|
||||
<div class="field"><label for="membership-duration">开通或续期时长</label><select id="membership-duration" v-model="duration" class="select"><option value="1_month">1个月</option><option value="3_months">3个月</option><option value="12_months">12个月</option><option value="3_years">3年</option><option value="permanent">永久</option></select></div>
|
||||
<div class="field"><label for="daily-limit">每日智能分析上限</label><input id="daily-limit" v-model.number="dailyLimit" class="input numeric" type="number" min="1" max="1000" /></div>
|
||||
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
|
||||
<button class="btn btn-primary" type="button" @click="update('activate')">开通 / 续期</button>
|
||||
<button class="btn" type="button" @click="update('set_limit')">保存每日上限</button>
|
||||
<button class="btn" type="button" :disabled="!selected.membership.active" @click="disable">{{ confirmDisable ? "确认停用" : "停用会员" }}</button>
|
||||
</div>
|
||||
<p v-else class="panel-loading muted">请选择账号</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
|
||||
import { api } from "../../shared/api/client";
|
||||
import { useUiStore } from "../../shared/stores/ui";
|
||||
|
||||
type ModelItem = {
|
||||
id: number;
|
||||
display_name: string;
|
||||
base_url: string;
|
||||
model_identifier: string;
|
||||
has_api_key: boolean;
|
||||
is_primary: boolean;
|
||||
is_fallback: boolean;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
const ui = useUiStore();
|
||||
const models = ref<ModelItem[]>([]);
|
||||
const editingId = ref<number | null>(null);
|
||||
const primaryId = ref<number | null>(null);
|
||||
const fallbackId = ref<number | null>(null);
|
||||
const errorMessage = ref("");
|
||||
const deleteConfirmation = ref<number | null>(null);
|
||||
const form = reactive({ display_name: "", base_url: "", model_identifier: "", api_key: "" });
|
||||
const editing = computed(() => models.value.find((item) => item.id === editingId.value) ?? null);
|
||||
|
||||
function resetForm(): void {
|
||||
editingId.value = null;
|
||||
Object.assign(form, { display_name: "", base_url: "", model_identifier: "", api_key: "" });
|
||||
}
|
||||
|
||||
function edit(model: ModelItem): void {
|
||||
deleteConfirmation.value = null;
|
||||
editingId.value = model.id;
|
||||
Object.assign(form, {
|
||||
display_name: model.display_name,
|
||||
base_url: model.base_url,
|
||||
model_identifier: model.model_identifier,
|
||||
api_key: "",
|
||||
});
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
try {
|
||||
models.value = await api.get<ModelItem[]>("/admin/models");
|
||||
primaryId.value = models.value.find((item) => item.is_primary)?.id ?? null;
|
||||
fallbackId.value = models.value.find((item) => item.is_fallback)?.id ?? null;
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "模型池读取失败。";
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
errorMessage.value = "";
|
||||
const wasEditing = editingId.value !== null;
|
||||
try {
|
||||
const payload = {
|
||||
display_name: form.display_name,
|
||||
base_url: form.base_url,
|
||||
model_identifier: form.model_identifier,
|
||||
...(form.api_key ? { api_key: form.api_key } : {}),
|
||||
};
|
||||
if (editingId.value) {
|
||||
await api.put(`/admin/models/${editingId.value}`, payload);
|
||||
} else {
|
||||
if (!form.api_key) {
|
||||
errorMessage.value = "新增模型时必须填写密钥。";
|
||||
return;
|
||||
}
|
||||
await api.post("/admin/models", payload);
|
||||
}
|
||||
resetForm();
|
||||
await load();
|
||||
ui.showToast(wasEditing ? "模型已更新" : "模型已添加");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "模型保存失败。";
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelection(): Promise<void> {
|
||||
if (!primaryId.value) {
|
||||
errorMessage.value = "请选择主模型。";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.put("/admin/models/selection", {
|
||||
primary_model_id: primaryId.value,
|
||||
fallback_model_id: fallbackId.value,
|
||||
});
|
||||
await load();
|
||||
ui.showToast("主模型和辅助模型已更新");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "模型选择保存失败。";
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(model: ModelItem): Promise<void> {
|
||||
if (deleteConfirmation.value !== model.id) {
|
||||
deleteConfirmation.value = model.id;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.delete(`/admin/models/${model.id}`);
|
||||
if (editingId.value === model.id) resetForm();
|
||||
await load();
|
||||
deleteConfirmation.value = null;
|
||||
ui.showToast("模型已删除");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "模型删除失败。";
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="system-grid model-grid">
|
||||
<section class="card">
|
||||
<header class="card-header"><h2>模型池</h2><span class="tag">{{ models.length }} / 20</span></header>
|
||||
<div class="model-list">
|
||||
<button v-for="model in models" :key="model.id" class="model-row" :class="{ active: editingId === model.id }" type="button" @click="edit(model)">
|
||||
<span><strong>{{ model.display_name }}</strong><small>{{ model.model_identifier }}</small></span>
|
||||
<span class="model-tags"><span v-if="model.is_primary" class="tag">主模型</span><span v-if="model.is_fallback" class="tag">辅助</span></span>
|
||||
</button>
|
||||
<p v-if="!models.length" class="panel-loading muted">尚未添加模型</p>
|
||||
</div>
|
||||
</section>
|
||||
<div class="system-stack">
|
||||
<section class="card">
|
||||
<header class="card-header"><h2>{{ editing ? "编辑模型" : "添加模型" }}</h2><button v-if="editing" class="btn btn-small" type="button" @click="resetForm">取消编辑</button></header>
|
||||
<form class="card-body form-grid" @submit.prevent="save">
|
||||
<div class="field"><label for="model-name">显示名称</label><input id="model-name" v-model="form.display_name" class="input" required /></div>
|
||||
<div class="field"><label for="model-url">服务地址</label><input id="model-url" v-model="form.base_url" class="input" type="url" required /></div>
|
||||
<div class="field"><label for="model-identifier">模型标识</label><input id="model-identifier" v-model="form.model_identifier" class="input" required /></div>
|
||||
<div class="field"><label for="model-key">密钥</label><input id="model-key" v-model="form.api_key" class="input" type="password" autocomplete="off" :placeholder="editing ? '留空则保持原密钥' : '请输入密钥'" /></div>
|
||||
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
|
||||
<div class="form-actions"><button v-if="editing && !editing.is_primary && !editing.is_fallback" class="btn" type="button" @click="remove(editing)">{{ deleteConfirmation === editing.id ? "确认删除" : "删除" }}</button><button class="btn btn-primary" type="submit">{{ editing ? "保存修改" : "添加模型" }}</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card">
|
||||
<header class="card-header"><h2>调用顺序</h2></header>
|
||||
<form class="card-body selection-grid" @submit.prevent="saveSelection">
|
||||
<div class="field"><label for="primary-model">主模型</label><select id="primary-model" v-model="primaryId" class="select" required><option :value="null" disabled>请选择</option><option v-for="model in models" :key="model.id" :value="model.id">{{ model.display_name }}</option></select></div>
|
||||
<div class="field"><label for="fallback-model">辅助模型</label><select id="fallback-model" v-model="fallbackId" class="select"><option :value="null">关闭</option><option v-for="model in models" :key="model.id" :value="model.id">{{ model.display_name }}</option></select></div>
|
||||
<button class="btn" type="submit" :disabled="!models.length">保存选择</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import { useSessionStore } from "../../shared/stores/session";
|
||||
|
||||
const router = useRouter();
|
||||
const session = useSessionStore();
|
||||
const mode = ref<"login" | "register">("login");
|
||||
const username = ref("");
|
||||
const password = ref("");
|
||||
const errorMessage = ref("");
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
errorMessage.value = "";
|
||||
try {
|
||||
await session.authenticate(mode.value, username.value, password.value);
|
||||
await router.replace("/workspace/emotion");
|
||||
} catch (error) {
|
||||
errorMessage.value = error instanceof Error ? error.message : "登录失败,请稍后重试。";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="auth-view">
|
||||
<section class="auth-panel card">
|
||||
<header class="auth-brand">
|
||||
<span class="brand-mark">复</span>
|
||||
<div><h1>小白复盘</h1><p>进入你的复盘工作台</p></div>
|
||||
</header>
|
||||
<div class="auth-tabs" role="tablist">
|
||||
<button type="button" :class="{ active: mode === 'login' }" role="tab" @click="mode = 'login'">登录</button>
|
||||
<button type="button" :class="{ active: mode === 'register' }" role="tab" @click="mode = 'register'">注册</button>
|
||||
</div>
|
||||
<form class="auth-form" @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<label for="username">账号名</label>
|
||||
<input id="username" v-model="username" class="input" autocomplete="username" placeholder="请输入账号名" required />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">密码</label>
|
||||
<input id="password" v-model="password" class="input" type="password" :autocomplete="mode === 'login' ? 'current-password' : 'new-password'" placeholder="请输入密码" required />
|
||||
</div>
|
||||
<p v-if="errorMessage" class="field-error" role="alert">{{ errorMessage }}</p>
|
||||
<button class="btn btn-primary auth-submit" type="submit" :disabled="session.busy">
|
||||
{{ session.busy ? "请稍候" : mode === "login" ? "登录" : "注册并登录" }}
|
||||
</button>
|
||||
</form>
|
||||
<p class="auth-risk">数据仅供复盘研究,不构成投资建议<br />股市有风险,投资需谨慎</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -1,64 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
|
||||
import { api } from "../../shared/api/client";
|
||||
|
||||
type Health = {
|
||||
status: string;
|
||||
environment: string;
|
||||
};
|
||||
|
||||
const health = ref<Health | null>(null);
|
||||
const failure = ref("");
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
health.value = await api.get<Health>("/health");
|
||||
} catch (error) {
|
||||
failure.value = error instanceof Error ? error.message : "服务连接失败";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="bootstrap-view">
|
||||
<section class="bootstrap-panel" aria-live="polite">
|
||||
<h1>小白复盘</h1>
|
||||
<p v-if="health">新系统基础服务已连接({{ health.environment }})</p>
|
||||
<p v-else-if="failure" class="failure">{{ failure }}</p>
|
||||
<p v-else>正在连接基础服务</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bootstrap-view {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-16);
|
||||
}
|
||||
|
||||
.bootstrap-panel {
|
||||
width: min(100%, 420px);
|
||||
padding: var(--space-22);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 var(--space-8);
|
||||
font-size: var(--font-page-title);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.failure {
|
||||
color: var(--warning);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import { useSessionStore } from "../../shared/stores/session";
|
||||
import MarketSettingsPanel from "../system/MarketSettingsPanel.vue";
|
||||
import MembershipAdminPanel from "../system/MembershipAdminPanel.vue";
|
||||
import ModelPoolPanel from "../system/ModelPoolPanel.vue";
|
||||
|
||||
const router = useRouter();
|
||||
const session = useSessionStore();
|
||||
const section = ref<"market" | "models" | "members">("market");
|
||||
|
||||
onMounted(() => {
|
||||
if (!session.isAdmin) router.replace("/workspace/emotion");
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main v-if="session.isAdmin" class="page-frame">
|
||||
<header class="page-header system-header">
|
||||
<div><h1>系统管理</h1><p class="page-subtitle">平台行情、模型池与会员配置</p></div>
|
||||
<div class="system-tabs" role="tablist">
|
||||
<button type="button" :class="{ active: section === 'market' }" @click="section = 'market'">行情管理</button>
|
||||
<button type="button" :class="{ active: section === 'models' }" @click="section = 'models'">模型池</button>
|
||||
<button type="button" :class="{ active: section === 'members' }" @click="section = 'members'">会员管理</button>
|
||||
</div>
|
||||
</header>
|
||||
<MarketSettingsPanel v-if="section === 'market'" />
|
||||
<ModelPoolPanel v-else-if="section === 'models'" />
|
||||
<MembershipAdminPanel v-else />
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
import EmptyState from "../../shared/components/EmptyState.vue";
|
||||
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 market = useMarketStore();
|
||||
const session = useSessionStore();
|
||||
const ui = useUiStore();
|
||||
const workspace = computed(() => findWorkspace(String(route.params.workspace)) ?? findWorkspace("emotion")!);
|
||||
const locked = computed(
|
||||
() => ["screener", "mentor", "heaven"].includes(workspace.value.key) && !session.account?.smart_access,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="page-frame">
|
||||
<header class="page-header">
|
||||
<h1>{{ workspace.title }}</h1>
|
||||
<p class="page-subtitle">{{ workspace.description }} · 数据日期 {{ market.selectedDate }}</p>
|
||||
</header>
|
||||
<div v-if="locked" class="notice notice-warning membership-lock">
|
||||
<span><strong>{{ workspace.title }}仅对会员开放</strong>,开通会员后可使用完整智能功能。</span>
|
||||
<button class="btn btn-small" type="button" @click="ui.openDialog('membership')">查看会员状态</button>
|
||||
</div>
|
||||
<section class="card" :class="{ 'locked-content': locked }" :aria-disabled="locked">
|
||||
<EmptyState title="暂无可显示数据" description="当前日期尚未载入该页面的有效行情数据。" />
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { findWorkspace, workspaceGroups, workspaces } from "./workspaceRegistry";
|
||||
|
||||
describe("workspace registry", () => {
|
||||
it("contains the sixteen ordered product workspaces without duplicate routes", () => {
|
||||
expect(workspaces).toHaveLength(16);
|
||||
expect(workspaces[0]?.key).toBe("emotion");
|
||||
expect(workspaces.at(-1)?.key).toBe("review");
|
||||
expect(new Set(workspaces.map((item) => item.key)).size).toBe(16);
|
||||
expect(workspaceGroups).toEqual(["市场复盘", "智能工具", "个人"]);
|
||||
});
|
||||
|
||||
it("resolves registered workspaces only", () => {
|
||||
expect(findWorkspace("heaven")?.title).toBe("问天");
|
||||
expect(findWorkspace("unknown")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
export type WorkspaceGroup = "市场复盘" | "智能工具" | "个人";
|
||||
|
||||
export type Workspace = {
|
||||
key: string;
|
||||
title: string;
|
||||
mark: string;
|
||||
group: WorkspaceGroup;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const workspaces: readonly Workspace[] = [
|
||||
{ key: "emotion", title: "情绪周期", mark: "情", group: "市场复盘", description: "全市场情绪温度与阶段" },
|
||||
{ key: "pool", title: "涨停池", mark: "涨", group: "市场复盘", description: "当日封板股票与结构" },
|
||||
{ key: "broken", title: "炸板池", mark: "炸", group: "市场复盘", description: "触板未封股票与原因" },
|
||||
{ key: "limit-down", title: "跌停板", mark: "跌", group: "市场复盘", description: "跌停风险与聚集" },
|
||||
{ key: "yesterday", title: "昨日涨停", mark: "昨", group: "市场复盘", description: "昨日涨停次日反馈" },
|
||||
{ key: "performance", title: "涨停表现", mark: "表", group: "市场复盘", description: "晋级、兑现与断板" },
|
||||
{ key: "ladder", title: "市场天梯", mark: "梯", group: "市场复盘", description: "连板梯队与市场高度" },
|
||||
{ key: "rotation", title: "板块轮动", mark: "转", group: "市场复盘", description: "热点迁移与板块成分" },
|
||||
{ key: "auction", title: "集合竞价", mark: "竞", group: "市场复盘", description: "题材承接与竞价异动" },
|
||||
{ key: "themes", title: "题材库", mark: "题", group: "市场复盘", description: "题材排行与成分股" },
|
||||
{ key: "popularity", title: "人气热榜", mark: "热", group: "市场复盘", description: "双平台人气与共识" },
|
||||
{ key: "dragon-list", title: "龙虎榜", mark: "榜", group: "市场复盘", description: "上榜明细与活跃席位" },
|
||||
{ key: "screener", title: "智能选股", mark: "选", group: "智能工具", description: "阶段、策略与自定义选股" },
|
||||
{ key: "mentor", title: "问师", mark: "师", group: "智能工具", description: "思维模型复盘对话" },
|
||||
{ key: "heaven", title: "问天", mark: "天", group: "智能工具", description: "观势、观气与观心" },
|
||||
{ key: "review", title: "我的复盘", mark: "复", group: "个人", description: "自选、日志与每日复盘" },
|
||||
] as const;
|
||||
|
||||
export const workspaceGroups: readonly WorkspaceGroup[] = ["市场复盘", "智能工具", "个人"];
|
||||
|
||||
export function findWorkspace(key: string): Workspace | undefined {
|
||||
return workspaces.find((workspace) => workspace.key === key);
|
||||
}
|
||||
Reference in New Issue
Block a user