Step F2:项目列表 & 当前项目上下文

This commit is contained in:
Cheng Zhou
2025-12-16 20:31:20 +08:00
parent 78d13d077c
commit cc54d80b3a
19 changed files with 239 additions and 24 deletions
+4
View File
@@ -0,0 +1,4 @@
import { apiGet } from "./axios";
import type { ApiListResponse, Study } from "../types/api";
export const fetchStudies = () => apiGet<ApiListResponse<Study>>("/api/v1/studies");
+28 -19
View File
@@ -1,35 +1,44 @@
<template>
<el-container class="layout">
<el-header class="header">
<div class="logo">CTMS</div>
<div class="user-info">
<span>{{ auth.user?.username }}</span>
<el-tag size="small" class="role">{{ auth.user?.role }}</el-tag>
<el-button size="small" type="danger" @click="onLogout">退出登录</el-button>
</div>
</el-header>
<el-container>
<el-aside width="200px" class="aside">
<el-menu router default-active="/home">
<el-menu-item index="/home">首页</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<router-view />
</el-main>
</el-container>
<el-container class="layout">
<el-header class="header">
<div class="logo">CTMS</div>
<div class="current-study" v-if="study.currentStudy">
<el-tag type="success" size="small">{{ study.currentStudy.name }}</el-tag>
</div>
<div class="user-info">
<StudySelector />
<span>{{ auth.user?.username }}</span>
<el-tag size="small" class="role">{{ auth.user?.role }}</el-tag>
<el-button size="small" type="danger" @click="onLogout">退出登录</el-button>
</div>
</el-header>
<el-container>
<el-aside width="200px" class="aside" v-if="study.currentStudy">
<el-menu router :default-active="$route.path">
<el-menu-item index="/home">首页</el-menu-item>
<el-menu-item index="/study/home">项目首页</el-menu-item>
</el-menu>
</el-aside>
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import StudySelector from "./StudySelector.vue";
const auth = useAuthStore();
const study = useStudyStore();
const router = useRouter();
const onLogout = () => {
auth.logout();
study.clearCurrentStudy();
router.push("/login");
};
</script>
+32
View File
@@ -0,0 +1,32 @@
<template>
<el-dropdown>
<span class="el-dropdown-link">
<el-tag size="small" type="info">
{{ study.currentStudy?.name || "未选择项目" }}
</el-tag>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="goSelect">切换项目</el-dropdown-item>
<el-dropdown-item @click="clearStudy">清除当前项目</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</template>
<script setup lang="ts">
import { useRouter } from "vue-router";
import { useStudyStore } from "../store/study";
const router = useRouter();
const study = useStudyStore();
const goSelect = () => {
router.push("/studies");
};
const clearStudy = () => {
study.clearCurrentStudy();
router.push("/studies");
};
</script>
+7 -1
View File
@@ -5,10 +5,16 @@ import "element-plus/dist/index.css";
import App from "./App.vue";
import router from "./router";
import { useStudyStore } from "./store/study";
const app = createApp(App);
app.use(createPinia());
const pinia = createPinia();
app.use(pinia);
app.use(router);
app.use(ElementPlus);
// 初始化项目上下文
const studyStore = useStudyStore();
studyStore.loadCurrentStudy();
app.mount("#app");
+27 -1
View File
@@ -1,8 +1,11 @@
import { createRouter, createWebHistory, RouteRecordRaw } from "vue-router";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import Layout from "../components/Layout.vue";
import Home from "../views/Home.vue";
import Login from "../views/Login.vue";
import StudyList from "../views/StudyList.vue";
import StudyHome from "../views/StudyHome.vue";
const routes: RouteRecordRaw[] = [
{
@@ -22,6 +25,24 @@ const routes: RouteRecordRaw[] = [
component: Home,
meta: { title: "首页" },
},
{
path: "study/home",
name: "StudyHome",
component: StudyHome,
meta: { title: "项目首页", requiresStudy: true },
},
],
},
{
path: "/studies",
component: Layout,
children: [
{
path: "",
name: "StudyList",
component: StudyList,
meta: { title: "项目列表" },
},
],
},
];
@@ -33,13 +54,18 @@ const router = createRouter({
router.beforeEach((to, _from, next) => {
const auth = useAuthStore();
const studyStore = useStudyStore();
const token = auth.token;
if (!to.meta.public && !token) {
next({ path: "/login" });
return;
}
if (to.path === "/login" && token) {
next({ path: "/" });
next({ path: studyStore.currentStudy ? "/study/home" : "/home" });
return;
}
if (to.path.startsWith("/study") && !studyStore.currentStudy) {
next({ path: "/studies" });
return;
}
next();
+3
View File
@@ -3,6 +3,7 @@ import { ref } from "vue";
import { login as apiLogin, fetchMe } from "../api/auth";
import { setToken, clearToken, getToken } from "../utils/auth";
import type { UserInfo } from "../types/api";
import { useStudyStore } from "./study";
export const useAuthStore = defineStore("auth", () => {
const token = ref<string | null>(getToken());
@@ -31,6 +32,8 @@ export const useAuthStore = defineStore("auth", () => {
token.value = null;
user.value = null;
clearToken();
const studyStore = useStudyStore();
studyStore.clearCurrentStudy();
};
return {
+37
View File
@@ -0,0 +1,37 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import type { Study } from "../types/api";
const STUDY_KEY = "ctms_current_study";
export const useStudyStore = defineStore("study", () => {
const currentStudy = ref<Study | null>(null);
const setCurrentStudy = (study: Study) => {
currentStudy.value = study;
localStorage.setItem(STUDY_KEY, JSON.stringify(study));
};
const loadCurrentStudy = () => {
const raw = localStorage.getItem(STUDY_KEY);
if (raw) {
try {
currentStudy.value = JSON.parse(raw) as Study;
} catch {
currentStudy.value = null;
}
}
};
const clearCurrentStudy = () => {
currentStudy.value = null;
localStorage.removeItem(STUDY_KEY);
};
return {
currentStudy,
setCurrentStudy,
loadCurrentStudy,
clearCurrentStudy,
};
});
+7
View File
@@ -25,3 +25,10 @@ export interface UserInfo {
}
export type UserMeResponse = UserInfo;
export interface Study {
id: string;
code: string;
name: string;
status: string;
}
+24
View File
@@ -0,0 +1,24 @@
<template>
<div class="study-home" v-if="study.currentStudy">
<el-card>
<h2>项目首页</h2>
<p>项目名称{{ study.currentStudy.name }}</p>
<p>项目编号{{ study.currentStudy.code }}</p>
</el-card>
</div>
<div v-else class="study-home">
<el-alert type="info" title="请先选择一个项目" />
</div>
</template>
<script setup lang="ts">
import { useStudyStore } from "../store/study";
const study = useStudyStore();
</script>
<style scoped>
.study-home {
padding: 16px;
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<template>
<div class="study-list">
<el-card>
<div class="header">
<h3>项目列表</h3>
</div>
<el-table :data="studies" style="width: 100%" v-loading="loading">
<el-table-column prop="code" label="项目编号" width="160" />
<el-table-column prop="name" label="项目名称" />
<el-table-column prop="status" label="状态" width="120" />
<el-table-column label="操作" width="140">
<template #default="scope">
<el-button type="primary" size="small" @click="enterStudy(scope.row)">进入项目</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { fetchStudies } from "../api/studies";
import { useStudyStore } from "../store/study";
import type { Study } from "../types/api";
const studies = ref<Study[]>([]);
const loading = ref(false);
const router = useRouter();
const studyStore = useStudyStore();
const loadStudies = async () => {
loading.value = true;
try {
const { data } = await fetchStudies();
studies.value = data.items;
} finally {
loading.value = false;
}
};
const enterStudy = (study: Study) => {
studyStore.setCurrentStudy(study);
router.push("/study/home");
};
onMounted(() => {
loadStudies();
});
</script>
<style scoped>
.study-list {
padding: 16px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
</style>