发布候选:整合桌面端界面与发布稳定化里程碑
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled
This commit is contained in:
@@ -0,0 +1,429 @@
|
||||
<template>
|
||||
<div class="desktop-preferences">
|
||||
<header class="preferences-header">
|
||||
<div>
|
||||
<p class="preferences-kicker">CTMS Desktop</p>
|
||||
<h3>系统偏好</h3>
|
||||
</div>
|
||||
<el-button text :icon="Close" aria-label="关闭系统偏好" @click="emit('close-request')" />
|
||||
</header>
|
||||
|
||||
<section class="preference-section">
|
||||
<div class="section-title">连接</div>
|
||||
<div class="server-card">
|
||||
<span class="server-label">当前服务器</span>
|
||||
<code>{{ desktopServerUrl || "未配置" }}</code>
|
||||
<el-button size="small" @click="openServerSettings">服务器设置</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="preference-section">
|
||||
<div class="section-title">外观</div>
|
||||
<div class="preference-row">
|
||||
<div>
|
||||
<div class="row-title">界面主题</div>
|
||||
<div class="row-desc">切换桌面工作台与系统弹窗的明暗配色</div>
|
||||
</div>
|
||||
<div class="theme-segmented" role="group" aria-label="界面主题">
|
||||
<button
|
||||
v-for="option in themeOptions"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
class="theme-option"
|
||||
:class="{ active: desktopTheme === option.value }"
|
||||
@click="setTheme(option.value)"
|
||||
>
|
||||
<el-icon><component :is="option.icon" /></el-icon>
|
||||
<span>{{ option.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="preference-section">
|
||||
<div class="section-title">通知与更新</div>
|
||||
<div class="preference-row">
|
||||
<div>
|
||||
<div class="row-title">系统通知</div>
|
||||
<div class="row-desc">只显示不含项目详情的文件更新提示</div>
|
||||
</div>
|
||||
<div class="row-control">
|
||||
<el-switch
|
||||
v-model="desktopNotificationsEnabled"
|
||||
:loading="desktopNotificationLoading"
|
||||
@change="onDesktopNotificationChange"
|
||||
/>
|
||||
<el-tag size="small" :type="notificationPermissionTagType">{{ notificationPermissionText }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preference-row">
|
||||
<div>
|
||||
<div class="row-title">桌面更新</div>
|
||||
<div class="row-desc">正式版本按当前服务器发布源检查签名更新</div>
|
||||
</div>
|
||||
<el-button size="small" :disabled="!desktopUpdaterAvailable" :loading="desktopUpdateChecking" @click="checkDesktopUpdateNow">
|
||||
检查更新
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="preference-section">
|
||||
<div class="section-title">诊断信息</div>
|
||||
<div class="metadata-panel">
|
||||
<dl>
|
||||
<template v-for="row in clientMetadataRows" :key="row.label">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd>{{ row.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<el-button size="small" @click="copyClientMetadata">复制诊断信息</el-button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Close, Moon, Sunny } from "@element-plus/icons-vue";
|
||||
import {
|
||||
getDesktopNotificationSubscription,
|
||||
setDesktopNotificationSubscription,
|
||||
} from "../api/desktopNotifications";
|
||||
import {
|
||||
clientRuntime,
|
||||
getAppMetadata,
|
||||
getDesktopServerUrl,
|
||||
getNotificationPermission,
|
||||
isDesktopUpdaterAvailable,
|
||||
readDesktopThemePreference,
|
||||
requestNotificationPermission,
|
||||
setDesktopThemePreference,
|
||||
type DesktopThemePreference,
|
||||
type NotificationPermissionState,
|
||||
} from "../runtime";
|
||||
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
|
||||
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
|
||||
|
||||
const emit = defineEmits<{
|
||||
"close-request": [];
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const desktopServerUrl = ref(getDesktopServerUrl());
|
||||
const clientMetadata = getAppMetadata();
|
||||
const desktopCapabilities = clientRuntime.capabilities();
|
||||
const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
|
||||
const desktopNotificationsEnabled = ref(false);
|
||||
const desktopNotificationLoading = ref(false);
|
||||
const desktopUpdateChecking = ref(false);
|
||||
const desktopTheme = ref<DesktopThemePreference>(readDesktopThemePreference());
|
||||
const notificationPermission = ref<NotificationPermissionState>("unsupported");
|
||||
const themeOptions = [
|
||||
{ value: "light" as const, label: "明亮", icon: Sunny },
|
||||
{ value: "dark" as const, label: "暗黑", icon: Moon },
|
||||
];
|
||||
|
||||
const clientMetadataRows = computed(() => [
|
||||
{ label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` },
|
||||
{ label: "平台", value: clientMetadata.platform },
|
||||
{ label: "构建通道", value: clientMetadata.channel },
|
||||
{ label: "提交", value: clientMetadata.commit },
|
||||
{ label: "主题", value: desktopTheme.value === "dark" ? "暗黑" : "明亮" },
|
||||
{ label: "服务器", value: desktopServerUrl.value || "未配置" },
|
||||
{
|
||||
label: "能力",
|
||||
value: [
|
||||
desktopCapabilities.secureSessionStorage ? "安全会话" : "浏览器会话",
|
||||
desktopCapabilities.nativeFiles ? "原生文件" : "浏览器文件",
|
||||
desktopCapabilities.systemNotifications ? "系统通知" : "无系统通知",
|
||||
desktopCapabilities.automaticUpdates ? "自动更新" : "无自动更新",
|
||||
].join(" / "),
|
||||
},
|
||||
]);
|
||||
|
||||
const notificationPermissionText = computed(() => {
|
||||
if (notificationPermission.value === "granted") return "已授权";
|
||||
if (notificationPermission.value === "denied") return "已拒绝";
|
||||
if (notificationPermission.value === "prompt") return "待授权";
|
||||
return "不可用";
|
||||
});
|
||||
|
||||
const notificationPermissionTagType = computed(() => {
|
||||
if (notificationPermission.value === "granted") return "success";
|
||||
if (notificationPermission.value === "denied") return "danger";
|
||||
if (notificationPermission.value === "prompt") return "warning";
|
||||
return "info";
|
||||
});
|
||||
|
||||
const loadNotificationState = async () => {
|
||||
notificationPermission.value = await getNotificationPermission();
|
||||
try {
|
||||
const { data } = await getDesktopNotificationSubscription();
|
||||
desktopNotificationsEnabled.value = data.enabled;
|
||||
} catch {
|
||||
desktopNotificationsEnabled.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onDesktopNotificationChange = async (value: string | number | boolean) => {
|
||||
if (desktopNotificationLoading.value) return;
|
||||
desktopNotificationLoading.value = true;
|
||||
try {
|
||||
if (value) {
|
||||
notificationPermission.value = await requestNotificationPermission();
|
||||
if (notificationPermission.value !== "granted") {
|
||||
desktopNotificationsEnabled.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { data } = await setDesktopNotificationSubscription(Boolean(value));
|
||||
desktopNotificationsEnabled.value = data.enabled;
|
||||
triggerDesktopNotificationPoll();
|
||||
ElMessage.success(data.enabled ? "系统通知已开启" : "系统通知已关闭");
|
||||
} catch (error: any) {
|
||||
desktopNotificationsEnabled.value = !Boolean(value);
|
||||
ElMessage.error(error?.response?.data?.detail || "系统通知设置失败");
|
||||
} finally {
|
||||
desktopNotificationLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const checkDesktopUpdateNow = async () => {
|
||||
if (desktopUpdateChecking.value) return;
|
||||
desktopUpdateChecking.value = true;
|
||||
try {
|
||||
await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
|
||||
} finally {
|
||||
desktopUpdateChecking.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const setTheme = (theme: DesktopThemePreference) => {
|
||||
desktopTheme.value = setDesktopThemePreference(theme);
|
||||
};
|
||||
|
||||
const copyClientMetadata = async () => {
|
||||
const text = clientMetadataRows.value.map((row) => `${row.label}: ${row.value}`).join("\n");
|
||||
await navigator.clipboard?.writeText(text);
|
||||
ElMessage.success("诊断信息已复制");
|
||||
};
|
||||
|
||||
const openServerSettings = () => {
|
||||
emit("close-request");
|
||||
router.push("/desktop/server-settings");
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
void loadNotificationState();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.desktop-preferences {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.preferences-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.preferences-kicker {
|
||||
margin: 0 0 4px;
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.preference-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.server-card,
|
||||
.preference-row,
|
||||
.metadata-panel {
|
||||
border: 1px solid #dbe6f2;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.server-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.server-label,
|
||||
.row-desc {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
code {
|
||||
overflow-wrap: anywhere;
|
||||
color: #0f172a;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preference-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.row-title {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.row-control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.theme-segmented {
|
||||
display: inline-grid;
|
||||
grid-template-columns: repeat(2, minmax(76px, 1fr));
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid #dbe6f2;
|
||||
border-radius: 9px;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.theme-option {
|
||||
appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-width: 76px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.theme-option:hover {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.theme-option.active {
|
||||
background: #ffffff;
|
||||
color: #24496f;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.metadata-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 8px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"] .desktop-preferences-dialog .el-dialog__body) {
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .desktop-preferences {
|
||||
color: #e5edf7;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .preferences-kicker {
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) h3,
|
||||
:global([data-ctms-theme="dark"]) .row-title,
|
||||
:global([data-ctms-theme="dark"]) dd,
|
||||
:global([data-ctms-theme="dark"]) code {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .section-title,
|
||||
:global([data-ctms-theme="dark"]) .server-label,
|
||||
:global([data-ctms-theme="dark"]) .row-desc,
|
||||
:global([data-ctms-theme="dark"]) dt {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .server-card,
|
||||
:global([data-ctms-theme="dark"]) .preference-row,
|
||||
:global([data-ctms-theme="dark"]) .metadata-panel {
|
||||
border-color: #26364a;
|
||||
background: #172033;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .theme-segmented {
|
||||
border-color: #26364a;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .theme-option {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .theme-option:hover {
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
:global([data-ctms-theme="dark"]) .theme-option.active {
|
||||
background: #243247;
|
||||
color: #bfdbfe;
|
||||
box-shadow: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="desktop-settings-page">
|
||||
<section class="settings-panel">
|
||||
<div class="panel-header">
|
||||
<p class="eyebrow">CTMS Desktop</p>
|
||||
<h1>服务器设置</h1>
|
||||
<p class="description">配置桌面客户端要连接的 CTMS 服务端入口。业务数据仍由服务端统一保存和裁决。</p>
|
||||
</div>
|
||||
|
||||
<div v-if="currentServerUrl" class="current-server">
|
||||
<span>当前服务器</span>
|
||||
<code>{{ currentServerUrl }}</code>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="connectionStatus"
|
||||
class="connection-alert"
|
||||
:type="connectionStatus.type"
|
||||
:title="connectionStatus.title"
|
||||
:description="connectionStatus.message"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
<el-form-item label="服务器地址" :error="urlError">
|
||||
<el-input
|
||||
v-model.trim="serverUrl"
|
||||
size="large"
|
||||
placeholder="https://ctms.example.com"
|
||||
autocomplete="url"
|
||||
@keyup.enter="save"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div class="hint">
|
||||
允许 HTTPS 服务地址;本地开发可使用 http://localhost 或 http://127.0.0.1。
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<el-button v-if="canCancel" size="large" @click="goBack">取消</el-button>
|
||||
<el-button type="primary" size="large" :loading="saving" :disabled="!serverUrl.trim()" @click="save">
|
||||
保存并检查连接
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime";
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const studyStore = useStudyStore();
|
||||
|
||||
const currentServerUrl = getDesktopServerUrl();
|
||||
const serverUrl = ref(currentServerUrl || "");
|
||||
const urlError = ref("");
|
||||
const saving = ref(false);
|
||||
const connectionStatus = ref<{ type: "success" | "warning" | "error"; title: string; message: string } | null>(null);
|
||||
const canCancel = computed(() => Boolean(currentServerUrl));
|
||||
const HEALTH_TIMEOUT_MS = 10_000;
|
||||
|
||||
const checkHealth = async (baseUrl: string) => {
|
||||
const healthUrl = new URL("health", baseUrl).toString();
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(healthUrl, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`服务器健康检查返回 HTTP ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw new Error("连接超时,请确认服务端地址和网络状态");
|
||||
}
|
||||
if (error instanceof TypeError) {
|
||||
throw new Error("网络请求失败,请确认地址、证书或 CORS 配置");
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
};
|
||||
|
||||
const clearSessionForServerChange = async () => {
|
||||
await auth.logout();
|
||||
studyStore.clearCurrentStudy();
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
urlError.value = "";
|
||||
connectionStatus.value = null;
|
||||
const normalized = normalizeDesktopServerUrl(serverUrl.value);
|
||||
if (!normalized.ok) {
|
||||
urlError.value = normalized.reason;
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await checkHealth(normalized.url);
|
||||
const previous = getDesktopServerUrl();
|
||||
const result = setDesktopServerUrl(normalized.url);
|
||||
if (!result.ok) {
|
||||
urlError.value = result.reason;
|
||||
return;
|
||||
}
|
||||
if (previous !== result.url) {
|
||||
await clearSessionForServerChange();
|
||||
}
|
||||
connectionStatus.value = {
|
||||
type: "success",
|
||||
title: "连接已确认",
|
||||
message: result.url,
|
||||
};
|
||||
ElMessage.success("服务器连接已确认");
|
||||
router.replace("/login");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "无法连接服务器的 /health";
|
||||
connectionStatus.value = {
|
||||
type: "error",
|
||||
title: "连接检查失败",
|
||||
message,
|
||||
};
|
||||
urlError.value = message;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
router.back();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.desktop-settings-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
width: min(100%, 520px);
|
||||
padding: 32px;
|
||||
border: 1px solid #d9e2ef;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: #2563eb;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 28px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 12px 0 0;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.current-server {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #dbe6f2;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.current-server code {
|
||||
overflow-wrap: anywhere;
|
||||
color: #1e293b;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.connection-alert {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: -8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
</style>
|
||||
@@ -95,4 +95,16 @@ describe("Login protocol agreement", () => {
|
||||
expect(source).toContain("width: clamp(480px, 34vw, 560px);");
|
||||
expect(source).toContain("max-width: calc(100vw - 40px);");
|
||||
});
|
||||
|
||||
it("uses only server-configured email domains on web and desktop", () => {
|
||||
const source = readLoginView();
|
||||
|
||||
expect(source).toContain('fetchEmailDomains()');
|
||||
expect(source).toContain(':disabled="availableEmailDomains.length === 0"');
|
||||
expect(source).toContain("const availableEmailDomains = computed(() => configuredEmailDomains.value)");
|
||||
expect(source).toContain("configuredEmailDomains.value.includes(domain)");
|
||||
expect(source).not.toContain("preservedEmailDomain");
|
||||
expect(source).not.toContain("showDomainSelect");
|
||||
expect(source).not.toContain("email-domain-input");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -100,6 +100,12 @@
|
||||
<span class="tab-item active">账号登录</span>
|
||||
</div>
|
||||
|
||||
<div v-if="showDesktopServerSettings" class="desktop-server-status">
|
||||
<span class="desktop-server-label">服务器</span>
|
||||
<code>{{ desktopServerUrl || "未配置" }}</code>
|
||||
<RouterLink to="/desktop/server-settings" class="desktop-server-action">设置</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- 退出通知 -->
|
||||
<div v-if="logoutNotice" class="logout-notice" :class="'logout-notice--' + logoutNotice.type">
|
||||
<div class="ln-icon">
|
||||
@@ -153,22 +159,14 @@
|
||||
<span class="email-at-sign">@</span>
|
||||
<div class="email-domain-wrapper">
|
||||
<select
|
||||
v-if="showDomainSelect"
|
||||
v-model="form.emailDomain"
|
||||
class="email-domain-field"
|
||||
aria-label="邮箱域名"
|
||||
:disabled="availableEmailDomains.length === 0"
|
||||
>
|
||||
<option v-for="domain in availableEmailDomains" :key="domain" :value="domain">{{ domain }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-else
|
||||
v-model.trim="form.emailDomain"
|
||||
type="text"
|
||||
class="email-domain-field email-domain-input"
|
||||
placeholder="邮箱域名"
|
||||
aria-label="邮箱域名"
|
||||
/>
|
||||
<svg v-if="showDomainSelect" class="email-domain-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
|
||||
<svg class="email-domain-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="m6 9 6 6 6-6"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
@@ -195,7 +193,7 @@
|
||||
<span>登 录</span>
|
||||
</el-button>
|
||||
|
||||
<!-- 注册与忘记密码(左右分立) -->
|
||||
<!-- 忘记密码与注册 -->
|
||||
<div class="forgot-register-row">
|
||||
<RouterLink to="/forgot-password" class="forgot-link">忘记密码?</RouterLink>
|
||||
<RouterLink to="/register" class="register-link">新用户注册</RouterLink>
|
||||
@@ -246,7 +244,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, onMounted, watch } from "vue";
|
||||
import { computed, reactive, ref, onMounted, onUnmounted, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
|
||||
// 从 package.json 读取版本号,构建时由 Vite 注入
|
||||
@@ -257,6 +255,7 @@ import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { fetchEmailDomains } from "../api/auth";
|
||||
import { TEXT, requiredMessage } from "../locales";
|
||||
import { DESKTOP_SERVER_URL_CHANGED_EVENT, getDesktopServerUrl, isTauriRuntime } from "../runtime";
|
||||
import {
|
||||
consumeLogoutReason,
|
||||
LOGOUT_REASON_AUTH_EXPIRED,
|
||||
@@ -272,7 +271,6 @@ const AGREE_PROTOCOL_KEY = "ctms_agree_protocol";
|
||||
const formRef = ref<FormInstance>();
|
||||
const form = reactive({ email: "", emailLocal: "", emailDomain: "", password: "", agreeProtocol: false });
|
||||
const configuredEmailDomains = ref<string[]>([]);
|
||||
const preservedEmailDomain = ref("");
|
||||
|
||||
const rules: FormRules<typeof form> = {
|
||||
email: [
|
||||
@@ -287,15 +285,15 @@ const protocolDialogVisible = ref(false);
|
||||
const logoutNotice = ref<{ type: "info" | "warning"; title: string; message: string } | null>(null);
|
||||
const loginError = ref<{ title: string; message?: string } | null>(null);
|
||||
const protocolSections = authProtocolSections;
|
||||
const showDesktopServerSettings = isTauriRuntime();
|
||||
const desktopServerUrl = ref(getDesktopServerUrl());
|
||||
|
||||
const refreshDesktopServerUrl = () => {
|
||||
desktopServerUrl.value = getDesktopServerUrl();
|
||||
};
|
||||
|
||||
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
|
||||
const availableEmailDomains = computed(() => Array.from(new Set([
|
||||
...configuredEmailDomains.value,
|
||||
preservedEmailDomain.value,
|
||||
].filter(Boolean))));
|
||||
const showDomainSelect = computed(
|
||||
() => configuredEmailDomains.value.length > 0 || Boolean(preservedEmailDomain.value)
|
||||
);
|
||||
const availableEmailDomains = computed(() => configuredEmailDomains.value);
|
||||
|
||||
const syncEmailFromParts = () => {
|
||||
const local = form.emailLocal.trim().toLowerCase();
|
||||
@@ -309,14 +307,13 @@ const applyEmailValue = (email: string) => {
|
||||
const separator = normalized.lastIndexOf("@");
|
||||
if (separator > 0) {
|
||||
form.emailLocal = normalized.slice(0, separator);
|
||||
form.emailDomain = normalizeDomain(normalized.slice(separator + 1));
|
||||
preservedEmailDomain.value = configuredEmailDomains.value.includes(form.emailDomain)
|
||||
? ""
|
||||
: form.emailDomain;
|
||||
const domain = normalizeDomain(normalized.slice(separator + 1));
|
||||
form.emailDomain = configuredEmailDomains.value.includes(domain)
|
||||
? domain
|
||||
: configuredEmailDomains.value[0] || "";
|
||||
} else {
|
||||
form.emailLocal = normalized;
|
||||
form.emailDomain = configuredEmailDomains.value[0] || "";
|
||||
preservedEmailDomain.value = "";
|
||||
}
|
||||
syncEmailFromParts();
|
||||
};
|
||||
@@ -327,8 +324,12 @@ const loadEmailDomains = async () => {
|
||||
configuredEmailDomains.value = Array.from(new Set(
|
||||
data.items.map(normalizeDomain).filter(Boolean)
|
||||
));
|
||||
if (!configuredEmailDomains.value.includes(form.emailDomain)) {
|
||||
form.emailDomain = configuredEmailDomains.value[0] || "";
|
||||
}
|
||||
} catch {
|
||||
configuredEmailDomains.value = [];
|
||||
form.emailDomain = "";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -340,6 +341,7 @@ const handleAccountPaste = (event: ClipboardEvent) => {
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
|
||||
await loadEmailDomains();
|
||||
const reason = consumeLogoutReason();
|
||||
if (reason === LOGOUT_REASON_TIMEOUT) {
|
||||
@@ -353,7 +355,11 @@ onMounted(async () => {
|
||||
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
|
||||
});
|
||||
|
||||
watch(() => form.agreeProtocol, (v) => localStorage.setItem(AGREE_PROTOCOL_KEY, String(v)));
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
|
||||
});
|
||||
|
||||
watch(() => form.agreeProtocol, (checked) => localStorage.setItem(AGREE_PROTOCOL_KEY, String(checked)));
|
||||
watch(() => [form.emailLocal, form.emailDomain], syncEmailFromParts);
|
||||
|
||||
const openProtocolDialog = () => { protocolDialogVisible.value = true; };
|
||||
@@ -371,7 +377,11 @@ const onSubmit = async () => {
|
||||
const studyStore = useStudyStore();
|
||||
const userKey = auth.user?.email || form.email;
|
||||
await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin });
|
||||
router.push(studyStore.currentStudy ? "/project/overview" : auth.user?.is_admin ? "/admin/users" : "/admin/projects");
|
||||
if (studyStore.currentStudy) {
|
||||
router.push("/project/overview");
|
||||
} else {
|
||||
router.push(auth.user?.is_admin ? "/admin/users" : "/admin/projects");
|
||||
}
|
||||
} catch (error: any) {
|
||||
const status = error?.response?.status;
|
||||
const detail: string = error?.response?.data?.detail || error?.response?.data?.message || "";
|
||||
@@ -704,8 +714,8 @@ const onSubmit = async () => {
|
||||
|
||||
/* 卡片容器 */
|
||||
.login-card-container {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
width: clamp(480px, 34vw, 560px);
|
||||
max-width: calc(100vw - 40px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -777,6 +787,42 @@ const onSubmit = async () => {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.desktop-server-status {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin: -18px 0 24px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #dbe7f5;
|
||||
border-radius: 8px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.desktop-server-label {
|
||||
color: #64748b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.desktop-server-status code {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
color: #1e293b;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.desktop-server-action {
|
||||
color: #2563eb;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.desktop-server-action:hover {
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* ═══════════════════════
|
||||
通知与错误提示
|
||||
═══════════════════════ */
|
||||
@@ -919,8 +965,7 @@ const onSubmit = async () => {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.email-local-field::placeholder,
|
||||
.email-domain-input::placeholder {
|
||||
.email-local-field::placeholder {
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
@@ -952,10 +997,9 @@ const onSubmit = async () => {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.email-domain-input {
|
||||
width: 150px;
|
||||
padding-right: 4px;
|
||||
cursor: text;
|
||||
.email-domain-field:disabled {
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.email-domain-chevron {
|
||||
|
||||
@@ -10,19 +10,9 @@
|
||||
<div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div>
|
||||
<div class="avatar-email">{{ form.email }}</div>
|
||||
</div>
|
||||
<el-upload
|
||||
class="avatar-uploader"
|
||||
:show-file-list="false"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
action="/api/v1/auth/me/avatar"
|
||||
name="file"
|
||||
:headers="uploadHeaders"
|
||||
:before-upload="beforeAvatarUpload"
|
||||
:on-success="onAvatarUploaded"
|
||||
:on-error="onAvatarError"
|
||||
>
|
||||
<el-button :icon="Upload" class="upload-button">{{ TEXT.modules.profile.uploadAvatar }}</el-button>
|
||||
</el-upload>
|
||||
<el-button :icon="Upload" class="upload-button avatar-uploader" @click="selectAndUploadAvatar">
|
||||
{{ TEXT.modules.profile.uploadAvatar }}
|
||||
</el-button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -74,6 +64,45 @@
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<section class="form-section form-section--desktop">
|
||||
<div class="section-heading">
|
||||
<span class="section-kicker">Desktop</span>
|
||||
<h4>客户端与通知</h4>
|
||||
</div>
|
||||
<el-form-item v-if="isDesktop" label="系统通知">
|
||||
<div class="desktop-setting-stack">
|
||||
<div class="desktop-setting-row">
|
||||
<el-switch
|
||||
v-model="desktopNotificationsEnabled"
|
||||
:loading="desktopNotificationLoading"
|
||||
@change="onDesktopNotificationChange"
|
||||
/>
|
||||
<el-tag size="small" :type="notificationPermissionTagType">{{ notificationPermissionText }}</el-tag>
|
||||
</div>
|
||||
<span class="desktop-setting-hint">仅推送不含项目详情的文件更新提示</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isDesktop && desktopUpdaterAvailable" label="桌面更新">
|
||||
<div class="desktop-setting-row">
|
||||
<el-button size="small" :loading="desktopUpdateChecking" @click="checkDesktopUpdateNow">
|
||||
检查更新
|
||||
</el-button>
|
||||
<span class="desktop-setting-hint">正式版本会按发布源检查签名更新</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="客户端信息">
|
||||
<div class="client-metadata-panel">
|
||||
<dl class="client-metadata-list">
|
||||
<template v-for="row in clientMetadataRows" :key="row.label">
|
||||
<dt>{{ row.label }}</dt>
|
||||
<dd>{{ row.value }}</dd>
|
||||
</template>
|
||||
</dl>
|
||||
<el-button size="small" @click="copyClientMetadata">复制</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
|
||||
</div>
|
||||
@@ -85,12 +114,28 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import type { FormInstance, FormRules, UploadRawFile } from "element-plus";
|
||||
import type { FormInstance, FormRules } 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, uploadAvatar } from "../api/auth";
|
||||
import {
|
||||
getDesktopNotificationSubscription,
|
||||
setDesktopNotificationSubscription,
|
||||
} from "../api/desktopNotifications";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { getToken } from "../utils/auth";
|
||||
import {
|
||||
clientRuntime,
|
||||
getAppMetadata,
|
||||
getDesktopServerUrl,
|
||||
getNotificationPermission,
|
||||
isDesktopUpdaterAvailable,
|
||||
isTauriRuntime,
|
||||
pickFiles,
|
||||
requestNotificationPermission,
|
||||
type NotificationPermissionState,
|
||||
} from "../runtime";
|
||||
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
|
||||
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
|
||||
import { TEXT, requiredMessage } from "../locales";
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -99,6 +144,31 @@ const emit = defineEmits<{
|
||||
saved: [];
|
||||
}>();
|
||||
const auth = useAuthStore();
|
||||
const isDesktop = isTauriRuntime();
|
||||
const clientMetadata = getAppMetadata();
|
||||
const desktopCapabilities = clientRuntime.capabilities();
|
||||
const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
|
||||
const clientMetadataRows = [
|
||||
{ label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` },
|
||||
{ label: "平台", value: clientMetadata.platform },
|
||||
{ label: "构建通道", value: clientMetadata.channel },
|
||||
{ label: "提交", value: clientMetadata.commit },
|
||||
{ label: "服务器", value: getDesktopServerUrl() || "未配置" },
|
||||
{
|
||||
label: "能力",
|
||||
value: [
|
||||
desktopCapabilities.secureSessionStorage ? "安全会话" : "浏览器会话",
|
||||
desktopCapabilities.nativeFiles ? "原生文件" : "浏览器文件",
|
||||
desktopCapabilities.systemNotifications ? "系统通知" : "无系统通知",
|
||||
desktopCapabilities.automaticUpdates ? "自动更新" : "无自动更新",
|
||||
].join(" / "),
|
||||
},
|
||||
];
|
||||
const clientMetadataText = clientMetadataRows.map((row) => `${row.label}: ${row.value}`).join("\n");
|
||||
const desktopNotificationsEnabled = ref(false);
|
||||
const desktopNotificationLoading = ref(false);
|
||||
const desktopUpdateChecking = ref(false);
|
||||
const notificationPermission = ref<NotificationPermissionState>("unsupported");
|
||||
const formRef = ref<FormInstance>();
|
||||
const submitting = ref(false);
|
||||
const form = reactive({
|
||||
@@ -114,9 +184,6 @@ const savedProfile = ref({
|
||||
clinical_department: "",
|
||||
});
|
||||
const avatarPreview = ref<string | undefined>();
|
||||
const uploadHeaders = computed<Record<string, string>>(() => ({
|
||||
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(
|
||||
@@ -180,6 +247,72 @@ const loadProfile = async () => {
|
||||
avatarPreview.value = data.avatar_url || undefined;
|
||||
};
|
||||
|
||||
const loadDesktopNotificationSubscription = async () => {
|
||||
if (!isDesktop) return;
|
||||
notificationPermission.value = await getNotificationPermission().catch(() => "unsupported");
|
||||
const { data } = await getDesktopNotificationSubscription();
|
||||
desktopNotificationsEnabled.value = data.enabled;
|
||||
};
|
||||
|
||||
const notificationPermissionText = computed(() => {
|
||||
if (!isDesktop) return "不可用";
|
||||
if (notificationPermission.value === "granted") return "系统已允许";
|
||||
if (notificationPermission.value === "denied") return "系统已拒绝";
|
||||
if (notificationPermission.value === "prompt") return "等待授权";
|
||||
return "不可用";
|
||||
});
|
||||
|
||||
const notificationPermissionTagType = computed<"success" | "warning" | "danger" | "info">(() => {
|
||||
if (notificationPermission.value === "granted") return "success";
|
||||
if (notificationPermission.value === "denied") return "danger";
|
||||
if (notificationPermission.value === "prompt") return "warning";
|
||||
return "info";
|
||||
});
|
||||
|
||||
const onDesktopNotificationChange = async (value: string | number | boolean) => {
|
||||
if (!isDesktop || desktopNotificationLoading.value) return;
|
||||
desktopNotificationLoading.value = true;
|
||||
try {
|
||||
const enable = Boolean(value);
|
||||
if (enable) {
|
||||
const permission = await requestNotificationPermission();
|
||||
notificationPermission.value = permission;
|
||||
if (permission !== "granted") {
|
||||
desktopNotificationsEnabled.value = false;
|
||||
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
|
||||
return;
|
||||
}
|
||||
}
|
||||
const { data } = await setDesktopNotificationSubscription(enable);
|
||||
desktopNotificationsEnabled.value = data.enabled;
|
||||
if (data.enabled) triggerDesktopNotificationPoll();
|
||||
} catch (error: any) {
|
||||
desktopNotificationsEnabled.value = !Boolean(value);
|
||||
ElMessage.error(error?.response?.data?.detail || "通知设置保存失败");
|
||||
} finally {
|
||||
desktopNotificationLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const copyClientMetadata = async () => {
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
ElMessage.warning("当前环境无法访问剪贴板");
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(clientMetadataText);
|
||||
ElMessage.success("客户端信息已复制");
|
||||
};
|
||||
|
||||
const checkDesktopUpdateNow = async () => {
|
||||
if (desktopUpdateChecking.value) return;
|
||||
desktopUpdateChecking.value = true;
|
||||
try {
|
||||
await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
|
||||
} finally {
|
||||
desktopUpdateChecking.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate(async (valid) => {
|
||||
@@ -211,24 +344,31 @@ const onSubmit = async () => {
|
||||
});
|
||||
};
|
||||
|
||||
const onAvatarUploaded = async () => {
|
||||
await auth.fetchMe();
|
||||
avatarPreview.value = auth.user?.avatar_url || undefined;
|
||||
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) => {
|
||||
ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
|
||||
const selectAndUploadAvatar = async () => {
|
||||
const [file] = await pickFiles({
|
||||
multiple: false,
|
||||
accept: ["png", "jpg", "jpeg", "gif", "webp"],
|
||||
title: TEXT.modules.profile.uploadAvatar,
|
||||
});
|
||||
if (!file) return;
|
||||
const extensionAllowed = /\.(png|jpe?g|gif|webp)$/i.test(file.name);
|
||||
if (!avatarAcceptedTypes.has(file.type) && !extensionAllowed) {
|
||||
ElMessage.error(TEXT.modules.profile.avatarTypeInvalid);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await uploadAvatar(file);
|
||||
await auth.fetchMe();
|
||||
avatarPreview.value = auth.user?.avatar_url || undefined;
|
||||
ElMessage.success(TEXT.modules.profile.avatarUpdated);
|
||||
} catch (err: any) {
|
||||
ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadProfile();
|
||||
loadDesktopNotificationSubscription().catch(() => {});
|
||||
});
|
||||
|
||||
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
|
||||
@@ -343,6 +483,59 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
|
||||
border-top: 1px solid #e5ebf2;
|
||||
}
|
||||
|
||||
.form-section--desktop {
|
||||
margin-top: 26px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid #e5ebf2;
|
||||
}
|
||||
|
||||
.desktop-setting-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.desktop-setting-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.desktop-setting-hint {
|
||||
color: #7f92ad;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.client-metadata-panel {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.client-metadata-list {
|
||||
display: grid;
|
||||
grid-template-columns: 76px minmax(0, 1fr);
|
||||
gap: 6px 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: #40566f;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.client-metadata-list dt {
|
||||
color: #7f92ad;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.client-metadata-list dd {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-bottom: 18px;
|
||||
padding-left: 112px;
|
||||
|
||||
@@ -449,12 +449,14 @@ const loadEmailDomains = async () => {
|
||||
try {
|
||||
const { data } = await fetchEmailDomains();
|
||||
emailDomains.value = normalizeDomains(data.items);
|
||||
if (!form.emailDomain && emailDomains.value.length > 0) {
|
||||
form.emailDomain = emailDomains.value[0];
|
||||
syncEmailFromParts();
|
||||
if (!emailDomains.value.includes(form.emailDomain)) {
|
||||
form.emailDomain = emailDomains.value[0] || "";
|
||||
}
|
||||
syncEmailFromParts();
|
||||
} catch {
|
||||
emailDomains.value = [];
|
||||
form.emailDomain = "";
|
||||
syncEmailFromParts();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
<el-skeleton v-if="loading.notifications" :rows="3" animated />
|
||||
<StateEmpty v-else-if="!notifications.length" :description="TEXT.modules.projectOverview.notificationsEmpty" />
|
||||
<div v-else class="notification-list">
|
||||
<div class="notification-item" v-for="item in notifications" :key="item.id">
|
||||
<div class="notification-item" :class="{ 'is-unread': !item.read_at }" v-for="item in notifications" :key="item.id">
|
||||
<div class="notification-main">
|
||||
<div class="notification-title">
|
||||
<span class="notification-doc">{{ item.document_title }}</span>
|
||||
@@ -87,7 +87,7 @@
|
||||
<span v-if="item.change_summary" class="notification-summary">{{ item.change_summary }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-button link type="primary" @click="openDocument(item.document_id)">
|
||||
<el-button link type="primary" @click="openDocument(item)">
|
||||
{{ TEXT.common.actions.view }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -111,6 +111,7 @@ import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { fetchFinanceSummary, fetchOverdueAesCount, fetchProgress } from "../api/dashboard";
|
||||
import { listNotifications } from "../api/notifications";
|
||||
import { markDesktopNotificationRead } from "../api/desktopNotifications";
|
||||
import KpiCard from "../components/KpiCard.vue";
|
||||
import QuickActions from "../components/QuickActions.vue";
|
||||
import { List, Warning, Money } from "@element-plus/icons-vue";
|
||||
@@ -202,8 +203,10 @@ const formatDate = (value?: string | null) => {
|
||||
return value.replace("T", " ").replace("Z", "").split(".")[0];
|
||||
};
|
||||
|
||||
const openDocument = (documentId: string) => {
|
||||
router.push(`/documents/${documentId}`);
|
||||
const openDocument = async (item: NotificationItem) => {
|
||||
await markDesktopNotificationRead(item.id).catch(() => {});
|
||||
item.read_at = new Date().toISOString();
|
||||
router.push(`/documents/${item.document_id}`);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1954,6 +1954,7 @@ import {
|
||||
type SetupWorkflowTagMeta,
|
||||
} from "../../utils/setupPublishWorkflow";
|
||||
import { useSetupConfig } from "../../composables/useSetupConfig";
|
||||
import { saveFile } from "../../runtime";
|
||||
|
||||
type SetupStepKey =
|
||||
| "project-info"
|
||||
@@ -5174,7 +5175,7 @@ const buildExcelWorksheetXml = (
|
||||
return `<Worksheet ss:Name="${excelEscapeXml(sheetName)}"><Table>${metaXml}<Row/>${headerXml}${dataXml}</Table></Worksheet>`;
|
||||
};
|
||||
|
||||
const downloadVersionRaw = (versionItem: StudySetupConfigVersionItem) => {
|
||||
const downloadVersionRaw = async (versionItem: StudySetupConfigVersionItem) => {
|
||||
if (!project.value) return;
|
||||
const displayVersion = getDisplayVersionLabel(versionItem.version);
|
||||
const branchName = versionItem.branch_name || "main";
|
||||
@@ -5342,16 +5343,13 @@ ${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Ro
|
||||
</Workbook>`;
|
||||
|
||||
const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const safeVersionLabel = displayVersion.replace(/[^\w.-]/g, "_");
|
||||
const filename = `setup-config-${safeVersionLabel}-${project.value.code || project.value.id}.xls`;
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
await saveFile({
|
||||
suggestedName: filename,
|
||||
mimeType: "application/vnd.ms-excel;charset=utf-8",
|
||||
data: blob,
|
||||
});
|
||||
};
|
||||
|
||||
const removeVersion = async (targetVersion: number) => {
|
||||
|
||||
@@ -4,7 +4,11 @@ import { resolve } from "node:path";
|
||||
|
||||
const readProjects = () => readFileSync(resolve(__dirname, "./Projects.vue"), "utf8");
|
||||
const readRouter = () => readFileSync(resolve(__dirname, "../../router/index.ts"), "utf8");
|
||||
const readLayout = () => readFileSync(resolve(__dirname, "../../components/Layout.vue"), "utf8");
|
||||
const readLayout = () => [
|
||||
"../../components/WebLayout.vue",
|
||||
"../../components/DesktopLayout.vue",
|
||||
"../../components/layout/navigation.ts",
|
||||
].map(path => readFileSync(resolve(__dirname, path), "utf8")).join("\n");
|
||||
|
||||
describe("project management access", () => {
|
||||
it("shows project management to all signed-in users while keeping system operations admin-only", () => {
|
||||
|
||||
@@ -121,7 +121,10 @@
|
||||
{{ TEXT.common.labels.preview }}
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="downloadVersion(row)" v-if="canReadDocument">
|
||||
{{ TEXT.modules.fileVersionManagement.actions.download }}
|
||||
另存为
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="openVersion(row)" v-if="canReadDocument">
|
||||
打开
|
||||
</el-button>
|
||||
<el-button v-if="canDeleteDocument" link type="danger" size="small" @click="confirmDeleteVersion(row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
@@ -261,8 +264,13 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
上传文件
|
||||
</div>
|
||||
<div class="upload-zone" :class="{ 'has-file': uploadFile }" @click="triggerFileInput">
|
||||
<input type="file" ref="fileInputRef" @change="onFileChange" class="file-input-hidden" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.png,.jpg" />
|
||||
<div
|
||||
class="upload-zone"
|
||||
:class="{ 'has-file': uploadFile }"
|
||||
@click="triggerFileInput"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleUploadDrop"
|
||||
>
|
||||
<template v-if="!uploadFile">
|
||||
<div class="upload-zone-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
@@ -342,7 +350,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
||||
import { Edit, Upload, Share } from "@element-plus/icons-vue";
|
||||
@@ -370,6 +378,8 @@ import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
|
||||
import { usePermission } from "../../utils/permission";
|
||||
import StateError from "../../components/StateError.vue";
|
||||
import StateLoading from "../../components/StateLoading.vue";
|
||||
import { openFile, pickFiles, saveFile } from "../../runtime";
|
||||
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
|
||||
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
@@ -377,6 +387,7 @@ const study = useStudyStore();
|
||||
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
|
||||
const { can } = usePermission();
|
||||
const documentId = computed(() => route.params.id as string);
|
||||
let desktopRefreshCleanup: (() => void) | undefined;
|
||||
|
||||
const loading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
@@ -494,7 +505,6 @@ const editorDirtyGuard = useDrawerDirtyGuard(() => editorForm);
|
||||
const uploadVisible = ref(false);
|
||||
const uploading = ref(false);
|
||||
const uploadFormRef = ref<FormInstance>();
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null);
|
||||
const uploadForm = reactive({
|
||||
version_no: "",
|
||||
version_date: "",
|
||||
@@ -513,8 +523,19 @@ const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
|
||||
: null,
|
||||
}));
|
||||
|
||||
const triggerFileInput = () => { fileInputRef.value?.click(); };
|
||||
const removeFile = () => { uploadFile.value = null; if (fileInputRef.value) fileInputRef.value.value = ""; };
|
||||
const triggerFileInput = async () => {
|
||||
const [file] = await pickFiles({
|
||||
multiple: false,
|
||||
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
|
||||
title: "选择文档版本",
|
||||
});
|
||||
if (file) uploadFile.value = file;
|
||||
};
|
||||
const handleUploadDrop = (event: DragEvent) => {
|
||||
const [file] = Array.from(event.dataTransfer?.files || []);
|
||||
if (file) uploadFile.value = file;
|
||||
};
|
||||
const removeFile = () => { uploadFile.value = null; };
|
||||
|
||||
const distributeVisible = ref(false);
|
||||
const distributing = ref(false);
|
||||
@@ -706,11 +727,6 @@ const openUpload = () => {
|
||||
uploadVisible.value = true;
|
||||
};
|
||||
|
||||
const onFileChange = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files && target.files[0]) uploadFile.value = target.files[0];
|
||||
};
|
||||
|
||||
const submitUpload = async () => {
|
||||
if (!canUpdateDocument.value) { ElMessage.warning("权限不足"); return; }
|
||||
if (!uploadFormRef.value) return;
|
||||
@@ -797,13 +813,25 @@ const downloadVersion = async (version: DocumentVersion) => {
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a"); link.href = url; link.download = filename;
|
||||
document.body.appendChild(link); link.click(); link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed); }
|
||||
};
|
||||
|
||||
const openVersion = async (version: DocumentVersion) => {
|
||||
try {
|
||||
const response = await downloadDocumentVersion(version.id);
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
|
||||
await openFile({
|
||||
suggestedName: filename,
|
||||
mimeType: contentType,
|
||||
data: new Blob([response.data], { type: contentType }),
|
||||
});
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDeleteVersion = async (version: DocumentVersion) => {
|
||||
if (!version?.id) return;
|
||||
if (!canDeleteDocument.value) { ElMessage.warning("权限不足"); return; }
|
||||
@@ -871,9 +899,14 @@ watch(previewVisible, (visible) => {
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
desktopRefreshCleanup = onDesktopRefreshCurrentView(loadDetail);
|
||||
await loadRoleTemplates();
|
||||
await loadDetail();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
desktopRefreshCleanup?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, computed, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
@@ -178,6 +178,7 @@ import type { DocumentSummary } from "../../types/documents";
|
||||
import type { Site } from "../../types/api";
|
||||
import { displayDateTime, displayEnum, displayText } from "../../utils/display";
|
||||
import { TEXT } from "../../locales";
|
||||
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -191,6 +192,7 @@ const editorVisible = ref(false);
|
||||
const saving = ref(false);
|
||||
const editingDocumentId = ref("");
|
||||
const editorFormRef = ref<FormInstance>();
|
||||
let desktopRefreshCleanup: (() => void) | undefined;
|
||||
|
||||
const trialId = computed(() => (route.params.trialId as string) || "");
|
||||
|
||||
@@ -436,6 +438,14 @@ onMounted(() => {
|
||||
ensureTrialRoute();
|
||||
load();
|
||||
loadSites();
|
||||
desktopRefreshCleanup = onDesktopRefreshCurrentView(() => {
|
||||
load();
|
||||
loadSites();
|
||||
});
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
desktopRefreshCleanup?.();
|
||||
});
|
||||
|
||||
watch(
|
||||
|
||||
@@ -132,20 +132,17 @@
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>问题导入模板</span>
|
||||
</el-button>
|
||||
<el-upload
|
||||
<el-button
|
||||
v-if="canCreateIssue"
|
||||
ref="importUploadRef"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".xlsx,.csv"
|
||||
:on-change="onImportChange"
|
||||
:disabled="importing"
|
||||
class="toolbar-button toolbar-button-warning"
|
||||
type="warning"
|
||||
plain
|
||||
:loading="importing"
|
||||
@click="selectImportFile"
|
||||
>
|
||||
<el-button class="toolbar-button toolbar-button-warning" type="warning" plain :loading="importing">
|
||||
<el-icon><Upload /></el-icon>
|
||||
<span>导入问题</span>
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<el-icon><Upload /></el-icon>
|
||||
<span>导入问题</span>
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="toolbar-icons">
|
||||
<el-button circle title="刷新" @click="loadIssues">
|
||||
@@ -455,7 +452,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile, type UploadInstance } from "element-plus";
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
|
||||
import { CircleCheck, Delete, Document, Download, Location, Plus, Refresh, Search, Upload } from "@element-plus/icons-vue";
|
||||
import {
|
||||
createMonitoringVisitIssue,
|
||||
@@ -469,6 +466,7 @@ import {
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
import { pickFiles, saveFile } from "../../runtime";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import type { Site } from "../../types/api";
|
||||
@@ -523,7 +521,6 @@ const viewDialogVisible = ref(false);
|
||||
const formMode = ref<"create" | "edit">("create");
|
||||
const editingIssueId = ref("");
|
||||
const createFormRef = ref<FormInstance>();
|
||||
const importUploadRef = ref<UploadInstance>();
|
||||
const allItems = ref<MonitoringIssueRow[]>([]);
|
||||
const viewIssue = ref<MonitoringIssueRow | null>(null);
|
||||
const sitesLoading = ref(false);
|
||||
@@ -981,14 +978,7 @@ const handleExportExcel = async () => {
|
||||
const contentType = response.headers?.["content-type"] || "application/octet-stream";
|
||||
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
|
||||
const blob = new Blob([response.data], { type: contentType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
|
||||
ElMessage.success("导出成功");
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
|
||||
@@ -997,11 +987,9 @@ const handleExportExcel = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onImportChange = async (uploadFile: UploadFile) => {
|
||||
const importIssueFile = async (file: File) => {
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId || !canCreateIssue.value) return;
|
||||
const file = uploadFile.raw;
|
||||
if (!file) return;
|
||||
|
||||
importing.value = true;
|
||||
try {
|
||||
@@ -1018,10 +1006,18 @@ const onImportChange = async (uploadFile: UploadFile) => {
|
||||
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.uploadFailed);
|
||||
} finally {
|
||||
importing.value = false;
|
||||
importUploadRef.value?.clearFiles();
|
||||
}
|
||||
};
|
||||
|
||||
const selectImportFile = async () => {
|
||||
const [file] = await pickFiles({
|
||||
multiple: false,
|
||||
accept: ["xlsx", "csv"],
|
||||
title: "导入监查访视问题",
|
||||
});
|
||||
if (file) await importIssueFile(file);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => study.currentStudy?.id,
|
||||
(studyId) => {
|
||||
|
||||
@@ -27,3 +27,15 @@ describe("SubjectManagement drawer editor", () => {
|
||||
expect(source).not.toContain('router.push("/subjects/new")');
|
||||
});
|
||||
});
|
||||
|
||||
describe("SubjectManagement desktop list workflow", () => {
|
||||
it("selects rows for preview and opens details on explicit desktop actions", () => {
|
||||
const source = readSubjectManagementSource();
|
||||
|
||||
expect(source).toContain("@row-click=\"selectSubject\"");
|
||||
expect(source).toContain("@row-dblclick=\"openSubjectDetail\"");
|
||||
expect(source).toContain("@row-contextmenu=\"openSubjectContextMenu\"");
|
||||
expect(source).toContain("subject-preview-pane");
|
||||
expect(source).toContain("subject-context-menu");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<span v-if="selectedRows.length" class="selection-count">已选 {{ selectedRows.length }} 项</span>
|
||||
<el-button v-if="canCreateSubject" type="primary" @click="goNew" class="create-btn">
|
||||
<el-icon class="el-icon--left"><Plus /></el-icon>
|
||||
{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}
|
||||
@@ -31,77 +32,140 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
:data="pagedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
class="subject-table"
|
||||
:row-class-name="subjectRowClass"
|
||||
@row-click="onRowClick"
|
||||
table-layout="fixed"
|
||||
>
|
||||
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<div class="subject-info-cell">
|
||||
<span class="cell-mono cell-nowrap">{{ scope.row.subject_no || TEXT.common.fallback }}</span>
|
||||
<span v-for="badge in getAeBadges(scope.row)" :key="badge.type" :class="['ae-badge', `ae-badge-${badge.type}`]">
|
||||
<div class="subject-workbench">
|
||||
<div class="subject-table-pane" tabindex="0" @keydown.enter.prevent="openSelectedSubject">
|
||||
<el-table
|
||||
:data="pagedItems"
|
||||
v-loading="loading"
|
||||
style="width: 100%"
|
||||
class="subject-table"
|
||||
:row-class-name="subjectRowClass"
|
||||
highlight-current-row
|
||||
@selection-change="onSelectionChange"
|
||||
@row-click="selectSubject"
|
||||
@row-dblclick="openSubjectDetail"
|
||||
@row-contextmenu="openSubjectContextMenu"
|
||||
table-layout="fixed"
|
||||
>
|
||||
<el-table-column type="selection" width="42" />
|
||||
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
|
||||
<template #default="scope">
|
||||
<div class="subject-info-cell">
|
||||
<span class="cell-mono cell-nowrap">{{ scope.row.subject_no || TEXT.common.fallback }}</span>
|
||||
<span v-for="badge in getAeBadges(scope.row)" :key="badge.type" :class="['ae-badge', `ae-badge-${badge.type}`]">
|
||||
{{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
|
||||
<template #default="scope"><span class="cell-nowrap">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="consent_date" :label="TEXT.common.fields.consentDate">
|
||||
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.consent_date) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status">
|
||||
<template #default="scope"><span class="cell-nowrap">{{ displaySubjectStatus(scope.row) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate">
|
||||
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.enrollment_date) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
|
||||
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.completion_date) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
|
||||
<template #default="scope">
|
||||
<div class="cell-actions">
|
||||
<el-button v-if="canDeleteSubject" link type="danger" size="small" :disabled="isInactiveSite(scope.row.site_id)" @click.stop="remove(scope.row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<div v-if="!loading" class="table-empty">
|
||||
<div class="empty-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
</div>
|
||||
<span>{{ TEXT.modules.subjectManagement.empty }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div v-if="filteredItems.length > 0" class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.currentPage"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[5, 10, 20]"
|
||||
:total="filteredItems.length"
|
||||
layout="prev, pager, next, sizes, total"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="subject-preview-pane">
|
||||
<template v-if="selectedSubject">
|
||||
<div class="preview-head">
|
||||
<div>
|
||||
<div class="preview-kicker">当前参与者</div>
|
||||
<div class="preview-title">{{ selectedSubject.subject_no || TEXT.common.fallback }}</div>
|
||||
</div>
|
||||
<el-button size="small" type="primary" @click="openSubjectDetail(selectedSubject)">打开详情</el-button>
|
||||
</div>
|
||||
<dl class="preview-list">
|
||||
<dt>{{ TEXT.common.fields.site }}</dt>
|
||||
<dd>{{ siteMap[selectedSubject.site_id] || TEXT.common.fallback }}</dd>
|
||||
<dt>{{ TEXT.common.fields.status }}</dt>
|
||||
<dd>{{ displaySubjectStatus(selectedSubject) }}</dd>
|
||||
<dt>{{ TEXT.common.fields.consentDate }}</dt>
|
||||
<dd>{{ displayDate(selectedSubject.consent_date) }}</dd>
|
||||
<dt>{{ TEXT.common.fields.enrollmentDate }}</dt>
|
||||
<dd>{{ displayDate(selectedSubject.enrollment_date) }}</dd>
|
||||
<dt>{{ TEXT.common.fields.completionDate }}</dt>
|
||||
<dd>{{ displayDate(selectedSubject.completion_date) }}</dd>
|
||||
</dl>
|
||||
<div v-if="getAeBadges(selectedSubject).length" class="preview-badges">
|
||||
<span v-for="badge in getAeBadges(selectedSubject)" :key="badge.type" :class="['ae-badge', `ae-badge-${badge.type}`]">
|
||||
{{ badge.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
|
||||
<template #default="scope"><span class="cell-nowrap">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="consent_date" :label="TEXT.common.fields.consentDate">
|
||||
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.consent_date) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" :label="TEXT.common.fields.status">
|
||||
<template #default="scope"><span class="cell-nowrap">{{ displaySubjectStatus(scope.row) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate">
|
||||
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.enrollment_date) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
|
||||
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.completion_date) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
|
||||
<template #default="scope">
|
||||
<div class="cell-actions">
|
||||
<el-button v-if="canDeleteSubject" link type="danger" size="small" :disabled="isInactiveSite(scope.row.site_id)" @click.stop="remove(scope.row)">
|
||||
{{ TEXT.common.actions.delete }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<div v-if="!loading" class="table-empty">
|
||||
<div v-else class="preview-empty">
|
||||
<div class="empty-icon">
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>
|
||||
</div>
|
||||
<span>{{ TEXT.modules.subjectManagement.empty }}</span>
|
||||
<span>选择一行查看详情摘要</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div v-if="filteredItems.length > 0" class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="pagination.currentPage"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:page-sizes="[5, 10, 20]"
|
||||
:total="filteredItems.length"
|
||||
layout="prev, pager, next, sizes, total"
|
||||
small
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="contextMenu.visible"
|
||||
class="subject-context-menu"
|
||||
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<button type="button" @click="openSelectedSubject">打开详情</button>
|
||||
<button type="button" @click="copySelectedSubjectNo">复制编号</button>
|
||||
<button
|
||||
v-if="canDeleteSubject"
|
||||
type="button"
|
||||
class="danger"
|
||||
:disabled="!selectedSubject || isInactiveSite(selectedSubject.site_id)"
|
||||
@click="selectedSubject && remove(selectedSubject)"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<SubjectEditorDrawer v-model="subjectDrawerVisible" @success="handleSubjectEditorSuccess" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { Plus } from "@element-plus/icons-vue";
|
||||
@@ -114,6 +178,7 @@ import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { isSystemAdmin } from "../../utils/roles";
|
||||
import { TEXT } from "../../locales";
|
||||
import SubjectEditorDrawer from "../subjects/SubjectEditorDrawer.vue";
|
||||
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
@@ -121,6 +186,9 @@ const study = useStudyStore();
|
||||
const loading = ref(false);
|
||||
const subjectDrawerVisible = ref(false);
|
||||
const items = ref<any[]>([]);
|
||||
const selectedSubjectId = ref("");
|
||||
const selectedRows = ref<any[]>([]);
|
||||
const contextMenu = ref({ visible: false, x: 0, y: 0 });
|
||||
const siteOptions = ref<Array<{ id: string; name: string }>>([]);
|
||||
const siteMap = ref<Record<string, string>>({});
|
||||
const siteActiveMap = ref<Record<string, boolean>>({});
|
||||
@@ -133,6 +201,7 @@ const pagination = ref({
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
let desktopRefreshCleanup: (() => void) | undefined;
|
||||
const statusOptions = Object.entries(TEXT.enums.subjectStatus).map(([value, label]) => ({ value, label }));
|
||||
const resetFilters = () => {
|
||||
filters.value.keyword = "";
|
||||
@@ -185,8 +254,45 @@ const load = async () => {
|
||||
const goNew = () => { subjectDrawerVisible.value = true; };
|
||||
const goDetail = (id: string) => router.push(`/subjects/${id}`);
|
||||
const handleSubjectEditorSuccess = () => { load(); };
|
||||
const onRowClick = (row: any) => { if (!row?.id) return; goDetail(row.id); };
|
||||
const selectedSubject = computed(() => items.value.find((item) => item.id === selectedSubjectId.value) || null);
|
||||
const selectSubject = (row: any) => {
|
||||
contextMenu.value.visible = false;
|
||||
if (!row?.id) return;
|
||||
selectedSubjectId.value = row.id;
|
||||
};
|
||||
const openSubjectDetail = (row: any) => {
|
||||
if (!row?.id) return;
|
||||
goDetail(row.id);
|
||||
};
|
||||
const openSelectedSubject = () => {
|
||||
contextMenu.value.visible = false;
|
||||
if (selectedSubject.value?.id) goDetail(selectedSubject.value.id);
|
||||
};
|
||||
const onSelectionChange = (rows: any[]) => {
|
||||
selectedRows.value = rows;
|
||||
};
|
||||
const openSubjectContextMenu = (row: any, _column: unknown, event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
if (!row?.id) return;
|
||||
selectedSubjectId.value = row.id;
|
||||
contextMenu.value = {
|
||||
visible: true,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
};
|
||||
};
|
||||
const closeSubjectContextMenu = () => {
|
||||
contextMenu.value.visible = false;
|
||||
};
|
||||
const copySelectedSubjectNo = async () => {
|
||||
contextMenu.value.visible = false;
|
||||
const subjectNo = selectedSubject.value?.subject_no;
|
||||
if (!subjectNo) return;
|
||||
await navigator.clipboard?.writeText(subjectNo);
|
||||
ElMessage.success("编号已复制");
|
||||
};
|
||||
const remove = async (row: any) => {
|
||||
contextMenu.value.visible = false;
|
||||
const studyId = study.currentStudy?.id;
|
||||
if (!studyId) return;
|
||||
if (!canDeleteSubject.value) return;
|
||||
@@ -230,7 +336,11 @@ const pagedItems = computed(() => {
|
||||
return sortedItems.value.slice(start, end);
|
||||
});
|
||||
const subjectRowClass = ({ row }: { row: any }) =>
|
||||
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
|
||||
[
|
||||
row?.id ? "clickable-row" : "",
|
||||
row?.id && row.id === selectedSubjectId.value ? "row-selected" : "",
|
||||
isInactiveSite(row?.site_id) ? "row-inactive" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
|
||||
watch(() => [filters.value.keyword, filters.value.siteId, filters.value.status, pagination.value.pageSize], () => { pagination.value.currentPage = 1; });
|
||||
watch(() => filteredItems.value.length, (total) => {
|
||||
@@ -238,8 +348,30 @@ watch(() => filteredItems.value.length, (total) => {
|
||||
if (pagination.value.currentPage > maxPage) pagination.value.currentPage = maxPage;
|
||||
});
|
||||
watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
|
||||
watch(pagedItems, (rows) => {
|
||||
if (!rows.length) {
|
||||
selectedSubjectId.value = "";
|
||||
return;
|
||||
}
|
||||
if (!rows.some((item) => item.id === selectedSubjectId.value)) {
|
||||
selectedSubjectId.value = rows[0].id;
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => { await loadSites(); load(); });
|
||||
onMounted(async () => {
|
||||
document.addEventListener("click", closeSubjectContextMenu);
|
||||
desktopRefreshCleanup = onDesktopRefreshCurrentView(() => {
|
||||
loadSites();
|
||||
load();
|
||||
});
|
||||
await loadSites();
|
||||
load();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("click", closeSubjectContextMenu);
|
||||
desktopRefreshCleanup?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -256,6 +388,24 @@ onMounted(async () => { await loadSites(); load(); });
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.subject-workbench {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.subject-table-pane {
|
||||
min-width: 0;
|
||||
border-right: 1px solid #edf1f7;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.subject-preview-pane {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
background: #fbfcff;
|
||||
}
|
||||
|
||||
/* ==================== Toolbar ==================== */
|
||||
.table-card-toolbar {
|
||||
display: flex;
|
||||
@@ -281,6 +431,13 @@ onMounted(async () => { await loadSites(); load(); });
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.selection-count {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -342,6 +499,10 @@ onMounted(async () => { await loadSites(); load(); });
|
||||
|
||||
.subject-table :deep(.el-table__body tr) { cursor: pointer; transition: background 0.1s ease; }
|
||||
|
||||
.subject-table :deep(.el-table__body tr.row-selected > td) {
|
||||
background: #edf5ff !important;
|
||||
}
|
||||
|
||||
/* ==================== Cell Helpers ==================== */
|
||||
.cell-nowrap { white-space: nowrap; }
|
||||
.cell-mono { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 13px; color: #0a0a0a; font-weight: 600; }
|
||||
@@ -357,6 +518,106 @@ onMounted(async () => { await loadSites(); load(); });
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preview-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.preview-kicker {
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
margin-top: 4px;
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.preview-list {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 10px 12px;
|
||||
margin: 16px 0 0;
|
||||
}
|
||||
|
||||
.preview-list dt {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preview-list dd {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.preview-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.preview-empty {
|
||||
min-height: 360px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.subject-context-menu {
|
||||
position: fixed;
|
||||
z-index: 3000;
|
||||
min-width: 132px;
|
||||
padding: 5px;
|
||||
border: 1px solid #d7e2f0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.subject-context-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.subject-context-menu button:hover:not(:disabled) {
|
||||
background: #eef4ff;
|
||||
}
|
||||
|
||||
.subject-context-menu button.danger {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.subject-context-menu button:disabled {
|
||||
color: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ae-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -419,6 +680,9 @@ onMounted(async () => { await loadSites(); load(); });
|
||||
|
||||
/* ==================== Responsive ==================== */
|
||||
@media (max-width: 960px) {
|
||||
.subject-workbench { grid-template-columns: 1fr; }
|
||||
.subject-table-pane { border-right: 0; }
|
||||
.subject-preview-pane { display: none; }
|
||||
.toolbar-filters { flex-direction: column; align-items: stretch; }
|
||||
.filter-input, .filter-select { width: 100%; }
|
||||
.table-card-toolbar { flex-direction: column; gap: 12px; align-items: stretch; }
|
||||
|
||||
Reference in New Issue
Block a user