diff --git a/database/migrations/20250120_add_faq_replies.sql b/database/migrations/20250120_add_faq_replies.sql deleted file mode 100644 index e3a4cd6a..00000000 --- a/database/migrations/20250120_add_faq_replies.sql +++ /dev/null @@ -1,33 +0,0 @@ -CREATE EXTENSION IF NOT EXISTS "pgcrypto"; - -CREATE TABLE public.faq_replies ( - id uuid NOT NULL, - faq_id uuid NOT NULL, - study_id uuid, - content text NOT NULL, - created_by uuid NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL, - quote_reply_id uuid -); - -ALTER TABLE public.faq_replies OWNER TO ctms_user; - -ALTER TABLE ONLY public.faq_replies - ADD CONSTRAINT faq_replies_pkey PRIMARY KEY (id); - -CREATE INDEX ix_faq_replies_faq_id ON public.faq_replies USING btree (faq_id); -CREATE INDEX ix_faq_replies_study_id ON public.faq_replies USING btree (study_id); - -ALTER TABLE ONLY public.faq_replies - ADD CONSTRAINT faq_replies_created_by_fkey FOREIGN KEY (created_by) REFERENCES public.users(id); -ALTER TABLE ONLY public.faq_replies - ADD CONSTRAINT faq_replies_faq_id_fkey FOREIGN KEY (faq_id) REFERENCES public.faq_items(id); -ALTER TABLE ONLY public.faq_replies - ADD CONSTRAINT faq_replies_quote_reply_id_fkey FOREIGN KEY (quote_reply_id) REFERENCES public.faq_replies(id); -ALTER TABLE ONLY public.faq_replies - ADD CONSTRAINT faq_replies_study_id_fkey FOREIGN KEY (study_id) REFERENCES public.studies(id); - -INSERT INTO public.faq_replies (id, faq_id, study_id, content, created_by, created_at) -SELECT gen_random_uuid(), id, study_id, answer, created_by, updated_at -FROM public.faq_items -WHERE answer IS NOT NULL AND btrim(answer) <> ''; diff --git a/database/migrations/20250121_add_faq_reply_soft_delete.sql b/database/migrations/20250121_add_faq_reply_soft_delete.sql deleted file mode 100644 index f8e7f50a..00000000 --- a/database/migrations/20250121_add_faq_reply_soft_delete.sql +++ /dev/null @@ -1,2 +0,0 @@ -ALTER TABLE public.faq_replies - ADD COLUMN IF NOT EXISTS is_deleted boolean DEFAULT false NOT NULL; diff --git a/database/migrations/20250122_add_faq_best_reply.sql b/database/migrations/20250122_add_faq_best_reply.sql deleted file mode 100644 index c3fbe959..00000000 --- a/database/migrations/20250122_add_faq_best_reply.sql +++ /dev/null @@ -1,15 +0,0 @@ -ALTER TABLE public.faq_items - ADD COLUMN IF NOT EXISTS best_reply_id uuid; - -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint WHERE conname = 'faq_items_best_reply_id_fkey' - ) THEN - ALTER TABLE ONLY public.faq_items - ADD CONSTRAINT faq_items_best_reply_id_fkey - FOREIGN KEY (best_reply_id) REFERENCES public.faq_replies(id) ON DELETE SET NULL; - END IF; -END $$; - -CREATE INDEX IF NOT EXISTS ix_faq_items_best_reply_id ON public.faq_items USING btree (best_reply_id); diff --git a/database/migrations/20250123_add_faq_status.sql b/database/migrations/20250123_add_faq_status.sql deleted file mode 100644 index 1b7c9da2..00000000 --- a/database/migrations/20250123_add_faq_status.sql +++ /dev/null @@ -1,4 +0,0 @@ -ALTER TABLE public.faq_items - ADD COLUMN IF NOT EXISTS status character varying(20) DEFAULT 'PENDING' NOT NULL; - -UPDATE public.faq_items SET status = 'PENDING' WHERE status IS NULL; diff --git a/database/migrations/20250124_add_faq_resolved_by_confirm.sql b/database/migrations/20250124_add_faq_resolved_by_confirm.sql deleted file mode 100644 index 496a733e..00000000 --- a/database/migrations/20250124_add_faq_resolved_by_confirm.sql +++ /dev/null @@ -1,14 +0,0 @@ -ALTER TABLE public.faq_items - ADD COLUMN IF NOT EXISTS resolved_by_confirm boolean DEFAULT false NOT NULL; - -UPDATE public.faq_items -SET status = CASE - WHEN best_reply_id IS NOT NULL THEN 'RESOLVED' - WHEN EXISTS ( - SELECT 1 FROM public.faq_replies r - WHERE r.faq_id = faq_items.id AND r.is_deleted = false - ) THEN 'PROCESSING' - ELSE 'PENDING' -END, -resolved_by_confirm = false -WHERE status = 'RESOLVED' AND best_reply_id IS NULL; diff --git a/frontend/src/components/CommentList.vue b/frontend/src/components/CommentList.vue index 5575cbf2..da124d32 100644 --- a/frontend/src/components/CommentList.vue +++ b/frontend/src/components/CommentList.vue @@ -30,7 +30,7 @@ import { ElMessage } from "element-plus"; import { fetchComments, createComment } from "../api/comments"; import { useStudyStore } from "../store/study"; import { listMembers } from "../api/members"; -import { displayDateTime, displayUser } from "../utils/display"; +import { displayDateTime, displayUser, getMemberDisplayName } from "../utils/display"; interface Props { studyId: string; @@ -72,8 +72,8 @@ const loadMembers = async () => { const memberMap = computed(() => members.value.reduce>((acc, cur) => { - const username = cur?.user?.display_name || cur?.user?.username || cur?.username; - if (cur?.user_id) acc[cur.user_id] = username || cur.user_id; + const username = getMemberDisplayName(cur); + if (cur?.user_id && username) acc[cur.user_id] = username; return acc; }, {}) ); diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 2597a9ca..47bca628 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -27,6 +27,10 @@ 项目管理 + + + 审计日志 + diff --git a/frontend/src/components/attachments/AttachmentList.vue b/frontend/src/components/attachments/AttachmentList.vue index 5ec28fc4..3c1978df 100644 --- a/frontend/src/components/attachments/AttachmentList.vue +++ b/frontend/src/components/attachments/AttachmentList.vue @@ -51,7 +51,7 @@ import { formatFileSize } from "./attachmentUtils"; import { useAuthStore } from "../../store/auth"; import { useStudyStore } from "../../store/study"; import { listMembers } from "../../api/members"; -import { displayDateTime, displayUser } from "../../utils/display"; +import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../../utils/display"; const props = defineProps<{ studyId: string; @@ -90,12 +90,12 @@ const loadMembers = async () => { const uploaderLabel = (row: any) => { const memberMap = members.value.reduce>((acc, cur) => { - const username = cur?.user?.display_name || cur?.user?.username || cur?.username; - if (cur?.user_id) acc[cur.user_id] = username || cur.user_id; + const username = getMemberDisplayName(cur); + if (cur?.user_id && username) acc[cur.user_id] = username; return acc; }, {}); if (row?.uploaded_by && typeof row.uploaded_by === "object") { - return row.uploaded_by.display_name || row.uploaded_by.username || row.uploaded_by.id || "—"; + return getUserDisplayName(row.uploaded_by) || row.uploaded_by.id || "—"; } return displayUser(row.uploaded_by_id || row.uploaded_by, { members: memberMap }); }; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 16af6a78..81d55430 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -191,12 +191,6 @@ const routes: RouteRecordRaw[] = [ component: Verification, meta: { title: "核查进度", requiresStudy: true }, }, - { - path: "study/audit-logs", - name: "AuditLogs", - component: AuditLogs, - meta: { title: "审计日志", requiresStudy: true }, - }, ], }, { @@ -234,6 +228,12 @@ const routes: RouteRecordRaw[] = [ component: AdminSites, meta: { title: "中心管理", requiresAdmin: true }, }, + { + path: "audit-logs", + name: "AdminAuditLogs", + component: AuditLogs, + meta: { title: "审计日志", requiresAdmin: true }, + }, ], }, { @@ -261,6 +261,9 @@ router.beforeEach(async (to, _from, next) => { auth.logout(); } } + if (token && !studyStore.currentStudy) { + await studyStore.ensureDefaultStudy(); + } if (!to.meta.public && !token) { next({ path: "/login" }); return; @@ -287,6 +290,10 @@ router.beforeEach(async (to, _from, next) => { next({ path: "/workbench" }); return; } + if (to.path === "/workbench" && studyStore.currentStudy) { + next({ path: "/study/home" }); + return; + } next(); }); diff --git a/frontend/src/store/study.ts b/frontend/src/store/study.ts index 55638acd..8ae0013c 100644 --- a/frontend/src/store/study.ts +++ b/frontend/src/store/study.ts @@ -1,5 +1,6 @@ import { defineStore } from "pinia"; import { ref } from "vue"; +import { fetchStudies } from "../api/studies"; import type { Study } from "../types/api"; const STUDY_KEY = "ctms_current_study"; @@ -32,6 +33,21 @@ export const useStudyStore = defineStore("study", () => { currentStudyRole.value = savedRole || null; }; + const ensureDefaultStudy = async () => { + if (currentStudy.value) return currentStudy.value; + try { + const { data } = await fetchStudies(); + const items = (data as any).items || []; + if (items.length) { + setCurrentStudy(items[0] as Study); + return items[0] as Study; + } + } catch { + return null; + } + return null; + }; + const clearCurrentStudy = () => { currentStudy.value = null; currentStudyRole.value = null; @@ -54,6 +70,7 @@ export const useStudyStore = defineStore("study", () => { setCurrentStudy, setCurrentStudyRole, loadCurrentStudy, + ensureDefaultStudy, clearCurrentStudy, }; }); diff --git a/frontend/src/utils/display/index.ts b/frontend/src/utils/display/index.ts index f36d9503..6ba012f2 100644 --- a/frontend/src/utils/display/index.ts +++ b/frontend/src/utils/display/index.ts @@ -9,6 +9,23 @@ export const displayEnum = (enumMap: Record, value?: string | nu return enumMap[value] || displayFallback; }; +export const getUserDisplayName = (user?: any) => { + if (!user) return null; + return user.display_name || user.full_name || user.username || user.email || null; +}; + +export const getMemberDisplayName = (member?: any) => { + if (!member) return null; + return ( + getUserDisplayName(member.user) || + member.display_name || + member.full_name || + member.username || + member.email || + null + ); +}; + export const displayUser = ( userId?: string | null, opts?: { users?: Record; members?: Record } diff --git a/frontend/src/utils/permission.ts b/frontend/src/utils/permission.ts index 7321f936..319e9d5f 100644 --- a/frontend/src/utils/permission.ts +++ b/frontend/src/utils/permission.ts @@ -3,7 +3,7 @@ import { useAuthStore } from "../store/auth"; import { useStudyStore } from "../store/study"; const PERMISSIONS: Record = { - "milestone.create": ["ADMIN", "PM"], + "milestone.create": ["ADMIN", "PM", "CRA", "PV", "IMP"], "subject.create": ["ADMIN", "PM", "CRA"], "subject.enroll": ["ADMIN", "PM", "CRA"], "subject.complete": ["ADMIN", "PM", "CRA"], @@ -26,7 +26,7 @@ const PERMISSIONS: Record = { }; const REASONS: Record = { - "milestone.create": "仅项目负责人可以维护里程碑", + "milestone.create": "仅项目成员可维护里程碑", "subject.enroll": "仅 PM/CRA 可更新受试者状态", "subject.complete": "仅 PM/CRA 可更新受试者状态", "subject.drop": "仅 PM/CRA 可更新受试者状态", diff --git a/frontend/src/views/FaqDetail.vue b/frontend/src/views/FaqDetail.vue index 0c11df7a..db242dcb 100644 --- a/frontend/src/views/FaqDetail.vue +++ b/frontend/src/views/FaqDetail.vue @@ -117,7 +117,7 @@ import { setFaqStatus, } from "../api/faqs"; import { listMembers } from "../api/members"; -import { displayDateTime, displayUser } from "../utils/display"; +import { displayDateTime, displayUser, getMemberDisplayName, getUserDisplayName } from "../utils/display"; import { useAuthStore } from "../store/auth"; import { usePermission } from "../utils/permission"; import FaqItemForm from "../components/FaqItemForm.vue"; @@ -185,16 +185,28 @@ const bestReply = computed(() => { const memberMap = computed(() => members.value.reduce>((acc, cur) => { - const username = cur?.user?.display_name || cur?.user?.username || cur?.username; - if (cur?.user_id) acc[cur.user_id] = username || cur.user_id; + const username = getMemberDisplayName(cur); + if (cur?.user_id && username) acc[cur.user_id] = username; return acc; }, {}) ); const userMap = computed(() => { - if (!auth.user?.id) return {}; - const name = auth.user.display_name || auth.user.username || auth.user.id; - return { [auth.user.id]: name }; + const map: Record = {}; + if (auth.user?.id) { + const name = getUserDisplayName(auth.user) || auth.user.id; + map[auth.user.id] = name; + } + const push = (id?: string | null, name?: string | null) => { + if (!id || !name) return; + map[id] = name; + }; + push(item.value?.created_by, item.value?.created_by_name || item.value?.created_by_display_name || item.value?.created_by_email); + replies.value.forEach((r) => { + push(r?.created_by, r?.created_by_name || r?.created_by_display_name || r?.created_by_email); + push(r?.quote?.created_by, r?.quote?.created_by_name || r?.quote?.created_by_display_name || r?.quote?.created_by_email); + }); + return map; }); const categoryName = computed(() => { diff --git a/frontend/src/views/MilestoneDetail.vue b/frontend/src/views/MilestoneDetail.vue index 53ecacdd..dbf25409 100644 --- a/frontend/src/views/MilestoneDetail.vue +++ b/frontend/src/views/MilestoneDetail.vue @@ -79,7 +79,7 @@ import { getMilestoneStatusLabel, getMilestoneTypeLabel, } from "../dictionaries/milestone.dict"; -import { displayDate, displayUser } from "../utils/display"; +import { displayDate, displayUser, getMemberDisplayName, getUserDisplayName } from "../utils/display"; const route = useRoute(); const study = useStudyStore(); @@ -134,17 +134,17 @@ const siteName = (row: any) => { const ownerName = (ownerId: string) => { const memberMap = members.value.reduce>((acc, cur) => { - const username = cur?.user?.display_name || cur?.user?.full_name || cur?.user?.username || cur?.username; - if (cur?.user_id) acc[cur.user_id] = username || cur.user_id; - if (cur?.id) acc[cur.id] = username || cur.id; + const username = getMemberDisplayName(cur); + if (cur?.user_id && username) acc[cur.user_id] = username; + if (cur?.id && username) acc[cur.id] = username; return acc; }, {}); const owner = milestone.value?.owner; - const nameFromObject = owner?.display_name || owner?.full_name || owner?.username; + const nameFromObject = getUserDisplayName(owner); if (nameFromObject) return nameFromObject; - return memberMap[ownerId] || ownerId || "—"; + return memberMap[ownerId] || "—"; }; onMounted(async () => { diff --git a/frontend/src/views/Milestones.vue b/frontend/src/views/Milestones.vue index c8dbf16d..58eaf347 100644 --- a/frontend/src/views/Milestones.vue +++ b/frontend/src/views/Milestones.vue @@ -83,7 +83,7 @@ import { getMilestoneStatusLabel, getMilestoneTypeLabel, } from "../dictionaries/milestone.dict"; -import { displayDate, displayUser } from "../utils/display"; +import { displayDate, displayUser, getMemberDisplayName } from "../utils/display"; const study = useStudyStore(); const router = useRouter(); @@ -150,14 +150,8 @@ const statusColor = (v: string) => getMilestoneStatusColor(v); const memberMap = computed(() => members.value.reduce>((acc, cur) => { - const username = - cur?.user?.full_name || - cur?.user?.display_name || - cur?.user?.username || - cur?.user?.email || - cur?.username || - cur?.email; - if (cur?.user_id) acc[cur.user_id] = username || cur.user_id; + const username = getMemberDisplayName(cur); + if (cur?.user_id && username) acc[cur.user_id] = username; return acc; }, {}) ); diff --git a/frontend/src/views/Verification.vue b/frontend/src/views/Verification.vue index 9982bda2..5871c581 100644 --- a/frontend/src/views/Verification.vue +++ b/frontend/src/views/Verification.vue @@ -77,7 +77,7 @@ import { useAuthStore } from "../store/auth"; import VerificationTable from "../components/VerificationTable.vue"; import SiteSelect from "../components/selectors/SiteSelect.vue"; import SubjectSelect from "../components/selectors/SubjectSelect.vue"; -import { displayDate } from "../utils/display"; +import { displayDate, getMemberDisplayName } from "../utils/display"; const study = useStudyStore(); const auth = useAuthStore(); @@ -188,9 +188,9 @@ const subjectMap = computed(() => const verifierMap = computed(() => members.value.reduce>((acc, cur) => { - const username = cur?.user?.display_name || cur?.user?.full_name || cur?.user?.username || cur?.username; - if (cur?.user_id) acc[cur.user_id] = username || cur.user_id; - if (cur?.id) acc[cur.id] = username || cur.id; + const username = getMemberDisplayName(cur); + if (cur?.user_id && username) acc[cur.user_id] = username; + if (cur?.id && username) acc[cur.id] = username; return acc; }, {}) ); diff --git a/frontend/src/views/admin/AuditLogs.vue b/frontend/src/views/admin/AuditLogs.vue index 207085c9..5ea92c0d 100644 --- a/frontend/src/views/admin/AuditLogs.vue +++ b/frontend/src/views/admin/AuditLogs.vue @@ -151,13 +151,12 @@ const eventTypeOptions = Object.entries(auditDict).map(([value, cfg]) => ({ })); const userOptions = computed(() => users.value.map((u: any) => ({ label: u.username, value: u.id }))); const isAdmin = computed(() => auth.user?.role === "ADMIN"); -const canProjectExport = computed(() => !!study.currentStudy && (auth.user?.role === "ADMIN" || auth.user?.role === "PM" || study.currentStudyRole === "PM")); +const canProjectExport = computed(() => !!study.currentStudy && auth.user?.role === "ADMIN"); const ensureAccess = () => { const role = auth.user?.role; - const studyRole = study.currentStudyRole; - if (!(role === "ADMIN" || role === "PM" || studyRole === "PM")) { - router.replace("/study/home"); + if (role !== "ADMIN") { + router.replace("/admin/users"); } };