优化个人中心与界面交互
This commit is contained in:
@@ -42,6 +42,12 @@ class ExtendResponse(BaseModel):
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
|
AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avatars"
|
||||||
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
|
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
|
AVATAR_ALLOWED_CONTENT_TYPES = {
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/gif": ".gif",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def issue_user_token(db_user) -> Token:
|
def issue_user_token(db_user) -> Token:
|
||||||
@@ -215,10 +221,15 @@ async def upload_avatar(
|
|||||||
current_user=Depends(get_current_user),
|
current_user=Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db_session),
|
db: AsyncSession = Depends(get_db_session),
|
||||||
) -> UserRead:
|
) -> UserRead:
|
||||||
|
ext = AVATAR_ALLOWED_CONTENT_TYPES.get(file.content_type or "")
|
||||||
|
if not ext:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="头像仅支持图片格式",
|
||||||
|
)
|
||||||
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
|
AVATAR_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
user_dir = AVATAR_ROOT / str(current_user.id)
|
user_dir = AVATAR_ROOT / str(current_user.id)
|
||||||
user_dir.mkdir(parents=True, exist_ok=True)
|
user_dir.mkdir(parents=True, exist_ok=True)
|
||||||
ext = Path(file.filename).suffix or ".png"
|
|
||||||
filename = f"{uuid.uuid4()}{ext}"
|
filename = f"{uuid.uuid4()}{ext}"
|
||||||
dest = user_dir / filename
|
dest = user_dir / filename
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
|
|||||||
@@ -274,6 +274,27 @@ async def test_dev_login_allows_plaintext_only_in_development(client_and_db):
|
|||||||
assert resp.json()["access_token"]
|
assert resp.json()["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_avatar_upload_rejects_non_image_files(client_and_db):
|
||||||
|
client, _ = client_and_db
|
||||||
|
original_env = settings.ENV
|
||||||
|
settings.ENV = "development"
|
||||||
|
try:
|
||||||
|
login_resp = await client.post("/api/v1/auth/dev-login", json={"email": "admin@test.com", "password": "admin123"})
|
||||||
|
finally:
|
||||||
|
settings.ENV = original_env
|
||||||
|
|
||||||
|
token = login_resp.json()["access_token"]
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/auth/me/avatar",
|
||||||
|
files={"file": ("avatar.txt", b"not an image", "text/plain")},
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert resp.json()["detail"] == "头像仅支持图片格式"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_dev_login_is_disabled_outside_development(client_and_db):
|
async def test_dev_login_is_disabled_outside_development(client_and_db):
|
||||||
client, _ = client_and_db
|
client, _ = client_and_db
|
||||||
@@ -357,4 +378,3 @@ async def test_login_challenge_cannot_be_reused(client_and_db):
|
|||||||
|
|
||||||
assert first.status_code == 200
|
assert first.status_code == 200
|
||||||
assert second.status_code == 401
|
assert second.status_code == 401
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,25 @@
|
|||||||
</el-main>
|
</el-main>
|
||||||
</el-container>
|
</el-container>
|
||||||
</el-container>
|
</el-container>
|
||||||
|
|
||||||
|
<el-dialog
|
||||||
|
v-model="profileDialogVisible"
|
||||||
|
class="profile-settings-dialog"
|
||||||
|
:show-close="false"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="false"
|
||||||
|
:before-close="beforeProfileDialogClose"
|
||||||
|
width="980px"
|
||||||
|
align-center
|
||||||
|
destroy-on-close
|
||||||
|
@closed="profileDialogDirty = false"
|
||||||
|
>
|
||||||
|
<ProfileSettings
|
||||||
|
@close-request="requestProfileDialogClose"
|
||||||
|
@dirty-change="profileDialogDirty = $event"
|
||||||
|
@saved="profileDialogVisible = false"
|
||||||
|
/>
|
||||||
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -221,6 +240,7 @@ import {
|
|||||||
import { ElMessage, ElMessageBox } from "element-plus";
|
import { ElMessage, ElMessageBox } from "element-plus";
|
||||||
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
|
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
|
||||||
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
|
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
|
||||||
|
import ProfileSettings from "../views/ProfileSettings.vue";
|
||||||
|
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const study = useStudyStore();
|
const study = useStudyStore();
|
||||||
@@ -235,6 +255,8 @@ const canAccessProjectPath = (path: string) =>
|
|||||||
const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path));
|
const canAccessAnyProjectPath = (paths: string[]) => paths.some((path) => canAccessProjectPath(path));
|
||||||
const hasAnyProjectModuleAccess = computed(() => canAccessAnyProjectPath(projectRouteLandingPaths));
|
const hasAnyProjectModuleAccess = computed(() => canAccessAnyProjectPath(projectRouteLandingPaths));
|
||||||
const loggingOut = ref(false);
|
const loggingOut = ref(false);
|
||||||
|
const profileDialogVisible = ref(false);
|
||||||
|
const profileDialogDirty = ref(false);
|
||||||
|
|
||||||
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
|
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
|
||||||
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
|
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
|
||||||
@@ -480,7 +502,33 @@ const onCommand = (cmd: string) => {
|
|||||||
if (cmd === "logout") {
|
if (cmd === "logout") {
|
||||||
onLogout();
|
onLogout();
|
||||||
} else if (cmd === "profile") {
|
} else if (cmd === "profile") {
|
||||||
router.push("/profile");
|
profileDialogVisible.value = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDiscardProfileChanges = async () => {
|
||||||
|
if (!profileDialogDirty.value) return true;
|
||||||
|
const confirmed = await ElMessageBox.confirm(
|
||||||
|
"当前修改尚未保存,关闭后将丢失这些改动。",
|
||||||
|
"确认关闭个人中心",
|
||||||
|
{
|
||||||
|
type: "warning",
|
||||||
|
confirmButtonText: "关闭",
|
||||||
|
cancelButtonText: "继续编辑",
|
||||||
|
}
|
||||||
|
).catch(() => false);
|
||||||
|
return Boolean(confirmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestProfileDialogClose = async () => {
|
||||||
|
if (await confirmDiscardProfileChanges()) {
|
||||||
|
profileDialogVisible.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const beforeProfileDialogClose = async (done: () => void) => {
|
||||||
|
if (await confirmDiscardProfileChanges()) {
|
||||||
|
done();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@@ -931,4 +979,18 @@ const onCommand = (cmd: string) => {
|
|||||||
background-color: var(--el-color-primary-light-9);
|
background-color: var(--el-color-primary-light-9);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:global(.profile-settings-dialog) {
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.profile-settings-dialog .el-dialog__header) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.profile-settings-dialog .el-dialog__body) {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -52,4 +52,19 @@ describe("Layout breadcrumbs", () => {
|
|||||||
expect(source).toContain("forceLogout(LOGOUT_REASON_MANUAL)");
|
expect(source).toContain("forceLogout(LOGOUT_REASON_MANUAL)");
|
||||||
expect(source).toContain('loggingOut ? "正在退出..." : TEXT.menu.logout');
|
expect(source).toContain('loggingOut ? "正在退出..." : TEXT.menu.logout');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens profile settings as a desktop-style dialog instead of navigating away", () => {
|
||||||
|
const source = readLayout();
|
||||||
|
|
||||||
|
expect(source).toContain('v-model="profileDialogVisible"');
|
||||||
|
expect(source).toContain('class="profile-settings-dialog"');
|
||||||
|
expect(source).toContain(':close-on-click-modal="false"');
|
||||||
|
expect(source).toContain(':close-on-press-escape="false"');
|
||||||
|
expect(source).toContain('@close-request="requestProfileDialogClose"');
|
||||||
|
expect(source).toContain('@dirty-change="profileDialogDirty = $event"');
|
||||||
|
expect(source).toContain('@saved="profileDialogVisible = false"');
|
||||||
|
expect(source).toContain("profileDialogVisible.value = true");
|
||||||
|
expect(source).toContain("confirmDiscardProfileChanges");
|
||||||
|
expect(source).not.toContain('router.push("/profile")');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -950,8 +950,7 @@ export const TEXT = {
|
|||||||
default: "当前角色无权执行该操作",
|
default: "当前角色无权执行该操作",
|
||||||
},
|
},
|
||||||
profile: {
|
profile: {
|
||||||
title: "个人设置",
|
title: "个人中心",
|
||||||
subtitle: "更新个人信息或修改登录密码",
|
|
||||||
uploadAvatar: "上传头像",
|
uploadAvatar: "上传头像",
|
||||||
currentPassword: "当前密码",
|
currentPassword: "当前密码",
|
||||||
currentPasswordHint: "修改密码时必填",
|
currentPasswordHint: "修改密码时必填",
|
||||||
@@ -965,6 +964,7 @@ export const TEXT = {
|
|||||||
updateSuccess: "已更新",
|
updateSuccess: "已更新",
|
||||||
updateFailed: "更新失败",
|
updateFailed: "更新失败",
|
||||||
avatarUpdated: "头像已更新",
|
avatarUpdated: "头像已更新",
|
||||||
|
avatarTypeInvalid: "头像仅支持图片格式",
|
||||||
avatarUploadFailed: "头像上传失败",
|
avatarUploadFailed: "头像上传失败",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,9 +8,40 @@ describe("profile settings autocomplete", () => {
|
|||||||
it("disables browser autofill for profile name, department, and current password", () => {
|
it("disables browser autofill for profile name, department, and current password", () => {
|
||||||
const source = readProfileView();
|
const source = readProfileView();
|
||||||
|
|
||||||
expect(source).toContain('<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="form" autocomplete="off">');
|
expect(source).toContain('<el-form ref="formRef" :model="form" :rules="rules" label-width="112px" class="form" autocomplete="off">');
|
||||||
expect(source).toContain('autocomplete="off"');
|
expect(source).toContain('autocomplete="off"');
|
||||||
expect(source).toContain('autocomplete="new-password"');
|
expect(source).toContain('autocomplete="new-password"');
|
||||||
expect(source).toContain('name="ctms-profile-current-password"');
|
expect(source).toContain('name="ctms-profile-current-password"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("profile settings layout", () => {
|
||||||
|
it("uses a settings-window layout, grouped sections, and aligned actions", () => {
|
||||||
|
const source = readProfileView();
|
||||||
|
|
||||||
|
expect(source).not.toContain("<el-card");
|
||||||
|
expect(source).toContain('class="profile-layout"');
|
||||||
|
expect(source).toContain('class="avatar-panel"');
|
||||||
|
expect(source).toContain('aria-label="关闭个人中心"');
|
||||||
|
expect(source).toContain("@click=\"emit('close-request')\"");
|
||||||
|
expect(source).toContain('saved: []');
|
||||||
|
expect(source).toContain('emit("saved")');
|
||||||
|
expect(source).toContain('class="form-section"');
|
||||||
|
expect(source).toContain('class="form-section form-section--password"');
|
||||||
|
expect(source).toContain("grid-template-columns: 260px minmax(0, 1fr);");
|
||||||
|
expect(source).toContain("min-height: 620px;");
|
||||||
|
expect(source).toContain("padding-left: 112px;");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("profile avatar upload", () => {
|
||||||
|
it("limits avatar uploads to supported image MIME types", () => {
|
||||||
|
const source = readProfileView();
|
||||||
|
|
||||||
|
expect(source).toContain('accept="image/png,image/jpeg,image/gif,image/webp"');
|
||||||
|
expect(source).toContain(':before-upload="beforeAvatarUpload"');
|
||||||
|
expect(source).toContain('const avatarAcceptedTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])');
|
||||||
|
expect(source).toContain("avatarAcceptedTypes.has(file.type)");
|
||||||
|
expect(source).toContain("TEXT.modules.profile.avatarTypeInvalid");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,70 +1,103 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<el-card class="unified-shell">
|
<div class="profile-layout">
|
||||||
<h3 class="title">{{ TEXT.modules.profile.title }}</h3>
|
<aside class="profile-aside">
|
||||||
<p class="subtitle">{{ TEXT.modules.profile.subtitle }}</p>
|
<div class="avatar-panel">
|
||||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="form" autocomplete="off">
|
<el-avatar :size="88" :src="avatarPreview" :alt="form.full_name || form.email" class="profile-avatar">
|
||||||
<el-form-item :label="TEXT.common.fields.avatar">
|
{{ profileInitial }}
|
||||||
<div class="avatar-row">
|
</el-avatar>
|
||||||
<el-avatar :size="64" :src="avatarPreview" :alt="form.full_name || form.email">
|
<div class="avatar-meta">
|
||||||
{{ form.full_name?.charAt(0)?.toUpperCase() || form.email?.charAt(0)?.toUpperCase() }}
|
<div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div>
|
||||||
</el-avatar>
|
<div class="avatar-email">{{ form.email }}</div>
|
||||||
<el-upload
|
|
||||||
class="avatar-uploader"
|
|
||||||
:show-file-list="false"
|
|
||||||
action="/api/v1/auth/me/avatar"
|
|
||||||
name="file"
|
|
||||||
:headers="uploadHeaders"
|
|
||||||
:on-success="onAvatarUploaded"
|
|
||||||
:on-error="onAvatarError"
|
|
||||||
>
|
|
||||||
<el-button>{{ TEXT.modules.profile.uploadAvatar }}</el-button>
|
|
||||||
</el-upload>
|
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
<el-upload
|
||||||
<el-form-item :label="TEXT.common.fields.email">
|
class="avatar-uploader"
|
||||||
<el-input v-model="form.email" disabled />
|
:show-file-list="false"
|
||||||
</el-form-item>
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
|
action="/api/v1/auth/me/avatar"
|
||||||
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
|
name="file"
|
||||||
</el-form-item>
|
:headers="uploadHeaders"
|
||||||
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
|
:before-upload="beforeAvatarUpload"
|
||||||
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
|
:on-success="onAvatarUploaded"
|
||||||
</el-form-item>
|
:on-error="onAvatarError"
|
||||||
<el-divider />
|
>
|
||||||
<el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
|
<el-button :icon="Upload" class="upload-button">{{ TEXT.modules.profile.uploadAvatar }}</el-button>
|
||||||
<el-input
|
</el-upload>
|
||||||
v-model="form.current_password"
|
|
||||||
type="password"
|
|
||||||
show-password
|
|
||||||
autocomplete="new-password"
|
|
||||||
name="ctms-profile-current-password"
|
|
||||||
:placeholder="TEXT.modules.profile.currentPasswordHint"
|
|
||||||
/>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
|
|
||||||
<el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item :label="TEXT.modules.profile.confirmPassword" prop="confirmPassword">
|
|
||||||
<el-input v-model="form.confirmPassword" type="password" show-password :placeholder="TEXT.modules.profile.confirmPasswordHint" />
|
|
||||||
</el-form-item>
|
|
||||||
<div class="actions">
|
|
||||||
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
|
|
||||||
</div>
|
</div>
|
||||||
</el-form>
|
</aside>
|
||||||
</el-card>
|
|
||||||
|
<main class="profile-main">
|
||||||
|
<header class="profile-header">
|
||||||
|
<div>
|
||||||
|
<h3 class="title">{{ TEXT.modules.profile.title }}</h3>
|
||||||
|
</div>
|
||||||
|
<el-button class="dialog-close" text :icon="Close" aria-label="关闭个人中心" @click="emit('close-request')" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<el-form ref="formRef" :model="form" :rules="rules" label-width="112px" class="form" autocomplete="off">
|
||||||
|
<section class="form-section">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="section-kicker">Account</span>
|
||||||
|
<h4>基本信息</h4>
|
||||||
|
</div>
|
||||||
|
<el-form-item :label="TEXT.common.fields.email">
|
||||||
|
<el-input v-model="form.email" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="TEXT.common.fields.name" prop="full_name">
|
||||||
|
<el-input v-model="form.full_name" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.name" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="TEXT.common.fields.clinicalDepartment" prop="clinical_department">
|
||||||
|
<el-input v-model="form.clinical_department" autocomplete="off" :placeholder="TEXT.common.placeholders.input + TEXT.common.fields.clinicalDepartment" />
|
||||||
|
</el-form-item>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-section form-section--password">
|
||||||
|
<div class="section-heading">
|
||||||
|
<span class="section-kicker">Security</span>
|
||||||
|
<h4>修改密码</h4>
|
||||||
|
</div>
|
||||||
|
<el-form-item :label="TEXT.modules.profile.currentPassword" prop="current_password">
|
||||||
|
<el-input
|
||||||
|
v-model="form.current_password"
|
||||||
|
type="password"
|
||||||
|
show-password
|
||||||
|
autocomplete="new-password"
|
||||||
|
name="ctms-profile-current-password"
|
||||||
|
:placeholder="TEXT.modules.profile.currentPasswordHint"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="TEXT.modules.profile.newPassword" prop="password">
|
||||||
|
<el-input v-model="form.password" type="password" show-password :placeholder="TEXT.modules.profile.newPasswordHint" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="TEXT.modules.profile.confirmPassword" prop="confirmPassword">
|
||||||
|
<el-input v-model="form.confirmPassword" type="password" show-password :placeholder="TEXT.modules.profile.confirmPasswordHint" />
|
||||||
|
</el-form-item>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from "vue";
|
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||||
import type { FormInstance, FormRules } from "element-plus";
|
import type { FormInstance, FormRules, UploadRawFile } from "element-plus";
|
||||||
import { ElMessage } from "element-plus";
|
import { ElMessage } from "element-plus";
|
||||||
|
import { Close, Upload } from "@element-plus/icons-vue";
|
||||||
import { updateProfile, fetchMe } from "../api/auth";
|
import { updateProfile, fetchMe } from "../api/auth";
|
||||||
import { useAuthStore } from "../store/auth";
|
import { useAuthStore } from "../store/auth";
|
||||||
import { getToken } from "../utils/auth";
|
import { getToken } from "../utils/auth";
|
||||||
import { TEXT, requiredMessage } from "../locales";
|
import { TEXT, requiredMessage } from "../locales";
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"close-request": [];
|
||||||
|
"dirty-change": [dirty: boolean];
|
||||||
|
saved: [];
|
||||||
|
}>();
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const formRef = ref<FormInstance>();
|
const formRef = ref<FormInstance>();
|
||||||
const submitting = ref(false);
|
const submitting = ref(false);
|
||||||
@@ -76,10 +109,22 @@ const form = reactive({
|
|||||||
password: "",
|
password: "",
|
||||||
confirmPassword: "",
|
confirmPassword: "",
|
||||||
});
|
});
|
||||||
|
const savedProfile = ref({
|
||||||
|
full_name: "",
|
||||||
|
clinical_department: "",
|
||||||
|
});
|
||||||
const avatarPreview = ref<string | undefined>();
|
const avatarPreview = ref<string | undefined>();
|
||||||
const uploadHeaders = computed<Record<string, string>>(() => ({
|
const uploadHeaders = computed<Record<string, string>>(() => ({
|
||||||
Authorization: `Bearer ${getToken() || ""}`,
|
Authorization: `Bearer ${getToken() || ""}`,
|
||||||
}));
|
}));
|
||||||
|
const profileInitial = computed(() => (form.full_name?.charAt(0) || form.email?.charAt(0) || "?").toUpperCase());
|
||||||
|
const avatarAcceptedTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
||||||
|
const hasUnsavedChanges = computed(
|
||||||
|
() =>
|
||||||
|
form.full_name !== savedProfile.value.full_name ||
|
||||||
|
form.clinical_department !== savedProfile.value.clinical_department ||
|
||||||
|
Boolean(form.current_password || form.password || form.confirmPassword)
|
||||||
|
);
|
||||||
|
|
||||||
const rules: FormRules<typeof form> = {
|
const rules: FormRules<typeof form> = {
|
||||||
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
|
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
|
||||||
@@ -128,6 +173,10 @@ const loadProfile = async () => {
|
|||||||
form.email = data.email;
|
form.email = data.email;
|
||||||
form.full_name = data.full_name;
|
form.full_name = data.full_name;
|
||||||
form.clinical_department = data.clinical_department;
|
form.clinical_department = data.clinical_department;
|
||||||
|
savedProfile.value = {
|
||||||
|
full_name: data.full_name,
|
||||||
|
clinical_department: data.clinical_department,
|
||||||
|
};
|
||||||
avatarPreview.value = data.avatar_url || undefined;
|
avatarPreview.value = data.avatar_url || undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -148,7 +197,12 @@ const onSubmit = async () => {
|
|||||||
form.current_password = "";
|
form.current_password = "";
|
||||||
form.password = "";
|
form.password = "";
|
||||||
form.confirmPassword = "";
|
form.confirmPassword = "";
|
||||||
|
savedProfile.value = {
|
||||||
|
full_name: form.full_name,
|
||||||
|
clinical_department: form.clinical_department,
|
||||||
|
};
|
||||||
avatarPreview.value = auth.user?.avatar_url || avatarPreview.value;
|
avatarPreview.value = auth.user?.avatar_url || avatarPreview.value;
|
||||||
|
emit("saved");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
ElMessage.error(error?.response?.data?.message || TEXT.modules.profile.updateFailed);
|
ElMessage.error(error?.response?.data?.message || TEXT.modules.profile.updateFailed);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -163,6 +217,12 @@ const onAvatarUploaded = async () => {
|
|||||||
ElMessage.success(TEXT.modules.profile.avatarUpdated);
|
ElMessage.success(TEXT.modules.profile.avatarUpdated);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const beforeAvatarUpload = (file: UploadRawFile) => {
|
||||||
|
if (avatarAcceptedTypes.has(file.type)) return true;
|
||||||
|
ElMessage.error(TEXT.modules.profile.avatarTypeInvalid);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const onAvatarError = (err: any) => {
|
const onAvatarError = (err: any) => {
|
||||||
ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
|
ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
|
||||||
};
|
};
|
||||||
@@ -170,24 +230,205 @@ const onAvatarError = (err: any) => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadProfile();
|
loadProfile();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
padding: 16px;
|
background: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.profile-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 260px minmax(0, 1fr);
|
||||||
|
min-height: 620px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-aside {
|
||||||
|
padding: 48px 32px;
|
||||||
|
border-right: 1px solid #e5ebf2;
|
||||||
|
background: linear-gradient(180deg, #f8fbfd 0%, #f1f5f9 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar {
|
||||||
|
--el-avatar-bg-color: #64748b;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
border: 4px solid #fff;
|
||||||
|
box-shadow: 0 12px 30px rgba(51, 65, 85, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-meta {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: #172033;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-email {
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 6px;
|
||||||
|
color: #718096;
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-uploader {
|
||||||
|
margin-top: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-button {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
color: #40566f;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-main {
|
||||||
|
padding: 42px 48px 36px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
margin-bottom: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-close {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
margin-top: -4px;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
color: #172033;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
.subtitle {
|
|
||||||
margin: 6px 0 16px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
.form {
|
.form {
|
||||||
max-width: 520px;
|
max-width: 620px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-section {
|
||||||
|
padding-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-section--password {
|
||||||
|
margin-top: 26px;
|
||||||
|
padding-top: 28px;
|
||||||
|
border-top: 1px solid #e5ebf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
padding-left: 112px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-kicker {
|
||||||
|
display: block;
|
||||||
|
color: #7f92ad;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
|
line-height: 1;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading h4 {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #1f2a3d;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form :deep(.el-form-item) {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form :deep(.el-form-item__label) {
|
||||||
|
color: #6f83a3;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form :deep(.el-input__wrapper) {
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 0 1px #dfe6ee inset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form :deep(.el-input__wrapper.is-focus) {
|
||||||
|
box-shadow: 0 0 0 1px #3f8f6b inset, 0 0 0 3px rgba(63, 143, 107, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
margin-top: 12px;
|
margin-top: 28px;
|
||||||
|
padding-left: 112px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions :deep(.el-button) {
|
||||||
|
min-width: 96px;
|
||||||
|
min-height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.profile-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-aside {
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid #e5ebf2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-main {
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.profile-main {
|
||||||
|
padding: 24px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-heading,
|
||||||
|
.actions {
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -151,6 +151,13 @@ describe("permission management custom roles", () => {
|
|||||||
expect(source).not.toContain("project.members.manage");
|
expect(source).not.toContain("project.members.manage");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows member names without avatar icons in the member management table", () => {
|
||||||
|
const source = readSource();
|
||||||
|
|
||||||
|
expect(source).not.toContain("member-avatar");
|
||||||
|
expect(source).not.toContain("memberInitial(row.full_name)");
|
||||||
|
});
|
||||||
|
|
||||||
it("uses QA and CTA as preset project permission role keys", () => {
|
it("uses QA and CTA as preset project permission role keys", () => {
|
||||||
const source = readSource();
|
const source = readSource();
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,6 @@
|
|||||||
<el-table-column prop="full_name" label="姓名" min-width="140">
|
<el-table-column prop="full_name" label="姓名" min-width="140">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="member-name-cell">
|
<div class="member-name-cell">
|
||||||
<span class="member-avatar">{{ (row.full_name || '?')[0] }}</span>
|
|
||||||
<span class="member-name">{{ row.full_name }}</span>
|
<span class="member-name">{{ row.full_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1782,21 +1781,6 @@ onMounted(async () => {
|
|||||||
.member-name-cell {
|
.member-name-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.member-avatar {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: linear-gradient(135deg, #6366f1, #8b5cf6);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-name {
|
.member-name {
|
||||||
|
|||||||
@@ -32,6 +32,14 @@ describe("Admin users table layout", () => {
|
|||||||
expect(viewSource).toContain("width: 28px;");
|
expect(viewSource).toContain("width: 28px;");
|
||||||
expect(viewSource).toContain("gap: 4px;");
|
expect(viewSource).toContain("gap: 4px;");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows names without avatar icons in the account management table", () => {
|
||||||
|
const viewSource = readUsersView();
|
||||||
|
|
||||||
|
expect(viewSource).not.toContain("user-avatar");
|
||||||
|
expect(viewSource).not.toContain("getInitials(scope.row.full_name)");
|
||||||
|
expect(viewSource).not.toContain("linear-gradient(135deg, #3f8f6b, #2d7a5a)");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Admin users summary header", () => {
|
describe("Admin users summary header", () => {
|
||||||
|
|||||||
@@ -87,9 +87,6 @@
|
|||||||
<el-table-column :label="TEXT.common.fields.name" width="360">
|
<el-table-column :label="TEXT.common.fields.name" width="360">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<div class="user-cell">
|
<div class="user-cell">
|
||||||
<div class="user-avatar" :class="'avatar--' + (scope.row.status || '').toLowerCase()">
|
|
||||||
{{ getInitials(scope.row.full_name) }}
|
|
||||||
</div>
|
|
||||||
<div class="user-info">
|
<div class="user-info">
|
||||||
<span class="user-name">{{ scope.row.full_name }}</span>
|
<span class="user-name">{{ scope.row.full_name }}</span>
|
||||||
<span class="user-email">{{ scope.row.email }}</span>
|
<span class="user-email">{{ scope.row.email }}</span>
|
||||||
@@ -195,13 +192,6 @@ const activeCount = computed(() => allUsers.value.filter(u => u.status === 'ACTI
|
|||||||
const pendingCount = computed(() => allUsers.value.filter(u => u.status === 'PENDING').length);
|
const pendingCount = computed(() => allUsers.value.filter(u => u.status === 'PENDING').length);
|
||||||
const disabledCount = computed(() => allUsers.value.filter(u => u.status === 'DISABLED').length);
|
const disabledCount = computed(() => allUsers.value.filter(u => u.status === 'DISABLED').length);
|
||||||
|
|
||||||
const getInitials = (name: string) => {
|
|
||||||
if (!name) return '?';
|
|
||||||
const parts = name.trim().split(/\s+/);
|
|
||||||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
|
||||||
return name.slice(0, 2).toUpperCase();
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusType = (status: string) => {
|
const statusType = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case "ACTIVE": return "success";
|
case "ACTIVE": return "success";
|
||||||
@@ -487,37 +477,6 @@ onBeforeUnmount(() => {
|
|||||||
.user-cell {
|
.user-cell {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar {
|
|
||||||
width: 36px;
|
|
||||||
height: 36px;
|
|
||||||
border-radius: 10px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #fff;
|
|
||||||
flex-shrink: 0;
|
|
||||||
background: var(--ctms-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar.avatar--active {
|
|
||||||
background: linear-gradient(135deg, #3f8f6b, #2d7a5a);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar.avatar--pending {
|
|
||||||
background: linear-gradient(135deg, #c58b2a, #a87420);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar.avatar--disabled {
|
|
||||||
background: linear-gradient(135deg, #94a3b8, #64748b);
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-avatar.avatar--rejected {
|
|
||||||
background: linear-gradient(135deg, #c24b4b, #a83a3a);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-info {
|
.user-info {
|
||||||
|
|||||||
@@ -382,8 +382,17 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.legend-dot.active {
|
.legend-dot.active {
|
||||||
border-color: var(--ctms-primary);
|
position: relative;
|
||||||
box-shadow: 0 0 0 2px rgba(63, 93, 117, 0.18);
|
border: 3px solid #2f5f86;
|
||||||
|
box-shadow: 0 0 0 3px rgba(47, 95, 134, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-dot.active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 2px;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: #2f5f86;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-dot.pending {
|
.legend-dot.pending {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding-bottom: 4px;
|
padding: 10px 0 4px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -125,7 +125,7 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
|
|||||||
height: 2px;
|
height: 2px;
|
||||||
background-color: var(--ctms-border-color);
|
background-color: var(--ctms-border-color);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
margin-top: 6px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-connector::after {
|
.stage-connector::after {
|
||||||
@@ -145,7 +145,9 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.connector-in_progress {
|
.connector-in_progress {
|
||||||
background: linear-gradient(90deg, var(--ctms-primary), rgba(63, 93, 117, 0.3));
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, #2f5f86 0%, rgba(47, 95, 134, 0.42) 68%, rgba(47, 95, 134, 0.14) 100%);
|
||||||
|
box-shadow: 0 2px 8px rgba(47, 95, 134, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.connector-blocked {
|
.connector-blocked {
|
||||||
@@ -158,8 +160,10 @@ const connectorClass = (status: StageStatus) => `connector-${status.toLowerCase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.connector-in_progress::after {
|
.connector-in_progress::after {
|
||||||
border-top-color: var(--ctms-primary);
|
width: 7px;
|
||||||
border-right-color: var(--ctms-primary);
|
height: 7px;
|
||||||
|
border-top-color: #2f5f86;
|
||||||
|
border-right-color: #2f5f86;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connector-blocked::after {
|
.connector-blocked::after {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const readStageNode = () => readFileSync(resolve(__dirname, "./StageNode.vue"), "utf8");
|
||||||
|
const readCenterProgressRow = () => readFileSync(resolve(__dirname, "./CenterProgressRow.vue"), "utf8");
|
||||||
|
const readProjectOverview = () => readFileSync(resolve(__dirname, "../ProjectOverview.vue"), "utf8");
|
||||||
|
|
||||||
|
describe("project overview stage progress styling", () => {
|
||||||
|
it("makes the in-progress stage visually distinct from pending stages", () => {
|
||||||
|
const source = readStageNode();
|
||||||
|
|
||||||
|
expect(source).toContain(".stage-in_progress .stage-dot");
|
||||||
|
expect(source).toContain("border: 3px solid #2f5f86");
|
||||||
|
expect(source).toContain(".stage-in_progress .stage-dot::after");
|
||||||
|
expect(source).toContain(".stage-in_progress .stage-dot::before");
|
||||||
|
expect(source).toContain("animation: progress-halo");
|
||||||
|
expect(source).toContain(".stage-in_progress .stage-label");
|
||||||
|
expect(source).toContain("font-weight: 800");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("highlights the active connector and legend consistently", () => {
|
||||||
|
const rowSource = readCenterProgressRow();
|
||||||
|
const overviewSource = readProjectOverview();
|
||||||
|
|
||||||
|
expect(rowSource).toContain(".connector-in_progress");
|
||||||
|
expect(rowSource).toContain("height: 3px");
|
||||||
|
expect(rowSource).toContain("linear-gradient(90deg, #2f5f86");
|
||||||
|
expect(overviewSource).toContain(".legend-dot.active::after");
|
||||||
|
expect(overviewSource).toContain("background: #2f5f86");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -44,11 +44,14 @@ const statusClass = computed(() => `stage-${props.status.toLowerCase()}`);
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
min-width: 64px;
|
min-width: 64px;
|
||||||
|
position: relative;
|
||||||
|
padding-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-dot {
|
.stage-dot {
|
||||||
width: 14px;
|
position: relative;
|
||||||
height: 14px;
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2px solid var(--ctms-border-color);
|
border: 2px solid var(--ctms-border-color);
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
@@ -60,7 +63,7 @@ const statusClass = computed(() => `stage-${props.status.toLowerCase()}`);
|
|||||||
color: var(--ctms-text-secondary);
|
color: var(--ctms-text-secondary);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease, background-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-date {
|
.stage-date {
|
||||||
@@ -80,15 +83,55 @@ const statusClass = computed(() => `stage-${props.status.toLowerCase()}`);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stage-in_progress .stage-dot {
|
.stage-in_progress .stage-dot {
|
||||||
border-color: var(--ctms-primary);
|
width: 18px;
|
||||||
box-shadow: 0 0 0 3px rgba(63, 93, 117, 0.15);
|
height: 18px;
|
||||||
|
border: 3px solid #2f5f86;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
animation: pulse-ring 2s ease-in-out infinite;
|
box-shadow:
|
||||||
|
0 0 0 4px rgba(47, 95, 134, 0.14),
|
||||||
|
0 6px 14px rgba(47, 95, 134, 0.22);
|
||||||
|
animation: progress-ring 1.8s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-ring {
|
.stage-in_progress .stage-dot::after {
|
||||||
0%, 100% { box-shadow: 0 0 0 3px rgba(63, 93, 117, 0.15); }
|
content: "";
|
||||||
50% { box-shadow: 0 0 0 5px rgba(63, 93, 117, 0.08); }
|
position: absolute;
|
||||||
|
inset: 3px;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: #2f5f86;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stage-in_progress .stage-dot::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -8px;
|
||||||
|
border-radius: inherit;
|
||||||
|
border: 1px solid rgba(47, 95, 134, 0.24);
|
||||||
|
animation: progress-halo 1.8s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress-ring {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 4px rgba(47, 95, 134, 0.14),
|
||||||
|
0 6px 14px rgba(47, 95, 134, 0.22);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 6px rgba(47, 95, 134, 0.08),
|
||||||
|
0 8px 18px rgba(47, 95, 134, 0.26);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progress-halo {
|
||||||
|
0% {
|
||||||
|
opacity: 0.65;
|
||||||
|
transform: scale(0.82);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.25);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-not_started .stage-dot {
|
.stage-not_started .stage-dot {
|
||||||
@@ -108,8 +151,13 @@ const statusClass = computed(() => `stage-${props.status.toLowerCase()}`);
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stage-in_progress .stage-label {
|
.stage-in_progress .stage-label {
|
||||||
color: var(--ctms-primary);
|
margin-top: 1px;
|
||||||
font-weight: 600;
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #244966;
|
||||||
|
background: rgba(47, 95, 134, 0.1);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(47, 95, 134, 0.14);
|
||||||
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stage-blocked .stage-label {
|
.stage-blocked .stage-label {
|
||||||
|
|||||||
Reference in New Issue
Block a user