chore: stop tracking frontend node_modules
This commit is contained in:
@@ -50,7 +50,6 @@ const rules: FormRules = {
|
||||
const isEdit = computed(() => !!props.category);
|
||||
|
||||
const reset = () => {
|
||||
form.study_id = study.currentStudy?.id || "";
|
||||
form.name = "";
|
||||
form.description = "";
|
||||
form.sort_order = 0;
|
||||
@@ -61,7 +60,6 @@ watch(
|
||||
() => props.category,
|
||||
(val) => {
|
||||
if (val) {
|
||||
form.study_id = val.study_id || "";
|
||||
form.name = val.name;
|
||||
form.description = val.description || "";
|
||||
form.sort_order = val.sort_order || 0;
|
||||
@@ -86,16 +84,26 @@ const close = () => emit("update:modelValue", false);
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
|
||||
// 验证当前项目存在
|
||||
if (!study.currentStudy?.id) {
|
||||
ElMessage.warning(TEXT.common.empty.selectProject);
|
||||
return;
|
||||
}
|
||||
|
||||
await formRef.value.validate();
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
study_id: study.currentStudy?.id || null,
|
||||
study_id: study.currentStudy.id,
|
||||
name: form.name,
|
||||
description: form.description || null,
|
||||
sort_order: form.sort_order,
|
||||
is_active: form.is_active,
|
||||
};
|
||||
console.log('[FaqCategoryForm] payload:', payload);
|
||||
console.log('[FaqCategoryForm] isEdit:', isEdit.value);
|
||||
console.log('[FaqCategoryForm] study.currentStudy:', study.currentStudy);
|
||||
if (isEdit.value && props.category) {
|
||||
await updateFaqCategory(props.category.id, payload);
|
||||
} else {
|
||||
@@ -105,7 +113,8 @@ const onSubmit = async () => {
|
||||
emit("success");
|
||||
close();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed);
|
||||
const errorMsg = e?.response?.data?.message || e?.response?.data?.detail || TEXT.common.messages.saveFailed;
|
||||
ElMessage.error(typeof errorMsg === 'string' ? errorMsg : TEXT.common.messages.saveFailed);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ export interface Site {
|
||||
city?: string | null;
|
||||
pi_name?: string | null;
|
||||
contact?: string | null;
|
||||
enrollment_target?: number | null;
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
export type DocumentStatus = "DRAFT" | "ACTIVE" | "ARCHIVED";
|
||||
export type DocumentScopeType = "GLOBAL" | "SITE" | "DERIVED";
|
||||
export type DocumentVersionStatus =
|
||||
| "DRAFT"
|
||||
@@ -13,19 +12,16 @@ export interface DocumentVersionSummary {
|
||||
id: string;
|
||||
version_no: string;
|
||||
status: DocumentVersionStatus;
|
||||
effective_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DocumentSummary {
|
||||
id: string;
|
||||
trial_id: string;
|
||||
site_id?: string | null;
|
||||
doc_no: string;
|
||||
doc_type: string;
|
||||
title: string;
|
||||
scope_type: DocumentScopeType;
|
||||
owner_id?: string | null;
|
||||
status: DocumentStatus;
|
||||
current_effective_version_id?: string | null;
|
||||
current_effective_version?: DocumentVersionSummary | null;
|
||||
created_at: string;
|
||||
|
||||
+467
-152
@@ -1,83 +1,119 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-content">
|
||||
<div class="login-brand">
|
||||
<h1 class="brand-title">{{ TEXT.common.appName }}</h1>
|
||||
<p class="brand-subtitle">{{ TEXT.modules.auth.brandSubtitle }}</p>
|
||||
</div>
|
||||
|
||||
<el-card class="login-card">
|
||||
<div class="login-header">
|
||||
<h2 class="form-title">{{ TEXT.modules.auth.loginTitle }}</h2>
|
||||
<p class="form-desc">{{ TEXT.modules.auth.loginDesc }}</p>
|
||||
<!-- Vanta.js 网络背景 -->
|
||||
<div ref="vantaCanvas" id="vanta-canvas" class="vanta-background"></div>
|
||||
|
||||
<!-- 登录卡片 -->
|
||||
<div class="login-wrapper">
|
||||
<div class="max-w-md w-full card-container">
|
||||
<div class="card-content">
|
||||
<!-- 顶部装饰区域 -->
|
||||
<div class="login-header" id="vanta-header">
|
||||
<!-- 左上角:安全访问 -->
|
||||
<div class="header-left">
|
||||
<span class="secure-badge">安全访问</span>
|
||||
</div>
|
||||
|
||||
<!-- 中间:CTMS -->
|
||||
<div class="header-center">
|
||||
<div class="ctms-logo">CTMS</div>
|
||||
<div class="header-divider"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单内容 -->
|
||||
<div class="login-form-section">
|
||||
<div class="form-label-section">
|
||||
<span class="auth-badge">身份验证</span>
|
||||
<h3 class="form-title">账户登录</h3>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
@keyup.enter="onSubmit"
|
||||
class="login-form"
|
||||
>
|
||||
<!-- 邮箱字段 -->
|
||||
<div class="form-group">
|
||||
<label for="email" class="input-label">邮箱</label>
|
||||
<el-form-item prop="email">
|
||||
<el-input
|
||||
id="email"
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="your@email.com"
|
||||
autocomplete="email"
|
||||
class="custom-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 密码字段 -->
|
||||
<div class="form-group">
|
||||
<div class="password-label-row">
|
||||
<label for="password" class="input-label">密码</label>
|
||||
<a href="#" class="forgot-link">忘记密码?</a>
|
||||
</div>
|
||||
<el-form-item prop="password">
|
||||
<el-input
|
||||
id="password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="••••••••"
|
||||
autocomplete="current-password"
|
||||
show-password
|
||||
class="custom-input"
|
||||
/>
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<!-- 记住设备 -->
|
||||
<div class="remember-row">
|
||||
<el-checkbox id="remember" v-model="form.remember" class="custom-checkbox">
|
||||
<span class="remember-text">记住此设备</span>
|
||||
</el-checkbox>
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<div class="button-group">
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
@click="onSubmit"
|
||||
class="login-btn"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="btn-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
|
||||
</svg>
|
||||
登录
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
|
||||
<!-- 底部信息 -->
|
||||
<div class="footer-section">
|
||||
<div class="footer-divider"></div>
|
||||
<p class="footer-text">
|
||||
还没有账户? <RouterLink to="/register" class="footer-link">立即注册</RouterLink>
|
||||
</p>
|
||||
<div class="status-row">
|
||||
<span class="status-dot"></span>
|
||||
<span class="status-text">系统状态:正常运行</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form
|
||||
:model="form"
|
||||
ref="formRef"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
@keyup.enter.native="onSubmit"
|
||||
class="elegant-form"
|
||||
>
|
||||
<el-form-item :label="TEXT.modules.auth.emailLabel" prop="email">
|
||||
<el-input
|
||||
v-model="form.email"
|
||||
:placeholder="TEXT.modules.auth.emailPlaceholder"
|
||||
autocomplete="email"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Message /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item :label="TEXT.modules.auth.passwordLabel" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
:placeholder="TEXT.modules.auth.passwordPlaceholder"
|
||||
autocomplete="current-password"
|
||||
show-password
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><Lock /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<div class="form-options">
|
||||
<el-checkbox v-model="form.remember">{{ TEXT.modules.auth.remember }}</el-checkbox>
|
||||
<RouterLink to="/forgot-password" class="forgot-link">{{ TEXT.modules.auth.forgot }}</RouterLink>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
class="submit-btn"
|
||||
@click="onSubmit"
|
||||
>
|
||||
{{ TEXT.modules.auth.loginButton }}
|
||||
</el-button>
|
||||
|
||||
<div class="register-prompt">
|
||||
<span>{{ TEXT.modules.auth.registerPrompt }}</span>
|
||||
<RouterLink to="/register">{{ TEXT.modules.auth.registerNow }}</RouterLink>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<div class="login-footer">
|
||||
<p>{{ TEXT.modules.auth.footer }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted } from "vue";
|
||||
import { reactive, ref, onMounted, onUnmounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElCheckbox, ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||
import { Message, Lock } from "@element-plus/icons-vue";
|
||||
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { getCachedCredential, setCachedCredential, clearCachedCredential } from "../utils/auth";
|
||||
import { TEXT, requiredMessage } from "../locales";
|
||||
@@ -86,6 +122,7 @@ const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const vantaCanvas = ref<HTMLDivElement>();
|
||||
const form = reactive({
|
||||
email: "",
|
||||
password: "",
|
||||
@@ -101,7 +138,9 @@ const rules: FormRules<typeof form> = {
|
||||
};
|
||||
|
||||
const loading = ref(false);
|
||||
let vantaEffect: any = null;
|
||||
|
||||
// 初始化 Vanta.js 效果
|
||||
onMounted(() => {
|
||||
const cached = getCachedCredential();
|
||||
if (cached) {
|
||||
@@ -109,8 +148,61 @@ onMounted(() => {
|
||||
form.password = cached.password;
|
||||
form.remember = true;
|
||||
}
|
||||
|
||||
// 动态加载 Three.js 和 Vanta.js
|
||||
loadVantaEffect();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (vantaEffect) {
|
||||
vantaEffect.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
// 加载 Vanta.js 网络效果
|
||||
const loadVantaEffect = () => {
|
||||
// 检查是否已加载
|
||||
if ((window as any).VANTA && (window as any).THREE) {
|
||||
initVanta();
|
||||
return;
|
||||
}
|
||||
|
||||
// 加载 Three.js
|
||||
const threeScript = document.createElement('script');
|
||||
threeScript.src = 'https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js';
|
||||
threeScript.onload = () => {
|
||||
// 加载 Vanta.js NET 效果
|
||||
const vantaScript = document.createElement('script');
|
||||
vantaScript.src = 'https://cdn.jsdelivr.net/npm/vanta@latest/dist/vanta.net.min.js';
|
||||
vantaScript.onload = () => {
|
||||
initVanta();
|
||||
};
|
||||
document.head.appendChild(vantaScript);
|
||||
};
|
||||
document.head.appendChild(threeScript);
|
||||
};
|
||||
|
||||
const initVanta = () => {
|
||||
if (vantaCanvas.value && (window as any).VANTA) {
|
||||
vantaEffect = (window as any).VANTA.NET({
|
||||
el: vantaCanvas.value,
|
||||
mouseControls: true,
|
||||
touchControls: true,
|
||||
gyroControls: false,
|
||||
minHeight: 200.00,
|
||||
minWidth: 200.00,
|
||||
scale: 1.00,
|
||||
scaleMobile: 1.00,
|
||||
color: 0xd1d5db,
|
||||
backgroundColor: 0x171717,
|
||||
points: 8,
|
||||
maxDistance: 20.00,
|
||||
spacing: 18.00,
|
||||
showDots: true
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
const valid = await formRef.value.validate();
|
||||
@@ -134,141 +226,364 @@ const onSubmit = async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 导入 Inter 字体 */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
/* 主容器 */
|
||||
.login-container {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: #0a0a0a;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background-color: var(--ctms-bg-base);
|
||||
padding: 1rem;
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
/* Vanta 背景 */
|
||||
.vanta-background {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* 登录包装器 */
|
||||
.login-wrapper {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
}
|
||||
|
||||
/* 卡片容器 - 带渐变边框效果 */
|
||||
.card-container {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.card-container::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
background: linear-gradient(to bottom right, #525252, transparent, #262626);
|
||||
border-radius: 0.75rem;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
/* 卡片内容 */
|
||||
.card-content {
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
background: #171717;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* 登录头部 */
|
||||
.login-header {
|
||||
height: 140px;
|
||||
position: relative;
|
||||
background: #171717;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
width: 420px;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
/* 左上角 */
|
||||
.header-left {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.login-brand {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
/* 中间区域 */
|
||||
.header-center {
|
||||
position: absolute;
|
||||
top: 70%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
letter-spacing: -1px;
|
||||
margin: 0;
|
||||
/* CTMS Logo */
|
||||
.ctms-logo {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 0.5rem;
|
||||
text-shadow: 0 0 30px rgba(102, 126, 234, 0.3);
|
||||
animation: glow 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--ctms-text-secondary);
|
||||
margin-top: 8px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
@keyframes glow {
|
||||
0%, 100% {
|
||||
filter: drop-shadow(0 0 8px rgba(102, 126, 234, 0.4));
|
||||
}
|
||||
50% {
|
||||
filter: drop-shadow(0 0 16px rgba(102, 126, 234, 0.6));
|
||||
}
|
||||
}
|
||||
|
||||
.login-card {
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
background: #ffffff;
|
||||
border-radius: 16px;
|
||||
box-shadow: none;
|
||||
padding: 12px;
|
||||
.secure-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: rgba(38, 38, 38, 0.8);
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
.header-divider {
|
||||
height: 0.25rem;
|
||||
width: 3rem;
|
||||
background: linear-gradient(90deg, #667eea, #764ba2, #f093fb);
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* 登录表单区域 */
|
||||
.login-form-section {
|
||||
padding: 1.5rem 1.5rem 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #171717;
|
||||
}
|
||||
|
||||
.form-label-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: #262626;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
color: #a3a3a3;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--ctms-text-main);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: #e5e5e5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.form-desc {
|
||||
font-size: 14px;
|
||||
color: var(--ctms-text-secondary);
|
||||
margin-top: 8px;
|
||||
/* 表单样式 */
|
||||
.login-form {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.elegant-form :deep(.el-form-item__label) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ctms-text-regular);
|
||||
padding-bottom: 8px;
|
||||
.login-form :deep(.el-form-item) {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.elegant-form :deep(.el-input__wrapper) {
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 0 0 1px var(--ctms-border-color) inset;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
transition: var(--ctms-transition);
|
||||
.login-form :deep(.el-form-item__error) {
|
||||
color: #f87171;
|
||||
font-size: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.elegant-form :deep(.el-input__wrapper.is-focus) {
|
||||
box-shadow: 0 0 0 1px var(--ctms-primary) inset !important;
|
||||
background-color: #ffffff;
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-options {
|
||||
.input-label {
|
||||
display: block;
|
||||
color: #d4d4d4;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.password-label-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.forgot-link {
|
||||
font-size: 13px;
|
||||
color: var(--ctms-primary);
|
||||
color: #a3a3a3;
|
||||
font-size: 0.75rem;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: 12px;
|
||||
background: var(--ctms-primary);
|
||||
border: none;
|
||||
.forgot-link:hover {
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
/* 自定义输入框样式 */
|
||||
.custom-input :deep(.el-input__wrapper) {
|
||||
background: #262626;
|
||||
border: 1px solid #404040;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
box-shadow: none;
|
||||
transition: var(--ctms-transition);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.submit-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
.custom-input :deep(.el-input__wrapper:hover) {
|
||||
background: #262626;
|
||||
border-color: #525252;
|
||||
}
|
||||
|
||||
.register-prompt {
|
||||
margin-top: 24px;
|
||||
.custom-input :deep(.el-input__wrapper.is-focus) {
|
||||
background: #262626;
|
||||
border-color: #525252;
|
||||
box-shadow: 0 0 0 2px rgba(82, 82, 82, 0.2);
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-input__inner) {
|
||||
color: #e5e5e5;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-input__inner::placeholder) {
|
||||
color: #737373;
|
||||
}
|
||||
|
||||
.custom-input :deep(.el-icon) {
|
||||
color: #a3a3a3;
|
||||
}
|
||||
|
||||
/* 记住设备行 */
|
||||
.remember-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.custom-checkbox :deep(.el-checkbox__inner) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
background: #262626;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.custom-checkbox :deep(.el-checkbox__input.is-checked .el-checkbox__inner) {
|
||||
background: #262626;
|
||||
border-color: #404040;
|
||||
}
|
||||
|
||||
.custom-checkbox :deep(.el-checkbox__inner::after) {
|
||||
border-color: #e5e5e5;
|
||||
}
|
||||
|
||||
.remember-text {
|
||||
color: #a3a3a3;
|
||||
font-size: 0.75rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* 按钮组 */
|
||||
.button-group {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 2.75rem;
|
||||
background: #262626;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
color: #e5e5e5;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background: #404040;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* 底部区域 */
|
||||
.footer-section {
|
||||
padding-top: 1rem;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: var(--ctms-text-secondary);
|
||||
}
|
||||
|
||||
.register-prompt a {
|
||||
color: var(--ctms-primary);
|
||||
font-weight: 600;
|
||||
.footer-divider {
|
||||
height: 1px;
|
||||
background: linear-gradient(to right, transparent, #525252, transparent);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
color: #a3a3a3;
|
||||
font-size: 0.75rem;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: #d4d4d4;
|
||||
text-decoration: none;
|
||||
margin-left: 4px;
|
||||
transition: text-decoration 0.2s;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
.footer-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.login-footer p {
|
||||
font-size: 12px;
|
||||
color: var(--ctms-text-disabled);
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
color: #a3a3a3;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 640px) {
|
||||
.login-wrapper {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.login-form-section {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<el-tag :type="statusType(scope.row.status)">{{ displayEnum(TEXT.enums.userStatus, scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="220" fixed="right">
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="220" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
v-if="scope.row.status === 'PENDING'"
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
</div>
|
||||
<el-table :data="memberRows" v-loading="loading" stripe>
|
||||
<el-table-column prop="full_name" :label="TEXT.modules.adminProjectMembers.username" min-width="160" />
|
||||
<el-table-column prop="role_in_study" :label="TEXT.modules.adminProjectMembers.projectRole" width="140">
|
||||
<el-table-column prop="role_in_study" :label="TEXT.modules.adminProjectMembers.projectRole" min-width="140">
|
||||
<template #default="scope">
|
||||
<el-tag>{{ displayEnum(TEXT.enums.userRole, scope.row.role_in_study) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.status" width="160">
|
||||
<el-table-column :label="TEXT.common.fields.status" width="100">
|
||||
<template #default="scope">
|
||||
<el-tooltip
|
||||
v-if="scope.row.effectiveStatus === 'DISABLED_GLOBAL'"
|
||||
@@ -29,10 +29,10 @@
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="added_at" :label="TEXT.modules.adminProjectMembers.addedAt" width="200">
|
||||
<el-table-column prop="added_at" :label="TEXT.modules.adminProjectMembers.addedAt" min-width="180">
|
||||
<template #default="scope">{{ displayDateTime(scope.row.added_at) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="360" fixed="right">
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="300" align="center" class-name="action-column">
|
||||
<template #default="scope">
|
||||
<el-select
|
||||
v-model="scope.row.role_in_study"
|
||||
@@ -412,3 +412,14 @@ onMounted(async () => {
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.el-table .action-column .cell {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.action-column .el-select {
|
||||
margin-right: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="site" :label="TEXT.common.fields.status" prop="is_active">
|
||||
<el-switch v-model="form.is_active" :active-text="TEXT.common.actions.enable" :inactive-text="TEXT.common.actions.disable" />
|
||||
<el-form-item label="入组目标" prop="enrollment_target">
|
||||
<el-input-number v-model="form.enrollment_target" :min="0" :step="1" :placeholder="'请输入入组目标人数'" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
@@ -77,6 +77,7 @@ const form = reactive({
|
||||
phone: "",
|
||||
contact: "",
|
||||
craSelections: [] as string[],
|
||||
enrollment_target: 0,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
@@ -102,6 +103,7 @@ const resetForm = () => {
|
||||
form.phone = "";
|
||||
form.contact = "";
|
||||
form.craSelections = [];
|
||||
form.enrollment_target = 0;
|
||||
form.is_active = true;
|
||||
};
|
||||
|
||||
@@ -125,6 +127,7 @@ watch(
|
||||
optionByLabel[opt.label] = opt.value;
|
||||
});
|
||||
form.craSelections = rawSelections.map((s) => optionByLabel[s] || s);
|
||||
form.enrollment_target = (props.site as any)?.enrollment_target || 0;
|
||||
form.is_active = props.site.is_active;
|
||||
}
|
||||
}
|
||||
@@ -158,6 +161,7 @@ const onSubmit = async () => {
|
||||
city: form.city,
|
||||
pi_name: form.pi_name,
|
||||
contact,
|
||||
enrollment_target: form.enrollment_target,
|
||||
is_active: form.is_active,
|
||||
});
|
||||
ElMessage.success(TEXT.modules.adminSites.updateSuccess);
|
||||
@@ -173,6 +177,7 @@ const onSubmit = async () => {
|
||||
city: form.city,
|
||||
pi_name: form.pi_name,
|
||||
contact,
|
||||
enrollment_target: form.enrollment_target,
|
||||
});
|
||||
ElMessage.success(TEXT.modules.adminSites.createSuccess);
|
||||
logAudit("SITE_STATUS_CHANGED", {
|
||||
|
||||
@@ -9,26 +9,26 @@
|
||||
<el-button type="primary" @click="openCreate">{{ TEXT.common.actions.add }}{{ TEXT.modules.adminSites.siteLabel }}</el-button>
|
||||
</div>
|
||||
<el-table :data="sites" v-loading="loading" stripe :row-class-name="siteRowClass">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.siteName" min-width="180" />
|
||||
<el-table-column prop="city" :label="TEXT.common.fields.city" width="140" />
|
||||
<el-table-column prop="pi_name" :label="TEXT.modules.adminSites.piLabel" width="160" />
|
||||
<el-table-column :label="TEXT.common.fields.phone" width="160">
|
||||
<el-table-column prop="name" :label="TEXT.common.fields.siteName" width="180" />
|
||||
<el-table-column prop="city" :label="TEXT.common.fields.city" width="100" />
|
||||
<el-table-column prop="pi_name" :label="TEXT.modules.adminSites.piLabel" width="120" />
|
||||
<el-table-column :label="TEXT.common.fields.phone" width="140">
|
||||
<template #default="scope">
|
||||
{{ phoneText(scope.row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="contact" :label="TEXT.common.fields.contact" min-width="180">
|
||||
<el-table-column prop="contact" :label="TEXT.common.fields.contact" width="160">
|
||||
<template #default="scope">
|
||||
{{ contactLabel(scope.row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.status" width="120">
|
||||
<el-table-column :label="TEXT.common.fields.status" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag type="success" v-if="scope.row.is_active">已解锁</el-tag>
|
||||
<el-tag type="danger" v-else>已锁定</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="260" fixed="right">
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="200" align="center" class-name="action-column">
|
||||
<template #default="scope">
|
||||
<el-button link type="primary" size="small" @click="openEdit(scope.row)">{{ TEXT.common.actions.edit }}</el-button>
|
||||
<el-button
|
||||
@@ -290,4 +290,9 @@ onMounted(async () => {
|
||||
.row-inactive .el-tag {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.el-table .action-column .cell {
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
<div class="ctms-page-header">
|
||||
<div>
|
||||
<h1 class="ctms-page-title">
|
||||
{{ detail.doc_no || TEXT.common.fallback }}
|
||||
<span class="text-secondary mx-2">/</span>
|
||||
{{ detail.title || TEXT.modules.fileVersionManagement.title }}
|
||||
</h1>
|
||||
<p class="ctms-page-subtitle">
|
||||
@@ -27,14 +25,9 @@
|
||||
|
||||
<el-card class="ctms-section-card" v-loading="loading">
|
||||
<el-descriptions :column="3" border class="ctms-descriptions">
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.docNo">{{ detail.doc_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.fields.docType">{{ displayText(detail.doc_type, TEXT.enums.documentType) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.fields.site">{{ displaySite(detail.site_id) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.status">
|
||||
<el-tag :type="getStatusType(detail.status)" effect="light" size="small">{{ displayEnum(TEXT.enums.documentStatus, detail.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.labels.currentEffective">{{ detail.current_effective_version?.version_no || TEXT.common.fallback }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.effectiveAt">{{ formatDate(detail.current_effective_version?.effective_at) }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="TEXT.modules.fileVersionManagement.columns.updatedAt">{{ formatDate(detail.updated_at) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
@@ -83,7 +76,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="280" fixed="right">
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="280" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="downloadVersion(row)" v-if="canAction('view')">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.download }}
|
||||
@@ -144,7 +137,7 @@
|
||||
<span :class="{ 'text-danger': row.stats?.overdue }">{{ formatDate(row.due_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.ackAction" width="140" fixed="right">
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.labels.ackAction" width="140" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" size="small" @click="ack(row, 'RECEIVED')" v-if="canAction('ack')">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.ackReceived }}
|
||||
@@ -311,21 +304,6 @@ import type { UserInfo } from "../../types/api";
|
||||
import { TEXT } from "../../locales";
|
||||
import { displayEnum, displayText, getUserDisplayName } from "../../utils/display";
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "success";
|
||||
case "DRAFT":
|
||||
return "info";
|
||||
case "ARCHIVED":
|
||||
return "info";
|
||||
case "OBSOLETE":
|
||||
return "danger";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getVersionStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case "APPROVED":
|
||||
@@ -353,11 +331,9 @@ const detail = reactive<DocumentDetail>({
|
||||
id: "",
|
||||
trial_id: "",
|
||||
site_id: null,
|
||||
doc_no: "",
|
||||
doc_type: "",
|
||||
title: "",
|
||||
scope_type: "GLOBAL",
|
||||
status: "ACTIVE",
|
||||
current_effective_version_id: null,
|
||||
current_effective_version: null,
|
||||
created_at: "",
|
||||
|
||||
@@ -32,12 +32,6 @@
|
||||
<el-option v-for="item in docTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctms-filter-item">
|
||||
<span class="ctms-filter-label">{{ TEXT.modules.fileVersionManagement.filters.status }}</span>
|
||||
<el-select v-model="filters.status" :placeholder="TEXT.modules.fileVersionManagement.filters.all" clearable>
|
||||
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="ctms-filter-spacer"></div>
|
||||
<div class="ctms-filter-actions">
|
||||
<el-button type="primary" @click="load">{{ TEXT.common.actions.search }}</el-button>
|
||||
@@ -54,11 +48,6 @@
|
||||
:row-class-name="documentRowClass"
|
||||
class="ctms-table"
|
||||
>
|
||||
<el-table-column prop="doc_no" :label="TEXT.modules.fileVersionManagement.columns.docNo" width="160">
|
||||
<template #default="{ row }">
|
||||
<span class="font-mono font-medium">{{ row.doc_no }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" :label="TEXT.modules.fileVersionManagement.columns.title" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span class="font-semibold">{{ row.title }}</span>
|
||||
@@ -90,24 +79,12 @@
|
||||
<span v-else class="text-secondary">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.status" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" effect="light">
|
||||
{{ displayEnum(TEXT.enums.documentStatus, row.status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.effectiveAt" width="180">
|
||||
<template #default="{ row }">
|
||||
<span>{{ formatDate(row.current_effective_version?.effective_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updated_at" :label="TEXT.modules.fileVersionManagement.columns.updatedAt" width="180">
|
||||
<template #default="{ row }">
|
||||
<span class="text-secondary">{{ formatDate(row.updated_at) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" fixed="right">
|
||||
<el-table-column :label="TEXT.modules.fileVersionManagement.columns.actions" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" :disabled="isInactiveSite(row.site_id)" @click.stop="confirmDelete(row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
@@ -165,22 +142,6 @@ import type { Site } from "../../types/api";
|
||||
import { displayEnum, displayText } from "../../utils/display";
|
||||
import { TEXT } from "../../locales";
|
||||
|
||||
const getStatusType = (status: string) => {
|
||||
switch (status) {
|
||||
case "ACTIVE":
|
||||
return "success";
|
||||
case "DRAFT":
|
||||
return "info";
|
||||
case "ARCHIVED":
|
||||
return "info";
|
||||
case "OBSOLETE":
|
||||
return "danger";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const study = useStudyStore();
|
||||
@@ -197,17 +158,12 @@ const filters = reactive({
|
||||
scope_type: "",
|
||||
site_id: "",
|
||||
doc_type: "",
|
||||
status: "",
|
||||
});
|
||||
|
||||
const docTypeOptions = Object.keys(TEXT.enums.documentType).map((value) => ({
|
||||
value,
|
||||
label: TEXT.enums.documentType[value as keyof typeof TEXT.enums.documentType],
|
||||
}));
|
||||
const statusOptions = Object.keys(TEXT.enums.documentStatus).map((value) => ({
|
||||
value,
|
||||
label: TEXT.enums.documentStatus[value as keyof typeof TEXT.enums.documentStatus],
|
||||
}));
|
||||
const scopeOptions = [
|
||||
{ value: "GLOBAL", label: TEXT.enums.scopeType.GLOBAL },
|
||||
{ value: "SITE", label: TEXT.enums.scopeType.SITE },
|
||||
@@ -289,7 +245,6 @@ const load = async () => {
|
||||
scope_type: filters.scope_type || undefined,
|
||||
site_id: filters.site_id || undefined,
|
||||
doc_type: filters.doc_type || undefined,
|
||||
status: filters.status || undefined,
|
||||
});
|
||||
items.value = data.items || [];
|
||||
} catch (e: any) {
|
||||
@@ -303,7 +258,6 @@ const resetFilters = () => {
|
||||
filters.scope_type = "";
|
||||
filters.site_id = "";
|
||||
filters.doc_type = "";
|
||||
filters.status = "";
|
||||
load();
|
||||
};
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<el-table-column prop="remark" :label="TEXT.common.fields.remark" min-width="160" show-overflow-tooltip>
|
||||
<template #default="scope">{{ scope.row.remark || TEXT.common.fallback }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<span class="font-mono text-main">{{ row.project_no }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
@@ -115,7 +115,7 @@
|
||||
<span class="font-mono text-main">{{ row.approval_no }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120" fixed="right">
|
||||
<el-table-column :label="TEXT.common.labels.actions" width="120" align="center">
|
||||
<template #default="scope">
|
||||
<el-button
|
||||
link
|
||||
|
||||
Reference in New Issue
Block a user