Step F4:任务(Task)& 里程碑(Milestone)管理页面
This commit is contained in:
@@ -0,0 +1,22 @@
|
|||||||
|
# CTMS 项目快速上手
|
||||||
|
|
||||||
|
## 账号
|
||||||
|
- 管理员:`admin / admin123`(默认存在,用于创建项目、成员、FAQ 等)
|
||||||
|
- 示例 PM:`pm_faq / pm123456`(可作为项目 PM 登录)
|
||||||
|
- 示例 CRA:`cra_faq / cra123456`(只读成员)
|
||||||
|
|
||||||
|
> 若找不到示例用户,可用管理员在 `/api/v1/users` 创建,或在 UI 的登录页使用管理员登录后到“项目列表”添加成员。
|
||||||
|
|
||||||
|
## 访问方式
|
||||||
|
- 前端:`http://localhost`(Nginx 反代到 Vite 开发容器)
|
||||||
|
- 后端 API:同域 `/api/v1/*`(已在前端代理)
|
||||||
|
|
||||||
|
## 常用流程
|
||||||
|
1. 用 `admin / admin123` 登录前端。
|
||||||
|
2. 在“项目列表”进入一个项目或新建项目后进入。
|
||||||
|
3. Dashboard 查看进度、待办;左侧导航进入“里程碑”“任务”等页面。
|
||||||
|
4. 退出登录或切换项目可通过顶部右侧下拉。
|
||||||
|
|
||||||
|
## 注意
|
||||||
|
- 令牌与当前项目上下文保存在浏览器 LocalStorage 中,清除后需重新登录/选择项目。
|
||||||
|
- 若后端重启,确认数据库容器仍健康,前端会弹出错误提示。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { apiGet, apiPost, apiPatch } from "./axios";
|
||||||
|
import type { ApiListResponse } from "../types/api";
|
||||||
|
|
||||||
|
export const fetchMilestones = (studyId: string) =>
|
||||||
|
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/milestones/`);
|
||||||
|
|
||||||
|
export const createMilestone = (studyId: string, payload: Record<string, any>) =>
|
||||||
|
apiPost(`/api/v1/studies/${studyId}/milestones/`, payload);
|
||||||
|
|
||||||
|
export const updateMilestone = (studyId: string, milestoneId: string, payload: Record<string, any>) =>
|
||||||
|
apiPatch(`/api/v1/studies/${studyId}/milestones/${milestoneId}`, payload);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { apiGet, apiPost, apiPatch } from "./axios";
|
||||||
|
import type { ApiListResponse } from "../types/api";
|
||||||
|
|
||||||
|
export const fetchTasks = (studyId: string, params?: Record<string, any>) =>
|
||||||
|
apiGet<ApiListResponse<any>>(`/api/v1/studies/${studyId}/tasks/`, { params });
|
||||||
|
|
||||||
|
export const createTask = (studyId: string, payload: Record<string, any>) =>
|
||||||
|
apiPost(`/api/v1/studies/${studyId}/tasks/`, payload);
|
||||||
|
|
||||||
|
export const updateTask = (studyId: string, taskId: string, payload: Record<string, any>) =>
|
||||||
|
apiPatch(`/api/v1/studies/${studyId}/tasks/${taskId}`, payload);
|
||||||
@@ -17,6 +17,8 @@
|
|||||||
<el-menu router :default-active="$route.path">
|
<el-menu router :default-active="$route.path">
|
||||||
<el-menu-item index="/home">首页</el-menu-item>
|
<el-menu-item index="/home">首页</el-menu-item>
|
||||||
<el-menu-item index="/study/home">项目首页</el-menu-item>
|
<el-menu-item index="/study/home">项目首页</el-menu-item>
|
||||||
|
<el-menu-item index="/study/milestones">里程碑</el-menu-item>
|
||||||
|
<el-menu-item index="/study/tasks">任务</el-menu-item>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-aside>
|
</el-aside>
|
||||||
<el-main>
|
<el-main>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog :title="isEdit ? '编辑里程碑' : '新增里程碑'" v-model="visible" width="520px" @close="onClose">
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||||
|
<el-form-item label="名称" prop="name">
|
||||||
|
<el-input v-model="form.name" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="类型" prop="type">
|
||||||
|
<el-select v-model="form.type" placeholder="请选择">
|
||||||
|
<el-option v-for="item in types" :key="item" :label="item" :value="item" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="计划日期" prop="planned_date">
|
||||||
|
<el-date-picker v-model="form.planned_date" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="实际日期" prop="actual_date">
|
||||||
|
<el-date-picker v-model="form.actual_date" type="date" placeholder="选择日期" value-format="YYYY-MM-DD" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态" prop="status">
|
||||||
|
<el-select v-model="form.status" placeholder="请选择">
|
||||||
|
<el-option v-for="item in statuses" :key="item" :label="item" :value="item" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="备注" prop="notes">
|
||||||
|
<el-input v-model="form.notes" type="textarea" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="onClose">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="onSubmit">提交</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref, watch } from "vue";
|
||||||
|
import type { FormInstance, FormRules } from "element-plus";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { createMilestone, updateMilestone } from "../api/milestones";
|
||||||
|
import { useStudyStore } from "../store/study";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue: boolean;
|
||||||
|
milestone?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
const emit = defineEmits(["update:modelValue", "success"]);
|
||||||
|
|
||||||
|
const formRef = ref<FormInstance>();
|
||||||
|
const study = useStudyStore();
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (v: boolean) => emit("update:modelValue", v),
|
||||||
|
});
|
||||||
|
|
||||||
|
const types = ["ETHICS_SUBMISSION", "ETHICS_APPROVAL", "SIV", "FPI", "LPI", "DBL", "CSR"];
|
||||||
|
const statuses = ["NOT_STARTED", "IN_PROGRESS", "DONE", "BLOCKED"];
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: "",
|
||||||
|
type: "",
|
||||||
|
planned_date: "",
|
||||||
|
actual_date: "",
|
||||||
|
status: "NOT_STARTED",
|
||||||
|
notes: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules: FormRules = {
|
||||||
|
name: [{ required: true, message: "请输入名称", trigger: "blur" }],
|
||||||
|
type: [{ required: true, message: "请选择类型", trigger: "change" }],
|
||||||
|
planned_date: [{ required: true, message: "请选择计划日期", trigger: "change" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitting = ref(false);
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.milestone);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.milestone,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
form.name = val.name || "";
|
||||||
|
form.type = val.type || "";
|
||||||
|
form.planned_date = val.planned_date || "";
|
||||||
|
form.actual_date = val.actual_date || "";
|
||||||
|
form.status = val.status || "NOT_STARTED";
|
||||||
|
form.notes = val.notes || "";
|
||||||
|
} else {
|
||||||
|
form.name = "";
|
||||||
|
form.type = "";
|
||||||
|
form.planned_date = "";
|
||||||
|
form.actual_date = "";
|
||||||
|
form.status = "NOT_STARTED";
|
||||||
|
form.notes = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClose = () => {
|
||||||
|
visible.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async () => {
|
||||||
|
if (!formRef.value) return;
|
||||||
|
await formRef.value.validate(async (valid) => {
|
||||||
|
if (!valid) return;
|
||||||
|
if (!study.currentStudy) {
|
||||||
|
ElMessage.error("未选择项目");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
submitting.value = true;
|
||||||
|
try {
|
||||||
|
if (isEdit.value && props.milestone) {
|
||||||
|
await updateMilestone(study.currentStudy.id, props.milestone.id, form);
|
||||||
|
} else {
|
||||||
|
await createMilestone(study.currentStudy.id, form);
|
||||||
|
}
|
||||||
|
ElMessage.success("提交成功");
|
||||||
|
emit("success");
|
||||||
|
onClose();
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || "提交失败");
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -7,6 +7,7 @@ import Login from "../views/Login.vue";
|
|||||||
import StudyList from "../views/StudyList.vue";
|
import StudyList from "../views/StudyList.vue";
|
||||||
import StudyHome from "../views/StudyHome.vue";
|
import StudyHome from "../views/StudyHome.vue";
|
||||||
import Tasks from "../views/Tasks.vue";
|
import Tasks from "../views/Tasks.vue";
|
||||||
|
import Milestones from "../views/Milestones.vue";
|
||||||
import Subjects from "../views/Subjects.vue";
|
import Subjects from "../views/Subjects.vue";
|
||||||
import Aes from "../views/Aes.vue";
|
import Aes from "../views/Aes.vue";
|
||||||
import DataQueries from "../views/DataQueries.vue";
|
import DataQueries from "../views/DataQueries.vue";
|
||||||
@@ -44,6 +45,12 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: Tasks,
|
component: Tasks,
|
||||||
meta: { title: "任务", requiresStudy: true },
|
meta: { title: "任务", requiresStudy: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "study/milestones",
|
||||||
|
name: "StudyMilestones",
|
||||||
|
component: Milestones,
|
||||||
|
meta: { title: "里程碑", requiresStudy: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "study/subjects",
|
path: "study/subjects",
|
||||||
name: "StudySubjects",
|
name: "StudySubjects",
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page">
|
||||||
|
<el-card class="mb-12">
|
||||||
|
<div class="actions">
|
||||||
|
<h3>里程碑</h3>
|
||||||
|
<el-button type="primary" v-if="canEdit" @click="openCreate">新增里程碑</el-button>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card>
|
||||||
|
<el-table :data="milestones" v-loading="loading" style="width: 100%">
|
||||||
|
<el-table-column prop="name" label="名称" />
|
||||||
|
<el-table-column prop="type" label="类型" width="160" />
|
||||||
|
<el-table-column prop="planned_date" label="计划日期" width="140" />
|
||||||
|
<el-table-column prop="actual_date" label="实际日期" width="140" />
|
||||||
|
<el-table-column prop="status" label="状态" width="140" />
|
||||||
|
<el-table-column prop="owner_id" label="负责人" width="160" />
|
||||||
|
<el-table-column label="操作" width="120" v-if="canEdit">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button type="primary" link size="small" @click="openEdit(scope.row)">编辑</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<MilestoneForm v-model="showForm" :milestone="editingMilestone" @success="loadMilestones" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, computed } from "vue";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { fetchMilestones } from "../api/milestones";
|
||||||
|
import { useStudyStore } from "../store/study";
|
||||||
|
import { useAuthStore } from "../store/auth";
|
||||||
|
import MilestoneForm from "../components/MilestoneForm.vue";
|
||||||
|
|
||||||
|
const study = useStudyStore();
|
||||||
|
const auth = useAuthStore();
|
||||||
|
|
||||||
|
const milestones = ref<any[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const showForm = ref(false);
|
||||||
|
const editingMilestone = ref<any | null>(null);
|
||||||
|
|
||||||
|
const canEdit = computed(() => auth.user?.role === "ADMIN" || auth.user?.role === "PM");
|
||||||
|
|
||||||
|
const loadMilestones = async () => {
|
||||||
|
if (!study.currentStudy) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data } = await fetchMilestones(study.currentStudy.id);
|
||||||
|
milestones.value = data.items || data;
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.response?.data?.message || "里程碑加载失败");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
editingMilestone.value = null;
|
||||||
|
showForm.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (row: any) => {
|
||||||
|
editingMilestone.value = row;
|
||||||
|
showForm.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadMilestones();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.mb-12 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import { ElAlert } from "element-plus";
|
import { ElAlert } from "element-plus";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { useStudyStore } from "../store/study";
|
import { useStudyStore } from "../store/study";
|
||||||
import { fetchFinanceSummary, fetchMyTodoTasks, fetchOverdueAesCount, fetchOverdueQueriesCount, fetchProgress } from "../api/dashboard";
|
import { fetchFinanceSummary, fetchMyTodoTasks, fetchOverdueAesCount, fetchOverdueQueriesCount, fetchProgress } from "../api/dashboard";
|
||||||
@@ -60,6 +61,7 @@ import QuickActions from "../components/QuickActions.vue";
|
|||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const progress = ref<any>(null);
|
const progress = ref<any>(null);
|
||||||
const financeSummary = ref<any>(null);
|
const financeSummary = ref<any>(null);
|
||||||
@@ -89,7 +91,9 @@ const formatPercent = (v?: number | null) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const goTasks = () => {
|
const goTasks = () => {
|
||||||
window.location.hash = "#"; // placeholder
|
if (study.currentStudy) {
|
||||||
|
router.push("/study/tasks");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
|
|||||||
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.
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.
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user