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
+7 -3
View File
@@ -5,7 +5,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member, require_study_roles
from app.crud import study as study_crud
from app.schemas.common import PaginatedResponse
from app.schemas.study import StudyCreate, StudyRead, StudyUpdate
from app.utils.pagination import paginate
router = APIRouter()
@@ -23,18 +25,20 @@ async def create_study(
return study
@router.get("/", response_model=list[StudyRead])
@router.get("/", response_model=PaginatedResponse[StudyRead])
async def list_studies(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> list[StudyRead]:
) -> PaginatedResponse[StudyRead]:
if current_user.role == "ADMIN":
studies = await study_crud.list_studies(db, skip=skip, limit=limit)
total = await study_crud.list_studies(db, skip=0, limit=10_000_000)
else:
studies = await study_crud.list_studies_for_user(db, current_user.id, skip=skip, limit=limit)
return list(studies)
total = await study_crud.list_studies_for_user(db, current_user.id, skip=0, limit=10_000_000)
return paginate(list(studies), total=len(total))
@router.get("/{study_id}", response_model=StudyRead, dependencies=[Depends(require_study_member())])
+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>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.