Step F2:项目列表 & 当前项目上下文
This commit is contained in:
@@ -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())])
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import { apiGet } from "./axios";
|
||||
import type { ApiListResponse, Study } from "../types/api";
|
||||
|
||||
export const fetchStudies = () => apiGet<ApiListResponse<Study>>("/api/v1/studies");
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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");
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
@@ -25,3 +25,10 @@ export interface UserInfo {
|
||||
}
|
||||
|
||||
export type UserMeResponse = UserInfo;
|
||||
|
||||
export interface Study {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
Reference in New Issue
Block a user