feat(desktop): 稳定桌面端界面与文件操作反馈
- 重构 DesktopPreferences 为分栏式设置面板,整合连接、外观、通知、更新与诊断信息分区,并补充过渡动效与暗色主题样式 - DesktopLayout 侧边栏导航分组支持展开折叠,调整管理/项目区块顺序并统一图标与标题 - 新增 fileTaskFeedback 工具,统一 pickFiles/saveFile/openFile 的成功/取消提示,替换审计导出、权限日志、附件、文档、线程、项目配置等处的直接调用 - desktopUpdateManager 暴露更新状态快照与状态变更监听,区分检查中、安装中、已推迟、失败等状态 - DesktopServerSettings 增加连接诊断信息(检查时间、健康地址、耗时、HTTP 状态) - unified-page.css 与 ProjectMilestones 引入 CSS 变量以适配暗色主题 - WebLayout 将服务器设置入口改为打开系统偏好面板,管理菜单中邮件服务归入系统设置分组 - ProfileSettings 移除已迁入偏好面板的桌面端专属区块 - 补充 Layout.desktop 布局与偏好面板契约测试
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,19 @@
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div v-if="connectionDiagnostic" class="connection-diagnostic">
|
||||
<div class="diagnostic-grid">
|
||||
<span>检查时间</span>
|
||||
<strong>{{ connectionDiagnostic.checkedAt }}</strong>
|
||||
<span>健康检查</span>
|
||||
<code>{{ connectionDiagnostic.healthUrl }}</code>
|
||||
<span>耗时</span>
|
||||
<strong>{{ connectionDiagnostic.durationMs }}ms</strong>
|
||||
<span>HTTP</span>
|
||||
<strong>{{ connectionDiagnostic.httpStatus || "-" }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
<el-form-item label="服务器地址" :error="urlError">
|
||||
<el-input
|
||||
@@ -33,10 +46,6 @@
|
||||
/>
|
||||
</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">
|
||||
@@ -51,7 +60,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { ElMessageBox } from "element-plus";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime";
|
||||
@@ -65,13 +74,46 @@ 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 connectionDiagnostic = ref<{
|
||||
healthUrl: string;
|
||||
checkedAt: string;
|
||||
durationMs: number;
|
||||
httpStatus?: number;
|
||||
} | null>(null);
|
||||
const canCancel = computed(() => Boolean(currentServerUrl));
|
||||
const HEALTH_TIMEOUT_MS = 10_000;
|
||||
|
||||
const formatDiagnosticTime = (date = new Date()) =>
|
||||
date.toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
const toConnectionError = (
|
||||
message: string,
|
||||
details: {
|
||||
serverUrl: string;
|
||||
healthUrl: string;
|
||||
durationMs: number;
|
||||
httpStatus?: number;
|
||||
},
|
||||
) => Object.assign(new Error(message), details);
|
||||
|
||||
const checkHealth = async (baseUrl: string) => {
|
||||
const healthUrl = new URL("health", baseUrl).toString();
|
||||
const controller = new AbortController();
|
||||
const startedAt = performance.now();
|
||||
const timeout = window.setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
|
||||
const details = () => ({
|
||||
serverUrl: baseUrl,
|
||||
healthUrl,
|
||||
durationMs: Math.max(0, Math.round(performance.now() - startedAt)),
|
||||
});
|
||||
try {
|
||||
const response = await fetch(healthUrl, {
|
||||
method: "GET",
|
||||
@@ -79,14 +121,21 @@ const checkHealth = async (baseUrl: string) => {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`服务器健康检查返回 HTTP ${response.status}`);
|
||||
throw toConnectionError(`服务器健康检查返回 HTTP ${response.status}`, {
|
||||
...details(),
|
||||
httpStatus: response.status,
|
||||
});
|
||||
}
|
||||
return {
|
||||
...details(),
|
||||
httpStatus: response.status,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
throw new Error("连接超时,请确认服务端地址和网络状态");
|
||||
throw toConnectionError("连接超时,请确认服务端地址和网络状态", details());
|
||||
}
|
||||
if (error instanceof TypeError) {
|
||||
throw new Error("网络请求失败,请确认地址、证书或 CORS 配置");
|
||||
throw toConnectionError("网络请求失败,请确认地址、证书或 CORS 配置", details());
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -99,6 +148,21 @@ const clearSessionForServerChange = async () => {
|
||||
studyStore.clearCurrentStudy();
|
||||
};
|
||||
|
||||
const confirmServerChange = async (previous: string | null, next: string) => {
|
||||
if (!previous || previous === next) return true;
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
"切换服务器会退出当前会话并清除当前项目上下文,确认后需要重新登录。",
|
||||
"确认切换服务器",
|
||||
{
|
||||
type: "warning",
|
||||
confirmButtonText: "切换并退出登录",
|
||||
cancelButtonText: "继续编辑",
|
||||
distinguishCancelAndClose: true,
|
||||
},
|
||||
).catch(() => null);
|
||||
return Boolean(confirmed);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
urlError.value = "";
|
||||
connectionStatus.value = null;
|
||||
@@ -110,8 +174,9 @@ const save = async () => {
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await checkHealth(normalized.url);
|
||||
const health = await checkHealth(normalized.url);
|
||||
const previous = getDesktopServerUrl();
|
||||
if (!(await confirmServerChange(previous, normalized.url))) return;
|
||||
const result = setDesktopServerUrl(normalized.url);
|
||||
if (!result.ok) {
|
||||
urlError.value = result.reason;
|
||||
@@ -125,15 +190,32 @@ const save = async () => {
|
||||
title: "连接已确认",
|
||||
message: result.url,
|
||||
};
|
||||
ElMessage.success("服务器连接已确认");
|
||||
connectionDiagnostic.value = {
|
||||
healthUrl: health.healthUrl,
|
||||
checkedAt: formatDiagnosticTime(),
|
||||
durationMs: health.durationMs,
|
||||
httpStatus: health.httpStatus,
|
||||
};
|
||||
router.replace("/login");
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "无法连接服务器的 /health";
|
||||
const details = error as Error & {
|
||||
serverUrl?: string;
|
||||
healthUrl?: string;
|
||||
durationMs?: number;
|
||||
httpStatus?: number;
|
||||
};
|
||||
connectionStatus.value = {
|
||||
type: "error",
|
||||
title: "连接检查失败",
|
||||
message,
|
||||
};
|
||||
connectionDiagnostic.value = {
|
||||
healthUrl: details.healthUrl || new URL("health", normalized.url).toString(),
|
||||
checkedAt: formatDiagnosticTime(),
|
||||
durationMs: details.durationMs ?? 0,
|
||||
httpStatus: details.httpStatus,
|
||||
};
|
||||
urlError.value = message;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
@@ -215,11 +297,31 @@ h1 {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: -8px;
|
||||
.connection-diagnostic {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
margin-bottom: 18px;
|
||||
padding: 12px;
|
||||
border: 1px solid #dbe6f2;
|
||||
border-radius: 8px;
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.diagnostic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 6px 10px;
|
||||
min-width: 0;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.diagnostic-grid strong {
|
||||
min-width: 0;
|
||||
color: #1e293b;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.actions {
|
||||
|
||||
@@ -64,45 +64,6 @@
|
||||
</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>
|
||||
@@ -118,25 +79,9 @@ import type { FormInstance, FormRules } from "element-plus";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Close, Upload } from "@element-plus/icons-vue";
|
||||
import { updateProfile, fetchMe, uploadAvatar } from "../api/auth";
|
||||
import {
|
||||
getDesktopNotificationSubscription,
|
||||
setDesktopNotificationSubscription,
|
||||
} from "../api/desktopNotifications";
|
||||
import { useAuthStore } from "../store/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";
|
||||
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
|
||||
|
||||
const emit = defineEmits<{
|
||||
"close-request": [];
|
||||
@@ -144,31 +89,6 @@ 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({
|
||||
@@ -247,72 +167,6 @@ 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) => {
|
||||
@@ -345,7 +199,7 @@ const onSubmit = async () => {
|
||||
};
|
||||
|
||||
const selectAndUploadAvatar = async () => {
|
||||
const [file] = await pickFiles({
|
||||
const [file] = await pickFilesWithFeedback({
|
||||
multiple: false,
|
||||
accept: ["png", "jpg", "jpeg", "gif", "webp"],
|
||||
title: TEXT.modules.profile.uploadAvatar,
|
||||
@@ -368,7 +222,6 @@ const selectAndUploadAvatar = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
loadProfile();
|
||||
loadDesktopNotificationSubscription().catch(() => {});
|
||||
});
|
||||
|
||||
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
|
||||
@@ -483,59 +336,6 @@ 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;
|
||||
|
||||
@@ -1954,7 +1954,7 @@ import {
|
||||
type SetupWorkflowTagMeta,
|
||||
} from "../../utils/setupPublishWorkflow";
|
||||
import { useSetupConfig } from "../../composables/useSetupConfig";
|
||||
import { saveFile } from "../../runtime";
|
||||
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
|
||||
|
||||
type SetupStepKey =
|
||||
| "project-info"
|
||||
@@ -5345,7 +5345,7 @@ ${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Ro
|
||||
const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
|
||||
const safeVersionLabel = displayVersion.replace(/[^\w.-]/g, "_");
|
||||
const filename = `setup-config-${safeVersionLabel}-${project.value.code || project.value.id}.xls`;
|
||||
await saveFile({
|
||||
await saveFileWithFeedback({
|
||||
suggestedName: filename,
|
||||
mimeType: "application/vnd.ms-excel;charset=utf-8",
|
||||
data: blob,
|
||||
|
||||
@@ -378,8 +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";
|
||||
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
|
||||
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
@@ -524,7 +524,7 @@ const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
|
||||
}));
|
||||
|
||||
const triggerFileInput = async () => {
|
||||
const [file] = await pickFiles({
|
||||
const [file] = await pickFilesWithFeedback({
|
||||
multiple: false,
|
||||
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
|
||||
title: "选择文档版本",
|
||||
@@ -813,7 +813,7 @@ 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 });
|
||||
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
|
||||
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob });
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed); }
|
||||
};
|
||||
|
||||
@@ -822,7 +822,7 @@ const openVersion = async (version: DocumentVersion) => {
|
||||
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({
|
||||
await openFileWithFeedback({
|
||||
suggestedName: filename,
|
||||
mimeType: contentType,
|
||||
data: new Blob([response.data], { type: contentType }),
|
||||
|
||||
@@ -480,7 +480,7 @@ watch(
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #0f2345;
|
||||
color: var(--unified-title-color);
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
@@ -520,11 +520,11 @@ watch(
|
||||
border-radius: 50%;
|
||||
border: 3px solid #2f84c6;
|
||||
box-sizing: border-box;
|
||||
background: #ffffff;
|
||||
background: var(--ctms-bg-card);
|
||||
}
|
||||
|
||||
.plan-time-label {
|
||||
color: #4b5563;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -546,10 +546,10 @@ watch(
|
||||
}
|
||||
|
||||
.time-editor-group {
|
||||
border: 1px solid #e8eef6;
|
||||
border: 1px solid var(--ctms-border-color);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px 10px;
|
||||
background: #fbfcfe;
|
||||
background: var(--ctms-bg-muted);
|
||||
}
|
||||
|
||||
.time-editor-group + .time-editor-group {
|
||||
@@ -566,7 +566,7 @@ watch(
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
color: #1a3560;
|
||||
color: var(--ctms-text-main);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -600,14 +600,14 @@ watch(
|
||||
|
||||
.duration-label {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.duration-value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1a3560;
|
||||
color: var(--ctms-text-main);
|
||||
}
|
||||
|
||||
.time-editor-footer {
|
||||
@@ -628,7 +628,7 @@ watch(
|
||||
.time-editor-form :deep(.el-form-item__label) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #4a6283;
|
||||
color: var(--ctms-text-regular);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
@@ -655,7 +655,7 @@ watch(
|
||||
|
||||
.status-detail {
|
||||
padding-left: 14px;
|
||||
color: #6b7280;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@@ -684,7 +684,7 @@ watch(
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #8a97ab;
|
||||
color: var(--ctms-text-secondary);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
@@ -466,7 +466,7 @@ import {
|
||||
import { fetchSites } from "../../api/sites";
|
||||
import StateEmpty from "../../components/StateEmpty.vue";
|
||||
import { TEXT } from "../../locales";
|
||||
import { pickFiles, saveFile } from "../../runtime";
|
||||
import { pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
import type { Site } from "../../types/api";
|
||||
@@ -978,7 +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 });
|
||||
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
|
||||
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob });
|
||||
ElMessage.success("导出成功");
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
|
||||
@@ -1010,7 +1010,7 @@ const importIssueFile = async (file: File) => {
|
||||
};
|
||||
|
||||
const selectImportFile = async () => {
|
||||
const [file] = await pickFiles({
|
||||
const [file] = await pickFilesWithFeedback({
|
||||
multiple: false,
|
||||
accept: ["xlsx", "csv"],
|
||||
title: "导入监查访视问题",
|
||||
|
||||
Reference in New Issue
Block a user