first
@@ -0,0 +1,158 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="会议核销SaaS系统 - 专业的多租户会议核销管理平台" />
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg viewBox='0 0 32 32' xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='32' height='32' rx='8' fill='%236366f1'/%3E%3Cpath d='M10 16L14.5 20.5L22 11.5' stroke='white' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E" />
|
||||
<title>会议核销系统</title>
|
||||
<style>
|
||||
:root {
|
||||
--boot-theme-rgb: 60, 110, 145;
|
||||
--boot-brand-gradient: linear-gradient(135deg, #3c6e91 0%, #5c88a7 100%);
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
html {
|
||||
min-width: 1200px;
|
||||
}
|
||||
.boot-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 4000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: opacity 0.28s ease, visibility 0.28s ease;
|
||||
}
|
||||
.boot-overlay.is-leaving {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
.boot-overlay__backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(var(--boot-theme-rgb), 0.28), transparent 42%),
|
||||
linear-gradient(135deg, rgba(15, 23, 42, 0.96), rgba(31, 41, 55, 0.9));
|
||||
}
|
||||
.boot-overlay__content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
width: min(420px, calc(100vw - 48px));
|
||||
padding: 32px 28px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.04));
|
||||
box-shadow:
|
||||
0 24px 80px rgba(15, 23, 42, 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.boot-overlay__mark {
|
||||
position: relative;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
.boot-overlay__mark-ring,
|
||||
.boot-overlay__mark-core {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.boot-overlay__mark-ring {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-top-color: rgba(255, 255, 255, 0.9);
|
||||
border-right-color: rgba(var(--boot-theme-rgb), 0.78);
|
||||
animation: boot-overlay-spin 1.2s linear infinite;
|
||||
}
|
||||
.boot-overlay__mark-core {
|
||||
inset: 14px;
|
||||
background: var(--boot-brand-gradient);
|
||||
box-shadow:
|
||||
0 10px 24px rgba(var(--boot-theme-rgb), 0.4),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.28);
|
||||
}
|
||||
.boot-overlay__text {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
color: #f8fafc;
|
||||
}
|
||||
.boot-overlay__text strong {
|
||||
font-size: 22px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.boot-overlay__text span {
|
||||
color: rgba(226, 232, 240, 0.78);
|
||||
font-size: 14px;
|
||||
}
|
||||
.boot-overlay__bar {
|
||||
position: relative;
|
||||
height: 6px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.boot-overlay__bar-fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 42%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.92), rgba(var(--boot-theme-rgb), 0.84));
|
||||
animation: boot-overlay-progress 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes boot-overlay-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes boot-overlay-progress {
|
||||
0% {
|
||||
transform: translateX(-120%);
|
||||
}
|
||||
55% {
|
||||
transform: translateX(115%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(180%);
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.boot-overlay__content {
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
padding: 28px 22px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.boot-overlay__text strong {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app-boot-overlay" class="boot-overlay" role="status" aria-live="polite" aria-label="页面加载中">
|
||||
<div class="boot-overlay__backdrop"></div>
|
||||
<div class="boot-overlay__content">
|
||||
<div class="boot-overlay__mark">
|
||||
<span class="boot-overlay__mark-ring"></span>
|
||||
<span class="boot-overlay__mark-core"></span>
|
||||
</div>
|
||||
<div class="boot-overlay__text">
|
||||
<strong>会议核销系统</strong>
|
||||
<span>正在初始化工作台...</span>
|
||||
</div>
|
||||
<div class="boot-overlay__bar" aria-hidden="true">
|
||||
<span class="boot-overlay__bar-fill"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "writeoff-frontend",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.7.7",
|
||||
"compressorjs": "^1.3.0",
|
||||
"element-plus": "^2.8.4",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.10",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"typescript": "^5.6.2",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<el-config-provider :size="elementSize">
|
||||
<RouterView />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useAppearanceStore } from "./stores/appearance";
|
||||
|
||||
const appearanceStore = useAppearanceStore();
|
||||
const { elementPlusSize: elementSize } = storeToRefs(appearanceStore);
|
||||
</script>
|
||||
@@ -0,0 +1,148 @@
|
||||
import axios from "axios";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { pinia } from "../stores";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { resolveLoginPath } from "../utils/authNavigation";
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: "/api",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
const FORCE_LOGOUT_CODES = new Set([11001, 11003, 11004, 11005, 11006, 11007]);
|
||||
let refreshPromise: Promise<string> | null = null;
|
||||
let pendingForceLogout = false;
|
||||
const getAuthStore = () => useAuthStore(pinia);
|
||||
|
||||
const isAuthSessionEndpoint = (url: string): boolean => {
|
||||
return /\/auth\/(login|platform-login|refresh|logout|logout-all)/.test(url);
|
||||
};
|
||||
|
||||
const resolveLoginPathByScope = (): string => {
|
||||
const authStore = getAuthStore();
|
||||
return resolveLoginPath(authStore.scope, authStore.tenantCode);
|
||||
};
|
||||
|
||||
const getForceLogoutMessage = (code: number): string => {
|
||||
if (code === 11003) {
|
||||
return "会话失效,请重新登录";
|
||||
}
|
||||
if (code === 11004) {
|
||||
return "账号有效期异常,请重新登录";
|
||||
}
|
||||
if (code === 11005 || code === 11006 || code === 11007) {
|
||||
return "登录状态异常,请重新登录";
|
||||
}
|
||||
return "登录状态已失效,请重新登录";
|
||||
};
|
||||
|
||||
const forceLogoutAndRedirect = (message?: string) => {
|
||||
if (pendingForceLogout) {
|
||||
return;
|
||||
}
|
||||
pendingForceLogout = true;
|
||||
const nextLoginPath = resolveLoginPathByScope();
|
||||
if (message) {
|
||||
ElMessage.error(message);
|
||||
}
|
||||
// Delay cleanup/redirect so business catch can receive response details first.
|
||||
window.setTimeout(() => {
|
||||
getAuthStore().clearAuthStorage();
|
||||
window.location.href = nextLoginPath;
|
||||
}, 600);
|
||||
};
|
||||
|
||||
const ensureRefreshedToken = async (): Promise<string> => {
|
||||
if (refreshPromise) {
|
||||
return refreshPromise;
|
||||
}
|
||||
refreshPromise = axios.post("/api/auth/refresh", null, { timeout: 10000 })
|
||||
.then((resp) => {
|
||||
const payload = resp?.data?.data || {};
|
||||
const token = String(payload?.token || "").trim();
|
||||
if (!token) {
|
||||
throw new Error("refresh token missing");
|
||||
}
|
||||
getAuthStore().saveAuthPayload(payload);
|
||||
return token;
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
return refreshPromise;
|
||||
};
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = getAuthStore().token;
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
http.interceptors.response.use((resp) => resp.data, (error) => {
|
||||
const requestUrl = String(error?.config?.url || "");
|
||||
const businessCode = Number(error?.response?.data?.code || 0);
|
||||
const isAuthRequest = isAuthSessionEndpoint(requestUrl);
|
||||
const originalRequest = error?.config as any;
|
||||
if (!isAuthRequest && businessCode === 11002 && !originalRequest?._retry) {
|
||||
originalRequest._retry = true;
|
||||
return ensureRefreshedToken()
|
||||
.then((token) => {
|
||||
originalRequest.headers = originalRequest.headers || {};
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return http.request(originalRequest);
|
||||
})
|
||||
.catch((refreshError) => {
|
||||
forceLogoutAndRedirect("会话已过期,请重新登录");
|
||||
return Promise.reject(refreshError);
|
||||
});
|
||||
}
|
||||
if (
|
||||
!isAuthRequest &&
|
||||
FORCE_LOGOUT_CODES.has(businessCode)
|
||||
) {
|
||||
const backendMessage = error?.response?.data?.message || error?.response?.data?.msg || error?.response?.data?.error;
|
||||
const fallbackMessage = backendMessage || getForceLogoutMessage(businessCode);
|
||||
if (!error?.response) {
|
||||
error.response = {
|
||||
status: 422,
|
||||
data: {
|
||||
code: businessCode || 11003,
|
||||
message: fallbackMessage,
|
||||
},
|
||||
};
|
||||
} else if (!error.response.data) {
|
||||
error.response.data = {
|
||||
code: businessCode || 11003,
|
||||
message: fallbackMessage,
|
||||
};
|
||||
}
|
||||
forceLogoutAndRedirect(fallbackMessage);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
const backendMessage = error?.response?.data?.message || error?.response?.data?.msg || error?.response?.data?.error;
|
||||
let errorMessage = backendMessage;
|
||||
if (!errorMessage) {
|
||||
if (error?.code === "ECONNABORTED") {
|
||||
errorMessage = "请求超时,请稍后重试";
|
||||
} else if (error?.message === "Network Error") {
|
||||
errorMessage = "网络异常,请检查网络连接";
|
||||
} else if (error?.response?.status) {
|
||||
errorMessage = `请求失败(${error.response.status})`;
|
||||
} else {
|
||||
errorMessage = "请求失败,请稍后重试";
|
||||
}
|
||||
}
|
||||
// 从响应头或响应体中解析 requestId,附加到错误提示
|
||||
const requestId = error?.response?.headers?.["x-request-id"]
|
||||
|| error?.response?.data?.requestId
|
||||
|| "";
|
||||
if (requestId) {
|
||||
errorMessage = `${errorMessage}(RequestId: ${requestId})`;
|
||||
}
|
||||
ElMessage.error(errorMessage);
|
||||
return Promise.reject(error);
|
||||
});
|
||||
|
||||
export default http;
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<el-breadcrumb separator="/" class="breadcrumb-nav">
|
||||
<el-breadcrumb-item v-for="item in breadcrumbs" :key="item.path">
|
||||
<router-link v-if="item.path && item.path !== currentPath" :to="item.path" class="breadcrumb-link">
|
||||
{{ item.title }}
|
||||
</router-link>
|
||||
<span v-else class="breadcrumb-current">{{ item.title }}</span>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
const currentPath = computed(() => route.path);
|
||||
|
||||
const breadcrumbs = computed(() => {
|
||||
const matched = route.matched;
|
||||
const items: Array<{ title: string; path: string }> = [];
|
||||
|
||||
for (const record of matched) {
|
||||
const meta = record.meta as Record<string, unknown> | undefined;
|
||||
const title = String(meta?.title || "").trim();
|
||||
if (!title) continue;
|
||||
items.push({ title, path: record.path || "" });
|
||||
}
|
||||
|
||||
// If no breadcrumbs resolved, show at least current page title
|
||||
if (items.length === 0) {
|
||||
const meta = route.meta as Record<string, unknown> | undefined;
|
||||
const title = String(meta?.title || "当前页面").trim();
|
||||
items.push({ title, path: route.path });
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumb-nav {
|
||||
line-height: 60px;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
color: var(--el-text-color-secondary);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<button class="search-trigger" :class="{ 'search-trigger--light': light }" @click="openDialog">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<span class="search-trigger__label">全局搜索</span>
|
||||
<span class="search-trigger__shortcut">Ctrl/Cmd + K</span>
|
||||
</button>
|
||||
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="全局快速搜索"
|
||||
width="720px"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="true"
|
||||
@closed="handleClosed"
|
||||
>
|
||||
<div class="global-search">
|
||||
<el-input
|
||||
ref="searchInputRef"
|
||||
v-model="keyword"
|
||||
clearable
|
||||
size="large"
|
||||
placeholder="搜索菜单、会议、用户"
|
||||
@keydown.down.prevent="moveActive(1)"
|
||||
@keydown.up.prevent="moveActive(-1)"
|
||||
@keydown.enter.prevent="handleEnter"
|
||||
/>
|
||||
|
||||
<div class="global-search__hint">回车跳转,方向键切换结果</div>
|
||||
|
||||
<div v-if="loading" class="global-search__state">搜索中...</div>
|
||||
<div v-else-if="!trimmedKeyword" class="global-search__state">输入关键词开始搜索</div>
|
||||
<div v-else-if="indexedGroups.length === 0" class="global-search__state">未找到匹配结果</div>
|
||||
|
||||
<div v-else class="global-search__results">
|
||||
<section v-for="group in indexedGroups" :key="group.type" class="global-search__group">
|
||||
<div class="global-search__group-title">{{ group.label }}</div>
|
||||
<button
|
||||
v-for="item in group.items"
|
||||
:key="`${item.type}-${item.routePath}-${item.globalIndex}`"
|
||||
class="global-search__item"
|
||||
:class="{ 'global-search__item--active': item.globalIndex === activeIndex }"
|
||||
@mouseenter="activeIndex = item.globalIndex"
|
||||
@click="handleSelect(item.routePath)"
|
||||
>
|
||||
<div class="global-search__item-main">
|
||||
<span class="global-search__item-title">{{ item.title }}</span>
|
||||
<el-tag v-if="item.badge" size="small" effect="plain">{{ item.badge }}</el-tag>
|
||||
</div>
|
||||
<div class="global-search__item-subtitle">{{ item.subtitle || item.routePath }}</div>
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { fetchGlobalSearch } from "../api/modules";
|
||||
|
||||
type SearchItem = {
|
||||
type: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
routePath: string;
|
||||
badge?: string;
|
||||
};
|
||||
|
||||
type SearchGroup = {
|
||||
type: string;
|
||||
label: string;
|
||||
items: SearchItem[];
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
light?: boolean;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const visible = ref(false);
|
||||
const keyword = ref("");
|
||||
const loading = ref(false);
|
||||
const groups = ref<SearchGroup[]>([]);
|
||||
const activeIndex = ref(-1);
|
||||
const searchInputRef = ref<any>(null);
|
||||
let searchTimer: number | null = null;
|
||||
|
||||
const trimmedKeyword = computed(() => keyword.value.trim());
|
||||
const indexedGroups = computed(() => {
|
||||
let cursor = 0;
|
||||
return groups.value.map((group) => ({
|
||||
...group,
|
||||
items: (group.items || []).map((item) => ({
|
||||
...item,
|
||||
globalIndex: cursor++,
|
||||
})),
|
||||
}));
|
||||
});
|
||||
const flatItems = computed(() => indexedGroups.value.flatMap((group) => group.items));
|
||||
|
||||
const focusSearchInput = async () => {
|
||||
await nextTick();
|
||||
searchInputRef.value?.focus?.();
|
||||
};
|
||||
|
||||
const openDialog = async () => {
|
||||
visible.value = true;
|
||||
await focusSearchInput();
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
visible.value = false;
|
||||
};
|
||||
|
||||
const runSearch = async () => {
|
||||
const currentKeyword = trimmedKeyword.value;
|
||||
if (!currentKeyword) {
|
||||
groups.value = [];
|
||||
activeIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const resp = await fetchGlobalSearch({ q: currentKeyword, limitPerType: 5 });
|
||||
groups.value = Array.isArray(resp?.data?.groups) ? resp.data.groups : [];
|
||||
activeIndex.value = flatItems.value.length > 0 ? 0 : -1;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleSearch = () => {
|
||||
if (searchTimer !== null) {
|
||||
window.clearTimeout(searchTimer);
|
||||
}
|
||||
searchTimer = window.setTimeout(() => {
|
||||
searchTimer = null;
|
||||
runSearch();
|
||||
}, 180);
|
||||
};
|
||||
|
||||
const moveActive = (step: number) => {
|
||||
const items = flatItems.value;
|
||||
if (!items.length) {
|
||||
activeIndex.value = -1;
|
||||
return;
|
||||
}
|
||||
const currentIndex = activeIndex.value < 0 ? 0 : activeIndex.value;
|
||||
activeIndex.value = (currentIndex + step + items.length) % items.length;
|
||||
};
|
||||
|
||||
const handleEnter = async () => {
|
||||
const current = flatItems.value[activeIndex.value];
|
||||
if (!current?.routePath) {
|
||||
return;
|
||||
}
|
||||
await handleSelect(current.routePath);
|
||||
};
|
||||
|
||||
const handleSelect = async (routePath: string) => {
|
||||
if (!routePath) {
|
||||
return;
|
||||
}
|
||||
closeDialog();
|
||||
await router.push(routePath);
|
||||
};
|
||||
|
||||
const handleClosed = () => {
|
||||
keyword.value = "";
|
||||
groups.value = [];
|
||||
activeIndex.value = -1;
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const handleGlobalShortcut = (event: KeyboardEvent) => {
|
||||
if (!(event.ctrlKey || event.metaKey) || String(event.key || "").toLowerCase() !== "k") {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!visible.value) {
|
||||
openDialog();
|
||||
return;
|
||||
}
|
||||
focusSearchInput();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => trimmedKeyword.value,
|
||||
() => {
|
||||
if (!visible.value) {
|
||||
return;
|
||||
}
|
||||
scheduleSearch();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
(value) => {
|
||||
if (value) {
|
||||
focusSearchInput();
|
||||
return;
|
||||
}
|
||||
if (searchTimer !== null) {
|
||||
window.clearTimeout(searchTimer);
|
||||
searchTimer = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("keydown", handleGlobalShortcut);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("keydown", handleGlobalShortcut);
|
||||
if (searchTimer !== null) {
|
||||
window.clearTimeout(searchTimer);
|
||||
searchTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-trigger--light {
|
||||
background: var(--wo-light-action-bg);
|
||||
color: var(--wo-light-action-text);
|
||||
}
|
||||
|
||||
.search-trigger svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.search-trigger__label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-trigger__shortcut {
|
||||
font-size: 11px;
|
||||
opacity: 0.72;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.global-search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.global-search__hint,
|
||||
.global-search__state {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.global-search__results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
max-height: 480px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.global-search__group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.global-search__group-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--el-color-primary);
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.global-search__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--wo-card-border);
|
||||
border-radius: 12px;
|
||||
background: var(--el-bg-color-overlay);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.global-search__item:hover,
|
||||
.global-search__item--active {
|
||||
border-color: var(--el-color-primary-light-3);
|
||||
background: color-mix(in srgb, var(--el-color-primary-light-9) 72%, transparent);
|
||||
}
|
||||
|
||||
.global-search__item-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.global-search__item-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.global-search__item-subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
html.dark .global-search__item {
|
||||
border-color: var(--wo-search-border);
|
||||
background: var(--wo-search-surface);
|
||||
}
|
||||
|
||||
html.dark .global-search__item:hover,
|
||||
html.dark .global-search__item--active {
|
||||
background: var(--wo-search-surface-hover);
|
||||
border-color: var(--el-color-primary-light-3);
|
||||
}
|
||||
|
||||
html.dark .global-search__hint,
|
||||
html.dark .global-search__state,
|
||||
html.dark .global-search__item-subtitle {
|
||||
color: var(--wo-search-muted);
|
||||
}
|
||||
|
||||
html.dark .global-search__item-title {
|
||||
color: var(--wo-search-title);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-trigger__shortcut {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<el-card :shadow="shadow" class="page-container">
|
||||
<template v-if="title || $slots.header" #header>
|
||||
<div class="page-container-header">
|
||||
<div class="page-container-title-wrap" v-if="title">
|
||||
<div class="title-indicator"></div>
|
||||
<span class="page-container-title">{{ title }}</span>
|
||||
</div>
|
||||
<slot name="header" />
|
||||
</div>
|
||||
</template>
|
||||
<slot />
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
/** 页面标题,显示在 card header 中 */
|
||||
title?: string;
|
||||
/** card shadow 模式,默认与 el-card 保持一致 */
|
||||
shadow?: "always" | "hover" | "never";
|
||||
}>(),
|
||||
{
|
||||
title: undefined,
|
||||
shadow: undefined,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
border: none;
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04), 0 0 0 1px rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.page-container-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.page-container-title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title-indicator {
|
||||
width: 4px;
|
||||
height: 20px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(180deg, #6366f1, #8b5cf6);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-container-title {
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
color: var(--el-text-color-primary);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="password-strength" v-if="password.length > 0">
|
||||
<div class="strength-bars">
|
||||
<div class="strength-bar" :class="level >= 1 ? barColorClass : ''"></div>
|
||||
<div class="strength-bar" :class="level >= 2 ? barColorClass : ''"></div>
|
||||
<div class="strength-bar" :class="level >= 3 ? barColorClass : ''"></div>
|
||||
</div>
|
||||
<span class="strength-label" :class="labelColorClass">{{ levelText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
password: string;
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 计算密码强度等级:
|
||||
* 0 = 空 / 过短
|
||||
* 1 = 弱:< 8 位或仅单一字符类型
|
||||
* 2 = 中:≥ 8 位 + 2 种字符类型
|
||||
* 3 = 强:≥ 8 位 + 3 种及以上字符类型
|
||||
*/
|
||||
const level = computed(() => {
|
||||
const pwd = props.password || "";
|
||||
if (pwd.length === 0) return 0;
|
||||
if (pwd.length < 8) return 1;
|
||||
|
||||
let types = 0;
|
||||
if (/[a-z]/.test(pwd)) types++;
|
||||
if (/[A-Z]/.test(pwd)) types++;
|
||||
if (/[0-9]/.test(pwd)) types++;
|
||||
if (/[^a-zA-Z0-9]/.test(pwd)) types++;
|
||||
|
||||
if (types <= 1) return 1;
|
||||
if (types === 2) return 2;
|
||||
return 3;
|
||||
});
|
||||
|
||||
const levelText = computed(() => {
|
||||
const texts = ["", "弱", "中", "强"];
|
||||
return texts[level.value] || "";
|
||||
});
|
||||
|
||||
const barColorClass = computed(() => {
|
||||
const classes = ["", "bar-weak", "bar-medium", "bar-strong"];
|
||||
return classes[level.value] || "";
|
||||
});
|
||||
|
||||
const labelColorClass = computed(() => {
|
||||
const classes = ["", "label-weak", "label-medium", "label-strong"];
|
||||
return classes[level.value] || "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.password-strength {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.strength-bars {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.strength-bar {
|
||||
height: 4px;
|
||||
flex: 1;
|
||||
border-radius: 2px;
|
||||
background: var(--wo-border-light, #e4e7ed);
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.bar-weak {
|
||||
background: #f56c6c;
|
||||
}
|
||||
|
||||
.bar-medium {
|
||||
background: #e6a23c;
|
||||
}
|
||||
|
||||
.bar-strong {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
.strength-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
min-width: 20px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.label-weak {
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.label-medium {
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.label-strong {
|
||||
color: #67c23a;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<el-space wrap class="query-toolbar">
|
||||
<slot />
|
||||
</el-space>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// 纯布局组件:用 el-space wrap 统一搜索栏 / 操作按钮的间距
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.query-toolbar {
|
||||
margin-bottom: var(--wo-spacing-md, 12px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="section-title">
|
||||
<slot>{{ title }}</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title?: string;
|
||||
}>(),
|
||||
{
|
||||
title: "",
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-title {
|
||||
font-weight: var(--wo-font-weight-title, 600);
|
||||
margin-bottom: var(--wo-spacing-sm, 8px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,155 @@
|
||||
export const PERMS = {
|
||||
project: {
|
||||
create: "project.create",
|
||||
keyChangeLogRead: "project.key-change-log.read",
|
||||
freeze: "project.freeze",
|
||||
unfreeze: "project.unfreeze",
|
||||
archive: "project.archive",
|
||||
bindUser: "project.bind.user",
|
||||
bindExecutorUser: "project.bind.executor_user",
|
||||
},
|
||||
meeting: {
|
||||
read: "meeting.read",
|
||||
manage: "meeting.manage",
|
||||
create: "meeting.create",
|
||||
delete: "meeting.delete",
|
||||
cancel: "meeting.cancel",
|
||||
submit: "meeting.submit",
|
||||
withdraw: "meeting.withdraw",
|
||||
materialSave: "meeting.material.save",
|
||||
materialSubmit: "meeting.material.submit",
|
||||
materialHistoryRead: "meeting.material.history.read",
|
||||
materialExport: "meeting.material.export",
|
||||
invoiceConfig: "meeting.invoice.config",
|
||||
changeLogRead: "meeting.change-log.read",
|
||||
},
|
||||
audit: {
|
||||
read: "audit.read",
|
||||
approve: "audit.approve",
|
||||
reject: "audit.reject",
|
||||
back: "audit.return",
|
||||
transfer: "audit.transfer",
|
||||
remind: "audit.remind",
|
||||
slaRead: "audit.sla.read",
|
||||
exportOpinions: "audit.export.opinions",
|
||||
materialRead: "audit.material.read",
|
||||
flowRead: "audit.flow.read",
|
||||
flowManage: "audit.flow.manage",
|
||||
logRead: "audit.log.read",
|
||||
},
|
||||
finance: {
|
||||
paymentConfirm: "finance.payment.confirm",
|
||||
ledgerExport: "finance.ledger.export",
|
||||
reconciliation: "finance.reconciliation",
|
||||
lock: "finance.lock",
|
||||
unlock: "finance.unlock",
|
||||
},
|
||||
user: {
|
||||
read: "user.read",
|
||||
create: "user.create",
|
||||
import: "user.import",
|
||||
update: "user.update",
|
||||
delete: "user.delete",
|
||||
roleAssign: "user.role.assign",
|
||||
enable: "user.enable",
|
||||
disable: "user.disable",
|
||||
resetPassword: "user.password.reset",
|
||||
roleHistoryRead: "user.role.history.read",
|
||||
delegationManage: "user.delegation.manage",
|
||||
},
|
||||
role: {
|
||||
read: "role.read",
|
||||
create: "role.create",
|
||||
update: "role.update",
|
||||
delete: "role.delete",
|
||||
enable: "role.enable",
|
||||
disable: "role.disable",
|
||||
permissionBind: "role.permission.bind",
|
||||
},
|
||||
permission: {
|
||||
read: "permission.read",
|
||||
},
|
||||
tenant: {
|
||||
manage: "tenant.manage",
|
||||
switch: "tenant.switch",
|
||||
},
|
||||
dataPermission: {
|
||||
read: "data.permission.read",
|
||||
manage: "data.permission.manage",
|
||||
},
|
||||
template: {
|
||||
read: "template.read",
|
||||
manage: "template.manage",
|
||||
create: "template.create",
|
||||
publish: "template.publish",
|
||||
disable: "template.disable",
|
||||
archive: "template.archive",
|
||||
rollback: "template.rollback",
|
||||
download: "template.download",
|
||||
downloadLogReadAll: "template.download.log.read.all",
|
||||
flowLink: "template.flow.link",
|
||||
},
|
||||
expert: {
|
||||
read: "expert.read",
|
||||
create: "expert.create",
|
||||
merge: "expert.merge",
|
||||
import: "expert.import",
|
||||
export: "expert.export",
|
||||
cardManage: "expert.card.manage",
|
||||
idCardOcr: "ocr.idcard",
|
||||
bankCardOcr: "ocr.bankcard",
|
||||
},
|
||||
notification: {
|
||||
policyRead: "notification.policy.read",
|
||||
policyManage: "notification.policy.manage",
|
||||
textTemplateRead: "notification.text-template.read",
|
||||
textTemplateManage: "notification.text-template.manage",
|
||||
dispatch: "notification.dispatch",
|
||||
taskRead: "notification.task.read",
|
||||
inAppRead: "notification.inapp.read",
|
||||
inAppMarkRead: "notification.inapp.mark-read",
|
||||
},
|
||||
observability: {
|
||||
read: "observability.read",
|
||||
manage: "observability.manage",
|
||||
},
|
||||
invoiceProfile: {
|
||||
read: "invoice.profile.read",
|
||||
manage: "invoice.profile.manage",
|
||||
},
|
||||
exportTask: {
|
||||
read: "export.task.read",
|
||||
manage: "export.task.manage",
|
||||
},
|
||||
dashboard: {
|
||||
read: "dashboard.read",
|
||||
},
|
||||
enterprise: {
|
||||
read: "enterprise.read",
|
||||
manage: "enterprise.manage",
|
||||
delete: "enterprise.delete",
|
||||
},
|
||||
dictionary: {
|
||||
read: "dictionary.read",
|
||||
manage: "dictionary.manage",
|
||||
},
|
||||
platform: {
|
||||
tenantManage: "platform.tenant.manage",
|
||||
userManage: "platform.user.manage",
|
||||
auditRead: "platform.audit.read",
|
||||
menuManage: "platform.menu.manage",
|
||||
roleRead: "platform.role.read",
|
||||
roleManage: "platform.role.manage",
|
||||
permissionRead: "platform.permission.read",
|
||||
expertRead: "platform.expert.read",
|
||||
expertManage: "platform.expert.manage",
|
||||
idCardOcr: "platform.ocr.idcard",
|
||||
bankCardOcr: "platform.ocr.bankcard",
|
||||
dictionaryRead: "platform.dictionary.read",
|
||||
dictionaryManage: "platform.dictionary.manage",
|
||||
sessionRead: "platform.session.read",
|
||||
sessionManage: "platform.session.manage",
|
||||
notifyGatewayRead: "platform.notify-gateway.read",
|
||||
notifyGatewayManage: "platform.notify-gateway.manage",
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* UI 常量 — 弹窗/抽屉尺寸 & 表单控件标准宽度
|
||||
* P0-03 & P0-04
|
||||
*/
|
||||
|
||||
/** Drawer 标准宽度档位 */
|
||||
export const DRAWER_SIZE = {
|
||||
/** 480px — 简单表单(新增角色、基本信息等) */
|
||||
sm: "480px",
|
||||
/** 640px — 中等表单(用户编辑、策略编辑等) */
|
||||
md: "640px",
|
||||
/** 55% — 稍大表单(项目编辑、详情预览等) */
|
||||
xl: "55%",
|
||||
/** 80% — 大型内容(银行卡列表、资料详情等) */
|
||||
lg: "80%",
|
||||
} as const;
|
||||
|
||||
/** Dialog 标准宽度档位 */
|
||||
export const DIALOG_WIDTH = {
|
||||
/** 520px — 简单确认/输入 */
|
||||
sm: "520px",
|
||||
/** 680px — 中等表单/列表 */
|
||||
md: "680px",
|
||||
/** 860px — 大型复杂表单 */
|
||||
lg: "860px",
|
||||
} as const;
|
||||
|
||||
/** 表单控件标准宽度档位 */
|
||||
export const INPUT_WIDTH = {
|
||||
/** 100px — 极窄(比较符等) */
|
||||
xs: "100px",
|
||||
/** 160px — 搜索栏下拉/状态选择 */
|
||||
sm: "160px",
|
||||
/** 220px — 标准表单下拉 */
|
||||
md: "220px",
|
||||
/** 280px — 宽表单下拉 */
|
||||
lg: "280px",
|
||||
/** 100% — 充满父容器 */
|
||||
full: "100%",
|
||||
} as const;
|
||||
|
||||
/** 标准 label-width(el-form) */
|
||||
export const LABEL_WIDTH = {
|
||||
/** 简洁表单 */
|
||||
sm: "70px",
|
||||
/** 标准表单 */
|
||||
md: "90px",
|
||||
/** 宽标签表单 */
|
||||
lg: "110px",
|
||||
} as const;
|
||||
@@ -0,0 +1,124 @@
|
||||
import { createApp } from "vue";
|
||||
import ElementPlus from "element-plus";
|
||||
import { ElMessage } from "element-plus";
|
||||
import "element-plus/dist/index.css";
|
||||
import "element-plus/theme-chalk/dark/css-vars.css";
|
||||
import "./styles/variables.css";
|
||||
import "./styles/theme.css";
|
||||
import "./styles/utilities.css";
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { pinia } from "./stores";
|
||||
import { useAppearanceStore } from "./stores/appearance";
|
||||
import { useAuthStore } from "./stores/auth";
|
||||
import { logoutAuth } from "./api/modules";
|
||||
import { resolveLoginPath } from "./utils/authNavigation";
|
||||
|
||||
const IDLE_TIMEOUT_MINUTES = Number(import.meta.env.VITE_IDLE_TIMEOUT_MINUTES || 60);
|
||||
const IDLE_TIMEOUT_MS = (Number.isFinite(IDLE_TIMEOUT_MINUTES) && IDLE_TIMEOUT_MINUTES > 0 ? IDLE_TIMEOUT_MINUTES : 60) * 60 * 1000;
|
||||
const IDLE_ACTIVITY_EVENTS = ["mousedown", "keydown", "scroll", "touchstart"] as const;
|
||||
const PUBLIC_ENTRY_PATH_PATTERNS = [/^\/login$/, /^\/[^/]+\/login$/, /^\/[^/]+\/setup-password$/];
|
||||
|
||||
let idleTimer: number | null = null;
|
||||
let lastActivityAt = Date.now();
|
||||
let idleLogoutPending = false;
|
||||
let previousAuthLoggedIn = false;
|
||||
|
||||
const stopIdleTimer = () => {
|
||||
if (idleTimer !== null) {
|
||||
window.clearTimeout(idleTimer);
|
||||
idleTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleIdleTimeout = () => {
|
||||
stopIdleTimer();
|
||||
const authStore = useAuthStore(pinia);
|
||||
if (!authStore.token) {
|
||||
previousAuthLoggedIn = false;
|
||||
return;
|
||||
}
|
||||
const elapsed = Date.now() - lastActivityAt;
|
||||
const delay = Math.max(0, IDLE_TIMEOUT_MS - elapsed);
|
||||
idleTimer = window.setTimeout(() => {
|
||||
void handleIdleTimeout();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const markUserActivity = () => {
|
||||
const authStore = useAuthStore(pinia);
|
||||
if (!authStore.token) {
|
||||
stopIdleTimer();
|
||||
previousAuthLoggedIn = false;
|
||||
return;
|
||||
}
|
||||
lastActivityAt = Date.now();
|
||||
scheduleIdleTimeout();
|
||||
};
|
||||
|
||||
async function handleIdleTimeout() {
|
||||
const authStore = useAuthStore(pinia);
|
||||
if (!authStore.token || idleLogoutPending) {
|
||||
return;
|
||||
}
|
||||
idleLogoutPending = true;
|
||||
const nextLoginPath = resolveLoginPath(authStore.scope, authStore.tenantCode);
|
||||
try {
|
||||
await logoutAuth();
|
||||
} catch (_error) {
|
||||
// ignore logout failure during idle cleanup
|
||||
}
|
||||
authStore.clearAuthStorage();
|
||||
window.location.href = nextLoginPath;
|
||||
}
|
||||
|
||||
const handleAuthStateChanged = () => {
|
||||
const authStore = useAuthStore(pinia);
|
||||
const loggedIn = Boolean(authStore.token);
|
||||
if (!loggedIn) {
|
||||
stopIdleTimer();
|
||||
previousAuthLoggedIn = false;
|
||||
idleLogoutPending = false;
|
||||
return;
|
||||
}
|
||||
if (!previousAuthLoggedIn) {
|
||||
lastActivityAt = Date.now();
|
||||
}
|
||||
previousAuthLoggedIn = true;
|
||||
scheduleIdleTimeout();
|
||||
};
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(pinia);
|
||||
const appearanceStore = useAppearanceStore(pinia);
|
||||
appearanceStore.initAppearance();
|
||||
|
||||
const isPublicEntryPath = (path: string) => PUBLIC_ENTRY_PATH_PATTERNS.some((pattern) => pattern.test(path));
|
||||
|
||||
// 全局错误边界:捕获未处理的组件异常,防止白屏
|
||||
app.config.errorHandler = (err, _instance, info) => {
|
||||
console.error("[全局错误边界]", info, err);
|
||||
ElMessage.error("页面发生异常,请尝试刷新页面");
|
||||
};
|
||||
|
||||
app.use(router).use(ElementPlus).mount("#app");
|
||||
|
||||
router.isReady().then(() => {
|
||||
if (isPublicEntryPath(router.currentRoute.value.path)) {
|
||||
appearanceStore.markBootstrapped();
|
||||
}
|
||||
appearanceStore.syncFromServer();
|
||||
for (const eventName of IDLE_ACTIVITY_EVENTS) {
|
||||
window.addEventListener(eventName, markUserActivity, { passive: true });
|
||||
}
|
||||
window.addEventListener("auth:token-updated", handleAuthStateChanged as EventListener);
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
markUserActivity();
|
||||
}
|
||||
});
|
||||
router.afterEach(() => {
|
||||
markUserActivity();
|
||||
});
|
||||
handleAuthStateChanged();
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import AppLayout from "../views/layout/AppLayout.vue";
|
||||
import TenantDashboardPage from "../views/modules/TenantDashboardPage.vue";
|
||||
import ProfilePage from "../views/modules/ProfilePage.vue";
|
||||
import ProjectPage from "../views/modules/ProjectPage.vue";
|
||||
import MeetingPage from "../views/modules/MeetingPage.vue";
|
||||
import AuditPage from "../views/modules/AuditPage.vue";
|
||||
import FinancePage from "../views/modules/FinancePage.vue";
|
||||
import UserPage from "../views/modules/UserPage.vue";
|
||||
import RolePage from "../views/modules/RolePage.vue";
|
||||
import TenantPage from "../views/modules/TenantPage.vue";
|
||||
import EnterprisePage from "../views/modules/EnterprisePage.vue";
|
||||
import MenuPage from "../views/modules/MenuPage.vue";
|
||||
import PlatformLoginPage from "../views/modules/PlatformLoginPage.vue";
|
||||
import TenantLoginPage from "../views/modules/TenantLoginPage.vue";
|
||||
import TenantPasswordSetupPage from "../views/modules/TenantPasswordSetupPage.vue";
|
||||
import AuditFlowPage from "../views/modules/AuditFlowPage.vue";
|
||||
import DataPermissionPage from "../views/modules/DataPermissionPage.vue";
|
||||
import TemplatePage from "../views/modules/TemplatePage.vue";
|
||||
import TemplateDownloadLogPage from "../views/modules/TemplateDownloadLogPage.vue";
|
||||
import AuditLogPage from "../views/modules/AuditLogPage.vue";
|
||||
import ExpertPage from "../views/modules/ExpertPage.vue";
|
||||
import NotificationPolicyPage from "../views/modules/NotificationPolicyPage.vue";
|
||||
import NotificationTextTemplatePage from "../views/modules/NotificationTextTemplatePage.vue";
|
||||
import InAppNotificationPage from "../views/modules/InAppNotificationPage.vue";
|
||||
import ObservabilityPage from "../views/modules/ObservabilityPage.vue";
|
||||
import InvoiceProfilePage from "../views/modules/InvoiceProfilePage.vue";
|
||||
import ExportTaskPage from "../views/modules/ExportTaskPage.vue";
|
||||
import OperationsDashboardPage from "../views/modules/OperationsDashboardPage.vue";
|
||||
import PlatformMenuPage from "../views/modules/PlatformMenuPage.vue";
|
||||
import PlatformUserPage from "../views/modules/PlatformUserPage.vue";
|
||||
import PlatformRolePage from "../views/modules/PlatformRolePage.vue";
|
||||
import PlatformDictionaryPage from "../views/modules/PlatformDictionaryPage.vue";
|
||||
import PlatformSessionPage from "../views/modules/PlatformSessionPage.vue";
|
||||
import PlatformNotifyGatewayPage from "../views/modules/PlatformNotifyGatewayPage.vue";
|
||||
import NotFoundPage from "../views/modules/NotFoundPage.vue";
|
||||
import { refreshAuth } from "../api/modules";
|
||||
import { pinia } from "../stores";
|
||||
import { useAuthStore } from "../stores/auth";
|
||||
import { resolveLoginPath } from "../utils/authNavigation";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: "/login",
|
||||
component: PlatformLoginPage,
|
||||
meta: { title: "平台管理端登录" },
|
||||
},
|
||||
{
|
||||
path: "/:tenantCode/login",
|
||||
component: TenantLoginPage,
|
||||
meta: { title: "租户登录" },
|
||||
},
|
||||
{
|
||||
path: "/:tenantCode/setup-password",
|
||||
component: TenantPasswordSetupPage,
|
||||
meta: { title: "租户管理员密码设置" },
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
component: AppLayout,
|
||||
children: [
|
||||
{ path: "", redirect: "/dashboard" },
|
||||
{ path: "/dashboard", component: TenantDashboardPage, meta: { title: "工作台" } },
|
||||
{ path: "/profile", component: ProfilePage, meta: { title: "个人设置" } },
|
||||
{ path: "/projects", component: ProjectPage, meta: { title: "项目管理" } },
|
||||
{ path: "/meetings", component: MeetingPage, meta: { title: "会议管理" } },
|
||||
{ path: "/audits", component: AuditPage, meta: { title: "审核管理" } },
|
||||
{ path: "/finance", component: FinancePage, meta: { title: "财务管理" } },
|
||||
{ path: "/users", component: UserPage, meta: { title: "用户管理" } },
|
||||
{ path: "/tenants", component: TenantPage, meta: { title: "租户管理" } },
|
||||
{ path: "/enterprises", component: EnterprisePage, meta: { title: "企业管理" } },
|
||||
{ path: "/menus", component: MenuPage, meta: { title: "菜单与权限" } },
|
||||
{ path: "/roles", component: RolePage, meta: { title: "角色管理" } },
|
||||
{ path: "/permissions", redirect: { path: "/menus", query: { tab: "permissions" } } },
|
||||
{ path: "/audit-flows", component: AuditFlowPage, meta: { title: "审核流配置" } },
|
||||
{ path: "/data-permissions", component: DataPermissionPage, meta: { title: "数据权限管理" } },
|
||||
{ path: "/templates", component: TemplatePage, meta: { title: "模板管理" } },
|
||||
{ path: "/template-download-logs", component: TemplateDownloadLogPage, meta: { title: "模板查看下载" } },
|
||||
{ path: "/audit-logs", component: AuditLogPage, meta: { title: "审计日志" } },
|
||||
{ path: "/experts", component: ExpertPage, meta: { title: "专家管理" } },
|
||||
{ path: "/invoice-profiles", component: InvoiceProfilePage, meta: { title: "发票管理" } },
|
||||
{ path: "/export-tasks", component: ExportTaskPage, meta: { title: "导出任务中心" } },
|
||||
{ path: "/notification-policies", component: NotificationPolicyPage, meta: { title: "通知策略中心" } },
|
||||
{ path: "/notification-text-templates", component: NotificationTextTemplatePage, meta: { title: "通知文案模板" } },
|
||||
{ path: "/in-app-notifications", component: InAppNotificationPage, meta: { title: "站内通知中心" } },
|
||||
{ path: "/observability", component: ObservabilityPage, meta: { title: "可观测性与告警" } },
|
||||
{ path: "/operations-dashboard", component: OperationsDashboardPage, meta: { title: "运营看板" } },
|
||||
{ path: "/platform/tenants", component: TenantPage, meta: { title: "平台租户管理" } },
|
||||
{ path: "/platform/audit-logs", component: AuditLogPage, meta: { title: "平台审计日志" } },
|
||||
{ path: "/platform/menus", component: PlatformMenuPage, meta: { title: "平台菜单与权限" } },
|
||||
{ path: "/platform/users", component: PlatformUserPage, meta: { title: "平台用户管理" } },
|
||||
{ path: "/platform/roles", component: PlatformRolePage, meta: { title: "平台角色管理" } },
|
||||
{ path: "/platform/dictionaries", component: PlatformDictionaryPage, meta: { title: "平台字典管理" } },
|
||||
{ path: "/platform/auth-sessions", component: PlatformSessionPage, meta: { title: "平台会话管理" } },
|
||||
{ path: "/platform/notify-gateways", component: PlatformNotifyGatewayPage, meta: { title: "通知网关配置" } },
|
||||
{ path: "/platform/permissions", redirect: { path: "/platform/menus", query: { tab: "permissions" } } },
|
||||
{ path: "/platform/experts", component: ExpertPage, meta: { title: "平台专家管理" } },
|
||||
{ path: "/:pathMatch(.*)*", component: NotFoundPage, meta: { title: "页面未找到" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
const authStore = useAuthStore(pinia);
|
||||
if (to.path === "/login" || /^\/[^/]+\/login$/.test(to.path) || /^\/[^/]+\/setup-password$/.test(to.path)) {
|
||||
return next();
|
||||
}
|
||||
const proceedWithTokenCheck = async () => {
|
||||
const token = authStore.token;
|
||||
if (!token) {
|
||||
try {
|
||||
const resp = await refreshAuth();
|
||||
const refreshedToken = String(resp?.data?.token || "").trim();
|
||||
if (!refreshedToken) {
|
||||
return next(resolveLoginPath(authStore.scope, authStore.tenantCode));
|
||||
}
|
||||
authStore.saveAuthPayload(resp?.data || null);
|
||||
} catch (_e) {
|
||||
return next(resolveLoginPath(authStore.scope, authStore.tenantCode));
|
||||
}
|
||||
}
|
||||
const scope = authStore.scope;
|
||||
const isPlatformPath = to.path.startsWith("/platform/");
|
||||
const isSharedProfilePath = to.path === "/profile";
|
||||
if (scope === "PLATFORM") {
|
||||
if (!isPlatformPath && !isSharedProfilePath) {
|
||||
return next("/platform/tenants");
|
||||
}
|
||||
return next();
|
||||
}
|
||||
if (to.path === "/audit-logs") {
|
||||
return next("/dashboard");
|
||||
}
|
||||
if (isPlatformPath) {
|
||||
return next("/dashboard");
|
||||
}
|
||||
next();
|
||||
};
|
||||
proceedWithTokenCheck();
|
||||
});
|
||||
|
||||
router.afterEach((to) => {
|
||||
const meta = to.meta as Record<string, unknown> | undefined;
|
||||
const pageTitle = String(meta?.title || "").trim();
|
||||
document.title = pageTitle ? `${pageTitle} - 会议核销系统` : "会议核销系统";
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,241 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { fetchProfilePreferences } from "../api/modules";
|
||||
|
||||
export type ThemeMode = "SYSTEM" | "LIGHT" | "DARK";
|
||||
export type DensityMode = "COMFORTABLE" | "COMPACT";
|
||||
export type ThemeScheme = "SLATE" | "OCEAN" | "FOREST" | "GRAPHITE" | "AMBER" | "RUBY" | "MIST" | "SAGE" | "DAWN";
|
||||
|
||||
type AppearancePayload = {
|
||||
themeMode?: unknown;
|
||||
density?: unknown;
|
||||
themeScheme?: unknown;
|
||||
};
|
||||
|
||||
type AppearanceState = {
|
||||
themeMode: ThemeMode;
|
||||
density: DensityMode;
|
||||
themeScheme: ThemeScheme;
|
||||
initialized: boolean;
|
||||
bootstrapped: boolean;
|
||||
};
|
||||
|
||||
const THEME_STORAGE_KEY = "themeMode";
|
||||
const DENSITY_STORAGE_KEY = "density";
|
||||
const THEME_SCHEME_STORAGE_KEY = "themeScheme";
|
||||
const APPEARANCE_STORAGE_KEY = "appearance";
|
||||
const APP_BOOT_OVERLAY_ID = "app-boot-overlay";
|
||||
|
||||
let mediaQuery: MediaQueryList | null = null;
|
||||
let mediaListenerBound = false;
|
||||
let authSyncListenerBound = false;
|
||||
let currentStore: ReturnType<typeof useAppearanceStore> | null = null;
|
||||
|
||||
export const normalizeThemeMode = (raw: unknown): ThemeMode => {
|
||||
const value = String(raw || "").trim().toUpperCase();
|
||||
if (value === "LIGHT" || value === "DARK" || value === "SYSTEM") {
|
||||
return value;
|
||||
}
|
||||
return "SYSTEM";
|
||||
};
|
||||
|
||||
export const normalizeDensity = (raw: unknown): DensityMode => {
|
||||
const value = String(raw || "").trim().toUpperCase();
|
||||
if (value === "COMPACT" || value === "COMFORTABLE") {
|
||||
return value;
|
||||
}
|
||||
return "COMFORTABLE";
|
||||
};
|
||||
|
||||
export const normalizeThemeScheme = (raw: unknown): ThemeScheme => {
|
||||
const value = String(raw || "").trim().toUpperCase();
|
||||
if (
|
||||
value === "SLATE" ||
|
||||
value === "OCEAN" ||
|
||||
value === "FOREST" ||
|
||||
value === "GRAPHITE" ||
|
||||
value === "AMBER" ||
|
||||
value === "RUBY" ||
|
||||
value === "MIST" ||
|
||||
value === "SAGE" ||
|
||||
value === "DAWN"
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return "SLATE";
|
||||
};
|
||||
|
||||
const readStoredAppearance = (): { themeMode?: string; density?: string; themeScheme?: string } => {
|
||||
const raw = localStorage.getItem(APPEARANCE_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return {
|
||||
themeMode: String(localStorage.getItem(THEME_STORAGE_KEY) || "").trim(),
|
||||
density: String(localStorage.getItem(DENSITY_STORAGE_KEY) || "").trim(),
|
||||
themeScheme: String(localStorage.getItem(THEME_SCHEME_STORAGE_KEY) || "").trim(),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) || {};
|
||||
return {
|
||||
themeMode: String(parsed?.themeMode || localStorage.getItem(THEME_STORAGE_KEY) || "").trim(),
|
||||
density: String(parsed?.density || localStorage.getItem(DENSITY_STORAGE_KEY) || "").trim(),
|
||||
themeScheme: String(parsed?.themeScheme || localStorage.getItem(THEME_SCHEME_STORAGE_KEY) || "").trim(),
|
||||
};
|
||||
} catch (_e) {
|
||||
return {
|
||||
themeMode: String(localStorage.getItem(THEME_STORAGE_KEY) || "").trim(),
|
||||
density: String(localStorage.getItem(DENSITY_STORAGE_KEY) || "").trim(),
|
||||
themeScheme: String(localStorage.getItem(THEME_SCHEME_STORAGE_KEY) || "").trim(),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const persistAppearance = (state: AppearanceState) => {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, state.themeMode);
|
||||
localStorage.setItem(DENSITY_STORAGE_KEY, state.density);
|
||||
localStorage.setItem(THEME_SCHEME_STORAGE_KEY, state.themeScheme);
|
||||
localStorage.setItem(
|
||||
APPEARANCE_STORAGE_KEY,
|
||||
JSON.stringify({ themeMode: state.themeMode, density: state.density, themeScheme: state.themeScheme }),
|
||||
);
|
||||
};
|
||||
|
||||
const resolveThemeMode = (themeMode: ThemeMode): "light" | "dark" => {
|
||||
if (themeMode === "LIGHT") {
|
||||
return "light";
|
||||
}
|
||||
if (themeMode === "DARK") {
|
||||
return "dark";
|
||||
}
|
||||
return mediaQuery?.matches ? "dark" : "light";
|
||||
};
|
||||
|
||||
const applyAppearanceToDocument = (state: AppearanceState) => {
|
||||
const root = document.documentElement;
|
||||
const resolvedTheme = resolveThemeMode(state.themeMode);
|
||||
root.dataset.themeMode = resolvedTheme;
|
||||
root.dataset.themePreference = state.themeMode.toLowerCase();
|
||||
root.dataset.density = state.density.toLowerCase();
|
||||
root.dataset.themeScheme = state.themeScheme.toLowerCase();
|
||||
root.classList.toggle("dark", resolvedTheme === "dark");
|
||||
};
|
||||
|
||||
const removeBootOverlay = () => {
|
||||
if (typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
const overlay = document.getElementById(APP_BOOT_OVERLAY_ID);
|
||||
if (!overlay) {
|
||||
return;
|
||||
}
|
||||
overlay.classList.add("is-leaving");
|
||||
window.setTimeout(() => {
|
||||
overlay.remove();
|
||||
}, 320);
|
||||
};
|
||||
|
||||
const handleSystemThemeChange = () => {
|
||||
if (currentStore?.themeMode === "SYSTEM") {
|
||||
currentStore.applyToDocument();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthTokenUpdated = () => {
|
||||
currentStore?.syncFromStorage();
|
||||
};
|
||||
|
||||
const ensureSystemThemeListener = () => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (!mediaQuery) {
|
||||
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
}
|
||||
if (mediaQuery && !mediaListenerBound) {
|
||||
if (typeof mediaQuery.addEventListener === "function") {
|
||||
mediaQuery.addEventListener("change", handleSystemThemeChange);
|
||||
} else if (typeof mediaQuery.addListener === "function") {
|
||||
mediaQuery.addListener(handleSystemThemeChange);
|
||||
}
|
||||
mediaListenerBound = true;
|
||||
}
|
||||
};
|
||||
|
||||
const ensureAuthSyncListener = () => {
|
||||
if (typeof window === "undefined" || authSyncListenerBound) {
|
||||
return;
|
||||
}
|
||||
window.addEventListener("auth:token-updated", handleAuthTokenUpdated as EventListener);
|
||||
authSyncListenerBound = true;
|
||||
};
|
||||
|
||||
export const useAppearanceStore = defineStore("appearance", {
|
||||
state: (): AppearanceState => ({
|
||||
themeMode: "SYSTEM",
|
||||
density: "COMFORTABLE",
|
||||
themeScheme: "SLATE",
|
||||
initialized: false,
|
||||
bootstrapped: false,
|
||||
}),
|
||||
getters: {
|
||||
elementPlusSize: (state): "small" | "default" => (state.density === "COMPACT" ? "small" : "default"),
|
||||
isBootstrapped: (state): boolean => state.bootstrapped,
|
||||
},
|
||||
actions: {
|
||||
initAppearance() {
|
||||
currentStore = this;
|
||||
ensureSystemThemeListener();
|
||||
ensureAuthSyncListener();
|
||||
this.syncFromStorage();
|
||||
},
|
||||
applyToDocument() {
|
||||
applyAppearanceToDocument(this);
|
||||
},
|
||||
setAppearance(payload: AppearancePayload, persist = true) {
|
||||
this.themeMode = normalizeThemeMode(payload?.themeMode ?? this.themeMode);
|
||||
this.density = normalizeDensity(payload?.density ?? this.density);
|
||||
this.themeScheme = normalizeThemeScheme(payload?.themeScheme ?? this.themeScheme);
|
||||
this.initialized = true;
|
||||
if (persist) {
|
||||
persistAppearance(this);
|
||||
}
|
||||
this.applyToDocument();
|
||||
},
|
||||
syncFromStorage() {
|
||||
const stored = readStoredAppearance();
|
||||
this.setAppearance(
|
||||
{
|
||||
themeMode: stored.themeMode,
|
||||
density: stored.density,
|
||||
themeScheme: stored.themeScheme,
|
||||
},
|
||||
true,
|
||||
);
|
||||
},
|
||||
async syncFromServer() {
|
||||
if (!String(localStorage.getItem("token") || "").trim()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetchProfilePreferences();
|
||||
const appearance = resp?.data || {};
|
||||
this.setAppearance(
|
||||
{
|
||||
themeMode: appearance?.themeMode,
|
||||
density: appearance?.density,
|
||||
themeScheme: appearance?.themeScheme,
|
||||
},
|
||||
true,
|
||||
);
|
||||
} catch (_e) {
|
||||
// Keep local preferences on sync failure.
|
||||
}
|
||||
},
|
||||
markBootstrapped() {
|
||||
if (this.bootstrapped) {
|
||||
return;
|
||||
}
|
||||
this.bootstrapped = true;
|
||||
removeBootOverlay();
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
import { defineStore } from "pinia";
|
||||
|
||||
export type AuthScope = "TENANT" | "PLATFORM";
|
||||
|
||||
export type StoredAppearance = {
|
||||
themeMode?: string;
|
||||
density?: string;
|
||||
themeScheme?: string;
|
||||
};
|
||||
|
||||
export type AuthPayload = {
|
||||
token?: string;
|
||||
permissions?: string[];
|
||||
roles?: string[];
|
||||
userId?: number | string | null;
|
||||
phone?: string;
|
||||
tenantId?: number | string | null;
|
||||
userName?: string;
|
||||
tenantName?: string;
|
||||
tenantCode?: string;
|
||||
tenant?: {
|
||||
logoUrl?: string;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
scope?: AuthScope;
|
||||
appearance?: StoredAppearance | null;
|
||||
};
|
||||
|
||||
type TenantProfilePatch = {
|
||||
tenantName?: string | null;
|
||||
logoUrl?: string | null;
|
||||
};
|
||||
|
||||
type AuthState = {
|
||||
token: string;
|
||||
permissions: string[];
|
||||
roles: string[];
|
||||
userId: number | null;
|
||||
phone: string;
|
||||
tenantId: number | null;
|
||||
userName: string;
|
||||
tenantName: string;
|
||||
tenantCode: string;
|
||||
tenantInfo: Record<string, unknown> | null;
|
||||
tenantLogoUrl: string;
|
||||
scope: AuthScope;
|
||||
appearance: StoredAppearance;
|
||||
authVersion: string;
|
||||
tenantContextVersion: string;
|
||||
};
|
||||
|
||||
const AUTH_EVENT_NAME = "auth:token-updated";
|
||||
|
||||
type TenantContextSnapshot = {
|
||||
scope: AuthScope;
|
||||
tenantId: number | null;
|
||||
userId: number | null;
|
||||
};
|
||||
|
||||
const parseNumber = (raw: string | null): number | null => {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
};
|
||||
|
||||
const parseStringArray = (raw: string | null): string[] => {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
|
||||
} catch (_e) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const parseObject = (raw: string | null): Record<string, unknown> | null => {
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? parsed : null;
|
||||
} catch (_e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const readStoredScope = (): AuthScope => (localStorage.getItem("scope") === "PLATFORM" ? "PLATFORM" : "TENANT");
|
||||
|
||||
const readStoredTenantContext = (): TenantContextSnapshot => ({
|
||||
scope: readStoredScope(),
|
||||
tenantId: parseNumber(localStorage.getItem("tenantId")),
|
||||
userId: parseNumber(localStorage.getItem("userId")),
|
||||
});
|
||||
|
||||
const normalizeTenantContext = (payload: AuthPayload | null | undefined): TenantContextSnapshot => {
|
||||
const data = payload || {};
|
||||
const nextTenantId = data.tenantId === undefined ? null : parseNumber(String(data.tenantId ?? ""));
|
||||
const nextUserId = data.userId === undefined ? null : parseNumber(String(data.userId ?? ""));
|
||||
return {
|
||||
scope: data.scope === "PLATFORM" ? "PLATFORM" : "TENANT",
|
||||
tenantId: nextTenantId,
|
||||
userId: nextUserId,
|
||||
};
|
||||
};
|
||||
|
||||
const isSameTenantContext = (left: TenantContextSnapshot, right: TenantContextSnapshot): boolean =>
|
||||
left.scope === right.scope && left.tenantId === right.tenantId && left.userId === right.userId;
|
||||
|
||||
const nextVersion = (): string => String(Date.now());
|
||||
|
||||
const readStoredAppearance = (): StoredAppearance => {
|
||||
const raw = localStorage.getItem("appearance");
|
||||
const fallbackThemeMode = String(localStorage.getItem("themeMode") || "").trim();
|
||||
const fallbackDensity = String(localStorage.getItem("density") || "").trim();
|
||||
const fallbackThemeScheme = String(localStorage.getItem("themeScheme") || "").trim();
|
||||
if (!raw) {
|
||||
return {
|
||||
themeMode: fallbackThemeMode,
|
||||
density: fallbackDensity,
|
||||
themeScheme: fallbackThemeScheme,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) || {};
|
||||
return {
|
||||
themeMode: String(parsed?.themeMode || fallbackThemeMode).trim(),
|
||||
density: String(parsed?.density || fallbackDensity).trim(),
|
||||
themeScheme: String(parsed?.themeScheme || fallbackThemeScheme).trim(),
|
||||
};
|
||||
} catch (_e) {
|
||||
return {
|
||||
themeMode: fallbackThemeMode,
|
||||
density: fallbackDensity,
|
||||
themeScheme: fallbackThemeScheme,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const readAuthStateFromStorage = (): AuthState => {
|
||||
const tenantInfo = parseObject(localStorage.getItem("tenantInfo"));
|
||||
const tenantLogoUrl = String(localStorage.getItem("tenantLogoUrl") || "").trim();
|
||||
const authVersion = String(localStorage.getItem("authVersion") || "").trim();
|
||||
return {
|
||||
token: String(localStorage.getItem("token") || "").trim(),
|
||||
permissions: parseStringArray(localStorage.getItem("permissions")),
|
||||
roles: parseStringArray(localStorage.getItem("roles")),
|
||||
userId: parseNumber(localStorage.getItem("userId")),
|
||||
phone: String(localStorage.getItem("phone") || "").trim(),
|
||||
tenantId: parseNumber(localStorage.getItem("tenantId")),
|
||||
userName: String(localStorage.getItem("userName") || "").trim(),
|
||||
tenantName: String(localStorage.getItem("tenantName") || "").trim(),
|
||||
tenantCode: String(localStorage.getItem("tenantCode") || "").trim(),
|
||||
tenantInfo,
|
||||
tenantLogoUrl,
|
||||
scope: readStoredScope(),
|
||||
appearance: readStoredAppearance(),
|
||||
authVersion,
|
||||
tenantContextVersion: String(localStorage.getItem("tenantContextVersion") || authVersion || "").trim(),
|
||||
};
|
||||
};
|
||||
|
||||
const applyAuthState = (target: AuthState, source: AuthState) => {
|
||||
target.token = source.token;
|
||||
target.permissions = source.permissions;
|
||||
target.roles = source.roles;
|
||||
target.userId = source.userId;
|
||||
target.phone = source.phone;
|
||||
target.tenantId = source.tenantId;
|
||||
target.userName = source.userName;
|
||||
target.tenantName = source.tenantName;
|
||||
target.tenantCode = source.tenantCode;
|
||||
target.tenantInfo = source.tenantInfo;
|
||||
target.tenantLogoUrl = source.tenantLogoUrl;
|
||||
target.scope = source.scope;
|
||||
target.appearance = source.appearance;
|
||||
target.authVersion = source.authVersion;
|
||||
target.tenantContextVersion = source.tenantContextVersion;
|
||||
};
|
||||
|
||||
const dispatchAuthUpdated = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
window.dispatchEvent(new CustomEvent(AUTH_EVENT_NAME));
|
||||
}
|
||||
};
|
||||
|
||||
export const useAuthStore = defineStore("auth", {
|
||||
state: (): AuthState => readAuthStateFromStorage(),
|
||||
getters: {
|
||||
hasPermission: (state) => (code: string): boolean => state.permissions.indexOf(code) >= 0,
|
||||
currentTenantLogo: (state): string => {
|
||||
const logoFromInfo = String(state.tenantInfo?.logoUrl || "").trim();
|
||||
return logoFromInfo || state.tenantLogoUrl;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
syncFromStorage() {
|
||||
applyAuthState(this, readAuthStateFromStorage());
|
||||
},
|
||||
saveAuthPayload(payload: AuthPayload | null | undefined) {
|
||||
const data = payload || {};
|
||||
const previousTenantContext = readStoredTenantContext();
|
||||
const nextTenantContext = normalizeTenantContext(data);
|
||||
const authVersion = nextVersion();
|
||||
const tenantContextVersion = isSameTenantContext(previousTenantContext, nextTenantContext)
|
||||
? String(localStorage.getItem("tenantContextVersion") || authVersion)
|
||||
: authVersion;
|
||||
localStorage.setItem("token", String(data.token || ""));
|
||||
localStorage.setItem("userId", String(data.userId || ""));
|
||||
localStorage.setItem("phone", String(data.phone || ""));
|
||||
localStorage.setItem("tenantId", String(data.tenantId || ""));
|
||||
localStorage.setItem("userName", String(data.userName || ""));
|
||||
localStorage.setItem("tenantName", String(data.tenantName || ""));
|
||||
localStorage.setItem("tenantCode", String(data.tenantCode || ""));
|
||||
localStorage.setItem("tenantLogoUrl", String(data.tenant?.logoUrl || ""));
|
||||
localStorage.setItem("tenantInfo", JSON.stringify(data.tenant || {}));
|
||||
localStorage.setItem("scope", data.scope === "PLATFORM" ? "PLATFORM" : "TENANT");
|
||||
localStorage.setItem("permissions", JSON.stringify(Array.isArray(data.permissions) ? data.permissions : []));
|
||||
localStorage.setItem("roles", JSON.stringify(Array.isArray(data.roles) ? data.roles : []));
|
||||
localStorage.setItem("appearance", JSON.stringify(data.appearance || {}));
|
||||
localStorage.setItem("themeMode", String(data.appearance?.themeMode || ""));
|
||||
localStorage.setItem("density", String(data.appearance?.density || ""));
|
||||
localStorage.setItem("themeScheme", String(data.appearance?.themeScheme || ""));
|
||||
localStorage.setItem("authVersion", authVersion);
|
||||
localStorage.setItem("tenantContextVersion", tenantContextVersion);
|
||||
this.syncFromStorage();
|
||||
dispatchAuthUpdated();
|
||||
},
|
||||
updateTenantProfile(patch: TenantProfilePatch | null | undefined) {
|
||||
const data = patch || {};
|
||||
const nextTenantName = data.tenantName === undefined ? this.tenantName : String(data.tenantName || "").trim();
|
||||
const nextLogoUrl = data.logoUrl === undefined ? this.currentTenantLogo : String(data.logoUrl || "").trim();
|
||||
const nextTenantInfo = {
|
||||
...(this.tenantInfo || {}),
|
||||
logoUrl: nextLogoUrl,
|
||||
};
|
||||
localStorage.setItem("tenantName", nextTenantName);
|
||||
localStorage.setItem("tenantLogoUrl", nextLogoUrl);
|
||||
localStorage.setItem("tenantInfo", JSON.stringify(nextTenantInfo));
|
||||
this.tenantName = nextTenantName;
|
||||
this.tenantLogoUrl = nextLogoUrl;
|
||||
this.tenantInfo = nextTenantInfo;
|
||||
dispatchAuthUpdated();
|
||||
},
|
||||
clearAuthStorage() {
|
||||
const clearedVersion = nextVersion();
|
||||
localStorage.removeItem("token");
|
||||
localStorage.removeItem("permissions");
|
||||
localStorage.removeItem("roles");
|
||||
localStorage.removeItem("userId");
|
||||
localStorage.removeItem("phone");
|
||||
localStorage.removeItem("tenantId");
|
||||
localStorage.removeItem("userName");
|
||||
localStorage.removeItem("tenantName");
|
||||
localStorage.removeItem("tenantLogoUrl");
|
||||
localStorage.removeItem("tenantInfo");
|
||||
localStorage.removeItem("tenantCode");
|
||||
localStorage.removeItem("scope");
|
||||
localStorage.removeItem("appearance");
|
||||
localStorage.removeItem("themeMode");
|
||||
localStorage.removeItem("density");
|
||||
localStorage.removeItem("themeScheme");
|
||||
localStorage.setItem("authVersion", clearedVersion);
|
||||
localStorage.setItem("tenantContextVersion", clearedVersion);
|
||||
this.syncFromStorage();
|
||||
dispatchAuthUpdated();
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
export const pinia = createPinia();
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { defineStore } from "pinia";
|
||||
import {
|
||||
fetchCurrentMenus,
|
||||
fetchFilePresignDownload,
|
||||
fetchPlatformCurrentMenus,
|
||||
fetchSwitchableTenants,
|
||||
} from "../api/modules";
|
||||
import { PERMS } from "../constants/permissions";
|
||||
import { useAuthStore } from "./auth";
|
||||
|
||||
export type MenuItem = {
|
||||
menuName: string;
|
||||
routePath: string;
|
||||
};
|
||||
|
||||
export type TenantOption = {
|
||||
tenantId: number;
|
||||
tenantCode: string;
|
||||
tenantName: string;
|
||||
logoUrl?: string;
|
||||
current?: boolean;
|
||||
};
|
||||
|
||||
const fallbackMenus: MenuItem[] = [];
|
||||
const platformMenus: MenuItem[] = [
|
||||
{ menuName: "租户管理", routePath: "/platform/tenants" },
|
||||
{ menuName: "平台用户管理", routePath: "/platform/users" },
|
||||
{ menuName: "平台角色管理", routePath: "/platform/roles" },
|
||||
{ menuName: "通知网关配置", routePath: "/platform/notify-gateways" },
|
||||
{ menuName: "专家管理", routePath: "/platform/experts" },
|
||||
{ menuName: "平台字典管理", routePath: "/platform/dictionaries" },
|
||||
{ menuName: "平台会话管理", routePath: "/platform/auth-sessions" },
|
||||
{ menuName: "平台审计日志", routePath: "/platform/audit-logs" },
|
||||
{ menuName: "平台菜单与权限", routePath: "/platform/menus" },
|
||||
];
|
||||
const platformRouteWhitelist = new Set(platformMenus.map((item) => item.routePath));
|
||||
|
||||
export const useMenuStore = defineStore("menu", {
|
||||
state: () => ({
|
||||
menus: [] as MenuItem[],
|
||||
tenantLogoDisplay: "",
|
||||
switchableTenants: [] as TenantOption[],
|
||||
tenantSwitching: false,
|
||||
}),
|
||||
getters: {
|
||||
useTopMenuLayout: (state): boolean => state.menus.length > 0 && state.menus.length <= 5,
|
||||
},
|
||||
actions: {
|
||||
reset() {
|
||||
this.menus = [];
|
||||
this.tenantLogoDisplay = "";
|
||||
this.switchableTenants = [];
|
||||
this.tenantSwitching = false;
|
||||
},
|
||||
setTenantSwitching(next: boolean) {
|
||||
this.tenantSwitching = next;
|
||||
},
|
||||
async loadTenantLogo() {
|
||||
const authStore = useAuthStore();
|
||||
this.tenantLogoDisplay = "";
|
||||
if (authStore.scope !== "TENANT") {
|
||||
return;
|
||||
}
|
||||
const tenantLogo = authStore.currentTenantLogo;
|
||||
if (!tenantLogo) {
|
||||
return;
|
||||
}
|
||||
if (tenantLogo.startsWith("http://") || tenantLogo.startsWith("https://")) {
|
||||
this.tenantLogoDisplay = tenantLogo;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetchFilePresignDownload({ objectKey: tenantLogo });
|
||||
this.tenantLogoDisplay = String(resp?.data?.signedUrl || "");
|
||||
} catch (_e) {
|
||||
this.tenantLogoDisplay = "";
|
||||
}
|
||||
},
|
||||
async loadMenus() {
|
||||
const authStore = useAuthStore();
|
||||
if (authStore.scope === "PLATFORM") {
|
||||
try {
|
||||
const resp = await fetchPlatformCurrentMenus();
|
||||
const list = Array.isArray(resp?.data) ? resp.data : [];
|
||||
const normalized = list
|
||||
.map((item: any) => ({
|
||||
menuName: String(item?.routePath || "") === "/platform/permissions" ? "平台菜单与权限" : String(item?.menuName || ""),
|
||||
routePath: String(item?.routePath || "") === "/platform/permissions" ? "/platform/menus" : String(item?.routePath || ""),
|
||||
}))
|
||||
.filter((item: MenuItem) => platformRouteWhitelist.has(item.routePath));
|
||||
const deduped = normalized.filter((item, index, arr) => arr.findIndex((row) => row.routePath === item.routePath) === index);
|
||||
this.menus = deduped.length > 0 ? deduped : platformMenus;
|
||||
} catch (_e) {
|
||||
this.menus = platformMenus;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetchCurrentMenus();
|
||||
const list = Array.isArray(resp?.data) ? resp.data : [];
|
||||
const resolved = list.length > 0 ? list : fallbackMenus;
|
||||
const normalized = resolved.map((item: any) => ({
|
||||
menuName: String(item?.routePath || "") === "/permissions" ? "菜单与权限" : String(item?.menuName || ""),
|
||||
routePath: String(item?.routePath || "") === "/permissions" ? "/menus" : String(item?.routePath || ""),
|
||||
}));
|
||||
const hasMenuEntry = normalized.some((item: MenuItem) => item.routePath === "/menus");
|
||||
const canSeeMenuPage = authStore.hasPermission(PERMS.role.read) || authStore.hasPermission(PERMS.permission.read);
|
||||
if (canSeeMenuPage && !hasMenuEntry) {
|
||||
normalized.push({ menuName: "菜单与权限", routePath: "/menus" });
|
||||
}
|
||||
this.menus = normalized.filter((item: MenuItem, index: number, arr: MenuItem[]) =>
|
||||
arr.findIndex((row) => row.routePath === item.routePath) === index,
|
||||
);
|
||||
} catch (_e) {
|
||||
this.menus = fallbackMenus;
|
||||
}
|
||||
},
|
||||
async loadSwitchableTenants() {
|
||||
const authStore = useAuthStore();
|
||||
const canSwitchTenant = authStore.scope === "TENANT" && authStore.hasPermission(PERMS.tenant.switch);
|
||||
if (!canSwitchTenant) {
|
||||
this.switchableTenants = [];
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetchSwitchableTenants();
|
||||
this.switchableTenants = Array.isArray(resp?.data) ? resp.data : [];
|
||||
} catch (_e) {
|
||||
this.switchableTenants = [];
|
||||
}
|
||||
},
|
||||
async refreshContext() {
|
||||
await this.loadTenantLogo();
|
||||
await this.loadMenus();
|
||||
await this.loadSwitchableTenants();
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
import { defineStore } from "pinia";
|
||||
import {
|
||||
fetchInAppNotifications,
|
||||
markAllInAppNotificationsRead,
|
||||
markInAppNotificationRead,
|
||||
} from "../api/modules";
|
||||
import { PERMS } from "../constants/permissions";
|
||||
import { useAuthStore } from "./auth";
|
||||
|
||||
const canReadInApp = () => {
|
||||
const authStore = useAuthStore();
|
||||
return authStore.scope === "TENANT" && authStore.hasPermission(PERMS.notification.inAppRead);
|
||||
};
|
||||
|
||||
const IN_APP_POLL_INTERVAL_MS = 30000;
|
||||
const IN_APP_WS_RECONNECT_MS = 5000;
|
||||
|
||||
let inAppRefreshTimer: number | null = null;
|
||||
let inAppWs: WebSocket | null = null;
|
||||
let inAppWsReconnectTimer: number | null = null;
|
||||
|
||||
const emitUnreadChanged = (unreadCount: number) => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent("in-app-unread-changed", { detail: { unreadCount } }));
|
||||
} catch (_error) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
export const useNotificationStore = defineStore("notification", {
|
||||
state: () => ({
|
||||
unreadCount: 0,
|
||||
notifRows: [] as any[],
|
||||
polling: false,
|
||||
websocketConnected: false,
|
||||
}),
|
||||
actions: {
|
||||
stopPolling() {
|
||||
if (inAppRefreshTimer !== null && typeof window !== "undefined") {
|
||||
window.clearInterval(inAppRefreshTimer);
|
||||
}
|
||||
inAppRefreshTimer = null;
|
||||
this.polling = false;
|
||||
},
|
||||
startPolling() {
|
||||
if (typeof window === "undefined" || inAppRefreshTimer !== null || !canReadInApp()) {
|
||||
return;
|
||||
}
|
||||
this.polling = true;
|
||||
inAppRefreshTimer = window.setInterval(() => {
|
||||
void this.loadUnreadCount();
|
||||
}, IN_APP_POLL_INTERVAL_MS);
|
||||
},
|
||||
closeWebSocket() {
|
||||
if (inAppWsReconnectTimer !== null && typeof window !== "undefined") {
|
||||
window.clearTimeout(inAppWsReconnectTimer);
|
||||
}
|
||||
inAppWsReconnectTimer = null;
|
||||
if (inAppWs) {
|
||||
try {
|
||||
inAppWs.close();
|
||||
} catch (_error) {
|
||||
// ignore
|
||||
}
|
||||
inAppWs = null;
|
||||
}
|
||||
this.websocketConnected = false;
|
||||
},
|
||||
scheduleWsReconnect() {
|
||||
if (typeof window === "undefined" || !canReadInApp() || inAppWsReconnectTimer !== null) {
|
||||
return;
|
||||
}
|
||||
inAppWsReconnectTimer = window.setTimeout(() => {
|
||||
inAppWsReconnectTimer = null;
|
||||
this.ensureWebSocket();
|
||||
}, IN_APP_WS_RECONNECT_MS);
|
||||
},
|
||||
ensureWebSocket() {
|
||||
if (typeof window === "undefined" || !canReadInApp()) {
|
||||
this.closeWebSocket();
|
||||
return;
|
||||
}
|
||||
if (inAppWs && (inAppWs.readyState === WebSocket.OPEN || inAppWs.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
const authStore = useAuthStore();
|
||||
if (!authStore.token) {
|
||||
this.closeWebSocket();
|
||||
return;
|
||||
}
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws/notifications?token=${encodeURIComponent(authStore.token)}`;
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
inAppWs = ws;
|
||||
ws.onopen = () => {
|
||||
this.websocketConnected = true;
|
||||
void this.loadUnreadCount();
|
||||
};
|
||||
ws.onmessage = (event) => {
|
||||
let data: any = null;
|
||||
try {
|
||||
data = JSON.parse(String(event.data || "{}"));
|
||||
} catch (_error) {
|
||||
data = null;
|
||||
}
|
||||
const type = String(data?.type || "");
|
||||
if (type === "IN_APP_UNREAD_CHANGED" || type === "IN_APP_NOTIFICATION_NEW") {
|
||||
void this.loadUnreadCount();
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
inAppWs = null;
|
||||
this.websocketConnected = false;
|
||||
this.scheduleWsReconnect();
|
||||
};
|
||||
ws.onerror = () => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch (_error) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
} catch (_error) {
|
||||
this.websocketConnected = false;
|
||||
this.scheduleWsReconnect();
|
||||
}
|
||||
},
|
||||
async startRealtime() {
|
||||
if (!canReadInApp()) {
|
||||
this.stopRealtime();
|
||||
this.notifRows = [];
|
||||
this.setUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
await this.loadUnreadCount();
|
||||
this.startPolling();
|
||||
this.ensureWebSocket();
|
||||
},
|
||||
stopRealtime() {
|
||||
this.stopPolling();
|
||||
this.closeWebSocket();
|
||||
},
|
||||
async refreshContext() {
|
||||
this.stopRealtime();
|
||||
if (!canReadInApp()) {
|
||||
this.notifRows = [];
|
||||
this.setUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
await this.startRealtime();
|
||||
},
|
||||
handleVisibilityVisible() {
|
||||
if (!canReadInApp()) {
|
||||
return;
|
||||
}
|
||||
void this.loadUnreadCount();
|
||||
this.ensureWebSocket();
|
||||
},
|
||||
reset() {
|
||||
this.stopRealtime();
|
||||
this.unreadCount = 0;
|
||||
this.notifRows = [];
|
||||
emitUnreadChanged(0);
|
||||
},
|
||||
setUnreadCount(nextCount: number) {
|
||||
this.unreadCount = Number.isFinite(nextCount) && nextCount >= 0 ? nextCount : 0;
|
||||
emitUnreadChanged(this.unreadCount);
|
||||
},
|
||||
async loadUnreadCount() {
|
||||
if (!canReadInApp()) {
|
||||
this.setUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetchInAppNotifications({ ts: Date.now() });
|
||||
const list = resp?.data?.list || [];
|
||||
this.setUnreadCount(list.filter((item: any) => String(item?.status || "") === "UNREAD").length);
|
||||
} catch (_e) {
|
||||
this.setUnreadCount(0);
|
||||
}
|
||||
},
|
||||
async loadRows() {
|
||||
if (!canReadInApp()) {
|
||||
this.notifRows = [];
|
||||
this.setUnreadCount(0);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetchInAppNotifications({ ts: Date.now() });
|
||||
const list = Array.isArray(resp?.data?.list) ? resp.data.list : [];
|
||||
this.notifRows = list;
|
||||
this.setUnreadCount(list.filter((item: any) => String(item?.status || "") === "UNREAD").length);
|
||||
} catch (_e) {
|
||||
this.notifRows = [];
|
||||
this.setUnreadCount(0);
|
||||
}
|
||||
},
|
||||
async markRead(id: number) {
|
||||
await markInAppNotificationRead(id);
|
||||
await this.loadRows();
|
||||
},
|
||||
async markAllRead() {
|
||||
const resp = await markAllInAppNotificationsRead();
|
||||
await this.loadRows();
|
||||
return Number(resp?.data?.affected || 0);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,677 @@
|
||||
/* ============================================
|
||||
* 全局主题 Token / 主题方案
|
||||
* ============================================
|
||||
* 主题模式(浅色 / 深色)与主题方案(商务蓝 / 海湾青 / 山岚绿)分离,
|
||||
* 避免全局固定为高饱和蓝紫,减少模板化观感。
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ---- 全局字体 ---- */
|
||||
--el-font-family: "Inter", "PingFang SC", "Microsoft YaHei", -apple-system, sans-serif;
|
||||
|
||||
/* ---- 全局圆角增强 ---- */
|
||||
--el-border-radius-base: 8px;
|
||||
--el-border-radius-small: 6px;
|
||||
|
||||
/* ---- 默认主题方案:商务蓝 ---- */
|
||||
--wo-theme-rgb: 60, 110, 145;
|
||||
--wo-brand-primary: #3c6e91;
|
||||
--wo-brand-primary-light: #6f93af;
|
||||
--wo-brand-primary-dark: #315977;
|
||||
--wo-brand-primary-light-3: #6f93af;
|
||||
--wo-brand-primary-light-5: #90aec3;
|
||||
--wo-brand-primary-light-7: #b7cad7;
|
||||
--wo-brand-primary-light-8: #d1dde6;
|
||||
--wo-brand-primary-light-9: #e8eff4;
|
||||
--wo-brand-primary-dark-2: #315977;
|
||||
--wo-brand-accent: #5c88a7;
|
||||
--wo-brand-accent-light: #87a9bf;
|
||||
--wo-brand-gradient: linear-gradient(135deg, #3c6e91 0%, #5c88a7 100%);
|
||||
--wo-sidebar-bg-light: linear-gradient(180deg, #22364b 0%, #2f4b64 100%);
|
||||
--wo-sidebar-bg-dark: linear-gradient(180deg, #22364b 0%, #2f4b64 100%);
|
||||
--wo-sidebar-text-light: rgba(255, 255, 255, 0.76);
|
||||
--wo-sidebar-text-dark: rgba(255, 255, 255, 0.76);
|
||||
--wo-sidebar-text-active-light: #ffffff;
|
||||
--wo-sidebar-text-active-dark: #ffffff;
|
||||
--wo-sidebar-brand-bg-light: rgba(0, 0, 0, 0.12);
|
||||
--wo-sidebar-brand-bg-dark: rgba(0, 0, 0, 0.12);
|
||||
--wo-sidebar-item-hover-light: rgba(255, 255, 255, 0.08);
|
||||
--wo-sidebar-item-hover-dark: rgba(255, 255, 255, 0.08);
|
||||
--wo-sidebar-item-active-bg-light: rgba(var(--wo-theme-rgb), 0.22);
|
||||
--wo-sidebar-item-active-bg-dark: rgba(var(--wo-theme-rgb), 0.22);
|
||||
--wo-sidebar-border-light: rgba(255, 255, 255, 0.08);
|
||||
--wo-sidebar-border-dark: rgba(255, 255, 255, 0.08);
|
||||
--wo-sidebar-bg: var(--wo-sidebar-bg-light);
|
||||
--wo-sidebar-text: var(--wo-sidebar-text-light);
|
||||
--wo-sidebar-text-active: var(--wo-sidebar-text-active-light);
|
||||
--wo-sidebar-brand-bg: var(--wo-sidebar-brand-bg-light);
|
||||
--wo-sidebar-item-hover: var(--wo-sidebar-item-hover-light);
|
||||
--wo-sidebar-item-active-bg: var(--wo-sidebar-item-active-bg-light);
|
||||
--wo-sidebar-border: var(--wo-sidebar-border-light);
|
||||
--wo-login-bg-platform: linear-gradient(135deg, #172638 0%, #27445d 52%, #1f3347 100%);
|
||||
--wo-login-bg-tenant: linear-gradient(135deg, #101d2b 0%, #1f3447 42%, #162838 100%);
|
||||
--wo-login-orb-platform-1: #3c6e91;
|
||||
--wo-login-orb-platform-2: #5c88a7;
|
||||
--wo-login-orb-platform-3: #87a9bf;
|
||||
--wo-login-orb-platform-4: #a7c0d0;
|
||||
--wo-login-orb-tenant-1: #4a87ad;
|
||||
--wo-login-orb-tenant-2: #3c6e91;
|
||||
--wo-login-orb-tenant-3: #5c88a7;
|
||||
--wo-login-orb-tenant-4: #87a9bf;
|
||||
--wo-app-header-bg-light: linear-gradient(135deg, #22364b 0%, #2f4b64 100%);
|
||||
--wo-app-header-bg-dark: linear-gradient(135deg, #111a23 0%, #203344 55%, #2f4b64 100%);
|
||||
--wo-profile-avatar-bg-light: linear-gradient(135deg, #edf3f7, #cfdde7);
|
||||
--wo-profile-avatar-bg-dark: linear-gradient(135deg, rgba(var(--wo-theme-rgb), 0.18), rgba(var(--wo-theme-rgb), 0.32));
|
||||
--wo-profile-avatar-text-light: #315977;
|
||||
--wo-profile-avatar-text-dark: #d5e3ec;
|
||||
|
||||
/* ---- Element Plus 主色映射 ---- */
|
||||
--el-color-primary: var(--wo-brand-primary);
|
||||
--el-color-primary-light-3: var(--wo-brand-primary-light-3);
|
||||
--el-color-primary-light-5: var(--wo-brand-primary-light-5);
|
||||
--el-color-primary-light-7: var(--wo-brand-primary-light-7);
|
||||
--el-color-primary-light-8: var(--wo-brand-primary-light-8);
|
||||
--el-color-primary-light-9: var(--wo-brand-primary-light-9);
|
||||
--el-color-primary-dark-2: var(--wo-brand-primary-dark-2);
|
||||
|
||||
/* ---- 壳层与内容区域 ---- */
|
||||
--wo-header-bg: #ffffff;
|
||||
--wo-header-shadow: 0 1px 4px rgba(15, 23, 42, 0.08);
|
||||
--wo-content-bg: #eef2f5;
|
||||
--wo-app-header-bg: var(--wo-app-header-bg-light);
|
||||
--wo-app-header-text: #ffffff;
|
||||
--wo-sub-header-bg: #ffffff;
|
||||
--wo-sub-header-border: #dde4ea;
|
||||
--wo-main-content-bg: #f6f8fa;
|
||||
--wo-card-border: #e4ebf1;
|
||||
--wo-top-nav-text: rgba(255, 255, 255, 0.74);
|
||||
--wo-top-nav-active-bg: rgba(255, 255, 255, 0.12);
|
||||
--wo-header-action-bg: rgba(255, 255, 255, 0.12);
|
||||
--wo-header-action-text: #ffffff;
|
||||
--wo-header-action-divider: rgba(255, 255, 255, 0.16);
|
||||
--wo-light-action-bg: #f3f5f7;
|
||||
--wo-light-action-text: #4b5563;
|
||||
--wo-light-action-divider: #d4dbe2;
|
||||
--wo-profile-avatar-bg: var(--wo-profile-avatar-bg-light);
|
||||
--wo-profile-avatar-text: var(--wo-profile-avatar-text-light);
|
||||
--wo-search-surface: rgba(255, 255, 255, 0.9);
|
||||
--wo-search-surface-hover: rgba(var(--wo-theme-rgb), 0.08);
|
||||
--wo-search-border: rgba(var(--wo-theme-rgb), 0.18);
|
||||
--wo-search-muted: #64748b;
|
||||
--wo-search-title: #1f2937;
|
||||
--wo-main-content-padding: 20px;
|
||||
--wo-shell-gap: 16px;
|
||||
--wo-header-action-size: 36px;
|
||||
--wo-header-avatar-size: 34px;
|
||||
--wo-header-avatar-icon-size: 18px;
|
||||
}
|
||||
|
||||
:root[data-theme-scheme="ocean"] {
|
||||
--wo-theme-rgb: 14, 116, 144;
|
||||
--wo-brand-primary: #0e7490;
|
||||
--wo-brand-primary-light: #4493a9;
|
||||
--wo-brand-primary-dark: #0c5d74;
|
||||
--wo-brand-primary-light-3: #4493a9;
|
||||
--wo-brand-primary-light-5: #73afbd;
|
||||
--wo-brand-primary-light-7: #a7ccd4;
|
||||
--wo-brand-primary-light-8: #c9dfe4;
|
||||
--wo-brand-primary-light-9: #e7f0f2;
|
||||
--wo-brand-primary-dark-2: #0c5d74;
|
||||
--wo-brand-accent: #0284c7;
|
||||
--wo-brand-accent-light: #5ba9d8;
|
||||
--wo-brand-gradient: linear-gradient(135deg, #0e7490 0%, #0284c7 100%);
|
||||
--wo-sidebar-bg-light: linear-gradient(180deg, #0f3b49 0%, #13566a 100%);
|
||||
--wo-sidebar-bg-dark: linear-gradient(180deg, #0f3b49 0%, #13566a 100%);
|
||||
--wo-login-bg-platform: linear-gradient(135deg, #0b2430 0%, #0f4b5d 52%, #12394a 100%);
|
||||
--wo-login-bg-tenant: linear-gradient(135deg, #081a22 0%, #0c3947 42%, #0f2f3a 100%);
|
||||
--wo-login-orb-platform-1: #0e7490;
|
||||
--wo-login-orb-platform-2: #0284c7;
|
||||
--wo-login-orb-platform-3: #67b7cf;
|
||||
--wo-login-orb-platform-4: #7dd3fc;
|
||||
--wo-login-orb-tenant-1: #06b6d4;
|
||||
--wo-login-orb-tenant-2: #0e7490;
|
||||
--wo-login-orb-tenant-3: #0284c7;
|
||||
--wo-login-orb-tenant-4: #67e8f9;
|
||||
--wo-app-header-bg-light: linear-gradient(135deg, #0f3b49 0%, #13566a 100%);
|
||||
--wo-app-header-bg-dark: linear-gradient(135deg, #0a1820 0%, #103744 55%, #13566a 100%);
|
||||
--wo-profile-avatar-bg-light: linear-gradient(135deg, #ebf6f8, #c5e2e8);
|
||||
--wo-profile-avatar-text-light: #0c5d74;
|
||||
--wo-profile-avatar-text-dark: #ccebf3;
|
||||
}
|
||||
|
||||
:root[data-theme-scheme="forest"] {
|
||||
--wo-theme-rgb: 74, 124, 89;
|
||||
--wo-brand-primary: #4a7c59;
|
||||
--wo-brand-primary-light: #749a7f;
|
||||
--wo-brand-primary-dark: #3c6548;
|
||||
--wo-brand-primary-light-3: #749a7f;
|
||||
--wo-brand-primary-light-5: #93b099;
|
||||
--wo-brand-primary-light-7: #bbd0bf;
|
||||
--wo-brand-primary-light-8: #d4e1d6;
|
||||
--wo-brand-primary-light-9: #ebf2ec;
|
||||
--wo-brand-primary-dark-2: #3c6548;
|
||||
--wo-brand-accent: #7c9a4d;
|
||||
--wo-brand-accent-light: #9cb374;
|
||||
--wo-brand-gradient: linear-gradient(135deg, #4a7c59 0%, #7c9a4d 100%);
|
||||
--wo-sidebar-bg-light: linear-gradient(180deg, #24372a 0%, #35523d 100%);
|
||||
--wo-sidebar-bg-dark: linear-gradient(180deg, #24372a 0%, #35523d 100%);
|
||||
--wo-login-bg-platform: linear-gradient(135deg, #18241b 0%, #2c4934 52%, #24372a 100%);
|
||||
--wo-login-bg-tenant: linear-gradient(135deg, #101712 0%, #22362a 42%, #1a291f 100%);
|
||||
--wo-login-orb-platform-1: #4a7c59;
|
||||
--wo-login-orb-platform-2: #7c9a4d;
|
||||
--wo-login-orb-platform-3: #9cb374;
|
||||
--wo-login-orb-platform-4: #bbd0bf;
|
||||
--wo-login-orb-tenant-1: #5b9a6d;
|
||||
--wo-login-orb-tenant-2: #4a7c59;
|
||||
--wo-login-orb-tenant-3: #7c9a4d;
|
||||
--wo-login-orb-tenant-4: #c6d9b6;
|
||||
--wo-app-header-bg-light: linear-gradient(135deg, #24372a 0%, #35523d 100%);
|
||||
--wo-app-header-bg-dark: linear-gradient(135deg, #111913 0%, #22362a 55%, #35523d 100%);
|
||||
--wo-profile-avatar-bg-light: linear-gradient(135deg, #eef4ef, #d4e2d7);
|
||||
--wo-profile-avatar-text-light: #3c6548;
|
||||
--wo-profile-avatar-text-dark: #dce9df;
|
||||
}
|
||||
|
||||
:root[data-theme-scheme="graphite"] {
|
||||
--wo-theme-rgb: 71, 85, 105;
|
||||
--wo-brand-primary: #475569;
|
||||
--wo-brand-primary-light: #6b778a;
|
||||
--wo-brand-primary-dark: #364152;
|
||||
--wo-brand-primary-light-3: #6b778a;
|
||||
--wo-brand-primary-light-5: #8791a0;
|
||||
--wo-brand-primary-light-7: #b0b7c2;
|
||||
--wo-brand-primary-light-8: #ccd1d8;
|
||||
--wo-brand-primary-light-9: #e8ebee;
|
||||
--wo-brand-primary-dark-2: #364152;
|
||||
--wo-brand-accent: #64748b;
|
||||
--wo-brand-accent-light: #8794a6;
|
||||
--wo-brand-gradient: linear-gradient(135deg, #334155 0%, #64748b 100%);
|
||||
--wo-sidebar-bg-light: linear-gradient(180deg, #1f2937 0%, #334155 100%);
|
||||
--wo-sidebar-bg-dark: linear-gradient(180deg, #1f2937 0%, #334155 100%);
|
||||
--wo-login-bg-platform: linear-gradient(135deg, #141c27 0%, #253243 52%, #1c2734 100%);
|
||||
--wo-login-bg-tenant: linear-gradient(135deg, #0f1620 0%, #1c2938 42%, #141d29 100%);
|
||||
--wo-login-orb-platform-1: #475569;
|
||||
--wo-login-orb-platform-2: #64748b;
|
||||
--wo-login-orb-platform-3: #94a3b8;
|
||||
--wo-login-orb-platform-4: #cbd5e1;
|
||||
--wo-login-orb-tenant-1: #64748b;
|
||||
--wo-login-orb-tenant-2: #475569;
|
||||
--wo-login-orb-tenant-3: #94a3b8;
|
||||
--wo-login-orb-tenant-4: #e2e8f0;
|
||||
--wo-app-header-bg-light: linear-gradient(135deg, #1f2937 0%, #334155 100%);
|
||||
--wo-app-header-bg-dark: linear-gradient(135deg, #111827 0%, #1f2937 55%, #334155 100%);
|
||||
--wo-profile-avatar-bg-light: linear-gradient(135deg, #eef2f5, #d7dde5);
|
||||
--wo-profile-avatar-text-light: #364152;
|
||||
--wo-profile-avatar-text-dark: #e2e8f0;
|
||||
}
|
||||
|
||||
:root[data-theme-scheme="amber"] {
|
||||
--wo-theme-rgb: 154, 107, 63;
|
||||
--wo-brand-primary: #9a6b3f;
|
||||
--wo-brand-primary-light: #b18761;
|
||||
--wo-brand-primary-dark: #7f5733;
|
||||
--wo-brand-primary-light-3: #b18761;
|
||||
--wo-brand-primary-light-5: #c5a080;
|
||||
--wo-brand-primary-light-7: #dcc0ab;
|
||||
--wo-brand-primary-light-8: #ead4c7;
|
||||
--wo-brand-primary-light-9: #f5ece6;
|
||||
--wo-brand-primary-dark-2: #7f5733;
|
||||
--wo-brand-accent: #c08a52;
|
||||
--wo-brand-accent-light: #d2a373;
|
||||
--wo-brand-gradient: linear-gradient(135deg, #7f5733 0%, #c08a52 100%);
|
||||
--wo-sidebar-bg-light: linear-gradient(180deg, #4a382c 0%, #6a503e 100%);
|
||||
--wo-sidebar-bg-dark: linear-gradient(180deg, #4a382c 0%, #6a503e 100%);
|
||||
--wo-login-bg-platform: linear-gradient(135deg, #241912 0%, #4b3527 52%, #35251b 100%);
|
||||
--wo-login-bg-tenant: linear-gradient(135deg, #1d140f 0%, #3b2a1f 42%, #2b1f18 100%);
|
||||
--wo-login-orb-platform-1: #9a6b3f;
|
||||
--wo-login-orb-platform-2: #c08a52;
|
||||
--wo-login-orb-platform-3: #d8b07b;
|
||||
--wo-login-orb-platform-4: #f0d3a8;
|
||||
--wo-login-orb-tenant-1: #b87d43;
|
||||
--wo-login-orb-tenant-2: #9a6b3f;
|
||||
--wo-login-orb-tenant-3: #c08a52;
|
||||
--wo-login-orb-tenant-4: #e8c18e;
|
||||
--wo-app-header-bg-light: linear-gradient(135deg, #4a382c 0%, #6a503e 100%);
|
||||
--wo-app-header-bg-dark: linear-gradient(135deg, #201610 0%, #443224 55%, #6a503e 100%);
|
||||
--wo-profile-avatar-bg-light: linear-gradient(135deg, #f7efe8, #ead7c5);
|
||||
--wo-profile-avatar-text-light: #7f5733;
|
||||
--wo-profile-avatar-text-dark: #f4e3d3;
|
||||
}
|
||||
|
||||
:root[data-theme-scheme="ruby"] {
|
||||
--wo-theme-rgb: 122, 62, 70;
|
||||
--wo-brand-primary: #7a3e46;
|
||||
--wo-brand-primary-light: #955962;
|
||||
--wo-brand-primary-dark: #65333a;
|
||||
--wo-brand-primary-light-3: #955962;
|
||||
--wo-brand-primary-light-5: #ac7478;
|
||||
--wo-brand-primary-light-7: #cdafb1;
|
||||
--wo-brand-primary-light-8: #decbcc;
|
||||
--wo-brand-primary-light-9: #f2e8e8;
|
||||
--wo-brand-primary-dark-2: #65333a;
|
||||
--wo-brand-accent: #a95c57;
|
||||
--wo-brand-accent-light: #c07a74;
|
||||
--wo-brand-gradient: linear-gradient(135deg, #65333a 0%, #a95c57 100%);
|
||||
--wo-sidebar-bg-light: linear-gradient(180deg, #392126 0%, #5a3138 100%);
|
||||
--wo-sidebar-bg-dark: linear-gradient(180deg, #392126 0%, #5a3138 100%);
|
||||
--wo-login-bg-platform: linear-gradient(135deg, #201014 0%, #44272e 52%, #311a1f 100%);
|
||||
--wo-login-bg-tenant: linear-gradient(135deg, #180c10 0%, #341c23 42%, #261319 100%);
|
||||
--wo-login-orb-platform-1: #7a3e46;
|
||||
--wo-login-orb-platform-2: #a95c57;
|
||||
--wo-login-orb-platform-3: #c78982;
|
||||
--wo-login-orb-platform-4: #e2b6af;
|
||||
--wo-login-orb-tenant-1: #a95c57;
|
||||
--wo-login-orb-tenant-2: #7a3e46;
|
||||
--wo-login-orb-tenant-3: #c78982;
|
||||
--wo-login-orb-tenant-4: #f0d0cb;
|
||||
--wo-app-header-bg-light: linear-gradient(135deg, #392126 0%, #5a3138 100%);
|
||||
--wo-app-header-bg-dark: linear-gradient(135deg, #180d10 0%, #311a20 55%, #5a3138 100%);
|
||||
--wo-profile-avatar-bg-light: linear-gradient(135deg, #f7eded, #ead6d5);
|
||||
--wo-profile-avatar-text-light: #65333a;
|
||||
--wo-profile-avatar-text-dark: #f2dfdc;
|
||||
}
|
||||
|
||||
:root[data-theme-scheme="mist"] {
|
||||
--wo-theme-rgb: 111, 143, 175;
|
||||
--wo-brand-primary: #6f8faf;
|
||||
--wo-brand-primary-light: #8ea8c0;
|
||||
--wo-brand-primary-dark: #5b7998;
|
||||
--wo-brand-primary-light-3: #8ea8c0;
|
||||
--wo-brand-primary-light-5: #a8bbcd;
|
||||
--wo-brand-primary-light-7: #c5d2dd;
|
||||
--wo-brand-primary-light-8: #dce5ec;
|
||||
--wo-brand-primary-light-9: #eff4f7;
|
||||
--wo-brand-primary-dark-2: #5b7998;
|
||||
--wo-brand-accent: #8fb4c8;
|
||||
--wo-brand-accent-light: #aac8d8;
|
||||
--wo-brand-gradient: linear-gradient(135deg, #6f8faf 0%, #8fb4c8 100%);
|
||||
--wo-sidebar-bg-light: linear-gradient(180deg, #fbfdff 0%, #f2f7fb 100%);
|
||||
--wo-sidebar-bg-dark: linear-gradient(180deg, #2a3b4d 0%, #3f5971 100%);
|
||||
--wo-sidebar-text-light: #6d8196;
|
||||
--wo-sidebar-text-dark: rgba(255, 255, 255, 0.76);
|
||||
--wo-sidebar-text-active-light: #284158;
|
||||
--wo-sidebar-text-active-dark: #ffffff;
|
||||
--wo-sidebar-brand-bg-light: rgba(111, 143, 175, 0.1);
|
||||
--wo-sidebar-brand-bg-dark: rgba(255, 255, 255, 0.08);
|
||||
--wo-sidebar-item-hover-light: rgba(111, 143, 175, 0.1);
|
||||
--wo-sidebar-item-hover-dark: rgba(255, 255, 255, 0.08);
|
||||
--wo-sidebar-item-active-bg-light: rgba(111, 143, 175, 0.16);
|
||||
--wo-sidebar-item-active-bg-dark: rgba(111, 143, 175, 0.24);
|
||||
--wo-sidebar-border-light: rgba(111, 143, 175, 0.14);
|
||||
--wo-sidebar-border-dark: rgba(255, 255, 255, 0.08);
|
||||
--wo-login-bg-platform: linear-gradient(135deg, #31475b 0%, #5d7c95 52%, #43617a 100%);
|
||||
--wo-login-bg-tenant: linear-gradient(135deg, #253749 0%, #506f87 42%, #395469 100%);
|
||||
--wo-login-orb-platform-1: #6f8faf;
|
||||
--wo-login-orb-platform-2: #8fb4c8;
|
||||
--wo-login-orb-platform-3: #c5dbe8;
|
||||
--wo-login-orb-platform-4: #dceaf4;
|
||||
--wo-login-orb-tenant-1: #7ba9c2;
|
||||
--wo-login-orb-tenant-2: #6f8faf;
|
||||
--wo-login-orb-tenant-3: #8fb4c8;
|
||||
--wo-login-orb-tenant-4: #cce0ec;
|
||||
--wo-app-header-bg-light: linear-gradient(135deg, #fcfdff 0%, #edf4f9 100%);
|
||||
--wo-app-header-bg-dark: linear-gradient(135deg, #1a2732 0%, #31495d 55%, #47617a 100%);
|
||||
--wo-profile-avatar-bg-light: linear-gradient(135deg, #f8fbfd, #dce7ef);
|
||||
--wo-profile-avatar-text-light: #47627a;
|
||||
--wo-profile-avatar-text-dark: #dce8f1;
|
||||
--wo-header-shadow: 0 8px 24px rgba(148, 163, 184, 0.18);
|
||||
--wo-content-bg: #f3f7fa;
|
||||
--wo-sub-header-border: #d8e2eb;
|
||||
--wo-main-content-bg: #f7fafc;
|
||||
--wo-card-border: #dde6ee;
|
||||
--wo-app-header-text: #26415a;
|
||||
--wo-top-nav-text: rgba(38, 65, 90, 0.72);
|
||||
--wo-top-nav-active-bg: rgba(111, 143, 175, 0.14);
|
||||
--wo-header-action-bg: rgba(111, 143, 175, 0.12);
|
||||
--wo-header-action-text: #39556e;
|
||||
--wo-header-action-divider: rgba(111, 143, 175, 0.18);
|
||||
--wo-light-action-bg: #edf4f8;
|
||||
--wo-light-action-text: #4c6277;
|
||||
--wo-light-action-divider: #d6e0e8;
|
||||
--wo-search-surface: rgba(255, 255, 255, 0.96);
|
||||
--wo-search-surface-hover: rgba(111, 143, 175, 0.1);
|
||||
--wo-search-border: rgba(111, 143, 175, 0.16);
|
||||
--wo-search-muted: #70859b;
|
||||
--wo-search-title: #22384d;
|
||||
}
|
||||
|
||||
:root[data-theme-scheme="sage"] {
|
||||
--wo-theme-rgb: 110, 148, 125;
|
||||
--wo-brand-primary: #6e947d;
|
||||
--wo-brand-primary-light: #8cad97;
|
||||
--wo-brand-primary-dark: #5a7a67;
|
||||
--wo-brand-primary-light-3: #8cad97;
|
||||
--wo-brand-primary-light-5: #a3beab;
|
||||
--wo-brand-primary-light-7: #c3d5c8;
|
||||
--wo-brand-primary-light-8: #dae6dc;
|
||||
--wo-brand-primary-light-9: #eef4ef;
|
||||
--wo-brand-primary-dark-2: #5a7a67;
|
||||
--wo-brand-accent: #92b29b;
|
||||
--wo-brand-accent-light: #b2cbb8;
|
||||
--wo-brand-gradient: linear-gradient(135deg, #6e947d 0%, #92b29b 100%);
|
||||
--wo-sidebar-bg-light: linear-gradient(180deg, #fbfdfb 0%, #eef5f0 100%);
|
||||
--wo-sidebar-bg-dark: linear-gradient(180deg, #25342a 0%, #425c4a 100%);
|
||||
--wo-sidebar-text-light: #6f8576;
|
||||
--wo-sidebar-text-dark: rgba(255, 255, 255, 0.76);
|
||||
--wo-sidebar-text-active-light: #294135;
|
||||
--wo-sidebar-text-active-dark: #ffffff;
|
||||
--wo-sidebar-brand-bg-light: rgba(110, 148, 125, 0.1);
|
||||
--wo-sidebar-brand-bg-dark: rgba(255, 255, 255, 0.08);
|
||||
--wo-sidebar-item-hover-light: rgba(110, 148, 125, 0.1);
|
||||
--wo-sidebar-item-hover-dark: rgba(255, 255, 255, 0.08);
|
||||
--wo-sidebar-item-active-bg-light: rgba(110, 148, 125, 0.15);
|
||||
--wo-sidebar-item-active-bg-dark: rgba(110, 148, 125, 0.24);
|
||||
--wo-sidebar-border-light: rgba(110, 148, 125, 0.14);
|
||||
--wo-sidebar-border-dark: rgba(255, 255, 255, 0.08);
|
||||
--wo-login-bg-platform: linear-gradient(135deg, #2d4135 0%, #56715f 52%, #3c5144 100%);
|
||||
--wo-login-bg-tenant: linear-gradient(135deg, #233229 0%, #496454 42%, #31453a 100%);
|
||||
--wo-login-orb-platform-1: #6e947d;
|
||||
--wo-login-orb-platform-2: #92b29b;
|
||||
--wo-login-orb-platform-3: #c3d5c8;
|
||||
--wo-login-orb-platform-4: #dce9de;
|
||||
--wo-login-orb-tenant-1: #86a992;
|
||||
--wo-login-orb-tenant-2: #6e947d;
|
||||
--wo-login-orb-tenant-3: #92b29b;
|
||||
--wo-login-orb-tenant-4: #d1e2d5;
|
||||
--wo-app-header-bg-light: linear-gradient(135deg, #fcfefc 0%, #edf5ef 100%);
|
||||
--wo-app-header-bg-dark: linear-gradient(135deg, #18231c 0%, #304439 55%, #496454 100%);
|
||||
--wo-profile-avatar-bg-light: linear-gradient(135deg, #f7fcf8, #dfeae2);
|
||||
--wo-profile-avatar-text-light: #476554;
|
||||
--wo-profile-avatar-text-dark: #dce9df;
|
||||
--wo-header-shadow: 0 8px 24px rgba(148, 163, 148, 0.16);
|
||||
--wo-content-bg: #f4f8f4;
|
||||
--wo-sub-header-border: #d8e5dc;
|
||||
--wo-main-content-bg: #f8fbf8;
|
||||
--wo-card-border: #dce7df;
|
||||
--wo-app-header-text: #2d4738;
|
||||
--wo-top-nav-text: rgba(45, 71, 56, 0.72);
|
||||
--wo-top-nav-active-bg: rgba(110, 148, 125, 0.14);
|
||||
--wo-header-action-bg: rgba(110, 148, 125, 0.12);
|
||||
--wo-header-action-text: #41604d;
|
||||
--wo-header-action-divider: rgba(110, 148, 125, 0.18);
|
||||
--wo-light-action-bg: #edf5ef;
|
||||
--wo-light-action-text: #576e60;
|
||||
--wo-light-action-divider: #d6e2d9;
|
||||
--wo-search-surface: rgba(255, 255, 255, 0.96);
|
||||
--wo-search-surface-hover: rgba(110, 148, 125, 0.1);
|
||||
--wo-search-border: rgba(110, 148, 125, 0.16);
|
||||
--wo-search-muted: #718576;
|
||||
--wo-search-title: #263b30;
|
||||
}
|
||||
|
||||
:root[data-theme-scheme="dawn"] {
|
||||
--wo-theme-rgb: 172, 117, 92;
|
||||
--wo-brand-primary: #ac755c;
|
||||
--wo-brand-primary-light: #c18f7b;
|
||||
--wo-brand-primary-dark: #8d604b;
|
||||
--wo-brand-primary-light-3: #c18f7b;
|
||||
--wo-brand-primary-light-5: #cfa595;
|
||||
--wo-brand-primary-light-7: #e1c3b7;
|
||||
--wo-brand-primary-light-8: #ecd5cd;
|
||||
--wo-brand-primary-light-9: #f7eeea;
|
||||
--wo-brand-primary-dark-2: #8d604b;
|
||||
--wo-brand-accent: #d19b83;
|
||||
--wo-brand-accent-light: #dfb6a4;
|
||||
--wo-brand-gradient: linear-gradient(135deg, #ac755c 0%, #d19b83 100%);
|
||||
--wo-sidebar-bg-light: linear-gradient(180deg, #fffaf7 0%, #f7efe9 100%);
|
||||
--wo-sidebar-bg-dark: linear-gradient(180deg, #3b2a22 0%, #5c4134 100%);
|
||||
--wo-sidebar-text-light: #8b6d5d;
|
||||
--wo-sidebar-text-dark: rgba(255, 255, 255, 0.76);
|
||||
--wo-sidebar-text-active-light: #5b4034;
|
||||
--wo-sidebar-text-active-dark: #ffffff;
|
||||
--wo-sidebar-brand-bg-light: rgba(172, 117, 92, 0.1);
|
||||
--wo-sidebar-brand-bg-dark: rgba(255, 255, 255, 0.08);
|
||||
--wo-sidebar-item-hover-light: rgba(172, 117, 92, 0.1);
|
||||
--wo-sidebar-item-hover-dark: rgba(255, 255, 255, 0.08);
|
||||
--wo-sidebar-item-active-bg-light: rgba(172, 117, 92, 0.15);
|
||||
--wo-sidebar-item-active-bg-dark: rgba(172, 117, 92, 0.24);
|
||||
--wo-sidebar-border-light: rgba(172, 117, 92, 0.14);
|
||||
--wo-sidebar-border-dark: rgba(255, 255, 255, 0.08);
|
||||
--wo-login-bg-platform: linear-gradient(135deg, #3c2b23 0%, #76523e 52%, #563c30 100%);
|
||||
--wo-login-bg-tenant: linear-gradient(135deg, #2f211b 0%, #644736 42%, #483329 100%);
|
||||
--wo-login-orb-platform-1: #ac755c;
|
||||
--wo-login-orb-platform-2: #d19b83;
|
||||
--wo-login-orb-platform-3: #e6c3b3;
|
||||
--wo-login-orb-platform-4: #f3dfd6;
|
||||
--wo-login-orb-tenant-1: #c88b6d;
|
||||
--wo-login-orb-tenant-2: #ac755c;
|
||||
--wo-login-orb-tenant-3: #d19b83;
|
||||
--wo-login-orb-tenant-4: #edd2c6;
|
||||
--wo-app-header-bg-light: linear-gradient(135deg, #fffdfb 0%, #f7efe9 100%);
|
||||
--wo-app-header-bg-dark: linear-gradient(135deg, #201611 0%, #3f2d24 55%, #604436 100%);
|
||||
--wo-profile-avatar-bg-light: linear-gradient(135deg, #fff8f4, #eedcd2);
|
||||
--wo-profile-avatar-text-light: #704d3d;
|
||||
--wo-profile-avatar-text-dark: #f2dfd7;
|
||||
--wo-header-shadow: 0 8px 24px rgba(180, 148, 127, 0.18);
|
||||
--wo-content-bg: #faf5f1;
|
||||
--wo-sub-header-border: #eadbd2;
|
||||
--wo-main-content-bg: #fcf8f5;
|
||||
--wo-card-border: #ecddd4;
|
||||
--wo-app-header-text: #67483a;
|
||||
--wo-top-nav-text: rgba(103, 72, 58, 0.72);
|
||||
--wo-top-nav-active-bg: rgba(172, 117, 92, 0.14);
|
||||
--wo-header-action-bg: rgba(172, 117, 92, 0.12);
|
||||
--wo-header-action-text: #855e4b;
|
||||
--wo-header-action-divider: rgba(172, 117, 92, 0.18);
|
||||
--wo-light-action-bg: #f6ede7;
|
||||
--wo-light-action-text: #7a5f52;
|
||||
--wo-light-action-divider: #e5d7cf;
|
||||
--wo-search-surface: rgba(255, 255, 255, 0.96);
|
||||
--wo-search-surface-hover: rgba(172, 117, 92, 0.1);
|
||||
--wo-search-border: rgba(172, 117, 92, 0.16);
|
||||
--wo-search-muted: #8c6e5e;
|
||||
--wo-search-title: #573d31;
|
||||
}
|
||||
|
||||
:root[data-theme-mode="dark"] {
|
||||
--wo-header-bg: #111827;
|
||||
--wo-header-shadow: 0 1px 4px rgba(0, 0, 0, 0.28);
|
||||
--wo-content-bg: #111827;
|
||||
--wo-app-header-bg: var(--wo-app-header-bg-dark);
|
||||
--wo-app-header-text: #f9fafb;
|
||||
--wo-sub-header-bg: #111827;
|
||||
--wo-sub-header-border: rgba(148, 163, 184, 0.22);
|
||||
--wo-main-content-bg: #0b1120;
|
||||
--wo-card-border: rgba(148, 163, 184, 0.2);
|
||||
--wo-top-nav-text: rgba(255, 255, 255, 0.72);
|
||||
--wo-top-nav-active-bg: rgba(var(--wo-theme-rgb), 0.2);
|
||||
--wo-header-action-bg: rgba(255, 255, 255, 0.08);
|
||||
--wo-header-action-text: #f9fafb;
|
||||
--wo-header-action-divider: rgba(148, 163, 184, 0.24);
|
||||
--wo-light-action-bg: rgba(255, 255, 255, 0.06);
|
||||
--wo-light-action-text: #d1d5db;
|
||||
--wo-light-action-divider: rgba(148, 163, 184, 0.2);
|
||||
--wo-profile-avatar-bg: var(--wo-profile-avatar-bg-dark);
|
||||
--wo-profile-avatar-text: var(--wo-profile-avatar-text-dark);
|
||||
--wo-search-surface: rgba(15, 23, 42, 0.82);
|
||||
--wo-search-surface-hover: rgba(var(--wo-theme-rgb), 0.18);
|
||||
--wo-search-border: rgba(var(--wo-theme-rgb), 0.34);
|
||||
--wo-search-muted: #94a3b8;
|
||||
--wo-search-title: #e5e7eb;
|
||||
--wo-sidebar-bg: var(--wo-sidebar-bg-dark);
|
||||
--wo-sidebar-text: var(--wo-sidebar-text-dark);
|
||||
--wo-sidebar-text-active: var(--wo-sidebar-text-active-dark);
|
||||
--wo-sidebar-brand-bg: var(--wo-sidebar-brand-bg-dark);
|
||||
--wo-sidebar-item-hover: var(--wo-sidebar-item-hover-dark);
|
||||
--wo-sidebar-item-active-bg: var(--wo-sidebar-item-active-bg-dark);
|
||||
--wo-sidebar-border: var(--wo-sidebar-border-dark);
|
||||
}
|
||||
|
||||
:root[data-density="compact"] {
|
||||
--wo-main-content-padding: 16px;
|
||||
--wo-shell-gap: 12px;
|
||||
--wo-header-action-size: 32px;
|
||||
--wo-header-avatar-size: 30px;
|
||||
--wo-header-avatar-icon-size: 16px;
|
||||
}
|
||||
|
||||
/* ---- 全局平滑过渡 ---- */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-property: background-color, border-color, box-shadow, color, opacity, transform;
|
||||
transition-duration: 0s; /* default off; components opt in */
|
||||
}
|
||||
|
||||
/* ---- 按钮 hover 微弱上浮 ---- */
|
||||
.el-button {
|
||||
transition: all 0.2s ease !important;
|
||||
}
|
||||
|
||||
.el-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(var(--wo-theme-rgb), 0.12);
|
||||
}
|
||||
|
||||
.el-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ---- Primary 按钮渐变 ---- */
|
||||
.el-button--primary {
|
||||
background: var(--wo-brand-gradient) !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 2px 8px rgba(var(--wo-theme-rgb), 0.22);
|
||||
}
|
||||
|
||||
.el-button--primary:hover {
|
||||
box-shadow: 0 4px 14px rgba(var(--wo-theme-rgb), 0.28) !important;
|
||||
}
|
||||
|
||||
.el-button--primary.is-plain {
|
||||
background: transparent !important;
|
||||
border: 1px solid var(--wo-brand-primary) !important;
|
||||
color: var(--wo-brand-primary) !important;
|
||||
}
|
||||
|
||||
.el-button--primary.is-plain:hover {
|
||||
background: rgba(var(--wo-theme-rgb), 0.06) !important;
|
||||
}
|
||||
|
||||
/* ---- 表格样式增强 ---- */
|
||||
.el-table {
|
||||
--el-table-border-color: var(--wo-card-border);
|
||||
--el-table-header-bg-color: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.el-table th.el-table__cell {
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.el-table .el-table__row:hover > td.el-table__cell {
|
||||
background: rgba(var(--wo-theme-rgb), 0.04) !important;
|
||||
}
|
||||
|
||||
/* ---- 卡片增强 ---- */
|
||||
.el-card {
|
||||
border: 1px solid var(--wo-card-border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05);
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.el-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.el-card__header {
|
||||
border-bottom: 1px solid var(--wo-card-border);
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
/* ---- 抽屉 / 弹窗头 ---- */
|
||||
.el-drawer__header,
|
||||
.el-dialog__header {
|
||||
border-bottom: 1px solid var(--wo-card-border);
|
||||
padding: 16px 20px !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
.el-drawer__title,
|
||||
.el-dialog__title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html.dark body {
|
||||
color: var(--el-text-color-primary);
|
||||
background: var(--wo-main-content-bg);
|
||||
}
|
||||
|
||||
html.dark .el-card,
|
||||
html.dark .el-dialog,
|
||||
html.dark .el-drawer {
|
||||
background-color: var(--el-bg-color-overlay);
|
||||
}
|
||||
|
||||
html.dark .el-table {
|
||||
--el-table-header-bg-color: rgba(15, 23, 42, 0.92);
|
||||
--el-table-tr-bg-color: rgba(15, 23, 42, 0.88);
|
||||
--el-table-row-hover-bg-color: rgba(var(--wo-theme-rgb), 0.12);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
html.dark .el-table .el-table__cell,
|
||||
html.dark .el-descriptions__label,
|
||||
html.dark .el-descriptions__content,
|
||||
html.dark .el-form-item__label,
|
||||
html.dark .el-empty__description p {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
html.dark .el-descriptions {
|
||||
--el-descriptions-table-border: var(--wo-card-border);
|
||||
}
|
||||
|
||||
html.dark .el-textarea__inner,
|
||||
html.dark .el-input__wrapper,
|
||||
html.dark .el-select__wrapper {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
/* ---- Input 聚焦环 ---- */
|
||||
.el-input__wrapper.is-focus {
|
||||
box-shadow: 0 0 0 2px rgba(var(--wo-theme-rgb), 0.18) !important;
|
||||
}
|
||||
|
||||
.el-textarea__inner:focus {
|
||||
box-shadow: 0 0 0 2px rgba(var(--wo-theme-rgb), 0.18) !important;
|
||||
}
|
||||
|
||||
/* ---- Badge 渐变 ---- */
|
||||
.el-badge__content--danger {
|
||||
background: linear-gradient(135deg, #ef4444, #f97316);
|
||||
}
|
||||
|
||||
/* ---- Tag 圆角增大 ---- */
|
||||
.el-tag {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ---- 滚动条美化 ---- */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(var(--wo-theme-rgb), 0.18);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(var(--wo-theme-rgb), 0.3);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/* ============================================
|
||||
* 全局工具类 (Utility Classes)
|
||||
* ============================================
|
||||
* 命名规则:{属性缩写}-{档位}
|
||||
* mt = margin-top, mb = margin-bottom
|
||||
* ml = margin-left, mr = margin-right
|
||||
* pt/pb/pl/pr = padding
|
||||
* gap = flex/grid gap
|
||||
* w = width
|
||||
*/
|
||||
|
||||
/* ---- Margin Top ---- */
|
||||
.mt-xs { margin-top: var(--wo-spacing-xs); }
|
||||
.mt-sm { margin-top: var(--wo-spacing-sm); }
|
||||
.mt-md { margin-top: var(--wo-spacing-md); }
|
||||
.mt-lg { margin-top: var(--wo-spacing-lg); }
|
||||
.mt-xl { margin-top: var(--wo-spacing-xl); }
|
||||
|
||||
/* ---- Margin Bottom ---- */
|
||||
.mb-xs { margin-bottom: var(--wo-spacing-xs); }
|
||||
.mb-sm { margin-bottom: var(--wo-spacing-sm); }
|
||||
.mb-md { margin-bottom: var(--wo-spacing-md); }
|
||||
.mb-lg { margin-bottom: var(--wo-spacing-lg); }
|
||||
.mb-xl { margin-bottom: var(--wo-spacing-xl); }
|
||||
|
||||
/* ---- Margin Left ---- */
|
||||
.ml-xs { margin-left: var(--wo-spacing-xs); }
|
||||
.ml-sm { margin-left: var(--wo-spacing-sm); }
|
||||
.ml-md { margin-left: var(--wo-spacing-md); }
|
||||
.ml-lg { margin-left: var(--wo-spacing-lg); }
|
||||
|
||||
/* ---- Margin Right ---- */
|
||||
.mr-xs { margin-right: var(--wo-spacing-xs); }
|
||||
.mr-sm { margin-right: var(--wo-spacing-sm); }
|
||||
.mr-md { margin-right: var(--wo-spacing-md); }
|
||||
.mr-lg { margin-right: var(--wo-spacing-lg); }
|
||||
|
||||
/* ---- Gap ---- */
|
||||
.gap-xs { gap: var(--wo-spacing-xs); }
|
||||
.gap-sm { gap: var(--wo-spacing-sm); }
|
||||
.gap-md { gap: var(--wo-spacing-md); }
|
||||
.gap-lg { gap: var(--wo-spacing-lg); }
|
||||
|
||||
/* ---- 文字辅助 ---- */
|
||||
.text-regular { color: var(--wo-text-regular); }
|
||||
.text-secondary { color: var(--wo-text-secondary); }
|
||||
.text-placeholder { color: var(--wo-text-placeholder); }
|
||||
.text-title { font-weight: var(--wo-font-weight-title); }
|
||||
.text-hint { font-size: 12px; color: var(--wo-text-secondary); }
|
||||
|
||||
/* ---- 行高 ---- */
|
||||
.lh-loose { line-height: var(--wo-line-height-loose); }
|
||||
|
||||
/* ---- 宽度 ---- */
|
||||
.w-full { width: 100%; }
|
||||
.w-input-xs { width: var(--wo-input-xs); }
|
||||
.w-input-sm { width: var(--wo-input-sm); }
|
||||
.w-input-md { width: var(--wo-input-md); }
|
||||
.w-input-lg { width: var(--wo-input-lg); }
|
||||
|
||||
/* ---- Flex 快捷 ---- */
|
||||
.flex-center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.flex-between {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.flex-end {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.flex-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.flex-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---- 预览盒(通知策略等处使用) ---- */
|
||||
.preview-box {
|
||||
width: 100%;
|
||||
background: var(--wo-bg-light);
|
||||
border: 1px solid var(--wo-border-light);
|
||||
border-radius: var(--wo-radius-md);
|
||||
padding: 10px 12px;
|
||||
line-height: var(--wo-line-height-loose);
|
||||
}
|
||||
|
||||
/* ---- 图片缩略图 ---- */
|
||||
.thumbnail-sm {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--wo-radius-sm);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
/* ---- 审核状态文字 ---- */
|
||||
.text-danger { color: var(--el-color-danger, #f56c6c); }
|
||||
.text-warning { color: var(--el-color-warning, #e6a23c); }
|
||||
|
||||
/* ---- Display helpers ---- */
|
||||
.flex { display: flex; }
|
||||
.inline-flex { display: inline-flex; }
|
||||
.flex-1 { flex: 1; }
|
||||
|
||||
/* ---- Padding ---- */
|
||||
.p-sm { padding: var(--wo-spacing-sm); }
|
||||
.p-md { padding: var(--wo-spacing-md); }
|
||||
|
||||
/* ---- Compound helpers ---- */
|
||||
.ml-sm-text-danger { margin-left: var(--wo-spacing-sm); color: var(--el-color-danger, #f56c6c); }
|
||||
.ml-sm-text-warning { margin-left: var(--wo-spacing-sm); color: var(--el-color-warning, #e6a23c); }
|
||||
.ml-sm-text-regular { margin-left: var(--wo-spacing-sm); color: var(--wo-text-regular); }
|
||||
.ml-sm-text-secondary { margin-left: var(--wo-spacing-sm); color: var(--wo-text-secondary); }
|
||||
.mt-xs-text-danger { margin-top: var(--wo-spacing-xs); color: var(--el-color-danger, #f56c6c); }
|
||||
|
||||
/* ---- Section Card (bordered content area) ---- */
|
||||
.section-card {
|
||||
border: 1px solid var(--wo-border-light);
|
||||
border-radius: var(--wo-radius-md);
|
||||
padding: var(--wo-spacing-md);
|
||||
}
|
||||
.section-card-sm {
|
||||
border: 1px solid var(--wo-border-light);
|
||||
border-radius: var(--wo-radius-sm);
|
||||
padding: var(--wo-spacing-sm);
|
||||
}
|
||||
|
||||
/* ---- Font Weight ---- */
|
||||
.fw-600 { font-weight: 600; }
|
||||
@@ -0,0 +1,58 @@
|
||||
/* ============================================
|
||||
* 全局 Design Token / CSS 变量
|
||||
* ============================================
|
||||
* 约定:所有自定义变量以 --wo- 前缀(writeOff),
|
||||
* 避免与 Element Plus 的 --el- 命名空间冲突。
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ---- 间距 Token ---- */
|
||||
--wo-spacing-xxs: 2px;
|
||||
--wo-spacing-xs: 4px;
|
||||
--wo-spacing-sm: 8px;
|
||||
--wo-spacing-md: 12px;
|
||||
--wo-spacing-lg: 16px;
|
||||
--wo-spacing-xl: 24px;
|
||||
--wo-spacing-xxl: 32px;
|
||||
|
||||
/* ---- 圆角 ---- */
|
||||
--wo-radius-sm: 4px;
|
||||
--wo-radius-md: 6px;
|
||||
--wo-radius-lg: 8px;
|
||||
|
||||
/* ---- 弹窗 / 抽屉标准档位 ---- */
|
||||
--wo-drawer-sm: 480px;
|
||||
--wo-drawer-md: 640px;
|
||||
--wo-drawer-lg: 80%;
|
||||
|
||||
--wo-dialog-sm: 520px;
|
||||
--wo-dialog-md: 680px;
|
||||
--wo-dialog-lg: 860px;
|
||||
|
||||
/* ---- 表单控件宽度标准档位 ---- */
|
||||
--wo-input-xs: 100px;
|
||||
--wo-input-sm: 160px;
|
||||
--wo-input-md: 220px;
|
||||
--wo-input-lg: 280px;
|
||||
|
||||
/* ---- 文字颜色(引用 Element Plus 变量,保持主题一致) ---- */
|
||||
--wo-text-primary: var(--el-text-color-primary);
|
||||
--wo-text-regular: var(--el-text-color-regular);
|
||||
--wo-text-secondary: var(--el-text-color-secondary);
|
||||
--wo-text-placeholder: var(--el-text-color-placeholder);
|
||||
|
||||
/* ---- 背景 & 边框(引用 Element Plus 变量) ---- */
|
||||
--wo-bg-light: #f0f2f8;
|
||||
--wo-border-light: #edf0f7;
|
||||
--wo-border-base: var(--el-border-color, #dcdfe6);
|
||||
|
||||
/* ---- 品牌色 Token ---- */
|
||||
--wo-brand-primary: #3c6e91;
|
||||
--wo-brand-accent: #5c88a7;
|
||||
|
||||
/* ---- 标题字重 ---- */
|
||||
--wo-font-weight-title: 700;
|
||||
|
||||
/* ---- 行高 ---- */
|
||||
--wo-line-height-loose: 1.8;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import http from "../api/http";
|
||||
|
||||
const PASSWORD_PREFIX = "rsa:";
|
||||
|
||||
const decodeBase64 = (value: string): Uint8Array => {
|
||||
const normalized = String(value || "").trim();
|
||||
const binary = window.atob(normalized);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
|
||||
const encodeBase64 = (bytes: Uint8Array): string => {
|
||||
let binary = "";
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return window.btoa(binary);
|
||||
};
|
||||
|
||||
const loadPasswordPublicKey = async (): Promise<CryptoKey> => {
|
||||
const resp = await http.get("/auth/password-public-key");
|
||||
const publicKey = String(resp?.data?.publicKey || "").trim();
|
||||
if (!publicKey) {
|
||||
throw new Error("Password public key is missing");
|
||||
}
|
||||
return window.crypto.subtle.importKey(
|
||||
"spki",
|
||||
decodeBase64(publicKey),
|
||||
{
|
||||
name: "RSA-OAEP",
|
||||
hash: "SHA-256",
|
||||
},
|
||||
false,
|
||||
["encrypt"],
|
||||
);
|
||||
};
|
||||
|
||||
const getPasswordPublicKey = async (): Promise<CryptoKey> => {
|
||||
if (!window.isSecureContext || !window.crypto?.subtle) {
|
||||
throw new Error("Current page is not in a secure context");
|
||||
}
|
||||
return loadPasswordPublicKey();
|
||||
};
|
||||
|
||||
export const encryptLoginPassword = async (password: string): Promise<string> => {
|
||||
try {
|
||||
const cryptoKey = await getPasswordPublicKey();
|
||||
const encoded = new TextEncoder().encode(password);
|
||||
const encrypted = await window.crypto.subtle.encrypt({ name: "RSA-OAEP" }, cryptoKey, encoded);
|
||||
return `${PASSWORD_PREFIX}${encodeBase64(new Uint8Array(encrypted))}`;
|
||||
} catch (error) {
|
||||
console.warn("Login password encryption is unavailable, falling back to plain password submission.", error);
|
||||
return password;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AuthScope } from "../stores/auth";
|
||||
|
||||
export const resolveLoginPath = (scope?: AuthScope | null, tenantCode?: string | null): string => {
|
||||
if (scope === "TENANT") {
|
||||
const normalizedTenantCode = String(tenantCode || "").trim();
|
||||
if (normalizedTenantCode) {
|
||||
return `/${normalizedTenantCode}/login`;
|
||||
}
|
||||
}
|
||||
return "/login";
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
export const readTextFile = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error || new Error("文件读取失败"));
|
||||
reader.readAsText(file, "utf-8");
|
||||
});
|
||||
|
||||
export const downloadCsvTemplate = (fileName: string, headers: string[], sampleRows: string[][] = []) => {
|
||||
downloadCsvRows(fileName, [headers, ...sampleRows]);
|
||||
};
|
||||
|
||||
export const downloadCsvRows = (fileName: string, rows: string[][]) => {
|
||||
const csv = toCsv(rows);
|
||||
const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const parseDelimitedText = (text: string): string[][] => {
|
||||
const normalized = String(text || "").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
if (!normalized.trim()) {
|
||||
return [];
|
||||
}
|
||||
const firstLine = normalized.split("\n").find((line) => line.trim().length > 0) || "";
|
||||
const delimiter = firstLine.includes("\t") ? "\t" : ",";
|
||||
return parseWithDelimiter(normalized, delimiter)
|
||||
.map((row) => row.map((cell) => cell.trim()))
|
||||
.filter((row) => row.some((cell) => cell.length > 0));
|
||||
};
|
||||
|
||||
export const findMissingHeaders = (headerRow: string[], requiredHeaders: string[]): string[] => {
|
||||
const existing = new Set(headerRow.map((item) => String(item || "").trim()));
|
||||
return requiredHeaders.filter((header) => !existing.has(header));
|
||||
};
|
||||
|
||||
const toCsv = (rows: string[][]): string =>
|
||||
rows
|
||||
.map((row) => row.map(escapeCell).join(","))
|
||||
.join("\r\n");
|
||||
|
||||
const escapeCell = (value: string): string => {
|
||||
const text = String(value ?? "");
|
||||
if (/[",\r\n]/.test(text)) {
|
||||
return `"${text.replace(/"/g, "\"\"")}"`;
|
||||
}
|
||||
return text;
|
||||
};
|
||||
|
||||
const parseWithDelimiter = (text: string, delimiter: string): string[][] => {
|
||||
const rows: string[][] = [];
|
||||
let currentRow: string[] = [];
|
||||
let currentCell = "";
|
||||
let insideQuotes = false;
|
||||
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
const char = text[i];
|
||||
const next = text[i + 1];
|
||||
|
||||
if (char === "\"") {
|
||||
if (insideQuotes && next === "\"") {
|
||||
currentCell += "\"";
|
||||
i += 1;
|
||||
} else {
|
||||
insideQuotes = !insideQuotes;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!insideQuotes && char === delimiter) {
|
||||
currentRow.push(currentCell);
|
||||
currentCell = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!insideQuotes && char === "\n") {
|
||||
currentRow.push(currentCell);
|
||||
rows.push(currentRow);
|
||||
currentRow = [];
|
||||
currentCell = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
currentCell += char;
|
||||
}
|
||||
|
||||
currentRow.push(currentCell);
|
||||
rows.push(currentRow);
|
||||
return rows;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import Compressor from "compressorjs";
|
||||
|
||||
/**
|
||||
* 异步压缩图片文件
|
||||
* @param file 原始图片文件
|
||||
* @param quality 压缩质量 (0 ~ 1),默认 0.8
|
||||
* @param maxWidth 最大宽度,默认 1920
|
||||
* @returns 压缩后的文件对象
|
||||
*/
|
||||
export const compressImageFile = (
|
||||
file: File,
|
||||
quality = 0.8,
|
||||
maxWidth = 1920
|
||||
): Promise<File> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
// 只有图片类型才进行压缩,直接返回其他文件类型(如 PDF)
|
||||
if (!file.type.startsWith("image/")) {
|
||||
resolve(file);
|
||||
return;
|
||||
}
|
||||
|
||||
new Compressor(file, {
|
||||
quality,
|
||||
maxWidth,
|
||||
// 小于 200KB 的图片不不处理
|
||||
convertSize: 200000,
|
||||
success(result: File | Blob) {
|
||||
// compressorjs 可能会返回 Blob,需确保转回 File
|
||||
const compressedFile = new File([result], file.name, {
|
||||
type: result.type,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
resolve(compressedFile);
|
||||
},
|
||||
error(err) {
|
||||
// 如果压缩失败,最好打印个警告,然后兜底返回原文件
|
||||
console.warn("Image compression failed:", err.message);
|
||||
resolve(file);
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
const STATUS_TEXT_MAP: Record<string, string> = {
|
||||
ENABLED: "启用",
|
||||
DISABLED: "停用",
|
||||
DELETED: "已删除",
|
||||
PENDING: "待处理",
|
||||
CLAIMED: "已认领",
|
||||
IN_REVIEW: "审核中",
|
||||
APPROVED: "已通过",
|
||||
REJECTED: "已拒绝",
|
||||
RETURNED: "已退回",
|
||||
TRANSFERRED: "已转交",
|
||||
NOT_STARTED: "未开始",
|
||||
IN_PROGRESS: "进行中",
|
||||
COMPLETED: "已完成",
|
||||
CANCELED: "已取消",
|
||||
CANCELLED: "已取消",
|
||||
DELAYED: "已延期",
|
||||
FROZEN: "已冻结",
|
||||
WAITING: "待开始",
|
||||
WAIT_SUBMIT: "待提交",
|
||||
WAIT_FINANCE_CONFIRM: "待财务确认",
|
||||
CONFIRMED: "已确认",
|
||||
PARTIAL_PAID: "部分支付",
|
||||
SETTLED: "已结算",
|
||||
PROCESSING: "处理中",
|
||||
SUCCESS: "成功",
|
||||
FAILED: "失败",
|
||||
LOCKED: "已锁定",
|
||||
UNLOCKED: "已解锁",
|
||||
SAVE: "保存",
|
||||
SUBMIT: "提交",
|
||||
CREATE: "创建",
|
||||
UPDATE: "更新",
|
||||
DELETE: "删除",
|
||||
EXPIRED: "已过期",
|
||||
DRAFT: "草稿",
|
||||
PUBLISHED: "已发布",
|
||||
ARCHIVED: "已归档",
|
||||
ROLE: "角色",
|
||||
USER: "用户",
|
||||
ALL: "全部",
|
||||
IDS: "指定ID",
|
||||
OWNER: "负责人",
|
||||
PROJECT: "项目级",
|
||||
MEETING: "会议级",
|
||||
APPEND: "追加",
|
||||
REPLACE: "替换",
|
||||
EXECUTOR: "执行人",
|
||||
FINANCE: "财务",
|
||||
IN_APP: "站内",
|
||||
SMS: "短信",
|
||||
EMAIL: "邮件",
|
||||
AGENDA: "议程模板",
|
||||
SIGN_IN: "签到模板",
|
||||
INVITATION: "邀请函模板",
|
||||
OTHER: "其他",
|
||||
SUBMITTER: "提交人",
|
||||
AUDITOR: "审核人",
|
||||
FINANCE_ROLE: "财务角色",
|
||||
TARGET_USER: "目标用户",
|
||||
AUDIT_APPROVED: "审核通过",
|
||||
AUDIT_REJECTED: "审核拒绝",
|
||||
FINANCE_CONFIRMED: "财务已确认",
|
||||
USER_CREATED: "用户创建",
|
||||
DELIVERED: "已送达",
|
||||
UNREAD: "未读",
|
||||
READ: "已读",
|
||||
};
|
||||
|
||||
const AUDIT_NODE_MAP: Record<string, string> = {
|
||||
INIT_REVIEW: "初审",
|
||||
RE_REVIEW: "复审",
|
||||
FINAL_REVIEW: "终审",
|
||||
};
|
||||
|
||||
const ASSIGNEE_TYPE_MAP: Record<string, string> = {
|
||||
ROLE: "按角色",
|
||||
USER: "按用户",
|
||||
};
|
||||
|
||||
const BIZ_SCENE_MAP: Record<string, string> = {
|
||||
MEETING_RECOMMEND: "会议推荐",
|
||||
AUDIT_NOTIFY: "审核通知",
|
||||
SETTLEMENT: "结算",
|
||||
};
|
||||
|
||||
const MODULE_CODE_MAP: Record<string, string> = {
|
||||
BASIC_INFO: "基础资料",
|
||||
WRITE_OFF_DOCS: "核销证明",
|
||||
EXPERT_LIST: "参会专家",
|
||||
MEETING_INVOICE: "发票与流水",
|
||||
EXPERT_PROFILE: "专家主档案校验",
|
||||
};
|
||||
|
||||
const formatText = (mapping: Record<string, string>, value?: unknown): string => {
|
||||
const key = String(value ?? "").trim();
|
||||
if (!key) {
|
||||
return "-";
|
||||
}
|
||||
return mapping[key] || key;
|
||||
};
|
||||
|
||||
export const toZhStatus = (value?: unknown): string => formatText(STATUS_TEXT_MAP, value);
|
||||
|
||||
export const toZhAuditNode = (value?: unknown): string => formatText(AUDIT_NODE_MAP, value);
|
||||
|
||||
export const toZhAssigneeType = (value?: unknown): string => formatText(ASSIGNEE_TYPE_MAP, value);
|
||||
|
||||
export const toZhBizScene = (value?: unknown): string => formatText(BIZ_SCENE_MAP, value);
|
||||
|
||||
export const toZhModuleCode = (value?: unknown): string => formatText(MODULE_CODE_MAP, value);
|
||||
@@ -0,0 +1,826 @@
|
||||
<template>
|
||||
<el-container class="layout-root">
|
||||
<div class="layout-watermark" :style="watermarkStyle" aria-hidden="true"></div>
|
||||
<template v-if="useTopMenuLayout">
|
||||
<el-container direction="vertical" class="top-layout">
|
||||
<el-header class="app-header" height="60px">
|
||||
<div class="brand-area">
|
||||
<img v-if="tenantLogoDisplay" :src="tenantLogoDisplay" alt="tenant-logo" class="brand-logo" />
|
||||
<span v-else class="brand-text">会议核销系统</span>
|
||||
</div>
|
||||
|
||||
<nav class="top-nav">
|
||||
<router-link
|
||||
v-for="menu in menus"
|
||||
:key="menu.routePath"
|
||||
:to="menu.routePath"
|
||||
class="top-nav-item"
|
||||
:class="{ 'top-nav-item--active': activePath === menu.routePath }"
|
||||
>
|
||||
{{ menu.menuName }}
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<GlobalSearchLauncher />
|
||||
|
||||
<el-dropdown
|
||||
v-if="authScope === 'TENANT' && canSwitchTenant && switchableTenants.length > 1"
|
||||
trigger="click"
|
||||
@command="handleTenantSwitchCommand"
|
||||
>
|
||||
<button class="tenant-switch-btn" :disabled="tenantSwitching">
|
||||
<span>{{ tenantDisplay }}</span>
|
||||
<span class="tenant-switch-arrow">▼</span>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="item in switchableTenants"
|
||||
:key="item.tenantId"
|
||||
:command="item.tenantId"
|
||||
:disabled="tenantSwitching || item.tenantId === selectedTenantSwitchId"
|
||||
>
|
||||
{{ item.tenantName }} ({{ item.tenantCode }}){{ item.current ? " - 当前" : "" }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<el-dropdown trigger="click" @command="handleUserCommand">
|
||||
<div class="user-identity">
|
||||
<div class="user-avatar">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="user-meta">
|
||||
<span class="user-name">{{ userDisplay }}</span>
|
||||
<span class="user-scope">{{ authScope === "TENANT" ? tenantDisplay : "平台管理员" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="dashboard">工作台</el-dropdown-item>
|
||||
<el-dropdown-item command="profile">个人设置</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">退出全部设备</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<div class="action-divider"></div>
|
||||
|
||||
<el-badge v-if="canReadInApp" :value="unreadInAppCount" :hidden="unreadInAppCount <= 0" class="notif-badge">
|
||||
<button class="icon-btn" title="站内通知" @click="showNotifDialog">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</el-badge>
|
||||
|
||||
<button class="icon-btn" title="退出登录" @click="logoutAll">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main class="main-content">
|
||||
<RouterView :key="routeViewKey" />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<el-aside width="220px" class="layout-aside">
|
||||
<div class="sidebar-brand">
|
||||
<img v-if="tenantLogoDisplay" :src="tenantLogoDisplay" alt="tenant-logo" class="brand-logo" />
|
||||
<span v-else class="brand-text brand-text--light">会议核销系统</span>
|
||||
</div>
|
||||
|
||||
<el-menu :default-active="activePath" router class="sidebar-menu">
|
||||
<el-menu-item v-for="menu in menus" :key="menu.routePath" :index="menu.routePath">
|
||||
{{ menu.menuName }}
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<el-container direction="vertical">
|
||||
<el-header class="sub-header" height="60px">
|
||||
<div class="sub-header-left">
|
||||
<BreadcrumbNav />
|
||||
</div>
|
||||
|
||||
<div class="header-actions header-actions--light">
|
||||
<GlobalSearchLauncher light />
|
||||
|
||||
<el-dropdown
|
||||
v-if="authScope === 'TENANT' && canSwitchTenant && switchableTenants.length > 1"
|
||||
trigger="click"
|
||||
@command="handleTenantSwitchCommand"
|
||||
>
|
||||
<button class="tenant-switch-btn tenant-switch-btn--light" :disabled="tenantSwitching">
|
||||
<span>{{ tenantDisplay }}</span>
|
||||
<span class="tenant-switch-arrow">▼</span>
|
||||
</button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="item in switchableTenants"
|
||||
:key="item.tenantId"
|
||||
:command="item.tenantId"
|
||||
:disabled="tenantSwitching || item.tenantId === selectedTenantSwitchId"
|
||||
>
|
||||
{{ item.tenantName }} ({{ item.tenantCode }}){{ item.current ? " - 当前" : "" }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<el-dropdown trigger="click" @command="handleUserCommand">
|
||||
<div class="user-identity">
|
||||
<div class="user-avatar user-avatar--light">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="user-meta user-meta--light">
|
||||
<span class="user-name">{{ userDisplay }}</span>
|
||||
<span class="user-scope">{{ authScope === "TENANT" ? tenantDisplay : "平台管理员" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="dashboard">工作台</el-dropdown-item>
|
||||
<el-dropdown-item command="profile">个人设置</el-dropdown-item>
|
||||
<el-dropdown-item divided command="logout">退出全部设备</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<div class="action-divider action-divider--light"></div>
|
||||
|
||||
<el-badge v-if="canReadInApp" :value="unreadInAppCount" :hidden="unreadInAppCount <= 0" class="notif-badge">
|
||||
<button class="icon-btn icon-btn--light" title="站内通知" @click="showNotifDialog">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
</button>
|
||||
</el-badge>
|
||||
|
||||
<button class="icon-btn icon-btn--light" title="退出登录" @click="logoutAll">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<polyline points="16 17 21 12 16 7" />
|
||||
<line x1="21" y1="12" x2="9" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main class="main-content">
|
||||
<RouterView :key="routeViewKey" />
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<el-dialog
|
||||
v-model="notifDialogVisible"
|
||||
title="站内通知中心"
|
||||
width="80%"
|
||||
:close-on-click-modal="true"
|
||||
destroy-on-close
|
||||
append-to-body
|
||||
>
|
||||
<div class="notif-toolbar">
|
||||
<div class="notif-tags">
|
||||
<el-tag type="info" size="small">总数 {{ notifRows.length }}</el-tag>
|
||||
<el-tag type="danger" size="small">未读 {{ notifUnreadCount }}</el-tag>
|
||||
</div>
|
||||
<div class="notif-actions">
|
||||
<el-switch
|
||||
v-model="notifOnlyUnread"
|
||||
inline-prompt
|
||||
active-text="仅看未读"
|
||||
inactive-text="全部"
|
||||
size="small"
|
||||
/>
|
||||
<el-button
|
||||
v-if="canMarkReadInApp"
|
||||
type="primary"
|
||||
plain
|
||||
size="small"
|
||||
class="notif-mark-all-read-btn"
|
||||
:disabled="notifUnreadCount <= 0"
|
||||
@click="handleNotifMarkAllRead"
|
||||
>
|
||||
全部标记已读
|
||||
</el-button>
|
||||
<el-button size="small" @click="loadNotifRows">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="notifDisplayRows" empty-text="暂无站内通知" max-height="420" stripe>
|
||||
<el-table-column prop="title" label="标题" min-width="180" />
|
||||
<el-table-column prop="content" label="内容" min-width="280" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="90" :formatter="notifStatusFormatter" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="canMarkReadInApp && row.status !== 'READ'"
|
||||
type="primary"
|
||||
size="small"
|
||||
link
|
||||
class="notif-mark-read-btn"
|
||||
@click="handleNotifMarkRead(row.id)"
|
||||
>
|
||||
标记已读
|
||||
</el-button>
|
||||
<span v-else class="notif-read-tag">已读</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { ElMessage } from "element-plus";
|
||||
import type { LocationQueryRaw, RouteLocationRaw } from "vue-router";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import BreadcrumbNav from "../../components/BreadcrumbNav.vue";
|
||||
import GlobalSearchLauncher from "../../components/GlobalSearchLauncher.vue";
|
||||
import { logoutAllAuth, switchTenant } from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAppearanceStore } from "../../stores/appearance";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { useMenuStore } from "../../stores/menu";
|
||||
import { useNotificationStore } from "../../stores/notification";
|
||||
import { resolveLoginPath } from "../../utils/authNavigation";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const appearanceStore = useAppearanceStore();
|
||||
const authStore = useAuthStore();
|
||||
const menuStore = useMenuStore();
|
||||
const notificationStore = useNotificationStore();
|
||||
const { scope: authScope, tenantId, userName, tenantName, tenantCode, tenantContextVersion } = storeToRefs(authStore);
|
||||
const { menus, tenantLogoDisplay, switchableTenants, tenantSwitching, useTopMenuLayout } = storeToRefs(menuStore);
|
||||
const { unreadCount: unreadInAppCount, notifRows } = storeToRefs(notificationStore);
|
||||
const activePath = computed(() => route.path);
|
||||
const selectedTenantSwitchId = computed(() => tenantId.value);
|
||||
|
||||
const notifDialogVisible = ref(false);
|
||||
const notifOnlyUnread = ref(false);
|
||||
|
||||
let suppressNextAuthRefresh = false;
|
||||
|
||||
const tenantSwitchSharedRoutePaths = new Set(["/dashboard", "/profile"]);
|
||||
|
||||
const userDisplay = computed(() => userName.value || "未知用户");
|
||||
const tenantDisplay = computed(() => tenantName.value || tenantCode.value || "未知租户");
|
||||
const canSwitchTenant = computed(() => authScope.value === "TENANT" && authStore.hasPermission(PERMS.tenant.switch));
|
||||
const canReadInApp = computed(() => authScope.value === "TENANT" && authStore.hasPermission(PERMS.notification.inAppRead));
|
||||
const canMarkReadInApp = computed(
|
||||
() => authScope.value === "TENANT" && authStore.hasPermission(PERMS.notification.inAppMarkRead),
|
||||
);
|
||||
|
||||
const watermarkDate = new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).format(new Date());
|
||||
const watermarkScopeLabel = computed(() => (authScope.value === "TENANT" ? tenantDisplay.value : "PLATFORM"));
|
||||
|
||||
const escapeSvgText = (value: string) =>
|
||||
String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const createWatermarkImage = (lines: string[]) => {
|
||||
const [line1 = "", line2 = ""] = lines.map((item) => escapeSvgText(item));
|
||||
const svg = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="320" height="240" viewBox="0 0 320 240">
|
||||
<g transform="rotate(-24 160 120)">
|
||||
<text x="24" y="112" font-size="15" font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif" fill="#0f172a" fill-opacity="0.1">
|
||||
${line1}
|
||||
</text>
|
||||
<text x="24" y="144" font-size="13" font-family="Arial, PingFang SC, Microsoft YaHei, sans-serif" fill="#334155" fill-opacity="0.14">
|
||||
${line2}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
|
||||
};
|
||||
|
||||
const watermarkStyle = computed(() => ({
|
||||
backgroundImage: createWatermarkImage([userDisplay.value, `${watermarkScopeLabel.value} ${watermarkDate}`.trim()]),
|
||||
}));
|
||||
|
||||
const notifUnreadCount = computed(() => notifRows.value.filter((item) => String(item?.status || "") === "UNREAD").length);
|
||||
const notifDisplayRows = computed(() =>
|
||||
notifOnlyUnread.value ? notifRows.value.filter((item) => String(item?.status || "") === "UNREAD") : notifRows.value,
|
||||
);
|
||||
const notifStatusFormatter = (_row: unknown, _column: unknown, value: unknown) => toZhStatus(value);
|
||||
const dashboardRoute = computed(() => (authScope.value === "PLATFORM" ? "/platform/tenants" : "/dashboard"));
|
||||
const profileRoute = computed(() => "/profile");
|
||||
const routeViewKey = computed(() => {
|
||||
const scopeKey = authScope.value === "PLATFORM" ? "PLATFORM" : "TENANT";
|
||||
const tenantKey = authScope.value === "TENANT" ? String(selectedTenantSwitchId.value || 0) : "platform";
|
||||
const contextKey = tenantContextVersion.value || "0";
|
||||
return `${scopeKey}:${tenantKey}:${contextKey}:${route.fullPath}`;
|
||||
});
|
||||
|
||||
const withSuppressedAuthRefresh = <T,>(callback: () => T): T => {
|
||||
suppressNextAuthRefresh = true;
|
||||
try {
|
||||
return callback();
|
||||
} finally {
|
||||
suppressNextAuthRefresh = false;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeTenantSwitchRoutePath = (path: string) => {
|
||||
if (path === "/permissions") {
|
||||
return "/menus";
|
||||
}
|
||||
return path;
|
||||
};
|
||||
|
||||
const cloneRouteQuery = (query: Record<string, unknown>): LocationQueryRaw => {
|
||||
const nextQuery: LocationQueryRaw = {};
|
||||
Object.keys(query || {}).forEach((key) => {
|
||||
const value = query[key];
|
||||
if (Array.isArray(value)) {
|
||||
nextQuery[key] = value.map((item) => String(item));
|
||||
return;
|
||||
}
|
||||
if (value === undefined || value === null || value === "") {
|
||||
return;
|
||||
}
|
||||
nextQuery[key] = String(value);
|
||||
});
|
||||
return nextQuery;
|
||||
};
|
||||
|
||||
const resolveTenantSwitchTargetRoute = (
|
||||
sourcePath: string,
|
||||
sourceQuery: Record<string, unknown>,
|
||||
sourceHash: string,
|
||||
): RouteLocationRaw => {
|
||||
const normalizedPath = normalizeTenantSwitchRoutePath(sourcePath);
|
||||
if (tenantSwitchSharedRoutePaths.has(normalizedPath)) {
|
||||
return {
|
||||
path: normalizedPath,
|
||||
query: cloneRouteQuery(sourceQuery),
|
||||
hash: sourceHash || undefined,
|
||||
};
|
||||
}
|
||||
const hasTargetMenu = menus.value.some((item) => item.routePath === normalizedPath);
|
||||
if (hasTargetMenu) {
|
||||
return {
|
||||
path: normalizedPath,
|
||||
query: cloneRouteQuery(sourceQuery),
|
||||
hash: sourceHash || undefined,
|
||||
};
|
||||
}
|
||||
return { path: "/dashboard" };
|
||||
};
|
||||
|
||||
const loadNotifRows = async () => {
|
||||
await notificationStore.loadRows();
|
||||
};
|
||||
|
||||
const showNotifDialog = async () => {
|
||||
notifDialogVisible.value = true;
|
||||
await loadNotifRows();
|
||||
};
|
||||
|
||||
const handleNotifMarkRead = async (id: number) => {
|
||||
await notificationStore.markRead(id);
|
||||
ElMessage.success("已标记为已读");
|
||||
};
|
||||
|
||||
const handleNotifMarkAllRead = async () => {
|
||||
const affected = await notificationStore.markAllRead();
|
||||
ElMessage.success(affected > 0 ? `已标记 ${affected} 条通知为已读` : "没有未读通知");
|
||||
};
|
||||
|
||||
const refreshLayoutState = async () => {
|
||||
authStore.syncFromStorage();
|
||||
await menuStore.refreshContext();
|
||||
await notificationStore.refreshContext();
|
||||
};
|
||||
|
||||
const logoutAll = async () => {
|
||||
const nextLoginPath = resolveLoginPath(authScope.value, tenantCode.value);
|
||||
try {
|
||||
await logoutAllAuth();
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
await router.replace(nextLoginPath);
|
||||
withSuppressedAuthRefresh(() => {
|
||||
authStore.clearAuthStorage();
|
||||
});
|
||||
menuStore.reset();
|
||||
notificationStore.reset();
|
||||
ElMessage.success("已退出全部设备");
|
||||
};
|
||||
|
||||
const handleUserCommand = async (command: string) => {
|
||||
if (command === "dashboard") {
|
||||
await router.push(dashboardRoute.value);
|
||||
return;
|
||||
}
|
||||
if (command === "profile") {
|
||||
await router.push(profileRoute.value);
|
||||
return;
|
||||
}
|
||||
if (command === "logout") {
|
||||
await logoutAll();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTenantSwitchCommand = async (value: string | number) => {
|
||||
const targetTenantId = Number(value);
|
||||
if (!Number.isFinite(targetTenantId) || targetTenantId <= 0) {
|
||||
return;
|
||||
}
|
||||
if (tenantSwitching.value || targetTenantId === selectedTenantSwitchId.value) {
|
||||
return;
|
||||
}
|
||||
menuStore.setTenantSwitching(true);
|
||||
const sourcePath = route.path;
|
||||
const sourceQuery = { ...(route.query as Record<string, unknown>) };
|
||||
const sourceHash = route.hash;
|
||||
try {
|
||||
const resp = await switchTenant({ tenantId: targetTenantId });
|
||||
withSuppressedAuthRefresh(() => {
|
||||
authStore.saveAuthPayload(resp?.data || null);
|
||||
});
|
||||
await refreshLayoutState();
|
||||
await router.push(resolveTenantSwitchTargetRoute(sourcePath, sourceQuery, sourceHash));
|
||||
ElMessage.success("租户切换成功");
|
||||
} finally {
|
||||
menuStore.setTenantSwitching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
notificationStore.handleVisibilityVisible();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthTokenUpdated = () => {
|
||||
if (suppressNextAuthRefresh) {
|
||||
return;
|
||||
}
|
||||
void refreshLayoutState();
|
||||
};
|
||||
|
||||
const handleAuthStorageChanged = (event: StorageEvent) => {
|
||||
if (event.key !== "authVersion" && event.key !== "tenantContextVersion") {
|
||||
return;
|
||||
}
|
||||
ElMessage.info("检测到其他标签页更新了登录状态,当前页面将刷新");
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener("auth:token-updated", handleAuthTokenUpdated as EventListener);
|
||||
window.addEventListener("storage", handleAuthStorageChanged);
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
|
||||
try {
|
||||
await refreshLayoutState();
|
||||
} finally {
|
||||
appearanceStore.markBootstrapped();
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("auth:token-updated", handleAuthTokenUpdated as EventListener);
|
||||
window.removeEventListener("storage", handleAuthStorageChanged);
|
||||
notificationStore.stopRealtime();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-root {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
font-family: "Inter", "PingFang SC", "Microsoft YaHei", -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.layout-watermark {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
background-repeat: repeat;
|
||||
background-position: 0 0;
|
||||
background-size: 320px 240px;
|
||||
}
|
||||
|
||||
.top-layout {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-header,
|
||||
.sub-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--wo-shell-gap);
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
background: var(--wo-app-header-bg);
|
||||
color: var(--wo-app-header-text);
|
||||
box-shadow: var(--wo-header-shadow);
|
||||
}
|
||||
|
||||
.sub-header {
|
||||
background: var(--wo-sub-header-bg);
|
||||
border-bottom: 1px solid var(--wo-sub-header-border);
|
||||
}
|
||||
|
||||
.brand-area,
|
||||
.sidebar-brand {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
height: 64px;
|
||||
padding: 0 20px;
|
||||
background: var(--wo-sidebar-brand-bg);
|
||||
border-bottom: 1px solid var(--wo-sidebar-border);
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
/* max-width: 128px; */
|
||||
max-height: 45px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
color: var(--wo-app-header-text);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.brand-text--light {
|
||||
color: var(--wo-sidebar-text-active);
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 10;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top-nav-item {
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
color: var(--wo-top-nav-text);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.top-nav-item:hover,
|
||||
.top-nav-item--active {
|
||||
color: var(--wo-app-header-text);
|
||||
background: var(--wo-top-nav-active-bg);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-actions--light {
|
||||
color: var(--wo-light-action-text);
|
||||
}
|
||||
|
||||
.sub-header-left {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.user-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: var(--wo-header-avatar-size);
|
||||
height: var(--wo-header-avatar-size);
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--wo-header-action-bg);
|
||||
color: var(--wo-header-action-text);
|
||||
}
|
||||
|
||||
.user-avatar--light {
|
||||
background: var(--wo-profile-avatar-bg);
|
||||
color: var(--wo-profile-avatar-text);
|
||||
}
|
||||
|
||||
.user-avatar svg {
|
||||
width: var(--wo-header-avatar-icon-size);
|
||||
height: var(--wo-header-avatar-icon-size);
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.user-meta--light .user-name {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.user-meta--light .user-scope {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--wo-app-header-text);
|
||||
}
|
||||
|
||||
.user-scope {
|
||||
font-size: 11px;
|
||||
color: var(--wo-top-nav-text);
|
||||
}
|
||||
|
||||
.tenant-switch-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: var(--wo-header-action-bg);
|
||||
color: var(--wo-header-action-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tenant-switch-btn--light {
|
||||
background: var(--wo-light-action-bg);
|
||||
color: var(--wo-light-action-text);
|
||||
}
|
||||
|
||||
.tenant-switch-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tenant-switch-arrow {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.action-divider {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--wo-header-action-divider);
|
||||
}
|
||||
|
||||
.action-divider--light {
|
||||
background: var(--wo-light-action-divider);
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: var(--wo-header-action-size);
|
||||
height: var(--wo-header-action-size);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 0;
|
||||
background: var(--wo-header-action-bg);
|
||||
color: var(--wo-header-action-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn--light {
|
||||
background: var(--wo-light-action-bg);
|
||||
color: var(--wo-light-action-text);
|
||||
}
|
||||
|
||||
.icon-btn svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.layout-aside {
|
||||
background: var(--wo-sidebar-bg);
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
height: calc(100% - 64px);
|
||||
border-right: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-menu-item) {
|
||||
color: var(--wo-sidebar-text);
|
||||
border-radius: 8px;
|
||||
margin: 4px 8px;
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-menu-item:hover) {
|
||||
color: var(--wo-sidebar-text-active);
|
||||
background: var(--wo-sidebar-item-hover);
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-menu-item.is-active) {
|
||||
color: var(--wo-sidebar-text-active);
|
||||
background: var(--wo-sidebar-item-active-bg);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background: var(--wo-main-content-bg);
|
||||
padding: var(--wo-main-content-padding);
|
||||
}
|
||||
|
||||
.notif-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.notif-tags,
|
||||
.notif-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
:deep(.notif-mark-all-read-btn.el-button--primary.is-plain) {
|
||||
background: rgba(var(--wo-theme-rgb), 0.08) !important;
|
||||
border-color: var(--wo-brand-primary) !important;
|
||||
color: var(--wo-brand-primary-dark-2) !important;
|
||||
}
|
||||
|
||||
:deep(.notif-mark-all-read-btn.el-button--primary.is-plain:hover) {
|
||||
background: rgba(var(--wo-theme-rgb), 0.14) !important;
|
||||
color: var(--wo-brand-primary-dark-2) !important;
|
||||
}
|
||||
|
||||
:deep(.notif-mark-read-btn.el-button.is-link) {
|
||||
color: var(--wo-brand-primary-dark-2) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.notif-mark-read-btn.el-button.is-link:hover) {
|
||||
color: var(--wo-brand-primary) !important;
|
||||
}
|
||||
|
||||
.notif-read-tag {
|
||||
color: #9ca3af;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<PageContainer title="审核流配置">
|
||||
<QueryToolbar>
|
||||
<el-button
|
||||
v-if="canManage"
|
||||
type="primary"
|
||||
@click="openCreateDrawer"
|
||||
>
|
||||
新增流程
|
||||
</el-button>
|
||||
<el-button @click="load">刷新</el-button>
|
||||
</QueryToolbar>
|
||||
<el-table :data="rows" class="mt-md" empty-text="暂无数据">
|
||||
<el-table-column prop="flowCode" label="流程编码" />
|
||||
<el-table-column prop="flowName" label="流程名称" />
|
||||
<el-table-column prop="status" label="状态" width="120" :formatter="statusFormatter" />
|
||||
<el-table-column label="默认" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.isDefault" type="success">默认</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="节点" min-width="260">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-for="node in row.nodes || []" :key="node.id" class="mr-xs">
|
||||
{{ toZhAuditNode(node.nodeCode || node.nodeName) }}{{
|
||||
node.assigneeType ? `(${toZhAssigneeType(node.assigneeType)}:${node.assigneeRefId || "-"})` : ""
|
||||
}}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" @click="handleCopy(row.id)">复制</el-button>
|
||||
<el-button v-if="canManage" size="small" @click="handleEdit(row)">编辑节点</el-button>
|
||||
<el-button v-if="canManage" size="small" @click="handleSetDefault(row.id)">设为默认</el-button>
|
||||
<el-button v-if="canManage && row.status === 'ENABLED'" size="small" @click="handleDisable(row.id)">
|
||||
停用
|
||||
</el-button>
|
||||
<el-button v-else-if="canManage" size="small" @click="handleEnable(row.id)">启用</el-button>
|
||||
<el-button
|
||||
v-if="canManage && row.status === 'DISABLED' && !row.isDefault"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDelete(row.id, row.flowName)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="mt-md"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="totalCount"
|
||||
v-model:current-page="pageNo"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@size-change="load"
|
||||
@current-change="load"
|
||||
/>
|
||||
|
||||
<el-drawer v-model="createVisible" title="新增审核流" :size="DRAWER_SIZE.lg" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="流程编码" prop="flowCode">
|
||||
<el-input v-model="form.flowCode" />
|
||||
</el-form-item>
|
||||
<el-form-item label="流程名称" prop="flowName">
|
||||
<el-input v-model="form.flowName" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="createNodes" class="mt-sm mb-md" empty-text="暂无数据">
|
||||
<el-table-column prop="nodeName" label="节点" width="120" />
|
||||
<el-table-column label="审核人来源" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.assigneeType" class="w-input-sm">
|
||||
<el-option label="不指定" value="" />
|
||||
<el-option label="按角色" value="ROLE" />
|
||||
<el-option label="按用户" value="USER" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源对象">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-if="row.assigneeType === 'ROLE'"
|
||||
v-model="row.assigneeRefId"
|
||||
placeholder="请选择角色"
|
||||
class="w-input-md"
|
||||
clearable
|
||||
>
|
||||
<el-option v-for="role in roles" :key="role.id" :label="role.roleName" :value="role.id" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-else-if="row.assigneeType === 'USER'"
|
||||
v-model="row.assigneeRefId"
|
||||
placeholder="请选择用户"
|
||||
class="w-input-md"
|
||||
clearable
|
||||
>
|
||||
<el-option v-for="user in users" :key="user.id" :label="`${user.userName}(${user.id})`" :value="user.id" />
|
||||
</el-select>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="handleCreate">创建</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑审核流节点来源" :width="DIALOG_WIDTH.lg">
|
||||
<el-table :data="editNodes" empty-text="暂无数据">
|
||||
<el-table-column prop="nodeName" label="节点" width="120" />
|
||||
<el-table-column label="审核人来源" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="row.assigneeType" class="w-input-sm">
|
||||
<el-option label="不指定" value="" />
|
||||
<el-option label="按角色" value="ROLE" />
|
||||
<el-option label="按用户" value="USER" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源对象">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-if="row.assigneeType === 'ROLE'"
|
||||
v-model="row.assigneeRefId"
|
||||
placeholder="请选择角色"
|
||||
class="w-input-md"
|
||||
clearable
|
||||
>
|
||||
<el-option v-for="role in roles" :key="role.id" :label="role.roleName" :value="role.id" />
|
||||
</el-select>
|
||||
<el-select
|
||||
v-else-if="row.assigneeType === 'USER'"
|
||||
v-model="row.assigneeRefId"
|
||||
placeholder="请选择用户"
|
||||
class="w-input-md"
|
||||
clearable
|
||||
>
|
||||
<el-option v-for="user in users" :key="user.id" :label="`${user.userName}(${user.id})`" :value="user.id" />
|
||||
</el-select>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSaveEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import {
|
||||
copyAuditFlow,
|
||||
createAuditFlow,
|
||||
deleteAuditFlow,
|
||||
disableAuditFlow,
|
||||
enableAuditFlow,
|
||||
fetchAuditFlows,
|
||||
fetchRoles,
|
||||
fetchUsers,
|
||||
setDefaultAuditFlow,
|
||||
updateAuditFlow,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhAssigneeType, toZhAuditNode, toZhStatus } from "../../utils/status";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.audit.flowRead));
|
||||
const canManage = computed(() => authStore.hasPermission(PERMS.audit.flowManage));
|
||||
const rows = ref<any[]>([]);
|
||||
const pageNo = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const totalCount = ref(0);
|
||||
const roles = ref<any[]>([]);
|
||||
const users = ref<any[]>([]);
|
||||
const createVisible = ref(false);
|
||||
const editVisible = ref(false);
|
||||
const editingFlow = ref<any>(null);
|
||||
const editNodes = ref<any[]>([]);
|
||||
const form = ref({
|
||||
flowCode: "FLOW_" + Date.now(),
|
||||
flowName: "新流程",
|
||||
});
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const formRules = ref<FormRules>({
|
||||
flowCode: [{ required: true, message: "请输入流程编码", trigger: "blur" }],
|
||||
flowName: [{ required: true, message: "请输入流程名称", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
|
||||
const createNodes = ref([
|
||||
{ nodeCode: "INIT_REVIEW", nodeName: "初审", sortNo: 1, assigneeType: "ROLE", assigneeRefId: 104 },
|
||||
{ nodeCode: "RE_REVIEW", nodeName: "复审", sortNo: 2, assigneeType: "ROLE", assigneeRefId: 104 },
|
||||
{ nodeCode: "FINAL_REVIEW", nodeName: "终审", sortNo: 3, assigneeType: "ROLE", assigneeRefId: 104 },
|
||||
]);
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
form.value = {
|
||||
flowCode: "FLOW_" + Date.now(),
|
||||
flowName: "新流程",
|
||||
};
|
||||
createNodes.value = [
|
||||
{ nodeCode: "INIT_REVIEW", nodeName: "初审", sortNo: 1, assigneeType: "ROLE", assigneeRefId: 104 },
|
||||
{ nodeCode: "RE_REVIEW", nodeName: "复审", sortNo: 2, assigneeType: "ROLE", assigneeRefId: 104 },
|
||||
{ nodeCode: "FINAL_REVIEW", nodeName: "终审", sortNo: 3, assigneeType: "ROLE", assigneeRefId: 104 },
|
||||
];
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!canRead.value) {
|
||||
rows.value = [];
|
||||
return;
|
||||
}
|
||||
const resp = await fetchAuditFlows({ pageNo: pageNo.value, pageSize: pageSize.value });
|
||||
rows.value = resp?.data?.list || [];
|
||||
totalCount.value = Number(resp?.data?.total || 0);
|
||||
};
|
||||
|
||||
const loadOptions = async () => {
|
||||
try {
|
||||
const [roleResp, userResp] = await Promise.all([fetchRoles(), fetchUsers()]);
|
||||
roles.value = roleResp?.data?.list || [];
|
||||
users.value = userResp?.data?.list || [];
|
||||
} catch (_e) {
|
||||
roles.value = [];
|
||||
users.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formRef.value) return;
|
||||
const valid = await formRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await createAuditFlow({
|
||||
flowCode: form.value.flowCode,
|
||||
flowName: form.value.flowName,
|
||||
nodes: createNodes.value,
|
||||
});
|
||||
ElMessage.success("流程创建成功");
|
||||
createVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleEdit = (row: any) => {
|
||||
editingFlow.value = row;
|
||||
editNodes.value = (row.nodes || []).map((node: any) => ({
|
||||
nodeCode: node.nodeCode,
|
||||
nodeName: node.nodeName,
|
||||
sortNo: node.sortNo,
|
||||
assigneeType: node.assigneeType || "",
|
||||
assigneeRefId: node.assigneeRefId || undefined,
|
||||
}));
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingFlow.value) {
|
||||
return;
|
||||
}
|
||||
await updateAuditFlow(editingFlow.value.id, {
|
||||
flowCode: editingFlow.value.flowCode,
|
||||
flowName: editingFlow.value.flowName,
|
||||
effectiveStartAt: editingFlow.value.effectiveStartAt,
|
||||
effectiveEndAt: editingFlow.value.effectiveEndAt,
|
||||
nodes: editNodes.value,
|
||||
});
|
||||
ElMessage.success("流程节点已更新");
|
||||
editVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleCopy = async (id: number) => {
|
||||
await copyAuditFlow(id);
|
||||
ElMessage.success("流程复制成功");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleSetDefault = async (id: number) => {
|
||||
await setDefaultAuditFlow(id);
|
||||
ElMessage.success("默认流程已更新");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleEnable = async (id: number) => {
|
||||
await enableAuditFlow(id);
|
||||
ElMessage.success("流程已启用");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDisable = async (id: number) => {
|
||||
await disableAuditFlow(id);
|
||||
ElMessage.success("流程已停用");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
await ElMessageBox.confirm(`确认删除审核流「${name}」?删除后不可恢复。`, "删除审核流", {
|
||||
type: "warning",
|
||||
confirmButtonText: "确认删除",
|
||||
cancelButtonText: "取消",
|
||||
});
|
||||
await deleteAuditFlow(id);
|
||||
ElMessage.success("审核流已删除");
|
||||
await load();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([load(), loadOptions()]);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<PageContainer title="审计日志">
|
||||
<QueryToolbar>
|
||||
<el-input-number v-model="query.userId" :min="1" placeholder="用户ID" />
|
||||
<el-input v-model="query.actionCode" placeholder="动作码,如:tenant.manage" class="w-input-md" />
|
||||
<el-input-number v-if="isPlatform" v-model="query.tenantId" :min="1" placeholder="租户ID" />
|
||||
<el-select v-if="isPlatform" v-model="query.scope" clearable placeholder="域" class="w-input-sm">
|
||||
<el-option label="租户域" value="TENANT" />
|
||||
<el-option label="平台域" value="PLATFORM" />
|
||||
</el-select>
|
||||
<el-button v-if="canRead" type="primary" @click="handleQuery">查询</el-button>
|
||||
<el-button v-if="canRead" @click="handleExport">导出(接口)</el-button>
|
||||
<el-button v-if="canRead" @click="handleCreateExportTask">创建导出任务</el-button>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-table :data="rows" class="mt-md" empty-text="暂无数据">
|
||||
<el-table-column v-if="isPlatform" prop="scope" label="域" width="100" :formatter="scopeFormatter" />
|
||||
<el-table-column v-if="isPlatform" prop="tenantId" label="租户ID" width="100" />
|
||||
<el-table-column prop="userId" label="用户" width="120" :formatter="userNameFormatter" />
|
||||
<el-table-column prop="actionCode" label="动作码" width="160" />
|
||||
<el-table-column prop="bizType" label="业务" width="110" />
|
||||
<el-table-column prop="bizId" label="业务ID" width="90" />
|
||||
<el-table-column prop="httpMethod" label="方法" width="90" />
|
||||
<el-table-column prop="requestUri" label="请求路径" min-width="220" />
|
||||
<el-table-column prop="requestId" label="RequestId" min-width="180" />
|
||||
<el-table-column prop="statusCode" label="状态码" width="90" />
|
||||
<el-table-column prop="success" label="结果" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.success ? 'success' : 'danger'">{{ row.success ? "成功" : "失败" }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="时间" width="180" />
|
||||
</el-table>
|
||||
<div class="flex-end mt-md">
|
||||
<el-pagination
|
||||
:current-page="query.pageNo"
|
||||
:page-size="query.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SectionTitle>导出任务</SectionTitle>
|
||||
<el-table :data="exportTasks" empty-text="暂无数据">
|
||||
<el-table-column prop="taskCode" label="任务编码" width="200" />
|
||||
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column prop="fileName" label="文件名" min-width="220" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
||||
<el-table-column prop="updatedAt" label="更新时间" width="180" />
|
||||
</el-table>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import SectionTitle from "../../components/SectionTitle.vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import {
|
||||
createAuditLogExportTask,
|
||||
exportAuditLogs,
|
||||
fetchAuditLogExportTasks,
|
||||
fetchAuditLogs,
|
||||
fetchPlatformUsers,
|
||||
fetchPlatformAuditLogs,
|
||||
fetchUsers,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const isPlatform = computed(() => authStore.scope === "PLATFORM");
|
||||
const canRead = computed(() =>
|
||||
isPlatform.value ? authStore.hasPermission(PERMS.platform.auditRead) : authStore.hasPermission(PERMS.audit.logRead),
|
||||
);
|
||||
const rows = ref<any[]>([]);
|
||||
const total = ref(0);
|
||||
const exportTasks = ref<any[]>([]);
|
||||
const userNameMap = ref<Record<number, string>>({});
|
||||
const query = ref<{
|
||||
userId?: number;
|
||||
actionCode?: string;
|
||||
tenantId?: number;
|
||||
scope?: "TENANT" | "PLATFORM";
|
||||
pageNo: number;
|
||||
pageSize: number;
|
||||
}>({
|
||||
userId: undefined,
|
||||
actionCode: "",
|
||||
tenantId: undefined,
|
||||
scope: undefined,
|
||||
pageNo: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
const scopeFormatter = (_row: any, _column: any, value: unknown) => (String(value || "").toUpperCase() === "PLATFORM" ? "平台域" : "租户域");
|
||||
const userNameFormatter = (_row: any, _column: any, value: unknown) => {
|
||||
const userId = Number(value || 0);
|
||||
if (!userId) {
|
||||
return "-";
|
||||
}
|
||||
return userNameMap.value[userId] || `用户#${userId}`;
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const resp = isPlatform.value ? await fetchPlatformUsers() : await fetchUsers();
|
||||
const users = resp?.data?.list || [];
|
||||
const map: Record<number, string> = {};
|
||||
users.forEach((item: any) => {
|
||||
const id = Number(item?.id || 0);
|
||||
if (id > 0) {
|
||||
map[id] = item?.userName || item?.phone || `用户#${id}`;
|
||||
}
|
||||
});
|
||||
userNameMap.value = map;
|
||||
} catch (_e) {
|
||||
userNameMap.value = {};
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!canRead.value) {
|
||||
rows.value = [];
|
||||
return;
|
||||
}
|
||||
const resp = isPlatform.value ? await fetchPlatformAuditLogs(query.value) : await fetchAuditLogs(query.value);
|
||||
rows.value = resp?.data?.list || [];
|
||||
total.value = Number(resp?.data?.total || 0);
|
||||
query.value.pageNo = Number(resp?.data?.pageNo || query.value.pageNo || 1);
|
||||
query.value.pageSize = Number(resp?.data?.pageSize || query.value.pageSize || 20);
|
||||
if (!isPlatform.value) {
|
||||
const taskResp = await fetchAuditLogExportTasks();
|
||||
exportTasks.value = taskResp?.data?.list || [];
|
||||
} else {
|
||||
exportTasks.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuery = async () => {
|
||||
query.value.pageNo = 1;
|
||||
await load();
|
||||
};
|
||||
|
||||
const handlePageChange = async (pageNo: number) => {
|
||||
query.value.pageNo = Number(pageNo || 1);
|
||||
await load();
|
||||
};
|
||||
|
||||
const handlePageSizeChange = async (pageSize: number) => {
|
||||
query.value.pageSize = Number(pageSize || 20);
|
||||
query.value.pageNo = 1;
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!canRead.value) {
|
||||
return;
|
||||
}
|
||||
if (isPlatform.value) {
|
||||
const resp = await fetchPlatformAuditLogs(query.value);
|
||||
const list = resp?.data?.list || [];
|
||||
ElMessage.success(`平台审计查询返回 ${list.length} 条`);
|
||||
} else {
|
||||
const resp = await exportAuditLogs(query.value);
|
||||
const list = resp?.data?.list || [];
|
||||
ElMessage.success(`导出接口返回 ${list.length} 条`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateExportTask = async () => {
|
||||
if (!canRead.value) {
|
||||
return;
|
||||
}
|
||||
if (isPlatform.value) {
|
||||
ElMessage.warning("平台审计暂不支持导出任务,请先使用查询");
|
||||
return;
|
||||
}
|
||||
await createAuditLogExportTask({
|
||||
idempotencyKey: `audit-log-export-${Date.now()}`,
|
||||
userId: query.value.userId,
|
||||
actionCode: query.value.actionCode,
|
||||
fileName: `audit-log-${Date.now()}.csv`,
|
||||
});
|
||||
ElMessage.success("导出任务已创建");
|
||||
await load();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadUsers(), load()]);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<PageContainer title="数据权限管理">
|
||||
<QueryToolbar>
|
||||
<el-button v-if="canManage" type="primary" @click="openCreateDrawer">新增策略</el-button>
|
||||
<el-button v-if="canRead" @click="load">刷新</el-button>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-card shadow="never" class="mb-md">
|
||||
<template #header>当前用户生效范围</template>
|
||||
<div class="lh-loose">
|
||||
<div>用户ID:{{ currentScope.userId ?? "-" }}</div>
|
||||
<div>项目范围:{{ currentScope.projectAll ? "全部" : (currentScope.projectIds || []).join(",") || "-" }}</div>
|
||||
<div>会议范围:{{ currentScope.meetingAll ? "全部" : (currentScope.meetingIds || []).join(",") || "-" }}</div>
|
||||
<div>用户范围:{{ currentScope.userAll ? "全部" : (currentScope.userIds || []).join(",") || "-" }}</div>
|
||||
<div>专家范围:{{ currentScope.expertAll ? "全部" : (currentScope.expertIds || []).join(",") || "-" }}</div>
|
||||
<div>导出权限:{{ currentScope.exportAllowed ? "允许" : "禁止" }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-table :data="rows" class="mt-md">
|
||||
<el-table-column prop="policyName" label="策略名称" width="180" />
|
||||
<el-table-column label="当前账号命中" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="(currentScope.matchedPolicyIds || []).includes(row.id)" type="success">命中</el-tag>
|
||||
<el-tag v-else type="info">未命中</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="projectScope" label="项目范围" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column prop="projectIdsCsv" label="项目ID集" />
|
||||
<el-table-column prop="meetingScope" label="会议范围" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column prop="meetingIdsCsv" label="会议ID集" />
|
||||
<el-table-column prop="userScope" label="用户范围" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column prop="userIdsCsv" label="用户ID集" />
|
||||
<el-table-column prop="expertScope" label="专家范围" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column prop="expertIdsCsv" label="专家ID集" />
|
||||
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column label="操作" width="420">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button v-if="canManage" size="small" @click="openAssignRoles(row)">分配角色</el-button>
|
||||
<el-button v-if="canManage" size="small" @click="handleCopy(row.id)">复制</el-button>
|
||||
<el-button v-if="canManage && row.status !== 'ENABLED'" size="small" type="success" @click="handleEnable(row.id)">启用</el-button>
|
||||
<el-button v-else-if="canManage" size="small" type="warning" @click="handleDisable(row.id)">停用</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-drawer v-model="createVisible" title="新增策略" :size="DRAWER_SIZE.md" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="策略名称" prop="policyName">
|
||||
<el-input v-model="form.policyName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目范围" prop="projectScope">
|
||||
<el-select v-model="form.projectScope">
|
||||
<el-option label="全部" value="ALL" />
|
||||
<el-option label="指定ID" value="IDS" />
|
||||
<el-option label="负责人" value="OWNER" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.projectScope === 'IDS'" label="项目ID集" prop="projectIdsCsv">
|
||||
<el-input v-model="form.projectIdsCsv" placeholder="如 1001,1002" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会议范围" prop="meetingScope">
|
||||
<el-select v-model="form.meetingScope">
|
||||
<el-option label="全部" value="ALL" />
|
||||
<el-option label="指定ID" value="IDS" />
|
||||
<el-option label="负责人" value="OWNER" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.meetingScope === 'IDS'" label="会议ID集" prop="meetingIdsCsv">
|
||||
<el-input v-model="form.meetingIdsCsv" placeholder="如 2001,2002" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户范围" prop="userScope">
|
||||
<el-select v-model="form.userScope">
|
||||
<el-option label="全部" value="ALL" />
|
||||
<el-option label="指定ID" value="IDS" />
|
||||
<el-option label="负责人" value="OWNER" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.userScope === 'IDS'" label="用户ID集" prop="userIdsCsv">
|
||||
<el-input v-model="form.userIdsCsv" placeholder="如 3001,3002" />
|
||||
</el-form-item>
|
||||
<el-form-item label="专家范围" prop="expertScope">
|
||||
<el-select v-model="form.expertScope">
|
||||
<el-option label="全部" value="ALL" />
|
||||
<el-option label="指定ID" value="IDS" />
|
||||
<el-option label="负责人" value="OWNER" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.expertScope === 'IDS'" label="专家ID集" prop="expertIdsCsv">
|
||||
<el-input v-model="form.expertIdsCsv" placeholder="如 4001,4002" />
|
||||
</el-form-item>
|
||||
<el-form-item label="允许导出" prop="exportAllowed">
|
||||
<el-switch v-model="form.exportAllowed" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="handleCreate">创建</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑策略" :width="DIALOG_WIDTH.md">
|
||||
<el-form ref="editFormRef" :model="editForm" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="策略名称" prop="policyName">
|
||||
<el-input v-model="editForm.policyName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="项目范围" prop="projectScope">
|
||||
<el-select v-model="editForm.projectScope">
|
||||
<el-option label="全部" value="ALL" />
|
||||
<el-option label="指定ID" value="IDS" />
|
||||
<el-option label="负责人" value="OWNER" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.projectScope === 'IDS'" label="项目ID集" prop="projectIdsCsv">
|
||||
<el-input v-model="editForm.projectIdsCsv" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会议范围" prop="meetingScope">
|
||||
<el-select v-model="editForm.meetingScope">
|
||||
<el-option label="全部" value="ALL" />
|
||||
<el-option label="指定ID" value="IDS" />
|
||||
<el-option label="负责人" value="OWNER" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.meetingScope === 'IDS'" label="会议ID集" prop="meetingIdsCsv">
|
||||
<el-input v-model="editForm.meetingIdsCsv" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户范围" prop="userScope">
|
||||
<el-select v-model="editForm.userScope">
|
||||
<el-option label="全部" value="ALL" />
|
||||
<el-option label="指定ID" value="IDS" />
|
||||
<el-option label="负责人" value="OWNER" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.userScope === 'IDS'" label="用户ID集" prop="userIdsCsv">
|
||||
<el-input v-model="editForm.userIdsCsv" />
|
||||
</el-form-item>
|
||||
<el-form-item label="专家范围" prop="expertScope">
|
||||
<el-select v-model="editForm.expertScope">
|
||||
<el-option label="全部" value="ALL" />
|
||||
<el-option label="指定ID" value="IDS" />
|
||||
<el-option label="负责人" value="OWNER" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.expertScope === 'IDS'" label="专家ID集" prop="expertIdsCsv">
|
||||
<el-input v-model="editForm.expertIdsCsv" />
|
||||
</el-form-item>
|
||||
<el-form-item label="允许导出" prop="exportAllowed">
|
||||
<el-switch v-model="editForm.exportAllowed" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSaveEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="assignVisible" title="分配角色" :width="DIALOG_WIDTH.sm">
|
||||
<el-radio-group v-model="assignMode" class="mb-sm">
|
||||
<el-radio label="APPEND">追加</el-radio>
|
||||
<el-radio label="REPLACE">替换</el-radio>
|
||||
</el-radio-group>
|
||||
<el-select v-model="selectedRoleIds" multiple filterable class="w-full" placeholder="选择角色(可多选)">
|
||||
<el-option v-for="role in roles" :key="role.id" :label="role.roleName" :value="role.id" />
|
||||
</el-select>
|
||||
<template #footer>
|
||||
<el-button @click="assignVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleAssignRoles">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
import { ElMessage } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import {
|
||||
assignDataPermissionRoles,
|
||||
copyDataPermission,
|
||||
createDataPermission,
|
||||
disableDataPermission,
|
||||
enableDataPermission,
|
||||
fetchDataPermissionRoles,
|
||||
fetchCurrentDataScope,
|
||||
fetchDataPermissions,
|
||||
fetchRoles,
|
||||
updateDataPermission,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.dataPermission.read));
|
||||
const canManage = computed(() => authStore.hasPermission(PERMS.dataPermission.manage));
|
||||
const rows = ref<any[]>([]);
|
||||
const roles = ref<any[]>([]);
|
||||
const createVisible = ref(false);
|
||||
const form = ref({
|
||||
policyName: "新策略",
|
||||
projectScope: "ALL",
|
||||
projectIdsCsv: "",
|
||||
meetingScope: "ALL",
|
||||
meetingIdsCsv: "",
|
||||
userScope: "ALL",
|
||||
userIdsCsv: "",
|
||||
expertScope: "ALL",
|
||||
expertIdsCsv: "",
|
||||
moduleScope: "ALL",
|
||||
exportAllowed: false,
|
||||
});
|
||||
const editVisible = ref(false);
|
||||
const assignVisible = ref(false);
|
||||
const editId = ref<number | null>(null);
|
||||
const editForm = ref<any>({});
|
||||
const selectedPolicyId = ref<number | null>(null);
|
||||
const selectedRoleIds = ref<number[]>([]);
|
||||
const assignMode = ref<"APPEND" | "REPLACE">("APPEND");
|
||||
const currentScope = ref<any>({});
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const editFormRef = ref<FormInstance>();
|
||||
|
||||
const formRules = ref<FormRules>({
|
||||
policyName: [{ required: true, message: "请输入策略名称", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
form.value = {
|
||||
policyName: "新策略",
|
||||
projectScope: "ALL",
|
||||
projectIdsCsv: "",
|
||||
meetingScope: "ALL",
|
||||
meetingIdsCsv: "",
|
||||
userScope: "ALL",
|
||||
userIdsCsv: "",
|
||||
expertScope: "ALL",
|
||||
expertIdsCsv: "",
|
||||
moduleScope: "ALL",
|
||||
exportAllowed: false,
|
||||
};
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!canRead.value) {
|
||||
rows.value = [];
|
||||
return;
|
||||
}
|
||||
const [resp, scopeResp] = await Promise.all([fetchDataPermissions(), fetchCurrentDataScope()]);
|
||||
rows.value = resp?.data?.list || [];
|
||||
currentScope.value = scopeResp?.data || {};
|
||||
};
|
||||
|
||||
const loadRoles = async () => {
|
||||
const roleResp = await fetchRoles();
|
||||
roles.value = roleResp?.data?.list || [];
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formRef.value) return;
|
||||
const valid = await formRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await createDataPermission(form.value);
|
||||
ElMessage.success("策略创建成功");
|
||||
createVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
editId.value = row.id;
|
||||
editForm.value = {
|
||||
policyName: row.policyName,
|
||||
projectScope: row.projectScope,
|
||||
projectIdsCsv: row.projectIdsCsv,
|
||||
meetingScope: row.meetingScope,
|
||||
meetingIdsCsv: row.meetingIdsCsv,
|
||||
userScope: row.userScope || "ALL",
|
||||
userIdsCsv: row.userIdsCsv,
|
||||
expertScope: row.expertScope || "ALL",
|
||||
expertIdsCsv: row.expertIdsCsv,
|
||||
moduleScope: row.moduleScope,
|
||||
exportAllowed: !!row.exportAllowed,
|
||||
};
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editId.value) return;
|
||||
|
||||
if (!editFormRef.value) return;
|
||||
const valid = await editFormRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await updateDataPermission(editId.value, editForm.value);
|
||||
ElMessage.success("策略更新成功");
|
||||
editVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const openAssignRoles = async (row: any) => {
|
||||
selectedPolicyId.value = row.id;
|
||||
const resp = await fetchDataPermissionRoles(row.id);
|
||||
selectedRoleIds.value = resp?.data || [];
|
||||
assignMode.value = "APPEND";
|
||||
assignVisible.value = true;
|
||||
};
|
||||
|
||||
const handleAssignRoles = async () => {
|
||||
if (!selectedPolicyId.value) {
|
||||
return;
|
||||
}
|
||||
await assignDataPermissionRoles(selectedPolicyId.value, { roleIds: selectedRoleIds.value, assignMode: assignMode.value });
|
||||
ElMessage.success("角色分配成功");
|
||||
assignVisible.value = false;
|
||||
};
|
||||
|
||||
const handleCopy = async (id: number) => {
|
||||
await copyDataPermission(id);
|
||||
ElMessage.success("策略复制成功");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleEnable = async (id: number) => {
|
||||
await enableDataPermission(id);
|
||||
ElMessage.success("策略已启用");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDisable = async (id: number) => {
|
||||
await disableDataPermission(id);
|
||||
ElMessage.success("策略已停用");
|
||||
await load();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([load(), loadRoles()]);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,350 @@
|
||||
<template>
|
||||
<PageContainer title="企业管理">
|
||||
<QueryToolbar>
|
||||
<el-button v-if="canManage" type="primary" @click="openCreateDrawer">新增企业</el-button>
|
||||
<el-button v-if="canRead" @click="load">刷新</el-button>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-table :data="rows" class="mt-md" empty-text="暂无数据">
|
||||
<!-- <el-table-column prop="id" label="ID" width="90" /> -->
|
||||
<el-table-column prop="enterpriseName" label="企业名称" width="220" />
|
||||
<el-table-column prop="enterpriseUrl" label="企业网址" />
|
||||
<el-table-column prop="logoUrl" label="Logo地址">
|
||||
|
||||
<template #default="{ row }">
|
||||
<img
|
||||
v-if="row.logoUrl"
|
||||
:src="row.logoDisplayUrl || row.logoUrl"
|
||||
alt="enterprise-logo"
|
||||
style="max-height: 36px; max-width: 140px; object-fit: contain;"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
||||
<el-table-column label="操作" width="260">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
v-if="canManage && row.status === 'ENABLED'"
|
||||
size="small"
|
||||
type="warning"
|
||||
@click="handleDisable(row.id)"
|
||||
>
|
||||
停用
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else-if="canManage"
|
||||
size="small"
|
||||
type="success"
|
||||
@click="handleEnable(row.id)"
|
||||
>
|
||||
启用
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canDelete && row.status === 'DISABLED'"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDelete(row.id, row.enterpriseName)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="mt-md"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="totalCount"
|
||||
v-model:current-page="pageNo"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@size-change="load"
|
||||
@current-change="load"
|
||||
/>
|
||||
|
||||
<el-drawer v-model="createVisible" title="新增企业" :size="DRAWER_SIZE.md" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="企业名称" prop="enterpriseName">
|
||||
<el-input v-model="form.enterpriseName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="企业网址">
|
||||
<el-input v-model="form.enterpriseUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Logo地址">
|
||||
<el-input v-model="form.logoUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Logo文件">
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:limit="1"
|
||||
accept="image/*"
|
||||
:on-change="onCreateLogoFileChange"
|
||||
>
|
||||
<el-button>选择Logo文件</el-button>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button v-if="canManage" :disabled="!createLogoFile" @click="uploadCreateLogo">
|
||||
上传Logo到OSS
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="handleCreate">创建</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑企业" :width="DIALOG_WIDTH.md">
|
||||
<el-form ref="editFormRef" :model="editForm" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="企业名称" prop="enterpriseName">
|
||||
<el-input v-model="editForm.enterpriseName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="企业网址">
|
||||
<el-input v-model="editForm.enterpriseUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Logo地址">
|
||||
<el-input v-model="editForm.logoUrl" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Logo文件">
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:limit="1"
|
||||
accept="image/*"
|
||||
:on-change="onEditLogoFileChange"
|
||||
>
|
||||
<el-button>选择Logo文件</el-button>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button v-if="canManage" :disabled="!editLogoFile" @click="uploadEditLogo">
|
||||
上传Logo到OSS
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import {
|
||||
createEnterprise,
|
||||
deleteEnterprise,
|
||||
disableEnterprise,
|
||||
enableEnterprise,
|
||||
fetchEnterpriseLogoUploadSign,
|
||||
fetchEnterprises,
|
||||
updateEnterprise,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { fetchFilePresignDownload } from "../../api/modules";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.enterprise.read));
|
||||
const canManage = computed(() => authStore.hasPermission(PERMS.enterprise.manage));
|
||||
const canDelete = computed(() => authStore.hasPermission(PERMS.enterprise.delete));
|
||||
const rows = ref<any[]>([]);
|
||||
const pageNo = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const totalCount = ref(0);
|
||||
const createVisible = ref(false);
|
||||
const editVisible = ref(false);
|
||||
const editId = ref<number | null>(null);
|
||||
const editForm = ref<any>({});
|
||||
const createLogoFile = ref<File | null>(null);
|
||||
const editLogoFile = ref<File | null>(null);
|
||||
const form = ref({
|
||||
enterpriseName: "",
|
||||
enterpriseUrl: "",
|
||||
logoUrl: "",
|
||||
});
|
||||
const formRef = ref<FormInstance>();
|
||||
const editFormRef = ref<FormInstance>();
|
||||
const formRules = ref<FormRules>({
|
||||
enterpriseName: [{ required: true, message: "请输入企业名称", trigger: "blur" }],
|
||||
});
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
|
||||
const load = async () => {
|
||||
const resp = await fetchEnterprises({ pageNo: pageNo.value, pageSize: pageSize.value });
|
||||
const list = resp?.data?.list || [];
|
||||
rows.value = list;
|
||||
totalCount.value = Number(resp?.data?.total || 0);
|
||||
await Promise.all(
|
||||
rows.value.map(async (row) => {
|
||||
const objectKey = String(row?.logoUrl || "");
|
||||
if (!objectKey) {
|
||||
row.logoDisplayUrl = "";
|
||||
return;
|
||||
}
|
||||
if (objectKey.startsWith("http://") || objectKey.startsWith("https://")) {
|
||||
row.logoDisplayUrl = objectKey;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const presignResp = await fetchFilePresignDownload({ objectKey });
|
||||
row.logoDisplayUrl = String(presignResp?.data?.signedUrl || "");
|
||||
} catch (_e) {
|
||||
row.logoDisplayUrl = "";
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
form.value = {
|
||||
enterpriseName: "",
|
||||
enterpriseUrl: "",
|
||||
logoUrl: "",
|
||||
};
|
||||
createLogoFile.value = null;
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const putToSignedUrl = async (url: string, file: File, contentType: string) => {
|
||||
const result = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": contentType || "application/octet-stream",
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(`上传失败: ${result.status}`);
|
||||
}
|
||||
};
|
||||
|
||||
const onCreateLogoFileChange = (uploadFile: any) => {
|
||||
createLogoFile.value = (uploadFile?.raw as File) || null;
|
||||
};
|
||||
|
||||
const onEditLogoFileChange = (uploadFile: any) => {
|
||||
editLogoFile.value = (uploadFile?.raw as File) || null;
|
||||
};
|
||||
|
||||
const uploadCreateLogo = async () => {
|
||||
if (!createLogoFile.value) {
|
||||
ElMessage.warning("请先选择Logo文件");
|
||||
return;
|
||||
}
|
||||
const resp = await fetchEnterpriseLogoUploadSign({
|
||||
fileName: createLogoFile.value.name,
|
||||
contentType: createLogoFile.value.type,
|
||||
});
|
||||
const uploadUrl = resp?.data?.uploadUrl;
|
||||
const objectKey = resp?.data?.objectKey;
|
||||
const contentType = resp?.data?.contentType || createLogoFile.value.type || "application/octet-stream";
|
||||
if (!uploadUrl || !objectKey) {
|
||||
ElMessage.error("获取Logo上传签名失败");
|
||||
return;
|
||||
}
|
||||
await putToSignedUrl(uploadUrl, createLogoFile.value, contentType);
|
||||
form.value.logoUrl = objectKey;
|
||||
ElMessage.success("Logo已上传并回填");
|
||||
};
|
||||
|
||||
const uploadEditLogo = async () => {
|
||||
if (!editLogoFile.value) {
|
||||
ElMessage.warning("请先选择Logo文件");
|
||||
return;
|
||||
}
|
||||
const resp = await fetchEnterpriseLogoUploadSign({
|
||||
fileName: editLogoFile.value.name,
|
||||
contentType: editLogoFile.value.type,
|
||||
});
|
||||
const uploadUrl = resp?.data?.uploadUrl;
|
||||
const objectKey = resp?.data?.objectKey;
|
||||
const contentType = resp?.data?.contentType || editLogoFile.value.type || "application/octet-stream";
|
||||
if (!uploadUrl || !objectKey) {
|
||||
ElMessage.error("获取Logo上传签名失败");
|
||||
return;
|
||||
}
|
||||
await putToSignedUrl(uploadUrl, editLogoFile.value, contentType);
|
||||
editForm.value.logoUrl = objectKey;
|
||||
ElMessage.success("Logo已上传并回填");
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formRef.value) return;
|
||||
const valid = await formRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await createEnterprise({
|
||||
enterpriseName: form.value.enterpriseName.trim(),
|
||||
enterpriseUrl: form.value.enterpriseUrl.trim(),
|
||||
logoUrl: form.value.logoUrl.trim(),
|
||||
});
|
||||
ElMessage.success("企业创建成功");
|
||||
createVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
editId.value = row.id;
|
||||
editForm.value = { ...row };
|
||||
editLogoFile.value = null;
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editId.value) return;
|
||||
if (!editFormRef.value) return;
|
||||
const valid = await editFormRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await updateEnterprise(editId.value, {
|
||||
enterpriseName: String(editForm.value.enterpriseName || "").trim(),
|
||||
enterpriseUrl: String(editForm.value.enterpriseUrl || "").trim(),
|
||||
logoUrl: String(editForm.value.logoUrl || "").trim(),
|
||||
});
|
||||
ElMessage.success("企业更新成功");
|
||||
editVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleEnable = async (id: number) => {
|
||||
await enableEnterprise(id);
|
||||
ElMessage.success("企业已启用");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDisable = async (id: number) => {
|
||||
await disableEnterprise(id);
|
||||
ElMessage.success("企业已停用");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number, name: string) => {
|
||||
await ElMessageBox.confirm(`确认删除企业「${name}」?删除后不可恢复。`, "删除企业", {
|
||||
type: "warning",
|
||||
confirmButtonText: "确认删除",
|
||||
cancelButtonText: "取消",
|
||||
});
|
||||
await deleteEnterprise(id);
|
||||
ElMessage.success("企业已删除");
|
||||
await load();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (canRead.value) {
|
||||
await load();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,170 @@
|
||||
<template>
|
||||
<PageContainer title="导出任务中心">
|
||||
<QueryToolbar>
|
||||
<el-button v-if="canManage" type="primary" @click="openCreateDrawer">创建导出任务</el-button>
|
||||
<el-button v-if="canRead" @click="load">刷新</el-button>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-table :data="rows" class="mt-md" empty-text="暂无数据">
|
||||
<el-table-column prop="taskCode" label="任务编码" width="180" :formatter="taskCodeFormatter" />
|
||||
<el-table-column prop="bizType" label="业务类型" width="140" :formatter="bizTypeFormatter" />
|
||||
<el-table-column prop="bizId" label="业务ID" width="120" />
|
||||
<el-table-column prop="fileName" label="文件名" width="180" />
|
||||
<el-table-column prop="fileOssKey" label="文件OSS" />
|
||||
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column prop="retryCount" label="重试" width="80" />
|
||||
<el-table-column prop="downloadCount" label="下载次数" width="90" />
|
||||
<el-table-column prop="tokenExpireAt" label="令牌过期" width="170" />
|
||||
<el-table-column prop="errorMessage" label="错误信息" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="170" />
|
||||
<el-table-column prop="finishedAt" label="完成时间" width="170" />
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" :disabled="row.status !== 'SUCCESS'" @click="handleRefreshToken(row)">刷新令牌</el-button>
|
||||
<el-button v-if="canRead" size="small" :disabled="!tokens[row.id]" @click="handleDownload(row)">下载</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-drawer v-model="createVisible" title="创建导出任务" :size="DRAWER_SIZE.md" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="任务编码" prop="taskCode"><el-input v-model="form.taskCode" /></el-form-item>
|
||||
<el-form-item label="业务类型" prop="bizType"><el-input v-model="form.bizType" /></el-form-item>
|
||||
<el-form-item label="业务ID" prop="bizId"><el-input v-model="form.bizId" /></el-form-item>
|
||||
<el-form-item label="文件名" prop="fileName"><el-input v-model="form.fileName" /></el-form-item>
|
||||
<el-form-item label="筛选JSON" prop="filtersJson"><el-input v-model="form.filtersJson" type="textarea" :rows="4" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="handleCreate">创建</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { DRAWER_SIZE, LABEL_WIDTH } from "../../constants/ui";
|
||||
import { ElMessage } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import { createExportTask, downloadExportTask, fetchExportTasks, refreshExportTaskToken } from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const TASK_CODE_TEXT_MAP: Record<string, string> = {
|
||||
EXPORT_LEDGER: "台账导出",
|
||||
PROJECT_EXPORT: "项目导出",
|
||||
USER_EXPORT: "用户导出",
|
||||
MEETING_EXPORT: "会议导出",
|
||||
NOTIFICATION_TASK_EXPORT: "通知任务导出",
|
||||
MEETING_MATERIAL_EXPORT: "会议资料包导出",
|
||||
MEETING_SUMMARY_GENERATE: "会议总结生成",
|
||||
};
|
||||
|
||||
const BIZ_TYPE_TEXT_MAP: Record<string, string> = {
|
||||
FINANCE: "财务",
|
||||
PROJECT: "项目",
|
||||
USER: "用户",
|
||||
MEETING: "会议",
|
||||
MEETING_MATERIAL: "会议资料包",
|
||||
MEETING_SUMMARY: "会议总结",
|
||||
NOTIFICATION_TASK: "通知任务",
|
||||
};
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.exportTask.read));
|
||||
const canManage = computed(() => authStore.hasPermission(PERMS.exportTask.manage));
|
||||
const rows = ref<any[]>([]);
|
||||
const tokens = ref<Record<number, string>>({});
|
||||
const createVisible = ref(false);
|
||||
const form = ref({
|
||||
taskCode: "EXPORT_LEDGER",
|
||||
bizType: "FINANCE",
|
||||
bizId: "1001",
|
||||
fileName: "ledger-export.csv",
|
||||
filtersJson: "{\"projectId\":1001}",
|
||||
});
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const formRules = ref<FormRules>({
|
||||
taskCode: [{ required: true, message: "请输入任务编码", trigger: "blur" }],
|
||||
bizType: [{ required: true, message: "请输入业务类型", trigger: "blur" }],
|
||||
fileName: [{ required: true, message: "请输入文件名", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
const taskCodeFormatter = (_row: any, _column: any, value: unknown) => TASK_CODE_TEXT_MAP[String(value || "").trim()] || String(value || "-");
|
||||
const bizTypeFormatter = (_row: any, _column: any, value: unknown) => BIZ_TYPE_TEXT_MAP[String(value || "").trim()] || String(value || "-");
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
form.value = {
|
||||
taskCode: "EXPORT_LEDGER",
|
||||
bizType: "FINANCE",
|
||||
bizId: "1001",
|
||||
fileName: "ledger-export.csv",
|
||||
filtersJson: "{\"projectId\":1001}",
|
||||
};
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const resp = await fetchExportTasks();
|
||||
rows.value = resp?.data?.list || [];
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formRef.value) return;
|
||||
const valid = await formRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await createExportTask({
|
||||
idempotencyKey: `export-${Date.now()}`,
|
||||
taskCode: form.value.taskCode,
|
||||
bizType: form.value.bizType,
|
||||
bizId: form.value.bizId,
|
||||
fileName: form.value.fileName,
|
||||
filtersJson: form.value.filtersJson,
|
||||
});
|
||||
ElMessage.success("导出任务创建成功");
|
||||
createVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleRefreshToken = async (row: any) => {
|
||||
const resp = await refreshExportTaskToken(row.id);
|
||||
const token = resp?.data?.downloadToken || "";
|
||||
if (!token) {
|
||||
ElMessage.error("令牌刷新失败");
|
||||
return;
|
||||
}
|
||||
tokens.value[row.id] = token;
|
||||
ElMessage.success("下载令牌已刷新");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDownload = async (row: any) => {
|
||||
const token = tokens.value[row.id];
|
||||
if (!token) {
|
||||
ElMessage.warning("请先刷新下载令牌");
|
||||
return;
|
||||
}
|
||||
const resp = await downloadExportTask(row.id, { token });
|
||||
const signedUrl = String(resp?.data?.signedUrl || "").trim();
|
||||
if (!signedUrl) {
|
||||
ElMessage.error("下载地址生成失败");
|
||||
return;
|
||||
}
|
||||
window.open(signedUrl, "_blank", "noopener,noreferrer");
|
||||
ElMessage.success("下载地址已生成,已尝试打开新窗口");
|
||||
await load();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (canRead.value) {
|
||||
await load();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<PageContainer title="财务管理">
|
||||
<QueryToolbar>
|
||||
<el-input-number v-model="form.projectId" :min="1" placeholder="项目ID" />
|
||||
<el-input-number v-model="form.meetingId" :min="1" placeholder="会议ID" />
|
||||
<el-input-number v-model="form.amountCent" :min="1" placeholder="金额(分)" />
|
||||
<el-input v-model="form.paymentVoucherOssKey" placeholder="凭证OSS Key" class="w-input-lg" />
|
||||
<el-input-number v-model="form.expectedAmountCent" :min="0" placeholder="对账应收(分)" />
|
||||
<el-input v-model="form.lockReason" placeholder="锁账原因" class="w-input-md" />
|
||||
<el-button v-if="canConfirm" type="primary" @click="handleConfirm">支付确认</el-button>
|
||||
<el-button @click="load">刷新</el-button>
|
||||
<el-button v-if="canExportLedger" @click="handleExportLedger">导出台账</el-button>
|
||||
<el-button v-if="canReconcile" @click="handleReconcile">发起对账</el-button>
|
||||
<el-button v-if="canLock" @click="handleLock">锁账</el-button>
|
||||
<el-button v-if="canUnlock" @click="handleUnlock">解锁</el-button>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-table :data="rows" class="mt-md" empty-text="暂无数据">
|
||||
<el-table-column prop="projectId" label="项目名称" min-width="160" :formatter="projectNameFormatter" />
|
||||
<el-table-column prop="meetingId" label="会议名称" min-width="160" :formatter="meetingNameFormatter" />
|
||||
<el-table-column prop="amountCent" label="支付金额(分)" />
|
||||
<el-table-column prop="status" label="支付状态" width="140" :formatter="statusFormatter" />
|
||||
</el-table>
|
||||
<el-divider />
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<SectionTitle>对账记录</SectionTitle>
|
||||
<el-table :data="reconciliationRows" empty-text="暂无数据">
|
||||
<el-table-column prop="projectId" label="项目名称" min-width="140" :formatter="projectNameFormatter" />
|
||||
<el-table-column prop="expectedAmountCent" label="应收(分)" />
|
||||
<el-table-column prop="actualAmountCent" label="实收(分)" />
|
||||
<el-table-column prop="diffAmountCent" label="差额(分)" />
|
||||
<el-table-column prop="resultStatus" label="结果" width="90" :formatter="statusFormatter" />
|
||||
</el-table>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<SectionTitle>锁账记录</SectionTitle>
|
||||
<el-table :data="lockLogRows" empty-text="暂无数据">
|
||||
<el-table-column prop="projectId" label="项目名称" min-width="140" :formatter="projectNameFormatter" />
|
||||
<el-table-column prop="lockStatus" label="状态" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column prop="reason" label="原因" />
|
||||
<el-table-column prop="createdAt" label="时间" width="170" />
|
||||
</el-table>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import SectionTitle from "../../components/SectionTitle.vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import {
|
||||
confirmPayment,
|
||||
exportFinanceLedger,
|
||||
fetchFinanceProjects,
|
||||
fetchFinanceReconciliationList,
|
||||
lockFinance,
|
||||
reconcileFinance,
|
||||
unlockFinance,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const rows = ref<any[]>([]);
|
||||
const canConfirm = computed(() => authStore.hasPermission(PERMS.finance.paymentConfirm));
|
||||
const canExportLedger = computed(() => authStore.hasPermission(PERMS.finance.ledgerExport));
|
||||
const canReconcile = computed(() => authStore.hasPermission(PERMS.finance.reconciliation));
|
||||
const canLock = computed(() => authStore.hasPermission(PERMS.finance.lock));
|
||||
const canUnlock = computed(() => authStore.hasPermission(PERMS.finance.unlock));
|
||||
const reconciliationRows = ref<any[]>([]);
|
||||
const lockLogRows = ref<any[]>([]);
|
||||
const form = ref({
|
||||
projectId: 1001,
|
||||
meetingId: 2001,
|
||||
amountCent: 100000,
|
||||
paymentVoucherOssKey: "1/1001/2001/finance/202603/voucher.pdf",
|
||||
expectedAmountCent: 100000,
|
||||
lockReason: "月结锁账",
|
||||
});
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
const projectNameFormatter = (row: any) => row?.projectName || row?.project || `项目#${row?.projectId || "-"}`;
|
||||
const meetingNameFormatter = (row: any) => row?.meetingTopic || row?.meetingName || `会议#${row?.meetingId || "-"}`;
|
||||
|
||||
const load = async () => {
|
||||
const resp = await fetchFinanceProjects();
|
||||
rows.value = resp?.data?.list || [];
|
||||
if (canReconcile.value || canLock.value || canUnlock.value) {
|
||||
const recResp = await fetchFinanceReconciliationList({ projectId: form.value.projectId });
|
||||
reconciliationRows.value = recResp?.data?.reconciliation || [];
|
||||
lockLogRows.value = recResp?.data?.lockLogs || [];
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await confirmPayment({
|
||||
...form.value,
|
||||
idempotencyKey: `pay-${Date.now()}-${form.value.meetingId}`,
|
||||
});
|
||||
ElMessage.success("支付确认成功");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleExportLedger = async () => {
|
||||
const resp = await exportFinanceLedger();
|
||||
ElMessage.success(`导出成功,共 ${resp?.data?.total || 0} 条`);
|
||||
};
|
||||
|
||||
const handleReconcile = async () => {
|
||||
await reconcileFinance({
|
||||
idempotencyKey: `recon-${Date.now()}-${form.value.projectId}`,
|
||||
projectId: form.value.projectId,
|
||||
expectedAmountCent: form.value.expectedAmountCent,
|
||||
});
|
||||
ElMessage.success("对账完成");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleLock = async () => {
|
||||
await lockFinance({
|
||||
idempotencyKey: `lock-${Date.now()}-${form.value.projectId}`,
|
||||
projectId: form.value.projectId,
|
||||
reason: form.value.lockReason || "锁账",
|
||||
});
|
||||
ElMessage.success("锁账成功");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleUnlock = async () => {
|
||||
await unlockFinance({
|
||||
idempotencyKey: `unlock-${Date.now()}-${form.value.projectId}`,
|
||||
projectId: form.value.projectId,
|
||||
reason: form.value.lockReason || "解锁",
|
||||
});
|
||||
ElMessage.success("解锁成功");
|
||||
await load();
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<PageContainer title="站内通知中心">
|
||||
<template #header>
|
||||
<div class="header-row">
|
||||
<el-space>
|
||||
<el-tag type="info">总数 {{ rows.length }}</el-tag>
|
||||
<el-tag type="danger">未读 {{ unreadCount }}</el-tag>
|
||||
<el-switch
|
||||
v-if="canRead"
|
||||
v-model="onlyUnread"
|
||||
inline-prompt
|
||||
active-text="仅看未读"
|
||||
inactive-text="全部"
|
||||
/>
|
||||
<el-button
|
||||
v-if="canRead && canMarkRead"
|
||||
type="primary"
|
||||
plain
|
||||
:disabled="unreadCount <= 0"
|
||||
@click="handleMarkAllRead"
|
||||
>
|
||||
全部标记已读
|
||||
</el-button>
|
||||
<el-button v-if="canRead" @click="load">刷新</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-if="!canRead"
|
||||
title="当前账号无查看站内通知权限"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-md"
|
||||
/>
|
||||
|
||||
<el-table v-else :data="displayRows" class="mt-md" empty-text="暂无站内通知">
|
||||
<el-table-column prop="title" label="标题" min-width="220" />
|
||||
<el-table-column prop="content" label="内容" min-width="360" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
||||
<el-table-column prop="readAt" label="已读时间" width="180" />
|
||||
<el-table-column label="操作" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="canMarkRead && row.status !== 'READ'"
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="handleMarkRead(row.id)"
|
||||
>
|
||||
标记已读
|
||||
</el-button>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { useNotificationStore } from "../../stores/notification";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const notificationStore = useNotificationStore();
|
||||
const { notifRows: rows } = storeToRefs(notificationStore);
|
||||
|
||||
const canRead = computed(() => authStore.scope === "TENANT" && authStore.hasPermission(PERMS.notification.inAppRead));
|
||||
const canMarkRead = computed(
|
||||
() => authStore.scope === "TENANT" && authStore.hasPermission(PERMS.notification.inAppMarkRead),
|
||||
);
|
||||
const onlyUnread = ref(false);
|
||||
const unreadCount = computed(() => rows.value.filter((item) => String(item?.status || "") === "UNREAD").length);
|
||||
const displayRows = computed(() =>
|
||||
onlyUnread.value ? rows.value.filter((item) => String(item?.status || "") === "UNREAD") : rows.value,
|
||||
);
|
||||
|
||||
const statusFormatter = (_row: unknown, _column: unknown, value: unknown) => toZhStatus(value);
|
||||
|
||||
const load = async () => {
|
||||
if (!canRead.value) {
|
||||
notificationStore.reset();
|
||||
return;
|
||||
}
|
||||
await notificationStore.loadRows();
|
||||
};
|
||||
|
||||
const handleMarkRead = async (id: number) => {
|
||||
await notificationStore.markRead(id);
|
||||
ElMessage.success("已标记为已读");
|
||||
};
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
const affected = await notificationStore.markAllRead();
|
||||
ElMessage.success(affected > 0 ? `已标记 ${affected} 条通知为已读` : "没有未读通知");
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<PageContainer title="发票管理">
|
||||
<QueryToolbar>
|
||||
<el-button v-if="canManage" type="primary" @click="openCreateDrawer">新增抬头</el-button>
|
||||
<el-button v-if="canRead" @click="load">刷新</el-button>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-table :data="rows">
|
||||
<el-table-column prop="companyName" label="企业名称" width="160" />
|
||||
<el-table-column prop="taxNo" label="税号" width="170" />
|
||||
<el-table-column prop="bankName" label="开户行" width="150" />
|
||||
<el-table-column prop="accountNo" label="账号" width="170" />
|
||||
<el-table-column prop="address" label="地址" />
|
||||
<el-table-column prop="phone" label="电话" width="130" />
|
||||
<el-table-column prop="defaultProjectId" label="默认项目" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column label="操作" width="240">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button v-if="canManage && row.status !== 'ENABLED'" size="small" type="success" @click="handleEnable(row.id)">启用</el-button>
|
||||
<el-button v-else-if="canManage" size="small" type="warning" @click="handleDisable(row.id)">停用</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-drawer v-model="createVisible" title="新增发票抬头" :size="DRAWER_SIZE.md" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="企业名称" prop="companyName"><el-input v-model="form.companyName" /></el-form-item>
|
||||
<el-form-item label="税号" prop="taxNo"><el-input v-model="form.taxNo" /></el-form-item>
|
||||
<el-form-item label="开户行" prop="bankName"><el-input v-model="form.bankName" /></el-form-item>
|
||||
<el-form-item label="账号" prop="accountNo"><el-input v-model="form.accountNo" /></el-form-item>
|
||||
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
|
||||
<el-form-item label="电话"><el-input v-model="form.phone" /></el-form-item>
|
||||
<el-form-item label="默认项目"><el-input-number v-model="form.defaultProjectId" :min="1" /></el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status">
|
||||
<el-option label="启用" value="ENABLED" />
|
||||
<el-option label="停用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="handleCreate">创建</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑发票抬头" :width="DIALOG_WIDTH.md">
|
||||
<el-form ref="editFormRef" :model="editForm" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="企业名称" prop="companyName"><el-input v-model="editForm.companyName" /></el-form-item>
|
||||
<el-form-item label="税号" prop="taxNo"><el-input v-model="editForm.taxNo" /></el-form-item>
|
||||
<el-form-item label="开户行" prop="bankName"><el-input v-model="editForm.bankName" /></el-form-item>
|
||||
<el-form-item label="账号" prop="accountNo"><el-input v-model="editForm.accountNo" /></el-form-item>
|
||||
<el-form-item label="地址"><el-input v-model="editForm.address" /></el-form-item>
|
||||
<el-form-item label="电话"><el-input v-model="editForm.phone" /></el-form-item>
|
||||
<el-form-item label="默认项目"><el-input-number v-model="editForm.defaultProjectId" :min="1" /></el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="editForm.status">
|
||||
<el-option label="启用" value="ENABLED" />
|
||||
<el-option label="停用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
import { ElMessage } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import {
|
||||
createInvoiceProfile,
|
||||
disableInvoiceProfile,
|
||||
enableInvoiceProfile,
|
||||
fetchInvoiceProfiles,
|
||||
updateInvoiceProfile,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.invoiceProfile.read));
|
||||
const canManage = computed(() => authStore.hasPermission(PERMS.invoiceProfile.manage));
|
||||
const rows = ref<any[]>([]);
|
||||
const createVisible = ref(false);
|
||||
const form = ref({
|
||||
companyName: "",
|
||||
taxNo: "",
|
||||
bankName: "",
|
||||
accountNo: "",
|
||||
address: "",
|
||||
phone: "",
|
||||
defaultProjectId: 1001,
|
||||
status: "ENABLED" as "ENABLED" | "DISABLED",
|
||||
});
|
||||
const editVisible = ref(false);
|
||||
const editId = ref<number | null>(null);
|
||||
const editForm = ref<any>({});
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const editFormRef = ref<FormInstance>();
|
||||
|
||||
const formRules = ref<FormRules>({
|
||||
companyName: [{ required: true, message: "请输入企业名称", trigger: "blur" }],
|
||||
taxNo: [{ required: true, message: "请输入税号", trigger: "blur" }],
|
||||
bankName: [{ required: true, message: "请输入开户行", trigger: "blur" }],
|
||||
accountNo: [{ required: true, message: "请输入账号", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
form.value = {
|
||||
companyName: "",
|
||||
taxNo: "",
|
||||
bankName: "",
|
||||
accountNo: "",
|
||||
address: "",
|
||||
phone: "",
|
||||
defaultProjectId: 1001,
|
||||
status: "ENABLED",
|
||||
};
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const resp = await fetchInvoiceProfiles();
|
||||
rows.value = resp?.data?.list || [];
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formRef.value) return;
|
||||
const valid = await formRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await createInvoiceProfile(form.value);
|
||||
ElMessage.success("新增成功");
|
||||
createVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
editId.value = row.id;
|
||||
editForm.value = { ...row };
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editId.value) return;
|
||||
|
||||
if (!editFormRef.value) return;
|
||||
const valid = await editFormRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await updateInvoiceProfile(editId.value, editForm.value);
|
||||
ElMessage.success("更新成功");
|
||||
editVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleEnable = async (id: number) => {
|
||||
await enableInvoiceProfile(id);
|
||||
ElMessage.success("抬头已启用");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDisable = async (id: number) => {
|
||||
await disableInvoiceProfile(id);
|
||||
ElMessage.success("抬头已停用");
|
||||
await load();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (canRead.value) {
|
||||
await load();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<PageContainer title="菜单与权限">
|
||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<el-tab-pane label="菜单管理" name="menus">
|
||||
<QueryToolbar>
|
||||
<el-button v-if="canManage" type="primary" @click="openCreateDrawer">新增菜单</el-button>
|
||||
<el-button v-if="canManage" :disabled="rows.length === 0" @click="handleSaveOrder">保存排序</el-button>
|
||||
<el-button v-if="canRead" @click="load">刷新</el-button>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-table :data="rows" class="mt-md" empty-text="暂无数据">
|
||||
<el-table-column prop="menuCode" label="菜单编码" width="160" />
|
||||
<el-table-column prop="menuName" label="菜单名称" width="160" />
|
||||
<el-table-column prop="routePath" label="路由地址" />
|
||||
<el-table-column prop="permissionCode" label="权限码" width="180" />
|
||||
<el-table-column label="排序" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-if="canManage" v-model="row.sortNo" :min="1" />
|
||||
<span v-else>{{ row.sortNo }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column label="操作" width="280">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" :disabled="!canMoveUp(row)" @click="moveUp(row)">上移</el-button>
|
||||
<el-button v-if="canManage" size="small" :disabled="!canMoveDown(row)" @click="moveDown(row)">下移</el-button>
|
||||
<el-button
|
||||
v-if="canManage"
|
||||
size="small"
|
||||
:type="row.status === 'ENABLED' ? 'warning' : 'success'"
|
||||
@click="toggleStatus(row)"
|
||||
>
|
||||
{{ row.status === "ENABLED" ? "停用" : "启用" }}
|
||||
</el-button>
|
||||
<el-button v-if="canManage" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="权限管理" name="permissions">
|
||||
<el-button v-if="canReadPermissions" type="primary" @click="loadPermissions">刷新</el-button>
|
||||
<el-table :data="permissions" class="mt-md" empty-text="暂无数据">
|
||||
<el-table-column prop="permissionCode" label="权限码" min-width="260" />
|
||||
<el-table-column prop="permissionName" label="权限名称" min-width="220" />
|
||||
<el-table-column prop="module" label="模块" width="160" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-drawer v-model="createVisible" title="新增菜单" :size="DRAWER_SIZE.md" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="菜单编码" prop="menuCode">
|
||||
<el-input v-model="form.menuCode" />
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单名称" prop="menuName">
|
||||
<el-input v-model="form.menuName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="路由" prop="routePath">
|
||||
<el-input v-model="form.routePath" placeholder="/projects" />
|
||||
</el-form-item>
|
||||
<el-form-item label="权限码" prop="permissionCode">
|
||||
<el-input v-model="form.permissionCode" placeholder="如 project.create" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sortNo" :min="1" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="handleCreate">创建</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑菜单" :width="DIALOG_WIDTH.md">
|
||||
<el-form ref="editFormRef" :model="editForm" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="菜单名称" prop="menuName">
|
||||
<el-input v-model="editForm.menuName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="路由地址" prop="routePath">
|
||||
<el-input v-model="editForm.routePath" />
|
||||
</el-form-item>
|
||||
<el-form-item label="权限码" prop="permissionCode">
|
||||
<el-input v-model="editForm.permissionCode" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="editForm.sortNo" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="editForm.status">
|
||||
<el-option label="启用" value="ENABLED" />
|
||||
<el-option label="停用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { createMenu, fetchMenuRoles, fetchMenus, fetchPermissions, reorderMenus, updateMenu } from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.role.read));
|
||||
const canReadPermissions = computed(() => authStore.hasPermission(PERMS.permission.read));
|
||||
const canManage = computed(() => authStore.hasPermission(PERMS.role.permissionBind));
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const activeTab = ref(String(route.query.tab || "menus") === "permissions" ? "permissions" : "menus");
|
||||
const rows = ref<any[]>([]);
|
||||
const permissions = ref<any[]>([]);
|
||||
const createVisible = ref(false);
|
||||
const editVisible = ref(false);
|
||||
const editId = ref<number | null>(null);
|
||||
const editForm = ref<any>({});
|
||||
const form = ref({
|
||||
menuCode: "",
|
||||
menuName: "",
|
||||
routePath: "",
|
||||
permissionCode: "",
|
||||
sortNo: 100,
|
||||
});
|
||||
const formRef = ref<FormInstance>();
|
||||
const editFormRef = ref<FormInstance>();
|
||||
const formRules = ref<FormRules>({
|
||||
menuCode: [{ required: true, message: "请输入菜单编码", trigger: "blur" }],
|
||||
menuName: [{ required: true, message: "请输入菜单名称", trigger: "blur" }],
|
||||
routePath: [{ required: true, message: "请输入路由地址", trigger: "blur" }],
|
||||
});
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
form.value = {
|
||||
menuCode: "",
|
||||
menuName: "",
|
||||
routePath: "",
|
||||
permissionCode: "",
|
||||
sortNo: 100,
|
||||
};
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const resp = await fetchMenus();
|
||||
rows.value = resp?.data?.list || [];
|
||||
};
|
||||
|
||||
const loadPermissions = async () => {
|
||||
const resp = await fetchPermissions();
|
||||
permissions.value = resp?.data?.list || [];
|
||||
};
|
||||
|
||||
const onTabChange = async (name: string | number) => {
|
||||
const tab = String(name);
|
||||
await router.replace({ path: "/menus", query: tab === "permissions" ? { tab: "permissions" } : {} });
|
||||
if (tab === "permissions") {
|
||||
await loadPermissions();
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
};
|
||||
|
||||
const canMoveUp = (row: any) => {
|
||||
const idx = rows.value.findIndex((x) => x.id === row.id);
|
||||
return idx > 0;
|
||||
};
|
||||
|
||||
const canMoveDown = (row: any) => {
|
||||
const idx = rows.value.findIndex((x) => x.id === row.id);
|
||||
return idx >= 0 && idx < rows.value.length - 1;
|
||||
};
|
||||
|
||||
const saveMenu = async (row: any, patch: any) => {
|
||||
await updateMenu(row.id, {
|
||||
menuName: String(patch.menuName ?? row.menuName).trim(),
|
||||
routePath: String(patch.routePath ?? row.routePath).trim(),
|
||||
permissionCode: String(patch.permissionCode ?? row.permissionCode).trim(),
|
||||
sortNo: Number(patch.sortNo ?? row.sortNo),
|
||||
status: String(patch.status ?? row.status) as "ENABLED" | "DISABLED",
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formRef.value) return;
|
||||
const valid = await formRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await createMenu({
|
||||
menuCode: form.value.menuCode.trim(),
|
||||
menuName: form.value.menuName.trim(),
|
||||
routePath: form.value.routePath.trim(),
|
||||
permissionCode: form.value.permissionCode.trim(),
|
||||
sortNo: form.value.sortNo,
|
||||
});
|
||||
ElMessage.success("菜单创建成功");
|
||||
createVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
editId.value = row.id;
|
||||
editForm.value = { ...row };
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editId.value) return;
|
||||
|
||||
if (!editFormRef.value) return;
|
||||
const valid = await editFormRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await saveMenu(editForm.value, { ...editForm.value, id: editId.value });
|
||||
ElMessage.success("菜单更新成功");
|
||||
editVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const moveUp = async (row: any) => {
|
||||
const idx = rows.value.findIndex((x) => x.id === row.id);
|
||||
if (idx <= 0) {
|
||||
return;
|
||||
}
|
||||
const prev = rows.value[idx - 1];
|
||||
await Promise.all([saveMenu(row, { sortNo: prev.sortNo }), saveMenu(prev, { sortNo: row.sortNo })]);
|
||||
ElMessage.success("菜单排序已更新");
|
||||
await load();
|
||||
};
|
||||
|
||||
const moveDown = async (row: any) => {
|
||||
const idx = rows.value.findIndex((x) => x.id === row.id);
|
||||
if (idx < 0 || idx >= rows.value.length - 1) {
|
||||
return;
|
||||
}
|
||||
const next = rows.value[idx + 1];
|
||||
await Promise.all([saveMenu(row, { sortNo: next.sortNo }), saveMenu(next, { sortNo: row.sortNo })]);
|
||||
ElMessage.success("菜单排序已更新");
|
||||
await load();
|
||||
};
|
||||
|
||||
const toggleStatus = async (row: any) => {
|
||||
const nextStatus = row.status === "ENABLED" ? "DISABLED" : "ENABLED";
|
||||
if (nextStatus === "DISABLED") {
|
||||
const roleResp = await fetchMenuRoles(row.id);
|
||||
const roleNames: string[] = roleResp?.data || [];
|
||||
const tip = roleNames.length > 0 ? `该菜单已绑定角色:${roleNames.join("、")}。停用后这些角色将看不到该菜单,确认继续?` : "确认停用该菜单?";
|
||||
await ElMessageBox.confirm(tip, "停用影响提示", {
|
||||
confirmButtonText: "确认停用",
|
||||
cancelButtonText: "取消",
|
||||
type: "warning",
|
||||
});
|
||||
}
|
||||
await saveMenu(row, { status: nextStatus });
|
||||
ElMessage.success(nextStatus === "ENABLED" ? "菜单已启用" : "菜单已停用");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleSaveOrder = async () => {
|
||||
await reorderMenus({
|
||||
menus: rows.value.map((x) => ({
|
||||
id: Number(x.id),
|
||||
sortNo: Number(x.sortNo),
|
||||
})),
|
||||
});
|
||||
ElMessage.success("菜单排序已批量保存");
|
||||
await load();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (activeTab.value === "permissions") {
|
||||
if (canReadPermissions.value) {
|
||||
await loadPermissions();
|
||||
}
|
||||
} else if (canRead.value) {
|
||||
await load();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<div class="not-found-page">
|
||||
<div class="nf-content">
|
||||
<div class="nf-code">404</div>
|
||||
<h1 class="nf-title">页面未找到</h1>
|
||||
<p class="nf-desc">抱歉,您访问的页面不存在或已被移除。</p>
|
||||
<div class="nf-actions">
|
||||
<el-button type="primary" size="large" @click="goHome">
|
||||
返回首页
|
||||
</el-button>
|
||||
<el-button size="large" @click="goBack">
|
||||
返回上一页
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from "vue-router";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const goHome = () => {
|
||||
if (authStore.scope === "PLATFORM") {
|
||||
router.push("/platform/tenants");
|
||||
} else {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (window.history.length > 1) {
|
||||
router.back();
|
||||
} else {
|
||||
goHome();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.not-found-page {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 60px);
|
||||
background-color: var(--el-bg-color-page);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.nf-content {
|
||||
text-align: center;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.nf-code {
|
||||
font-size: 120px;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
background: linear-gradient(135deg, #6366f1, #818cf8, #a5b4fc);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: -4px;
|
||||
}
|
||||
|
||||
.nf-title {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.nf-desc {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin: 0 0 32px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.nf-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,774 @@
|
||||
<template>
|
||||
<PageContainer title="通知策略中心">
|
||||
<QueryToolbar>
|
||||
<el-button v-if="canManage" type="primary" @click="openCreateDrawer">新增策略</el-button>
|
||||
<el-button v-if="canDispatch" type="warning" @click="openDispatchDialog">触发发送</el-button>
|
||||
<el-button v-if="canRead" @click="load">刷新</el-button>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-table :data="rows" class="mt-md" empty-text="暂无数据">
|
||||
<el-table-column prop="policyName" label="策略名称" width="160" />
|
||||
<el-table-column prop="eventCode" label="事件" width="150" :formatter="statusFormatter" />
|
||||
<el-table-column prop="channel" label="渠道" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column prop="receiverType" label="对象" width="120" :formatter="statusFormatter" />
|
||||
<el-table-column prop="templateId" label="文案模板" min-width="160" :formatter="templateNameFormatter" />
|
||||
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column label="操作" width="410">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button v-if="canManage" size="small" @click="handleBindEvent(row)">绑定事件</el-button>
|
||||
<el-button v-if="canDispatch" size="small" type="primary" plain @click="openDispatchDialog(row)">触发本策略</el-button>
|
||||
<el-button v-if="canManage && row.status !== 'ENABLED'" size="small" type="success" @click="handleEnable(row.id)">启用</el-button>
|
||||
<el-button v-else-if="canManage" size="small" type="warning" @click="handleDisable(row.id)">停用</el-button>
|
||||
<el-button
|
||||
v-if="canManage && row.status === 'DISABLED'"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDeletePolicy(row.id, row.policyName)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="mt-md"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="policyTotal"
|
||||
v-model:current-page="policyPageNo"
|
||||
v-model:page-size="policyPageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@size-change="load"
|
||||
@current-change="load"
|
||||
/>
|
||||
|
||||
<SectionTitle>通知发送任务</SectionTitle>
|
||||
<el-table :data="taskRows" empty-text="暂无数据">
|
||||
<el-table-column prop="eventCode" label="事件" width="140" :formatter="statusFormatter" />
|
||||
<el-table-column prop="channel" label="渠道" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column prop="receiverType" label="对象" width="110" :formatter="statusFormatter" />
|
||||
<el-table-column prop="receiverRef" label="接收标识" width="140" />
|
||||
<el-table-column prop="receiverResolveSource" label="接收人来源" width="130" :formatter="receiverResolveSourceFormatter" />
|
||||
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column prop="retryCount" label="重试" width="80" />
|
||||
<el-table-column prop="providerMessageId" label="消息ID" width="180" />
|
||||
<el-table-column prop="receiptCode" label="回执码" width="100" />
|
||||
<el-table-column prop="receiptMessage" label="回执信息" width="140" />
|
||||
<el-table-column prop="errorMessage" label="错误信息" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="170" />
|
||||
<el-table-column prop="sentAt" label="发送时间" width="170" />
|
||||
<el-table-column prop="receiptAt" label="回执时间" width="170" />
|
||||
<el-table-column label="回执" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canDispatch" size="small" :disabled="!row.providerMessageId" @click="handleMockReceipt(row)">回执成功</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex-end mt-md">
|
||||
<el-pagination
|
||||
:current-page="taskQuery.pageNo"
|
||||
:page-size="taskQuery.pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="taskTotal"
|
||||
@current-change="handleTaskPageChange"
|
||||
@size-change="handleTaskPageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-drawer v-model="createVisible" title="新增通知策略" :size="DRAWER_SIZE.md" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="策略名称" prop="policyName"><el-input v-model="form.policyName" /></el-form-item>
|
||||
<el-form-item label="事件" prop="eventCode">
|
||||
<el-select v-model="form.eventCode">
|
||||
<el-option label="审核通过" value="AUDIT_APPROVED" />
|
||||
<el-option label="审核拒绝" value="AUDIT_REJECTED" />
|
||||
<el-option label="财务已确认" value="FINANCE_CONFIRMED" />
|
||||
<el-option label="用户创建" value="USER_CREATED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="渠道" prop="channel">
|
||||
<el-select v-model="form.channel">
|
||||
<el-option label="站内" value="IN_APP" />
|
||||
<el-option label="短信" value="SMS" />
|
||||
<el-option label="邮件" value="EMAIL" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="对象" prop="receiverType">
|
||||
<el-select v-model="form.receiverType">
|
||||
<el-option label="提交人" value="SUBMITTER" />
|
||||
<el-option label="审核人" value="AUDITOR" />
|
||||
<el-option label="财务角色" value="FINANCE_ROLE" />
|
||||
<el-option label="目标用户" value="TARGET_USER" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="文案模板" prop="templateId">
|
||||
<el-select v-model="form.templateId" filterable placeholder="请选择模板" class="w-full">
|
||||
<el-option v-for="item in templateOptions" :key="item.id" :label="item.templateName" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="邮件主题"><el-input v-model="createComposer.subject" placeholder="例如:审核通知-${meetingTopic}" /></el-form-item>
|
||||
<el-form-item label="站内标题"><el-input v-model="createComposer.title" placeholder="例如:审核结果通知" /></el-form-item>
|
||||
<el-form-item label="正文模板">
|
||||
<el-input
|
||||
v-model="createComposer.content"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="支持占位符,如:会议《${meetingTopic}》状态:${result}"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="变量键值">
|
||||
<div class="w-full">
|
||||
<el-space
|
||||
v-for="(item, idx) in createComposer.variables"
|
||||
:key="`create-var-${idx}`"
|
||||
class="flex mb-sm"
|
||||
>
|
||||
<el-input v-model="item.key" placeholder="变量名,如 meetingTopic" class="w-input-md" />
|
||||
<el-input v-model="item.value" placeholder="变量值,如 学术会议A" class="w-input-lg" />
|
||||
<el-button type="danger" plain @click="removeVariable(createComposer, idx)">删除</el-button>
|
||||
</el-space>
|
||||
<el-button plain @click="addVariable(createComposer)">新增变量</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="预览">
|
||||
<div class="preview-box">
|
||||
<div><b>主题:</b>{{ buildPreview(createComposer).subject || "-" }}</div>
|
||||
<div><b>标题:</b>{{ buildPreview(createComposer).title || "-" }}</div>
|
||||
<div><b>正文:</b>{{ buildPreview(createComposer).content || "-" }}</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="变量JSON">
|
||||
<el-input :model-value="buildVariablesJson(createComposer)" type="textarea" :rows="4" readonly />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="handleCreate">创建</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑通知策略" :width="DIALOG_WIDTH.md">
|
||||
<el-form ref="editFormRef" :model="editForm" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="策略名称" prop="policyName"><el-input v-model="editForm.policyName" /></el-form-item>
|
||||
<el-form-item label="事件" prop="eventCode">
|
||||
<el-select v-model="editForm.eventCode">
|
||||
<el-option label="审核通过" value="AUDIT_APPROVED" />
|
||||
<el-option label="审核拒绝" value="AUDIT_REJECTED" />
|
||||
<el-option label="财务已确认" value="FINANCE_CONFIRMED" />
|
||||
<el-option label="用户创建" value="USER_CREATED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="渠道" prop="channel">
|
||||
<el-select v-model="editForm.channel">
|
||||
<el-option label="站内" value="IN_APP" />
|
||||
<el-option label="短信" value="SMS" />
|
||||
<el-option label="邮件" value="EMAIL" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="对象" prop="receiverType">
|
||||
<el-select v-model="editForm.receiverType">
|
||||
<el-option label="提交人" value="SUBMITTER" />
|
||||
<el-option label="审核人" value="AUDITOR" />
|
||||
<el-option label="财务角色" value="FINANCE_ROLE" />
|
||||
<el-option label="目标用户" value="TARGET_USER" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="文案模板" prop="templateId">
|
||||
<el-select v-model="editForm.templateId" filterable placeholder="请选择模板" class="w-full">
|
||||
<el-option v-for="item in templateOptions" :key="item.id" :label="item.templateName" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="邮件主题"><el-input v-model="editComposer.subject" placeholder="例如:审核通知-${meetingTopic}" /></el-form-item>
|
||||
<el-form-item label="站内标题"><el-input v-model="editComposer.title" placeholder="例如:审核结果通知" /></el-form-item>
|
||||
<el-form-item label="正文模板">
|
||||
<el-input
|
||||
v-model="editComposer.content"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="支持占位符,如:会议《${meetingTopic}》状态:${result}"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="变量键值">
|
||||
<div class="w-full">
|
||||
<el-space
|
||||
v-for="(item, idx) in editComposer.variables"
|
||||
:key="`edit-var-${idx}`"
|
||||
class="flex mb-sm"
|
||||
>
|
||||
<el-input v-model="item.key" placeholder="变量名,如 meetingTopic" class="w-input-md" />
|
||||
<el-input v-model="item.value" placeholder="变量值,如 学术会议A" class="w-input-lg" />
|
||||
<el-button type="danger" plain @click="removeVariable(editComposer, idx)">删除</el-button>
|
||||
</el-space>
|
||||
<el-button plain @click="addVariable(editComposer)">新增变量</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="预览">
|
||||
<div class="preview-box">
|
||||
<div><b>主题:</b>{{ buildPreview(editComposer).subject || "-" }}</div>
|
||||
<div><b>标题:</b>{{ buildPreview(editComposer).title || "-" }}</div>
|
||||
<div><b>正文:</b>{{ buildPreview(editComposer).content || "-" }}</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="变量JSON">
|
||||
<el-input :model-value="buildVariablesJson(editComposer)" type="textarea" :rows="4" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="editForm.status">
|
||||
<el-option label="启用" value="ENABLED" />
|
||||
<el-option label="停用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSaveEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="dispatchVisible" :title="dispatchDialogTitle" :width="DIALOG_WIDTH.md">
|
||||
<el-form ref="dispatchFormRef" :model="dispatchForm" label-position="left" :label-width="LABEL_WIDTH.lg" :rules="dispatchFormRules">
|
||||
<el-form-item label="事件" prop="eventCode">
|
||||
<el-select v-model="dispatchForm.eventCode" class="w-full">
|
||||
<el-option label="审核通过" value="AUDIT_APPROVED" />
|
||||
<el-option label="审核拒绝" value="AUDIT_REJECTED" />
|
||||
<el-option label="财务已确认" value="FINANCE_CONFIRMED" />
|
||||
<el-option label="用户创建" value="USER_CREATED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="业务类型">
|
||||
<el-input v-model="dispatchForm.bizType" placeholder="默认 MEETING" />
|
||||
</el-form-item>
|
||||
<el-form-item label="接收标识(可选)">
|
||||
<el-input v-model="dispatchForm.bizId" placeholder="可填 user-1 / 邮箱 / 手机号(显式覆盖);不填按策略对象解析" />
|
||||
<div class="text-hint mt-xs">
|
||||
业务场景可填 meeting-2;用户创建场景可填 user-1;系统会按策略对象(提交人/审核人/财务角色/目标用户)解析接收人。
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="变量键值">
|
||||
<div class="w-full">
|
||||
<el-space
|
||||
v-for="(item, idx) in dispatchComposer.variables"
|
||||
:key="`dispatch-var-${idx}`"
|
||||
class="flex mb-sm"
|
||||
>
|
||||
<el-input v-model="item.key" placeholder="变量名,如 meetingTopic" class="w-input-md" />
|
||||
<el-input v-model="item.value" placeholder="变量值,如 学术会议A" class="w-input-lg" />
|
||||
<el-button type="danger" plain @click="removeVariable(dispatchComposer, idx)">删除</el-button>
|
||||
</el-space>
|
||||
<el-space>
|
||||
<el-button plain @click="addVariable(dispatchComposer)">新增变量</el-button>
|
||||
<el-button plain @click="applyCurrentPolicyVariables">带入当前策略变量示例</el-button>
|
||||
</el-space>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="发送JSON">
|
||||
<el-input :model-value="buildDispatchVariablesJson()" type="textarea" :rows="5" readonly />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dispatchVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleDispatch">确认发送</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import SectionTitle from "../../components/SectionTitle.vue";
|
||||
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import {
|
||||
bindNotificationPolicyEvents,
|
||||
createNotificationPolicy,
|
||||
deleteNotificationPolicy,
|
||||
disableNotificationPolicy,
|
||||
dispatchNotification,
|
||||
enableNotificationPolicy,
|
||||
fetchNotificationPolicies,
|
||||
fetchNotificationTextTemplates,
|
||||
fetchNotificationTasks,
|
||||
ingestNotificationReceipt,
|
||||
updateNotificationPolicy,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.notification.policyRead));
|
||||
const canManage = computed(() => authStore.hasPermission(PERMS.notification.policyManage));
|
||||
const canDispatch = computed(() => authStore.hasPermission(PERMS.notification.dispatch));
|
||||
const canTaskRead = computed(() => authStore.hasPermission(PERMS.notification.taskRead));
|
||||
const rows = ref<any[]>([]);
|
||||
const policyPageNo = ref(1);
|
||||
const policyPageSize = ref(20);
|
||||
const policyTotal = ref(0);
|
||||
const taskRows = ref<any[]>([]);
|
||||
const taskTotal = ref(0);
|
||||
const taskQuery = ref<{ pageNo: number; pageSize: number }>({
|
||||
pageNo: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const templateNameMap = ref<Record<number, string>>({});
|
||||
const templateOptions = ref<Array<{ id: number; templateName: string }>>([]);
|
||||
type VariableItem = { key: string; value: string };
|
||||
type VariableComposer = { subject: string; title: string; content: string; variables: VariableItem[] };
|
||||
const createVisible = ref(false);
|
||||
const form = ref({
|
||||
policyName: "审核通过通知",
|
||||
eventCode: "AUDIT_APPROVED",
|
||||
channel: "IN_APP",
|
||||
receiverType: "SUBMITTER",
|
||||
templateId: 1,
|
||||
variablesJson: "{\"meetingTopic\":\"${meetingTopic}\"}",
|
||||
status: "ENABLED" as "ENABLED" | "DISABLED",
|
||||
});
|
||||
const createComposer = ref<VariableComposer>({
|
||||
subject: "",
|
||||
title: "审核通过通知",
|
||||
content: "会议《${meetingTopic}》已审核通过",
|
||||
variables: [{ key: "meetingTopic", value: "示例会议" }],
|
||||
});
|
||||
|
||||
const editVisible = ref(false);
|
||||
const editId = ref<number | null>(null);
|
||||
const editForm = ref<any>({});
|
||||
const editComposer = ref<VariableComposer>({
|
||||
subject: "",
|
||||
title: "",
|
||||
content: "",
|
||||
variables: [],
|
||||
});
|
||||
const formRef = ref<FormInstance>();
|
||||
const editFormRef = ref<FormInstance>();
|
||||
const dispatchFormRef = ref<FormInstance>();
|
||||
|
||||
const formRules = ref<FormRules>({
|
||||
policyName: [{ required: true, message: "请输入策略名称", trigger: "blur" }],
|
||||
eventCode: [{ required: true, message: "请选择事件", trigger: "change" }],
|
||||
channel: [{ required: true, message: "请选择渠道", trigger: "change" }],
|
||||
receiverType: [{ required: true, message: "请选择对象", trigger: "change" }],
|
||||
templateId: [{ required: true, message: "请选择文案模板", trigger: "change" }],
|
||||
});
|
||||
|
||||
const dispatchFormRules = ref<FormRules>({
|
||||
eventCode: [{ required: true, message: "请选择事件", trigger: "change" }],
|
||||
});
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
const isUserCreatedEvent = (value: unknown) => String(value || "").trim().toUpperCase() === "USER_CREATED";
|
||||
const resolveReceiverTypeByEvent = (eventCode: unknown, currentReceiverType: unknown) => {
|
||||
if (isUserCreatedEvent(eventCode)) {
|
||||
return "TARGET_USER";
|
||||
}
|
||||
return String(currentReceiverType || "").trim().toUpperCase() === "TARGET_USER"
|
||||
? "SUBMITTER"
|
||||
: String(currentReceiverType || "SUBMITTER");
|
||||
};
|
||||
const resolveBizTypeByEvent = (eventCode: unknown, currentBizType: unknown) => {
|
||||
if (isUserCreatedEvent(eventCode)) {
|
||||
return "USER";
|
||||
}
|
||||
return String(currentBizType || "").trim().toUpperCase() === "USER"
|
||||
? "MEETING"
|
||||
: String(currentBizType || "MEETING");
|
||||
};
|
||||
const receiverResolveSourceFormatter = (_row: any, _column: any, value: unknown) => {
|
||||
const source = String(value || "").trim().toUpperCase();
|
||||
if (source === "EXPLICIT_INPUT") {
|
||||
return "显式输入";
|
||||
}
|
||||
if (source === "RECEIVER_TYPE_SUBMITTER") {
|
||||
return "提交人";
|
||||
}
|
||||
if (source === "RECEIVER_TYPE_AUDITOR") {
|
||||
return "审核人";
|
||||
}
|
||||
if (source === "RECEIVER_TYPE_FINANCE_ROLE") {
|
||||
return "财务角色";
|
||||
}
|
||||
if (source === "RECEIVER_TYPE_TARGET_USER") {
|
||||
return "目标用户";
|
||||
}
|
||||
if (source === "FALLBACK_CURRENT_USER") {
|
||||
return "当前用户兜底";
|
||||
}
|
||||
return source || "-";
|
||||
};
|
||||
const dispatchVisible = ref(false);
|
||||
const dispatchDialogTitle = ref("触发通知发送");
|
||||
const dispatchForm = ref({
|
||||
eventCode: "AUDIT_APPROVED",
|
||||
bizType: "MEETING",
|
||||
bizId: "",
|
||||
policyId: 0,
|
||||
});
|
||||
const dispatchComposer = ref<VariableComposer>({
|
||||
subject: "",
|
||||
title: "",
|
||||
content: "",
|
||||
variables: [],
|
||||
});
|
||||
|
||||
const templateNameFormatter = (_row: any, _column: any, value: unknown) => {
|
||||
const templateId = Number(value || 0);
|
||||
if (!templateId) {
|
||||
return "-";
|
||||
}
|
||||
return templateNameMap.value[templateId] || `模板#${templateId}`;
|
||||
};
|
||||
|
||||
const loadTemplateMap = async () => {
|
||||
try {
|
||||
const resp = await fetchNotificationTextTemplates();
|
||||
const list = resp?.data?.list || [];
|
||||
const map: Record<number, string> = {};
|
||||
const options: Array<{ id: number; templateName: string }> = [];
|
||||
list.forEach((item: any) => {
|
||||
const id = Number(item?.id || 0);
|
||||
if (id > 0) {
|
||||
const name = item?.templateName || `文案模板#${id}`;
|
||||
map[id] = name;
|
||||
options.push({ id, templateName: name });
|
||||
}
|
||||
});
|
||||
templateNameMap.value = map;
|
||||
templateOptions.value = options;
|
||||
if (options.length > 0) {
|
||||
if (!options.some((x) => x.id === Number(form.value.templateId || 0))) {
|
||||
form.value.templateId = options[0].id;
|
||||
}
|
||||
if (editForm.value && editForm.value.templateId != null) {
|
||||
const currentEditId = Number(editForm.value.templateId || 0);
|
||||
if (!options.some((x) => x.id === currentEditId)) {
|
||||
editForm.value.templateId = options[0].id;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
templateNameMap.value = {};
|
||||
templateOptions.value = [];
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
form.value = {
|
||||
policyName: "审核通过通知",
|
||||
eventCode: "AUDIT_APPROVED",
|
||||
channel: "IN_APP",
|
||||
receiverType: "SUBMITTER",
|
||||
templateId: 1,
|
||||
variablesJson: "{\"meetingTopic\":\"${meetingTopic}\"}",
|
||||
status: "ENABLED",
|
||||
};
|
||||
createComposer.value = parseVariablesJson(form.value.variablesJson);
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => form.value.eventCode,
|
||||
(eventCode) => {
|
||||
form.value.receiverType = resolveReceiverTypeByEvent(eventCode, form.value.receiverType);
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => editForm.value?.eventCode,
|
||||
(eventCode) => {
|
||||
if (!editForm.value) {
|
||||
return;
|
||||
}
|
||||
editForm.value.receiverType = resolveReceiverTypeByEvent(eventCode, editForm.value.receiverType);
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => dispatchForm.value.eventCode,
|
||||
(eventCode) => {
|
||||
dispatchForm.value.bizType = resolveBizTypeByEvent(eventCode, dispatchForm.value.bizType);
|
||||
}
|
||||
);
|
||||
|
||||
const load = async () => {
|
||||
if (!canRead.value) {
|
||||
rows.value = [];
|
||||
taskRows.value = [];
|
||||
taskTotal.value = 0;
|
||||
return;
|
||||
}
|
||||
const resp = await fetchNotificationPolicies({ pageNo: policyPageNo.value, pageSize: policyPageSize.value });
|
||||
rows.value = resp?.data?.list || [];
|
||||
policyTotal.value = Number(resp?.data?.total || 0);
|
||||
if (canTaskRead.value) {
|
||||
const taskResp = await fetchNotificationTasks(taskQuery.value);
|
||||
taskRows.value = taskResp?.data?.list || [];
|
||||
taskTotal.value = Number(taskResp?.data?.total || 0);
|
||||
taskQuery.value.pageNo = Number(taskResp?.data?.pageNo || taskQuery.value.pageNo || 1);
|
||||
taskQuery.value.pageSize = Number(taskResp?.data?.pageSize || taskQuery.value.pageSize || 20);
|
||||
} else {
|
||||
taskRows.value = [];
|
||||
taskTotal.value = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTaskPageChange = async (pageNo: number) => {
|
||||
taskQuery.value.pageNo = Number(pageNo || 1);
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleTaskPageSizeChange = async (pageSize: number) => {
|
||||
taskQuery.value.pageSize = Number(pageSize || 20);
|
||||
taskQuery.value.pageNo = 1;
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formRef.value) return;
|
||||
const valid = await formRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
if (templateOptions.value.length === 0) {
|
||||
ElMessage.warning("请先创建通知文案模板");
|
||||
return;
|
||||
}
|
||||
await createNotificationPolicy({
|
||||
...form.value,
|
||||
variablesJson: buildVariablesJson(createComposer.value),
|
||||
});
|
||||
ElMessage.success("通知策略创建成功");
|
||||
createVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const openDispatchDialog = (row?: any) => {
|
||||
const sourcePolicy = row || null;
|
||||
let sourceComposer: VariableComposer = createComposer.value;
|
||||
if (sourcePolicy && sourcePolicy.variablesJson) {
|
||||
sourceComposer = parseVariablesJson(String(sourcePolicy.variablesJson || ""));
|
||||
}
|
||||
dispatchForm.value = {
|
||||
eventCode: sourcePolicy?.eventCode || form.value.eventCode || "AUDIT_APPROVED",
|
||||
bizType: resolveBizTypeByEvent(sourcePolicy?.eventCode || form.value.eventCode, sourcePolicy?.bizType),
|
||||
bizId: "",
|
||||
policyId: sourcePolicy?.id ? Number(sourcePolicy.id) : 0,
|
||||
};
|
||||
dispatchDialogTitle.value = sourcePolicy?.policyName
|
||||
? `触发通知发送 - ${String(sourcePolicy.policyName)}`
|
||||
: "触发通知发送";
|
||||
dispatchComposer.value = {
|
||||
subject: "",
|
||||
title: "",
|
||||
content: "",
|
||||
variables: sourceComposer.variables.map((x) => ({ key: x.key, value: x.value })),
|
||||
};
|
||||
dispatchVisible.value = true;
|
||||
};
|
||||
|
||||
const handleDispatch = async () => {
|
||||
if (!dispatchFormRef.value) return;
|
||||
const valid = await dispatchFormRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
const variablesJson = buildDispatchVariablesJson();
|
||||
await dispatchNotification({
|
||||
idempotencyKey: `dispatch-${Date.now()}`,
|
||||
eventCode: dispatchForm.value.eventCode,
|
||||
bizType: dispatchForm.value.bizType || "MEETING",
|
||||
bizId: dispatchForm.value.bizId ? dispatchForm.value.bizId : undefined,
|
||||
variablesJson,
|
||||
policyId: dispatchForm.value.policyId > 0 ? dispatchForm.value.policyId : undefined,
|
||||
});
|
||||
ElMessage.success(dispatchForm.value.policyId ? "当前策略通知任务已触发" : "通知任务已触发");
|
||||
dispatchVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleMockReceipt = async (row: any) => {
|
||||
await ingestNotificationReceipt({
|
||||
taskId: row.id,
|
||||
providerMessageId: row.providerMessageId,
|
||||
receiptCode: "DELIVERED",
|
||||
receiptMessage: "供应商回执送达",
|
||||
delivered: true,
|
||||
});
|
||||
ElMessage.success("回执已写入");
|
||||
await load();
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
editId.value = row.id;
|
||||
editForm.value = {
|
||||
policyName: row.policyName,
|
||||
eventCode: row.eventCode,
|
||||
channel: row.channel,
|
||||
receiverType: row.receiverType,
|
||||
templateId: row.templateId,
|
||||
variablesJson: row.variablesJson,
|
||||
status: row.status,
|
||||
};
|
||||
editComposer.value = parseVariablesJson(row.variablesJson);
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editId.value) return;
|
||||
|
||||
if (!editFormRef.value) return;
|
||||
const valid = await editFormRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
if (templateOptions.value.length === 0) {
|
||||
ElMessage.warning("请先创建通知文案模板");
|
||||
return;
|
||||
}
|
||||
await updateNotificationPolicy(editId.value, {
|
||||
...editForm.value,
|
||||
variablesJson: buildVariablesJson(editComposer.value),
|
||||
});
|
||||
ElMessage.success("通知策略更新成功");
|
||||
editVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleBindEvent = async (row: any) => {
|
||||
await bindNotificationPolicyEvents(row.id, { eventCode: row.eventCode });
|
||||
ElMessage.success("事件绑定成功");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleEnable = async (id: number) => {
|
||||
await enableNotificationPolicy(id);
|
||||
ElMessage.success("策略已启用");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDisable = async (id: number) => {
|
||||
await disableNotificationPolicy(id);
|
||||
ElMessage.success("策略已停用");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDeletePolicy = async (id: number, name: string) => {
|
||||
await ElMessageBox.confirm(`确认删除通知策略「${name}」?删除后不可恢复。`, "删除策略", {
|
||||
type: "warning",
|
||||
confirmButtonText: "确认删除",
|
||||
cancelButtonText: "取消",
|
||||
});
|
||||
await deleteNotificationPolicy(id);
|
||||
ElMessage.success("通知策略已删除");
|
||||
await load();
|
||||
};
|
||||
|
||||
const addVariable = (composer: VariableComposer) => {
|
||||
composer.variables.push({ key: "", value: "" });
|
||||
};
|
||||
|
||||
const removeVariable = (composer: VariableComposer, index: number) => {
|
||||
composer.variables.splice(index, 1);
|
||||
};
|
||||
|
||||
const parseVariablesJson = (raw?: string): VariableComposer => {
|
||||
const fallback: VariableComposer = { subject: "", title: "", content: "", variables: [] };
|
||||
if (!raw || !raw.trim()) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
const data = parsed as Record<string, unknown>;
|
||||
const composer: VariableComposer = {
|
||||
subject: String(data.subject || ""),
|
||||
title: String(data.title || ""),
|
||||
content: String(data.content || data.message || ""),
|
||||
variables: [],
|
||||
};
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
if (["subject", "title", "content", "message"].includes(key)) {
|
||||
return;
|
||||
}
|
||||
composer.variables.push({ key, value: value == null ? "" : String(value) });
|
||||
});
|
||||
return composer;
|
||||
} catch (_e) {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const buildVariablesJson = (composer: VariableComposer): string => {
|
||||
const result: Record<string, string> = {};
|
||||
if (composer.subject.trim()) {
|
||||
result.subject = composer.subject.trim();
|
||||
}
|
||||
if (composer.title.trim()) {
|
||||
result.title = composer.title.trim();
|
||||
}
|
||||
if (composer.content.trim()) {
|
||||
result.content = composer.content.trim();
|
||||
}
|
||||
composer.variables.forEach((item) => {
|
||||
const key = item.key.trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
result[key] = item.value;
|
||||
});
|
||||
return JSON.stringify(result);
|
||||
};
|
||||
|
||||
const buildDispatchVariablesJson = (): string => {
|
||||
const result: Record<string, string> = {};
|
||||
dispatchComposer.value.variables.forEach((item) => {
|
||||
const key = item.key.trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
result[key] = item.value;
|
||||
});
|
||||
return JSON.stringify(result);
|
||||
};
|
||||
|
||||
const applyCurrentPolicyVariables = () => {
|
||||
dispatchComposer.value.variables = createComposer.value.variables.map((x) => ({ key: x.key, value: x.value }));
|
||||
};
|
||||
|
||||
const renderTemplate = (template: string, vars: Record<string, string>): string => {
|
||||
if (!template) {
|
||||
return "";
|
||||
}
|
||||
return template.replace(/\$\{([^}]+)\}/g, (_m, key: string) => {
|
||||
const k = String(key || "").trim();
|
||||
return vars[k] ?? "";
|
||||
});
|
||||
};
|
||||
|
||||
const buildPreview = (composer: VariableComposer): { subject: string; title: string; content: string } => {
|
||||
const vars: Record<string, string> = {};
|
||||
composer.variables.forEach((item) => {
|
||||
const key = item.key.trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
vars[key] = item.value;
|
||||
});
|
||||
const subjectTpl = composer.subject.trim() || composer.title.trim();
|
||||
const titleTpl = composer.title.trim() || composer.subject.trim();
|
||||
const contentTpl = composer.content.trim();
|
||||
return {
|
||||
subject: renderTemplate(subjectTpl, vars),
|
||||
title: renderTemplate(titleTpl, vars),
|
||||
content: renderTemplate(contentTpl, vars),
|
||||
};
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadTemplateMap(), load()]);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<PageContainer title="通知文案模板">
|
||||
<QueryToolbar>
|
||||
<el-button v-if="canManage" type="primary" @click="openCreate">新增文案模板</el-button>
|
||||
<el-button v-if="canRead" @click="load">刷新</el-button>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-table :data="rows">
|
||||
<el-table-column prop="templateName" label="模板名称" min-width="180" />
|
||||
<el-table-column prop="subjectTemplate" label="邮件主题模板" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="titleTemplate" label="站内标题模板" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="contentTemplate" label="正文模板" min-width="260" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column label="操作" width="260">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button v-if="canManage && row.status !== 'ENABLED'" size="small" type="success" @click="handleEnable(row.id)">启用</el-button>
|
||||
<el-button v-else-if="canManage" size="small" type="warning" @click="handleDisable(row.id)">停用</el-button>
|
||||
<el-button
|
||||
v-if="canManage && row.status === 'DISABLED'"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDeleteTemplate(row.id, row.templateName)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="flex-end mt-md">
|
||||
<el-pagination
|
||||
:current-page="pageNo"
|
||||
:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="total"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handlePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="visible" :title="editingId ? '编辑通知文案模板' : '新增通知文案模板'" :width="DIALOG_WIDTH.lg">
|
||||
<el-form ref="formRef" :model="form" label-position="left" :label-width="LABEL_WIDTH.lg" :rules="formRules">
|
||||
<el-form-item label="模板名称" prop="templateName"><el-input v-model="form.templateName" /></el-form-item>
|
||||
<el-form-item label="邮件主题模板" prop="subjectTemplate"><el-input v-model="form.subjectTemplate" placeholder="支持占位符,如:审核通知-${meetingTopic}" /></el-form-item>
|
||||
<el-form-item label="站内标题模板"><el-input v-model="form.titleTemplate" placeholder="支持占位符,如:审核结果通知" /></el-form-item>
|
||||
<el-form-item label="正文模板">
|
||||
<el-input v-model="form.contentTemplate" type="textarea" :rows="4" placeholder="支持占位符,如:会议《${meetingTopic}》状态:${result}" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="form.status">
|
||||
<el-option label="启用" value="ENABLED" />
|
||||
<el-option label="停用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import {
|
||||
createNotificationTextTemplate,
|
||||
deleteNotificationTextTemplate,
|
||||
disableNotificationTextTemplate,
|
||||
enableNotificationTextTemplate,
|
||||
fetchNotificationTextTemplates,
|
||||
updateNotificationTextTemplate,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const canRead = computed(() =>
|
||||
authStore.hasPermission(PERMS.notification.textTemplateRead)
|
||||
|| authStore.hasPermission(PERMS.notification.policyRead)
|
||||
|| authStore.hasPermission(PERMS.notification.policyManage),
|
||||
);
|
||||
const canManage = computed(() =>
|
||||
authStore.hasPermission(PERMS.notification.textTemplateManage) || authStore.hasPermission(PERMS.notification.policyManage),
|
||||
);
|
||||
const rows = ref<any[]>([]);
|
||||
const pageNo = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const total = ref(0);
|
||||
const visible = ref(false);
|
||||
const editingId = ref<number | null>(null);
|
||||
const form = ref({
|
||||
templateName: "",
|
||||
subjectTemplate: "",
|
||||
titleTemplate: "",
|
||||
contentTemplate: "",
|
||||
status: "ENABLED" as "ENABLED" | "DISABLED",
|
||||
});
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const formRules = ref<FormRules>({
|
||||
templateName: [{ required: true, message: "请输入模板名称", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
|
||||
const load = async () => {
|
||||
if (!canRead.value) {
|
||||
rows.value = [];
|
||||
total.value = 0;
|
||||
return;
|
||||
}
|
||||
const resp = await fetchNotificationTextTemplates({ pageNo: pageNo.value, pageSize: pageSize.value });
|
||||
rows.value = resp?.data?.list || [];
|
||||
total.value = Number(resp?.data?.total || 0);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pageNo.value = page;
|
||||
load();
|
||||
};
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
pageSize.value = size;
|
||||
pageNo.value = 1;
|
||||
load();
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
editingId.value = null;
|
||||
form.value = {
|
||||
templateName: "审核通知模板",
|
||||
subjectTemplate: "审核通知-${meetingTopic}",
|
||||
titleTemplate: "审核通知",
|
||||
contentTemplate: "会议《${meetingTopic}》状态:${result}",
|
||||
status: "ENABLED",
|
||||
};
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
editingId.value = Number(row.id);
|
||||
form.value = {
|
||||
templateName: String(row.templateName || ""),
|
||||
subjectTemplate: String(row.subjectTemplate || ""),
|
||||
titleTemplate: String(row.titleTemplate || ""),
|
||||
contentTemplate: String(row.contentTemplate || ""),
|
||||
status: (row.status || "ENABLED") as "ENABLED" | "DISABLED",
|
||||
};
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formRef.value) return;
|
||||
const valid = await formRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
const payload = {
|
||||
templateName: form.value.templateName,
|
||||
subjectTemplate: form.value.subjectTemplate,
|
||||
titleTemplate: form.value.titleTemplate,
|
||||
contentTemplate: form.value.contentTemplate,
|
||||
status: form.value.status,
|
||||
};
|
||||
if (editingId.value) {
|
||||
await updateNotificationTextTemplate(editingId.value, payload);
|
||||
ElMessage.success("通知文案模板更新成功");
|
||||
} else {
|
||||
await createNotificationTextTemplate(payload);
|
||||
ElMessage.success("通知文案模板创建成功");
|
||||
}
|
||||
visible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleEnable = async (id: number) => {
|
||||
await enableNotificationTextTemplate(id);
|
||||
ElMessage.success("文案模板已启用");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDisable = async (id: number) => {
|
||||
await disableNotificationTextTemplate(id);
|
||||
ElMessage.success("文案模板已停用");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = async (id: number, name: string) => {
|
||||
await ElMessageBox.confirm(`确认删除文案模板「${name}」?删除后不可恢复。`, "删除模板", {
|
||||
type: "warning",
|
||||
confirmButtonText: "确认删除",
|
||||
cancelButtonText: "取消",
|
||||
});
|
||||
await deleteNotificationTextTemplate(id);
|
||||
ElMessage.success("文案模板已删除");
|
||||
await load();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<PageContainer title="可观测性与告警">
|
||||
<QueryToolbar>
|
||||
<el-select v-model="metricCode" class="w-input-md" placeholder="指标">
|
||||
<el-option label="API总请求" value="API_TOTAL" />
|
||||
<el-option label="API耗时总和" value="API_DURATION_MS" />
|
||||
<el-option label="异步任务总量" value="ASYNC_JOB_TOTAL" />
|
||||
<el-option label="异步任务失败" value="ASYNC_JOB_FAILED" />
|
||||
</el-select>
|
||||
<el-input-number v-model="minutes" :min="1" :max="1440" placeholder="时间窗(分钟)" />
|
||||
<el-button v-if="canRead" @click="loadMetrics">查询指标</el-button>
|
||||
<el-button v-if="canManage" @click="handleEvaluate">立即评估告警</el-button>
|
||||
<el-button v-if="canManage" @click="handleAutoEvaluate">自动评估一次</el-button>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-table :data="metricRows" class="mt-md" empty-text="暂无数据">
|
||||
<el-table-column prop="minuteSlot" label="分钟窗口" width="180" />
|
||||
<el-table-column prop="metricCode" label="指标编码" width="170" />
|
||||
<el-table-column prop="metricValue" label="指标值" />
|
||||
</el-table>
|
||||
<el-alert
|
||||
class="mt-sm"
|
||||
type="info"
|
||||
:closable="false"
|
||||
:title="`导出指标:总量 ${exportSummary.total || 0},失败 ${exportSummary.failed || 0}`"
|
||||
/>
|
||||
|
||||
<el-divider />
|
||||
<SectionTitle>告警规则</SectionTitle>
|
||||
<el-form ref="ruleFormRef" :inline="true" :model="ruleForm" :rules="ruleFormRules">
|
||||
<el-form-item label="规则编码" prop="ruleCode"><el-input v-model="ruleForm.ruleCode" /></el-form-item>
|
||||
<el-form-item label="规则名称" prop="ruleName"><el-input v-model="ruleForm.ruleName" /></el-form-item>
|
||||
<el-form-item label="比较符">
|
||||
<el-select v-model="ruleForm.compareOp" class="w-input-xs">
|
||||
<el-option label=">=" value=">=" />
|
||||
<el-option label=">" value=">" />
|
||||
<el-option label="<" value="<" />
|
||||
<el-option label="<=" value="<=" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="阈值"><el-input-number v-model="ruleForm.thresholdValue" :min="0" /></el-form-item>
|
||||
<el-form-item label="窗口"><el-input-number v-model="ruleForm.windowMinute" :min="1" /></el-form-item>
|
||||
<el-form-item label="抑制(分钟)"><el-input-number v-model="ruleForm.suppressWindowMinute" :min="0" /></el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="ruleForm.status" class="w-input-sm">
|
||||
<el-option label="启用" value="ENABLED" />
|
||||
<el-option label="停用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item><el-button v-if="canManage" type="primary" @click="handleCreateRule">新增规则</el-button></el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="ruleRows" class="mt-md" empty-text="暂无数据">
|
||||
<el-table-column prop="ruleCode" label="编码" width="160" />
|
||||
<el-table-column prop="ruleName" label="名称" width="180" />
|
||||
<el-table-column prop="compareOp" label="比较符" width="90" />
|
||||
<el-table-column prop="thresholdValue" label="阈值" width="100" />
|
||||
<el-table-column prop="windowMinute" label="窗口" width="90" />
|
||||
<el-table-column prop="suppressWindowMinute" label="抑制窗口" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-divider />
|
||||
<SectionTitle>告警事件</SectionTitle>
|
||||
<el-table :data="eventRows" empty-text="暂无数据">
|
||||
<el-table-column prop="ruleCode" label="规则编码" width="160" />
|
||||
<el-table-column prop="metricCode" label="指标" width="140" />
|
||||
<el-table-column prop="currentValue" label="当前值" width="100" />
|
||||
<el-table-column prop="thresholdValue" label="阈值" width="100" />
|
||||
<el-table-column prop="alertLevel" label="级别" width="80" />
|
||||
<el-table-column prop="status" label="状态" width="90" :formatter="statusFormatter" />
|
||||
<el-table-column prop="createdAt" label="时间" />
|
||||
<el-table-column prop="recoveredAt" label="恢复时间" />
|
||||
</el-table>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑告警规则" :width="DIALOG_WIDTH.sm">
|
||||
<el-form ref="editFormRef" :model="editForm" label-position="left" :label-width="LABEL_WIDTH.md" :rules="ruleFormRules">
|
||||
<el-form-item label="规则编码" prop="ruleCode"><el-input v-model="editForm.ruleCode" /></el-form-item>
|
||||
<el-form-item label="规则名称" prop="ruleName"><el-input v-model="editForm.ruleName" /></el-form-item>
|
||||
<el-form-item label="比较符"><el-input v-model="editForm.compareOp" /></el-form-item>
|
||||
<el-form-item label="阈值"><el-input-number v-model="editForm.thresholdValue" :min="0" /></el-form-item>
|
||||
<el-form-item label="窗口"><el-input-number v-model="editForm.windowMinute" :min="1" /></el-form-item>
|
||||
<el-form-item label="抑制(分钟)"><el-input-number v-model="editForm.suppressWindowMinute" :min="0" /></el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="editForm.status">
|
||||
<el-option label="启用" value="ENABLED" />
|
||||
<el-option label="停用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSaveRule">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import SectionTitle from "../../components/SectionTitle.vue";
|
||||
import { DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
import { ElMessage } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import {
|
||||
createAlertRule,
|
||||
evaluateAlertRules,
|
||||
evaluateAlertRulesAuto,
|
||||
fetchAlertEvents,
|
||||
fetchAlertRules,
|
||||
fetchObservabilityExportMetrics,
|
||||
fetchObservabilityMetrics,
|
||||
updateAlertRule,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.observability.read));
|
||||
const canManage = computed(() => authStore.hasPermission(PERMS.observability.manage));
|
||||
|
||||
const metricCode = ref("API_TOTAL");
|
||||
const minutes = ref(60);
|
||||
const metricRows = ref<any[]>([]);
|
||||
const exportSummary = ref<any>({ total: 0, failed: 0, points: [] });
|
||||
const ruleRows = ref<any[]>([]);
|
||||
const eventRows = ref<any[]>([]);
|
||||
|
||||
const ruleForm = ref({
|
||||
ruleCode: "API_5XX_RATE",
|
||||
ruleName: "API 5xx错误率",
|
||||
compareOp: ">=",
|
||||
thresholdValue: 5,
|
||||
windowMinute: 5,
|
||||
suppressWindowMinute: 5,
|
||||
status: "ENABLED" as "ENABLED" | "DISABLED",
|
||||
});
|
||||
|
||||
const ruleFormRef = ref<FormInstance>();
|
||||
const editFormRef = ref<FormInstance>();
|
||||
const ruleFormRules = ref<FormRules>({
|
||||
ruleCode: [{ required: true, message: "请输入规则编码", trigger: "blur" }],
|
||||
ruleName: [{ required: true, message: "请输入规则名称", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const editVisible = ref(false);
|
||||
const editId = ref<number | null>(null);
|
||||
const editForm = ref<any>({});
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
|
||||
const loadMetrics = async () => {
|
||||
const resp = await fetchObservabilityMetrics({ metricCode: metricCode.value, minutes: minutes.value });
|
||||
metricRows.value = resp?.data || [];
|
||||
const expResp = await fetchObservabilityExportMetrics({ minutes: minutes.value });
|
||||
exportSummary.value = expResp?.data || exportSummary.value;
|
||||
};
|
||||
|
||||
const loadRules = async () => {
|
||||
const resp = await fetchAlertRules();
|
||||
ruleRows.value = resp?.data || [];
|
||||
};
|
||||
|
||||
const loadEvents = async () => {
|
||||
const resp = await fetchAlertEvents();
|
||||
eventRows.value = resp?.data || [];
|
||||
};
|
||||
|
||||
const handleCreateRule = async () => {
|
||||
if (!ruleFormRef.value) return;
|
||||
const valid = await ruleFormRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await createAlertRule(ruleForm.value);
|
||||
ElMessage.success("规则创建成功");
|
||||
await loadRules();
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
editId.value = row.id;
|
||||
editForm.value = {
|
||||
ruleCode: row.ruleCode,
|
||||
ruleName: row.ruleName,
|
||||
compareOp: row.compareOp,
|
||||
thresholdValue: row.thresholdValue,
|
||||
windowMinute: row.windowMinute,
|
||||
suppressWindowMinute: row.suppressWindowMinute,
|
||||
status: row.status,
|
||||
};
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
const handleSaveRule = async () => {
|
||||
if (!editId.value) return;
|
||||
|
||||
if (!editFormRef.value) return;
|
||||
const valid = await editFormRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await updateAlertRule(editId.value, editForm.value);
|
||||
ElMessage.success("规则更新成功");
|
||||
editVisible.value = false;
|
||||
await loadRules();
|
||||
};
|
||||
|
||||
const handleEvaluate = async () => {
|
||||
const resp = await evaluateAlertRules();
|
||||
ElMessage.success(`评估完成,触发 ${resp?.data?.triggerCount || 0} 条告警`);
|
||||
await loadEvents();
|
||||
};
|
||||
|
||||
const handleAutoEvaluate = async () => {
|
||||
const resp = await evaluateAlertRulesAuto({ recoveryWindowMinute: 10 });
|
||||
ElMessage.success(
|
||||
`自动评估完成,触发 ${resp?.data?.triggerCount || 0} 条,恢复 ${resp?.data?.recoverCount || 0} 条`,
|
||||
);
|
||||
await loadEvents();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (canRead.value) {
|
||||
await Promise.all([loadMetrics(), loadRules(), loadEvents()]);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<PageContainer title="运营看板">
|
||||
<QueryToolbar>
|
||||
<el-button v-if="canRead" @click="load">刷新</el-button>
|
||||
</QueryToolbar>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<template #header>通知执行</template>
|
||||
<div>今日任务:{{ data.notification?.todayTotal || 0 }}</div>
|
||||
<div>已发送:{{ data.notification?.sentTotal || 0 }}</div>
|
||||
<div>已送达:{{ data.notification?.deliveredTotal || 0 }}</div>
|
||||
<div>失败:{{ data.notification?.failedTotal || 0 }}</div>
|
||||
<div>平均派发耗时(s):{{ (data.notification?.avgDispatchSeconds || 0).toFixed?.(2) || 0 }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<template #header>导出执行</template>
|
||||
<div>今日任务:{{ data.export?.todayTotal || 0 }}</div>
|
||||
<div>成功:{{ data.export?.successTotal || 0 }}</div>
|
||||
<div>失败:{{ data.export?.failedTotal || 0 }}</div>
|
||||
<div>平均完成耗时(s):{{ (data.export?.avgFinishSeconds || 0).toFixed?.(2) || 0 }}</div>
|
||||
<div>24h内将过期:{{ data.export?.expiring24h || 0 }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<template #header>告警处理</template>
|
||||
<div>当前活跃告警:{{ data.alert?.activeTotal || 0 }}</div>
|
||||
<div>今日恢复:{{ data.alert?.recoveredToday || 0 }}</div>
|
||||
<div>平均恢复时长(分):{{ (data.alert?.avgRecoverMinutes || 0).toFixed?.(2) || 0 }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-divider />
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<template #header>近7天通知趋势</template>
|
||||
<el-table :data="data.trends?.notification7d || []" size="small">
|
||||
<el-table-column prop="date" label="日期" width="110" />
|
||||
<el-table-column prop="count" label="数量" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<template #header>近7天导出趋势</template>
|
||||
<el-table :data="data.trends?.export7d || []" size="small">
|
||||
<el-table-column prop="date" label="日期" width="110" />
|
||||
<el-table-column prop="count" label="数量" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<template #header>近7天告警趋势</template>
|
||||
<el-table :data="data.trends?.alert7d || []" size="small">
|
||||
<el-table-column prop="date" label="日期" width="110" />
|
||||
<el-table-column prop="count" label="数量" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-divider />
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<template #header>通知失败渠道TOP</template>
|
||||
<el-table :data="data.tops?.notificationFailedChannels || []" size="small">
|
||||
<el-table-column prop="channel" label="渠道" :formatter="statusFormatter" />
|
||||
<el-table-column prop="cnt" label="失败次数" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<template #header>导出业务TOP</template>
|
||||
<el-table :data="data.tops?.exportBizTypeTop || []" size="small">
|
||||
<el-table-column prop="biz_type" label="业务类型" />
|
||||
<el-table-column prop="cnt" label="任务数" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card>
|
||||
<template #header>告警规则TOP</template>
|
||||
<el-table :data="data.tops?.alertRuleTop || []" size="small">
|
||||
<el-table-column prop="rule_code" label="规则编码" />
|
||||
<el-table-column prop="cnt" label="触发次数" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { fetchOperationsDashboard } from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.dashboard.read));
|
||||
const data = ref<any>({
|
||||
notification: {},
|
||||
export: {},
|
||||
alert: {},
|
||||
});
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
|
||||
const load = async () => {
|
||||
const resp = await fetchOperationsDashboard();
|
||||
data.value = resp?.data || data.value;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (canRead.value) {
|
||||
await load();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<PageContainer title="权限管理">
|
||||
<QueryToolbar>
|
||||
<el-button v-if="canRead" type="primary" @click="load">刷新</el-button>
|
||||
</QueryToolbar>
|
||||
<el-table :data="rows">
|
||||
<el-table-column prop="permissionCode" label="权限码" min-width="260" />
|
||||
<el-table-column prop="permissionName" label="权限名称" min-width="220" />
|
||||
<el-table-column prop="module" label="模块" width="160" />
|
||||
</el-table>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { fetchPermissions } from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const rows = ref<any[]>([]);
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.permission.read));
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const resp = await fetchPermissions();
|
||||
rows.value = resp?.data?.list || [];
|
||||
} catch (e: any) {
|
||||
rows.value = [];
|
||||
ElMessage.error(e?.response?.data?.message || "权限数据加载失败");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<PageContainer title="平台字典管理">
|
||||
<QueryToolbar>
|
||||
<el-button v-if="canManage" type="primary" plain @click="openCreateTypeDialog">新增字典类型</el-button>
|
||||
<el-button v-if="canRead" @click="loadTypes">刷新类型列表</el-button>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-table :data="typeOptions" class="mt-md" empty-text="暂无数据">
|
||||
<el-table-column prop="dictType" label="类型编码" width="220" />
|
||||
<el-table-column prop="dictName" label="名称" />
|
||||
<el-table-column prop="sortNo" label="排序" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="110" :formatter="statusFormatter" />
|
||||
<el-table-column prop="remark" label="备注" />
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canRead" size="small" type="primary" @click="openItems(row)">打开字典项</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-drawer
|
||||
v-model="itemsDrawerVisible"
|
||||
:title="currentType ? `${currentType.dictName}(${currentType.dictType})字典项` : '字典项'"
|
||||
size="70%"
|
||||
destroy-on-close
|
||||
>
|
||||
<QueryToolbar>
|
||||
<el-button v-if="canManage" type="primary" :disabled="!dictType" @click="openCreateDrawer">新增字典项</el-button>
|
||||
<el-button v-if="canRead" :disabled="!dictType" @click="load">刷新</el-button>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-table :data="rows" empty-text="暂无数据">
|
||||
<el-table-column prop="dictCode" label="编码" width="220" />
|
||||
<el-table-column prop="dictName" label="名称" />
|
||||
<el-table-column prop="sortNo" label="排序" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="110" :formatter="statusFormatter" />
|
||||
<el-table-column prop="remark" label="备注" />
|
||||
<el-table-column label="操作" width="260">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
v-if="canManage && row.status === 'ENABLED'"
|
||||
size="small"
|
||||
@click="handleDisable(row)"
|
||||
>
|
||||
停用
|
||||
</el-button>
|
||||
<el-button v-else-if="canManage" size="small" @click="handleEnable(row)">启用</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
|
||||
<el-drawer v-model="createVisible" title="新增字典项" :size="DRAWER_SIZE.sm" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="字典类型"><el-input :value="dictType" disabled /></el-form-item>
|
||||
<el-form-item label="编码" prop="dictCode"><el-input v-model="form.dictCode" placeholder="例如 CHIEF_PHYSICIAN" /></el-form-item>
|
||||
<el-form-item label="名称" prop="dictName"><el-input v-model="form.dictName" /></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="form.sortNo" :min="1" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="handleCreate">创建</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑字典项" :width="DIALOG_WIDTH.sm">
|
||||
<el-form ref="editFormRef" :model="editForm" label-position="left" :label-width="LABEL_WIDTH.md" :rules="editFormRules">
|
||||
<el-form-item label="名称" prop="dictName"><el-input v-model="editForm.dictName" /></el-form-item>
|
||||
<el-form-item label="排序"><el-input-number v-model="editForm.sortNo" :min="1" /></el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="editForm.status">
|
||||
<el-option label="启用" value="ENABLED" />
|
||||
<el-option label="停用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="editForm.remark" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="handleUpdate">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="createTypeVisible" title="新增字典类型" :width="DIALOG_WIDTH.sm">
|
||||
<el-form ref="typeFormRef" :model="typeForm" label-position="left" :label-width="LABEL_WIDTH.md" :rules="typeFormRules">
|
||||
<el-form-item label="类型编码" prop="dictType">
|
||||
<el-input v-model="typeForm.dictType" placeholder="例如 MEETING_CATEGORY" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型名称" prop="dictName">
|
||||
<el-input v-model="typeForm.dictName" placeholder="例如 会议类别" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="typeForm.sortNo" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="typeForm.remark" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createTypeVisible = false">取消</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="handleCreateType">创建</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
import { ElMessage } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import {
|
||||
createPlatformDictionaryType,
|
||||
createPlatformDictionary,
|
||||
disablePlatformDictionary,
|
||||
enablePlatformDictionary,
|
||||
fetchPlatformDictionaries,
|
||||
fetchPlatformDictionaryTypes,
|
||||
updatePlatformDictionary,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.platform.dictionaryRead));
|
||||
const canManage = computed(() => authStore.hasPermission(PERMS.platform.dictionaryManage));
|
||||
|
||||
const dictType = ref("");
|
||||
const typeOptions = ref<Array<{ dictType: string; dictName: string; sortNo?: number; status?: string; remark?: string }>>([]);
|
||||
const rows = ref<any[]>([]);
|
||||
const currentType = ref<{ dictType: string; dictName: string } | null>(null);
|
||||
const itemsDrawerVisible = ref(false);
|
||||
const createVisible = ref(false);
|
||||
const form = ref({
|
||||
dictCode: "",
|
||||
dictName: "",
|
||||
sortNo: 100,
|
||||
});
|
||||
const createTypeVisible = ref(false);
|
||||
const typeForm = ref({
|
||||
dictType: "",
|
||||
dictName: "",
|
||||
sortNo: 100,
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const editVisible = ref(false);
|
||||
const editId = ref<number | null>(null);
|
||||
const editForm = ref({
|
||||
dictName: "",
|
||||
sortNo: 100,
|
||||
status: "ENABLED" as "ENABLED" | "DISABLED",
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const editFormRef = ref<FormInstance>();
|
||||
const typeFormRef = ref<FormInstance>();
|
||||
|
||||
const formRules = ref<FormRules>({
|
||||
dictCode: [{ required: true, message: "请输入编码", trigger: "blur" }],
|
||||
dictName: [{ required: true, message: "请输入名称", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const editFormRules = ref<FormRules>({
|
||||
dictName: [{ required: true, message: "请输入名称", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const typeFormRules = ref<FormRules>({
|
||||
dictType: [{ required: true, message: "请输入类型编码", trigger: "blur" }],
|
||||
dictName: [{ required: true, message: "请输入类型名称", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
if (!dictType.value) {
|
||||
ElMessage.warning("请先新增并选择字典类型");
|
||||
return;
|
||||
}
|
||||
form.value = {
|
||||
dictCode: "",
|
||||
dictName: "",
|
||||
sortNo: 100,
|
||||
};
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const openItems = async (row: { dictType: string; dictName: string }) => {
|
||||
dictType.value = row.dictType;
|
||||
currentType.value = row;
|
||||
itemsDrawerVisible.value = true;
|
||||
await load();
|
||||
};
|
||||
|
||||
const openCreateTypeDialog = () => {
|
||||
typeForm.value = {
|
||||
dictType: "",
|
||||
dictName: "",
|
||||
sortNo: 100,
|
||||
remark: "",
|
||||
};
|
||||
createTypeVisible.value = true;
|
||||
};
|
||||
|
||||
const loadTypes = async () => {
|
||||
if (!canRead.value) {
|
||||
typeOptions.value = [];
|
||||
dictType.value = "";
|
||||
return;
|
||||
}
|
||||
const resp = await fetchPlatformDictionaryTypes();
|
||||
typeOptions.value = resp?.data || [];
|
||||
if (dictType.value && !typeOptions.value.some((item) => item.dictType === dictType.value)) {
|
||||
dictType.value = "";
|
||||
currentType.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!canRead.value || !dictType.value) {
|
||||
rows.value = [];
|
||||
return;
|
||||
}
|
||||
const resp = await fetchPlatformDictionaries({ dictType: dictType.value });
|
||||
rows.value = resp?.data || [];
|
||||
};
|
||||
|
||||
const handleCreateType = async () => {
|
||||
if (!typeFormRef.value) return;
|
||||
const valid = await typeFormRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await createPlatformDictionaryType(typeForm.value);
|
||||
ElMessage.success("字典类型创建成功");
|
||||
createTypeVisible.value = false;
|
||||
await loadTypes();
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formRef.value) return;
|
||||
const valid = await formRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await createPlatformDictionary({
|
||||
dictType: dictType.value,
|
||||
dictCode: form.value.dictCode,
|
||||
dictName: form.value.dictName,
|
||||
sortNo: form.value.sortNo,
|
||||
});
|
||||
ElMessage.success("字典项创建成功");
|
||||
createVisible.value = false;
|
||||
form.value = { dictCode: "", dictName: "", sortNo: 100 };
|
||||
await load();
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
editId.value = row.id;
|
||||
editForm.value = {
|
||||
dictName: row.dictName || "",
|
||||
sortNo: row.sortNo || 100,
|
||||
status: row.status || "ENABLED",
|
||||
remark: row.remark || "",
|
||||
};
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editId.value) return;
|
||||
|
||||
if (!editFormRef.value) return;
|
||||
const valid = await editFormRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await updatePlatformDictionary(editId.value, editForm.value);
|
||||
ElMessage.success("字典项更新成功");
|
||||
editVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleEnable = async (row: any) => {
|
||||
await enablePlatformDictionary(row.id);
|
||||
ElMessage.success("已启用");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDisable = async (row: any) => {
|
||||
await disablePlatformDictionary(row.id);
|
||||
ElMessage.success("已停用");
|
||||
await load();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTypes();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,589 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<!-- Animated background -->
|
||||
<div class="bg-gradient"></div>
|
||||
<div class="bg-orbs">
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
<div class="orb orb-3"></div>
|
||||
<div class="orb orb-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Login card -->
|
||||
<div class="login-card" :class="{ 'card-visible': cardVisible }">
|
||||
<!-- Header section -->
|
||||
<div class="card-header">
|
||||
<div class="logo-icon">
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" rx="10" fill="url(#logo-grad)" />
|
||||
<path d="M12 20L18 26L28 14" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<defs>
|
||||
<linearGradient id="logo-grad" x1="0" y1="0" x2="40" y2="40">
|
||||
<stop stop-color="#6366f1"/>
|
||||
<stop offset="1" stop-color="#8b5cf6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="card-title">核销管理系统</h1>
|
||||
<p class="card-subtitle">平台管理端登录</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<el-form :model="form" class="login-form" @keyup.enter="handleLogin">
|
||||
<div class="input-group">
|
||||
<label class="input-label">手机号</label>
|
||||
<el-input
|
||||
v-model="form.phone"
|
||||
placeholder="请输入手机号"
|
||||
size="large"
|
||||
:prefix-icon="PhoneIcon"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label class="input-label">密码</label>
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
:prefix-icon="LockIcon"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label class="input-label">验证码</label>
|
||||
<div class="captcha-row">
|
||||
<el-input
|
||||
v-model="form.captchaCode"
|
||||
placeholder="请输入验证码"
|
||||
size="large"
|
||||
clearable
|
||||
class="captcha-input"
|
||||
/>
|
||||
<img
|
||||
v-if="captchaImg"
|
||||
:src="captchaImg"
|
||||
class="captcha-img"
|
||||
@click="loadCaptcha"
|
||||
title="点击刷新验证码"
|
||||
/>
|
||||
<div v-else class="captcha-placeholder" @click="loadCaptcha">加载中</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录错误提示 -->
|
||||
<div v-if="lockCountdown > 0" class="login-alert login-alert-danger">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" style="width:16px;height:16px;flex-shrink:0">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span>账号已锁定,请 {{ formatCountdown(lockCountdown) }} 后重试</span>
|
||||
</div>
|
||||
<div v-else-if="remainingAttempts !== null && remainingAttempts <= 3" class="login-alert login-alert-warning">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" style="width:16px;height:16px;flex-shrink:0">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.72-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span>您还有 {{ remainingAttempts }} 次尝试机会</span>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="login-btn"
|
||||
:loading="loading"
|
||||
:disabled="lockCountdown > 0"
|
||||
@click="handleLogin"
|
||||
>
|
||||
<span v-if="!loading">登 录</span>
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="card-footer">
|
||||
<span class="footer-text">© {{ new Date().getFullYear() }} 核销管理系统</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, onMounted, onUnmounted, h } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { platformLogin, fetchCaptcha } from "../../api/modules";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
|
||||
// Simple icon render functions for prefix-icon
|
||||
const PhoneIcon = h("i", { class: "login-icon" }, [
|
||||
h(
|
||||
"svg",
|
||||
{
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
"stroke-width": "2",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
style: "width:16px;height:16px",
|
||||
},
|
||||
[
|
||||
h("path", {
|
||||
d: "M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z",
|
||||
}),
|
||||
]
|
||||
),
|
||||
]);
|
||||
|
||||
const LockIcon = h("i", { class: "login-icon" }, [
|
||||
h(
|
||||
"svg",
|
||||
{
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
"stroke-width": "2",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
style: "width:16px;height:16px",
|
||||
},
|
||||
[
|
||||
h("rect", { x: "3", y: "11", width: "18", height: "11", rx: "2", ry: "2" }),
|
||||
h("path", { d: "M7 11V7a5 5 0 0110 0v4" }),
|
||||
]
|
||||
),
|
||||
]);
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const loading = ref(false);
|
||||
const cardVisible = ref(false);
|
||||
|
||||
const captchaId = ref("");
|
||||
const captchaImg = ref("");
|
||||
const remainingAttempts = ref<number | null>(null);
|
||||
const lockCountdown = ref(0);
|
||||
let lockTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const form = reactive({
|
||||
phone: "13900000000",
|
||||
password: "123456",
|
||||
captchaCode: "",
|
||||
});
|
||||
|
||||
const loadCaptcha = async () => {
|
||||
try {
|
||||
const resp = await fetchCaptcha();
|
||||
captchaId.value = resp?.data?.captchaId || "";
|
||||
captchaImg.value = resp?.data?.image || "";
|
||||
} catch {
|
||||
captchaImg.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const formatCountdown = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const startLockCountdown = (seconds: number) => {
|
||||
if (lockTimer) clearInterval(lockTimer);
|
||||
lockCountdown.value = seconds;
|
||||
lockTimer = setInterval(() => {
|
||||
lockCountdown.value--;
|
||||
if (lockCountdown.value <= 0) {
|
||||
if (lockTimer) clearInterval(lockTimer);
|
||||
lockTimer = null;
|
||||
remainingAttempts.value = null;
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
cardVisible.value = true;
|
||||
}, 100);
|
||||
loadCaptcha();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (lockTimer) clearInterval(lockTimer);
|
||||
});
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (loading.value || lockCountdown.value > 0) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
authStore.clearAuthStorage();
|
||||
const resp = await platformLogin({
|
||||
phone: form.phone,
|
||||
password: form.password,
|
||||
captchaId: captchaId.value,
|
||||
captchaCode: form.captchaCode,
|
||||
});
|
||||
remainingAttempts.value = null;
|
||||
authStore.saveAuthPayload({
|
||||
...(resp?.data || {}),
|
||||
scope: "PLATFORM",
|
||||
});
|
||||
ElMessage.success("登录成功");
|
||||
await router.push("/platform/tenants");
|
||||
} catch (e: any) {
|
||||
const data = e?.response?.data;
|
||||
const errors = data?.errors || {};
|
||||
if (errors.lockRemainingSeconds) {
|
||||
startLockCountdown(Number(errors.lockRemainingSeconds));
|
||||
}
|
||||
if (errors.remainingAttempts !== undefined) {
|
||||
remainingAttempts.value = Number(errors.remainingAttempts);
|
||||
}
|
||||
ElMessage.error(data?.message || e?.message || "登录失败");
|
||||
form.captchaCode = "";
|
||||
await loadCaptcha();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ===== Page layout ===== */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: "Inter", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
/* ===== Animated gradient background ===== */
|
||||
.bg-gradient {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--wo-login-bg-platform);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ===== Floating orbs ===== */
|
||||
.bg-orbs {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.5;
|
||||
animation: float 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: radial-gradient(circle, var(--wo-login-orb-platform-1), transparent 70%);
|
||||
top: -100px;
|
||||
left: -100px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 350px;
|
||||
height: 350px;
|
||||
background: radial-gradient(circle, var(--wo-login-orb-platform-2), transparent 70%);
|
||||
bottom: -80px;
|
||||
right: -80px;
|
||||
animation-delay: -5s;
|
||||
animation-duration: 25s;
|
||||
}
|
||||
|
||||
.orb-3 {
|
||||
width: 250px;
|
||||
height: 250px;
|
||||
background: radial-gradient(circle, var(--wo-login-orb-platform-3), transparent 70%);
|
||||
top: 50%;
|
||||
right: 15%;
|
||||
animation-delay: -10s;
|
||||
animation-duration: 18s;
|
||||
}
|
||||
|
||||
.orb-4 {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: radial-gradient(circle, var(--wo-login-orb-platform-4), transparent 70%);
|
||||
bottom: 30%;
|
||||
left: 10%;
|
||||
animation-delay: -15s;
|
||||
animation-duration: 22s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
25% { transform: translate(30px, -40px) scale(1.05); }
|
||||
50% { transform: translate(-20px, 20px) scale(0.95); }
|
||||
75% { transform: translate(40px, 10px) scale(1.02); }
|
||||
}
|
||||
|
||||
/* ===== Glass card ===== */
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 420px;
|
||||
padding: 48px 40px 36px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 20px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
|
||||
/* Entrance animation */
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.96);
|
||||
transition: all 0.7s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.login-card.card-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
/* ===== Header ===== */
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: 16px;
|
||||
animation: logo-pop 0.6s 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
.logo-icon svg {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
filter: drop-shadow(0 4px 12px rgba(99, 102, 241, 0.4));
|
||||
}
|
||||
|
||||
@keyframes logo-pop {
|
||||
from { transform: scale(0); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin: 0 0 6px 0;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ===== Form ===== */
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
/* Element Plus input overrides */
|
||||
.login-form :deep(.el-input__wrapper) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
box-shadow: none;
|
||||
transition: all 0.3s ease;
|
||||
padding: 4px 14px;
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__wrapper:hover) {
|
||||
border-color: rgba(139, 92, 246, 0.4);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__wrapper.is-focus) {
|
||||
border-color: #8b5cf6;
|
||||
background: rgba(139, 92, 246, 0.08);
|
||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__inner) {
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__inner::placeholder) {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__prefix) {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__suffix) {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.login-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ===== Login button ===== */
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
margin-top: 8px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 4px;
|
||||
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #a78bfa 100%);
|
||||
background-size: 200% 200%;
|
||||
animation: btn-shimmer 3s ease-in-out infinite;
|
||||
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.35);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 24px rgba(99, 102, 241, 0.5);
|
||||
}
|
||||
|
||||
.login-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@keyframes btn-shimmer {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
/* ===== Footer ===== */
|
||||
.card-footer {
|
||||
text-align: center;
|
||||
margin-top: 32px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
width: calc(100vw - 32px);
|
||||
padding: 36px 24px 28px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Captcha ===== */
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.captcha-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.captcha-img {
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.captcha-img:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.captcha-placeholder {
|
||||
width: 130px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ===== Login alerts ===== */
|
||||
.login-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
animation: alert-in 0.3s ease;
|
||||
}
|
||||
|
||||
.login-alert-warning {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
border: 1px solid rgba(234, 179, 8, 0.3);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.login-alert-danger {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
@keyframes alert-in {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<PageContainer title="平台菜单与权限">
|
||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<el-tab-pane label="菜单管理" name="menus">
|
||||
<el-space wrap>
|
||||
<el-button v-if="canManage" type="primary" @click="openCreateDrawer">新增菜单</el-button>
|
||||
<el-button v-if="canManage" :disabled="rows.length === 0" @click="handleSaveOrder">保存排序</el-button>
|
||||
<el-button @click="loadMenus">刷新</el-button>
|
||||
</el-space>
|
||||
|
||||
<el-table :data="rows">
|
||||
<el-table-column prop="menuCode" label="菜单编码" width="180" />
|
||||
<el-table-column prop="menuName" label="菜单名称" width="180" />
|
||||
<el-table-column prop="routePath" label="路由地址" min-width="240" />
|
||||
<el-table-column prop="permissionCode" label="权限码" min-width="220" />
|
||||
<el-table-column label="排序" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-if="canManage" v-model="row.sortNo" :min="1" />
|
||||
<span v-else>{{ row.sortNo }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column label="操作" width="260">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button v-if="canManage" size="small" @click="openBindRoles(row)">绑角色</el-button>
|
||||
<el-button
|
||||
v-if="canManage"
|
||||
size="small"
|
||||
:type="row.status === 'ENABLED' ? 'warning' : 'success'"
|
||||
@click="toggleStatus(row)"
|
||||
>
|
||||
{{ row.status === "ENABLED" ? "停用" : "启用" }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="权限管理" name="permissions">
|
||||
<el-button type="primary" @click="loadPermissions">刷新</el-button>
|
||||
<el-table :data="permissions" class="mt-md">
|
||||
<el-table-column prop="permissionCode" label="权限码" min-width="260" />
|
||||
<el-table-column prop="permissionName" label="权限名称" min-width="220" />
|
||||
<el-table-column prop="module" label="模块" width="160" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-drawer v-model="createVisible" title="新增平台菜单" :size="DRAWER_SIZE.md" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="菜单编码" prop="menuCode">
|
||||
<el-input v-model="form.menuCode" />
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单名称" prop="menuName">
|
||||
<el-input v-model="form.menuName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="路由" prop="routePath">
|
||||
<el-input v-model="form.routePath" placeholder="/platform/xxx" />
|
||||
</el-form-item>
|
||||
<el-form-item label="权限码" prop="permissionCode">
|
||||
<el-input v-model="form.permissionCode" placeholder="如 platform.menu.manage" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sortNo" :min="1" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="handleCreate">创建</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑平台菜单" :width="DIALOG_WIDTH.md">
|
||||
<el-form ref="editFormRef" :model="editForm" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="菜单名称" prop="menuName">
|
||||
<el-input v-model="editForm.menuName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="路由地址" prop="routePath">
|
||||
<el-input v-model="editForm.routePath" />
|
||||
</el-form-item>
|
||||
<el-form-item label="权限码" prop="permissionCode">
|
||||
<el-input v-model="editForm.permissionCode" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="editForm.sortNo" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="editForm.status">
|
||||
<el-option label="启用" value="ENABLED" />
|
||||
<el-option label="停用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="bindVisible" title="菜单绑定角色" :width="DIALOG_WIDTH.md">
|
||||
<el-checkbox-group v-model="selectedRoleIds">
|
||||
<el-checkbox v-for="role in roles" :key="role.id" :label="role.id" class="mr-lg mb-sm">
|
||||
{{ role.roleName }}({{ role.roleCode }})
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<template #footer>
|
||||
<el-button @click="bindVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitBindRoles">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
import { ElMessage } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
bindPlatformMenuRoles,
|
||||
createPlatformMenu,
|
||||
fetchPlatformMenuRoles,
|
||||
fetchPlatformMenus,
|
||||
fetchPlatformPermissions,
|
||||
fetchPlatformRoles,
|
||||
reorderPlatformMenus,
|
||||
updatePlatformMenu,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const canManage = computed(() => authStore.hasPermission(PERMS.platform.menuManage));
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const activeTab = ref(String(route.query.tab || "menus") === "permissions" ? "permissions" : "menus");
|
||||
const rows = ref<any[]>([]);
|
||||
const permissions = ref<any[]>([]);
|
||||
const roles = ref<any[]>([]);
|
||||
const selectedMenuId = ref<number | null>(null);
|
||||
const selectedRoleIds = ref<number[]>([]);
|
||||
const bindVisible = ref(false);
|
||||
const createVisible = ref(false);
|
||||
const editVisible = ref(false);
|
||||
const editId = ref<number | null>(null);
|
||||
const editForm = ref<any>({});
|
||||
const form = ref({
|
||||
menuCode: "",
|
||||
menuName: "",
|
||||
routePath: "",
|
||||
permissionCode: "",
|
||||
sortNo: 100,
|
||||
});
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const editFormRef = ref<FormInstance>();
|
||||
const formRules = ref<FormRules>({
|
||||
menuCode: [{ required: true, message: "请输入菜单编码", trigger: "blur" }],
|
||||
menuName: [{ required: true, message: "请输入菜单名称", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
form.value = {
|
||||
menuCode: "",
|
||||
menuName: "",
|
||||
routePath: "",
|
||||
permissionCode: "",
|
||||
sortNo: 100,
|
||||
};
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const loadMenus = async () => {
|
||||
const resp = await fetchPlatformMenus();
|
||||
rows.value = resp?.data?.list || [];
|
||||
};
|
||||
|
||||
const loadPermissions = async () => {
|
||||
const resp = await fetchPlatformPermissions();
|
||||
permissions.value = resp?.data?.list || [];
|
||||
};
|
||||
|
||||
const onTabChange = async (name: string | number) => {
|
||||
const tab = String(name);
|
||||
await router.replace({ path: "/platform/menus", query: tab === "permissions" ? { tab: "permissions" } : {} });
|
||||
if (tab === "permissions") {
|
||||
await loadPermissions();
|
||||
} else {
|
||||
await loadMenus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formRef.value) return;
|
||||
const valid = await formRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await createPlatformMenu({
|
||||
menuCode: form.value.menuCode.trim(),
|
||||
menuName: form.value.menuName.trim(),
|
||||
routePath: form.value.routePath.trim(),
|
||||
permissionCode: form.value.permissionCode.trim(),
|
||||
sortNo: form.value.sortNo,
|
||||
});
|
||||
ElMessage.success("平台菜单创建成功");
|
||||
createVisible.value = false;
|
||||
await loadMenus();
|
||||
};
|
||||
|
||||
const openEdit = (row: any) => {
|
||||
editId.value = row.id;
|
||||
editForm.value = { ...row };
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editId.value) return;
|
||||
|
||||
if (!editFormRef.value) return;
|
||||
const valid = await editFormRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await updatePlatformMenu(editId.value, {
|
||||
menuName: String(editForm.value.menuName || "").trim(),
|
||||
routePath: String(editForm.value.routePath || "").trim(),
|
||||
permissionCode: String(editForm.value.permissionCode || "").trim(),
|
||||
sortNo: Number(editForm.value.sortNo || 100),
|
||||
status: String(editForm.value.status || "ENABLED") as "ENABLED" | "DISABLED",
|
||||
});
|
||||
ElMessage.success("平台菜单更新成功");
|
||||
editVisible.value = false;
|
||||
await loadMenus();
|
||||
};
|
||||
|
||||
const handleSaveOrder = async () => {
|
||||
await reorderPlatformMenus({
|
||||
menus: rows.value.map((x) => ({ id: Number(x.id), sortNo: Number(x.sortNo) })),
|
||||
});
|
||||
ElMessage.success("平台菜单排序已保存");
|
||||
await loadMenus();
|
||||
};
|
||||
|
||||
const toggleStatus = async (row: any) => {
|
||||
const nextStatus = row.status === "ENABLED" ? "DISABLED" : "ENABLED";
|
||||
await updatePlatformMenu(row.id, {
|
||||
menuName: row.menuName,
|
||||
routePath: row.routePath,
|
||||
permissionCode: row.permissionCode,
|
||||
sortNo: row.sortNo,
|
||||
status: nextStatus,
|
||||
});
|
||||
ElMessage.success(nextStatus === "ENABLED" ? "菜单已启用" : "菜单已停用");
|
||||
await loadMenus();
|
||||
};
|
||||
|
||||
const openBindRoles = async (row: any) => {
|
||||
selectedMenuId.value = row.id;
|
||||
const [roleResp, menuRoleResp] = await Promise.all([fetchPlatformRoles(), fetchPlatformMenuRoles(row.id)]);
|
||||
roles.value = roleResp?.data?.list || [];
|
||||
selectedRoleIds.value = menuRoleResp?.data || [];
|
||||
bindVisible.value = true;
|
||||
};
|
||||
|
||||
const submitBindRoles = async () => {
|
||||
if (!selectedMenuId.value) return;
|
||||
await bindPlatformMenuRoles(selectedMenuId.value, { roleIds: selectedRoleIds.value });
|
||||
ElMessage.success("平台菜单角色绑定成功");
|
||||
bindVisible.value = false;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (activeTab.value === "permissions") {
|
||||
await loadPermissions();
|
||||
} else {
|
||||
await loadMenus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,536 @@
|
||||
<template>
|
||||
<PageContainer title="通知网关配置">
|
||||
<QueryToolbar>
|
||||
<el-button v-if="canRead" @click="load">刷新配置</el-button>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-alert
|
||||
v-if="!canRead"
|
||||
title="当前账号无查看平台通知网关配置权限"
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-md"
|
||||
/>
|
||||
|
||||
<el-tabs v-else v-model="activeTab">
|
||||
<el-tab-pane label="邮件网关" name="EMAIL">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-title">SMTP / 邮件发送配置</div>
|
||||
<div class="card-subtitle">配置完成后,通知邮件和租户管理员账号邮件都会优先读取这里的网关参数。</div>
|
||||
</div>
|
||||
<el-tag :type="emailForm.status === 'ENABLED' ? 'success' : 'info'">{{ emailForm.status === "ENABLED" ? "已启用" : "未启用" }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="autofill-decoy" aria-hidden="true">
|
||||
<input type="text" name="username" autocomplete="username" tabindex="-1" />
|
||||
<input type="password" name="password" autocomplete="current-password" tabindex="-1" />
|
||||
</div>
|
||||
|
||||
<el-form :model="emailForm" label-position="left" :label-width="LABEL_WIDTH.lg" class="gateway-form" autocomplete="off">
|
||||
<el-form-item label="网关名称">
|
||||
<el-input v-model="emailForm.gatewayName" placeholder="例如 企业邮件网关" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Provider">
|
||||
<el-input v-model="emailForm.providerCode" placeholder="SMTP" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="emailForm.status" class="w-full">
|
||||
<el-option label="启用" value="ENABLED" />
|
||||
<el-option label="停用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="SMTP Host">
|
||||
<el-input v-model="emailForm.host" placeholder="例如 smtp.qq.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SMTP Port">
|
||||
<el-input-number v-model="emailForm.port" :min="1" :max="65535" />
|
||||
</el-form-item>
|
||||
<el-form-item label="用户名">
|
||||
<el-input
|
||||
v-model="emailForm.username"
|
||||
name="notify-gateway-email-username"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:readonly="emailCredentialReadonly"
|
||||
placeholder="通常为邮箱账号"
|
||||
@focus="unlockEmailCredentialInputs"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="密码 / 授权码">
|
||||
<el-input
|
||||
v-model="emailForm.password"
|
||||
name="notify-gateway-email-password"
|
||||
autocomplete="new-password"
|
||||
:readonly="emailCredentialReadonly"
|
||||
show-password
|
||||
placeholder="保存时将加密落库"
|
||||
@focus="unlockEmailCredentialInputs"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="协议">
|
||||
<el-input v-model="emailForm.protocol" placeholder="smtp" />
|
||||
</el-form-item>
|
||||
<el-form-item label="发件邮箱">
|
||||
<el-input v-model="emailForm.fromAddress" placeholder="例如 no-reply@company.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认主题">
|
||||
<el-input v-model="emailForm.defaultSubject" placeholder="系统通知" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SMTP Auth">
|
||||
<el-switch v-model="emailForm.smtpAuth" />
|
||||
</el-form-item>
|
||||
<el-form-item label="STARTTLS">
|
||||
<el-switch v-model="emailForm.starttlsEnable" />
|
||||
</el-form-item>
|
||||
<el-form-item label="STARTTLS Required">
|
||||
<el-switch v-model="emailForm.starttlsRequired" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SSL">
|
||||
<el-switch v-model="emailForm.sslEnable" />
|
||||
</el-form-item>
|
||||
<el-form-item label="连接超时(ms)">
|
||||
<el-input-number v-model="emailForm.connectTimeoutMs" :min="1000" :step="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="读取超时(ms)">
|
||||
<el-input-number v-model="emailForm.timeoutMs" :min="1000" :step="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="写入超时(ms)">
|
||||
<el-input-number v-model="emailForm.writeTimeoutMs" :min="1000" :step="1000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="熔断阈值">
|
||||
<el-input-number v-model="emailForm.failureThreshold" :min="1" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="熔断冷却(s)">
|
||||
<el-input-number v-model="emailForm.breakerCooldownSeconds" :min="1" :step="30" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="emailForm.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="action-row">
|
||||
<el-button v-if="canManage" type="primary" @click="handleSaveEmail">保存邮件网关</el-button>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="test-block">
|
||||
<div class="test-title">连通性测试</div>
|
||||
<el-form :model="emailForm" label-position="left" :label-width="LABEL_WIDTH.lg">
|
||||
<el-form-item label="测试收件人">
|
||||
<el-input v-model="emailForm.testReceiverRef" placeholder="例如 test@example.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="测试主题">
|
||||
<el-input v-model="emailForm.testSubject" placeholder="通知网关测试" />
|
||||
</el-form-item>
|
||||
<el-form-item label="测试内容">
|
||||
<el-input v-model="emailForm.testContent" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button v-if="canManage" @click="handleTestEmail">发送测试邮件</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="短信网关" name="SMS">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="card-title">短信网关配置</div>
|
||||
<div class="card-subtitle">当前版本支持平台统一保存短信参数,并提供参数校验/模拟受理测试。</div>
|
||||
</div>
|
||||
<el-tag :type="smsForm.status === 'ENABLED' ? 'success' : 'info'">{{ smsForm.status === "ENABLED" ? "已启用" : "未启用" }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
title="短信供应商回执适配入口:POST /api/notifications/receipts/providers/aliyun-sms,支持单条 JSON、JSON 数组与表单参数回调。"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="mb-md"
|
||||
/>
|
||||
|
||||
<div class="autofill-decoy" aria-hidden="true">
|
||||
<input type="text" name="username" autocomplete="username" tabindex="-1" />
|
||||
<input type="password" name="password" autocomplete="current-password" tabindex="-1" />
|
||||
</div>
|
||||
|
||||
<el-form :model="smsForm" label-position="left" :label-width="LABEL_WIDTH.lg" class="gateway-form" autocomplete="off">
|
||||
<el-form-item label="网关名称">
|
||||
<el-input v-model="smsForm.gatewayName" placeholder="例如 阿里云短信" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Provider">
|
||||
<el-select v-model="smsForm.providerCode" class="w-full">
|
||||
<el-option label="MOCK" value="MOCK" />
|
||||
<el-option label="ALIYUN_SMS" value="ALIYUN_SMS" />
|
||||
<el-option label="CUSTOM_SMS" value="CUSTOM_SMS" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="smsForm.status" class="w-full">
|
||||
<el-option label="启用" value="ENABLED" />
|
||||
<el-option label="停用" value="DISABLED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="模拟模式">
|
||||
<el-switch v-model="smsForm.mockEnabled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="服务地址">
|
||||
<el-input v-model="smsForm.endpoint" placeholder="例如 dysmsapi.aliyuncs.com" />
|
||||
</el-form-item>
|
||||
<el-form-item label="AccessKeyId">
|
||||
<el-input
|
||||
v-model="smsForm.accessKeyId"
|
||||
name="notify-gateway-sms-access-key-id"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:readonly="smsCredentialReadonly"
|
||||
placeholder="保存时将加密落库"
|
||||
@focus="unlockSmsCredentialInputs"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="AccessKeySecret">
|
||||
<el-input
|
||||
v-model="smsForm.accessKeySecret"
|
||||
name="notify-gateway-sms-access-key-secret"
|
||||
autocomplete="new-password"
|
||||
:readonly="smsCredentialReadonly"
|
||||
show-password
|
||||
placeholder="保存时将加密落库"
|
||||
@focus="unlockSmsCredentialInputs"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="RegionId">
|
||||
<el-input v-model="smsForm.regionId" placeholder="例如 cn-hangzhou" />
|
||||
</el-form-item>
|
||||
<el-form-item label="短信签名">
|
||||
<el-input v-model="smsForm.signName" placeholder="例如 会议核销系统" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板编码">
|
||||
<el-input v-model="smsForm.templateCode" placeholder="例如 SMS_0000001" />
|
||||
</el-form-item>
|
||||
<el-form-item label="静默窗口(s)">
|
||||
<el-input-number v-model="smsForm.quietPeriodSeconds" :min="0" :step="5" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单日上限">
|
||||
<el-input-number v-model="smsForm.dailyLimit" :min="1" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="熔断阈值">
|
||||
<el-input-number v-model="smsForm.failureThreshold" :min="1" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="熔断冷却(s)">
|
||||
<el-input-number v-model="smsForm.breakerCooldownSeconds" :min="1" :step="30" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="smsForm.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="action-row">
|
||||
<el-button v-if="canManage" type="primary" @click="handleSaveSms">保存短信网关</el-button>
|
||||
</div>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<div class="test-block">
|
||||
<div class="test-title">连通性测试</div>
|
||||
<el-form :model="smsForm" label-position="left" :label-width="LABEL_WIDTH.lg">
|
||||
<el-form-item label="测试手机号">
|
||||
<el-input v-model="smsForm.testReceiverRef" placeholder="例如 13800138000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="测试主题">
|
||||
<el-input v-model="smsForm.testSubject" placeholder="通知网关测试" />
|
||||
</el-form-item>
|
||||
<el-form-item label="测试内容">
|
||||
<el-input v-model="smsForm.testContent" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-button v-if="canManage" @click="handleTestSms">发送测试短信</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { LABEL_WIDTH } from "../../constants/ui";
|
||||
import { fetchPlatformNotifyGateways, savePlatformNotifyGateway, testPlatformNotifyGateway } from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
|
||||
type GatewayRow = {
|
||||
channelCode: "EMAIL" | "SMS";
|
||||
gatewayName: string;
|
||||
providerCode: string;
|
||||
status: "ENABLED" | "DISABLED";
|
||||
remark?: string;
|
||||
config?: Record<string, any>;
|
||||
};
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.platform.notifyGatewayRead));
|
||||
const canManage = computed(() => authStore.hasPermission(PERMS.platform.notifyGatewayManage));
|
||||
const activeTab = ref<"EMAIL" | "SMS">("EMAIL");
|
||||
const emailCredentialReadonly = ref(true);
|
||||
const smsCredentialReadonly = ref(true);
|
||||
|
||||
const unlockEmailCredentialInputs = () => {
|
||||
emailCredentialReadonly.value = false;
|
||||
};
|
||||
|
||||
const unlockSmsCredentialInputs = () => {
|
||||
smsCredentialReadonly.value = false;
|
||||
};
|
||||
|
||||
const emailForm = ref({
|
||||
gatewayName: "邮件网关",
|
||||
providerCode: "SMTP",
|
||||
status: "DISABLED" as "ENABLED" | "DISABLED",
|
||||
host: "",
|
||||
port: 587,
|
||||
username: "",
|
||||
password: "",
|
||||
protocol: "smtp",
|
||||
fromAddress: "",
|
||||
defaultSubject: "系统通知",
|
||||
smtpAuth: true,
|
||||
starttlsEnable: true,
|
||||
starttlsRequired: false,
|
||||
sslEnable: false,
|
||||
connectTimeoutMs: 5000,
|
||||
timeoutMs: 5000,
|
||||
writeTimeoutMs: 5000,
|
||||
failureThreshold: 3,
|
||||
breakerCooldownSeconds: 300,
|
||||
remark: "",
|
||||
testReceiverRef: "",
|
||||
testSubject: "通知网关测试",
|
||||
testContent: "这是一封来自平台通知网关配置中心的测试邮件。",
|
||||
});
|
||||
|
||||
const smsForm = ref({
|
||||
gatewayName: "短信网关",
|
||||
providerCode: "MOCK",
|
||||
status: "DISABLED" as "ENABLED" | "DISABLED",
|
||||
endpoint: "",
|
||||
accessKeyId: "",
|
||||
accessKeySecret: "",
|
||||
signName: "",
|
||||
templateCode: "",
|
||||
regionId: "cn-hangzhou",
|
||||
mockEnabled: true,
|
||||
quietPeriodSeconds: 30,
|
||||
dailyLimit: 10,
|
||||
failureThreshold: 3,
|
||||
breakerCooldownSeconds: 300,
|
||||
remark: "",
|
||||
testReceiverRef: "",
|
||||
testSubject: "通知网关测试",
|
||||
testContent: "这是一条来自平台通知网关配置中心的测试短信。",
|
||||
});
|
||||
|
||||
const applyGatewayRows = (rows: GatewayRow[]) => {
|
||||
const email = rows.find((item) => item.channelCode === "EMAIL");
|
||||
const sms = rows.find((item) => item.channelCode === "SMS");
|
||||
if (email) {
|
||||
emailForm.value = {
|
||||
...emailForm.value,
|
||||
gatewayName: email.gatewayName || "邮件网关",
|
||||
providerCode: email.providerCode || "SMTP",
|
||||
status: email.status || "DISABLED",
|
||||
host: String(email.config?.host || ""),
|
||||
port: Number(email.config?.port || 587),
|
||||
username: String(email.config?.username || ""),
|
||||
password: String(email.config?.password || ""),
|
||||
protocol: String(email.config?.protocol || "smtp"),
|
||||
fromAddress: String(email.config?.fromAddress || ""),
|
||||
defaultSubject: String(email.config?.defaultSubject || "系统通知"),
|
||||
smtpAuth: !!email.config?.smtpAuth,
|
||||
starttlsEnable: email.config?.starttlsEnable !== false,
|
||||
starttlsRequired: !!email.config?.starttlsRequired,
|
||||
sslEnable: !!email.config?.sslEnable,
|
||||
connectTimeoutMs: Number(email.config?.connectTimeoutMs || 5000),
|
||||
timeoutMs: Number(email.config?.timeoutMs || 5000),
|
||||
writeTimeoutMs: Number(email.config?.writeTimeoutMs || 5000),
|
||||
failureThreshold: Number(email.config?.failureThreshold || 3),
|
||||
breakerCooldownSeconds: Number(email.config?.breakerCooldownSeconds || 300),
|
||||
remark: String(email.remark || ""),
|
||||
};
|
||||
}
|
||||
if (sms) {
|
||||
smsForm.value = {
|
||||
...smsForm.value,
|
||||
gatewayName: sms.gatewayName || "短信网关",
|
||||
providerCode: sms.providerCode || "MOCK",
|
||||
status: sms.status || "DISABLED",
|
||||
endpoint: String(sms.config?.endpoint || ""),
|
||||
accessKeyId: String(sms.config?.accessKeyId || ""),
|
||||
accessKeySecret: String(sms.config?.accessKeySecret || ""),
|
||||
signName: String(sms.config?.signName || ""),
|
||||
templateCode: String(sms.config?.templateCode || ""),
|
||||
regionId: String(sms.config?.regionId || "cn-hangzhou"),
|
||||
mockEnabled: sms.config?.mockEnabled !== false,
|
||||
quietPeriodSeconds: Number(sms.config?.quietPeriodSeconds || 30),
|
||||
dailyLimit: Number(sms.config?.dailyLimit || 10),
|
||||
failureThreshold: Number(sms.config?.failureThreshold || 3),
|
||||
breakerCooldownSeconds: Number(sms.config?.breakerCooldownSeconds || 300),
|
||||
remark: String(sms.remark || ""),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!canRead.value) {
|
||||
return;
|
||||
}
|
||||
const resp = await fetchPlatformNotifyGateways();
|
||||
applyGatewayRows(Array.isArray(resp?.data) ? resp.data : []);
|
||||
emailCredentialReadonly.value = true;
|
||||
smsCredentialReadonly.value = true;
|
||||
};
|
||||
|
||||
const handleSaveEmail = async () => {
|
||||
await savePlatformNotifyGateway("EMAIL", {
|
||||
gatewayName: emailForm.value.gatewayName.trim(),
|
||||
providerCode: emailForm.value.providerCode.trim() || "SMTP",
|
||||
status: emailForm.value.status,
|
||||
remark: emailForm.value.remark.trim() || undefined,
|
||||
config: {
|
||||
host: emailForm.value.host.trim(),
|
||||
port: emailForm.value.port,
|
||||
username: emailForm.value.username.trim(),
|
||||
password: emailForm.value.password,
|
||||
protocol: emailForm.value.protocol.trim() || "smtp",
|
||||
fromAddress: emailForm.value.fromAddress.trim(),
|
||||
defaultSubject: emailForm.value.defaultSubject.trim() || "系统通知",
|
||||
smtpAuth: emailForm.value.smtpAuth,
|
||||
starttlsEnable: emailForm.value.starttlsEnable,
|
||||
starttlsRequired: emailForm.value.starttlsRequired,
|
||||
sslEnable: emailForm.value.sslEnable,
|
||||
connectTimeoutMs: emailForm.value.connectTimeoutMs,
|
||||
timeoutMs: emailForm.value.timeoutMs,
|
||||
writeTimeoutMs: emailForm.value.writeTimeoutMs,
|
||||
failureThreshold: emailForm.value.failureThreshold,
|
||||
breakerCooldownSeconds: emailForm.value.breakerCooldownSeconds,
|
||||
},
|
||||
});
|
||||
ElMessage.success("邮件网关配置已保存");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleSaveSms = async () => {
|
||||
await savePlatformNotifyGateway("SMS", {
|
||||
gatewayName: smsForm.value.gatewayName.trim(),
|
||||
providerCode: smsForm.value.providerCode.trim() || "MOCK",
|
||||
status: smsForm.value.status,
|
||||
remark: smsForm.value.remark.trim() || undefined,
|
||||
config: {
|
||||
endpoint: smsForm.value.endpoint.trim(),
|
||||
accessKeyId: smsForm.value.accessKeyId.trim(),
|
||||
accessKeySecret: smsForm.value.accessKeySecret,
|
||||
signName: smsForm.value.signName.trim(),
|
||||
templateCode: smsForm.value.templateCode.trim(),
|
||||
regionId: smsForm.value.regionId.trim() || "cn-hangzhou",
|
||||
mockEnabled: smsForm.value.mockEnabled,
|
||||
quietPeriodSeconds: smsForm.value.quietPeriodSeconds,
|
||||
dailyLimit: smsForm.value.dailyLimit,
|
||||
failureThreshold: smsForm.value.failureThreshold,
|
||||
breakerCooldownSeconds: smsForm.value.breakerCooldownSeconds,
|
||||
},
|
||||
});
|
||||
ElMessage.success("短信网关配置已保存");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleTestEmail = async () => {
|
||||
await testPlatformNotifyGateway("EMAIL", {
|
||||
receiverRef: emailForm.value.testReceiverRef.trim(),
|
||||
subject: emailForm.value.testSubject.trim(),
|
||||
content: emailForm.value.testContent.trim(),
|
||||
});
|
||||
ElMessage.success("测试邮件已发送");
|
||||
};
|
||||
|
||||
const handleTestSms = async () => {
|
||||
const resp = await testPlatformNotifyGateway("SMS", {
|
||||
receiverRef: smsForm.value.testReceiverRef.trim(),
|
||||
subject: smsForm.value.testSubject.trim(),
|
||||
content: smsForm.value.testContent.trim(),
|
||||
});
|
||||
ElMessage.success(String(resp?.data?.message || "测试短信已受理"));
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
margin-top: 6px;
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.gateway-form {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.autofill-decoy {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.autofill-decoy input {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.test-block {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.test-title {
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<PageContainer title="平台权限管理">
|
||||
<QueryToolbar>
|
||||
<el-button v-if="canRead" type="primary" @click="load">刷新</el-button>
|
||||
</QueryToolbar>
|
||||
<el-table :data="rows">
|
||||
<el-table-column prop="permissionCode" label="权限码" min-width="260" />
|
||||
<el-table-column prop="permissionName" label="权限名称" min-width="220" />
|
||||
<el-table-column prop="module" label="模块" width="160" />
|
||||
</el-table>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { fetchPlatformPermissions } from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const rows = ref<any[]>([]);
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.platform.permissionRead));
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const resp = await fetchPlatformPermissions();
|
||||
rows.value = resp?.data?.list || [];
|
||||
} catch (e: any) {
|
||||
rows.value = [];
|
||||
ElMessage.error(e?.response?.data?.message || "平台权限数据加载失败");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<PageContainer title="平台角色管理">
|
||||
<QueryToolbar>
|
||||
<el-button v-if="canManage" type="primary" @click="openCreateDrawer">新增角色</el-button>
|
||||
<el-button v-if="canRead" @click="load">刷新</el-button>
|
||||
</QueryToolbar>
|
||||
<el-table :data="rows">
|
||||
<el-table-column prop="roleCode" label="角色编码" />
|
||||
<el-table-column prop="roleName" label="角色名称" />
|
||||
<el-table-column prop="status" label="状态" width="120" :formatter="statusFormatter" />
|
||||
<el-table-column label="操作" width="360">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" @click="handleRename(row)">改名</el-button>
|
||||
<el-button v-if="canManage && canReadPerms" size="small" @click="openBindPermissions(row)">绑权限</el-button>
|
||||
<el-button v-if="canManage" size="small" @click="openBindMenus(row)">绑菜单</el-button>
|
||||
<el-button v-if="canManage && row.status === 'ENABLED'" size="small" type="warning" @click="handleDisable(row.id)">
|
||||
停用
|
||||
</el-button>
|
||||
<el-button v-else-if="canManage" size="small" type="success" @click="handleEnable(row.id)">启用</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-drawer v-model="createVisible" title="新增平台角色" :size="DRAWER_SIZE.sm" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="角色编码" prop="roleCode">
|
||||
<el-input v-model="form.roleCode" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色名称" prop="roleName">
|
||||
<el-input v-model="form.roleName" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="handleCreate">创建</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="bindDialogVisible" title="平台角色权限绑定" :width="DIALOG_WIDTH.md">
|
||||
<div v-if="permissionGroups.length === 0">暂无可绑定权限</div>
|
||||
<div v-for="group in permissionGroups" :key="group.module" class="mb-md">
|
||||
<div class="text-title mb-xs">{{ group.module }}</div>
|
||||
<el-checkbox-group v-model="selectedPermissionIds">
|
||||
<el-checkbox v-for="perm in group.items" :key="perm.id" :label="perm.id" class="mr-lg">
|
||||
{{ perm.permissionName }}({{ perm.permissionCode }})
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="bindDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitBindPermissions">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="bindMenuVisible" title="平台角色菜单绑定" :width="DIALOG_WIDTH.md">
|
||||
<div v-if="menus.length === 0">暂无可绑定菜单</div>
|
||||
<el-checkbox-group v-model="selectedMenuIds">
|
||||
<el-checkbox v-for="menu in menus" :key="menu.id" :label="menu.id" class="mr-lg mb-xs">
|
||||
{{ menu.menuName }}({{ menu.routePath }})
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<template #footer>
|
||||
<el-button @click="bindMenuVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitBindMenus">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import {
|
||||
bindPlatformRoleMenus,
|
||||
bindPlatformRolePermissions,
|
||||
createPlatformRole,
|
||||
disablePlatformRole,
|
||||
enablePlatformRole,
|
||||
fetchPlatformMenus,
|
||||
fetchPlatformRoleMenus,
|
||||
fetchPlatformPermissions,
|
||||
fetchPlatformRolePermissions,
|
||||
fetchPlatformRoles,
|
||||
updatePlatformRole,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const rows = ref<any[]>([]);
|
||||
const permissions = ref<any[]>([]);
|
||||
const menus = ref<any[]>([]);
|
||||
const selectedRoleId = ref<number | null>(null);
|
||||
const selectedPermissionIds = ref<number[]>([]);
|
||||
const selectedMenuIds = ref<number[]>([]);
|
||||
const createVisible = ref(false);
|
||||
const bindDialogVisible = ref(false);
|
||||
const bindMenuVisible = ref(false);
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.platform.roleRead));
|
||||
const canManage = computed(() => authStore.hasPermission(PERMS.platform.roleManage));
|
||||
const canReadPerms = computed(() => authStore.hasPermission(PERMS.platform.permissionRead));
|
||||
const form = ref({
|
||||
roleCode: "PLATFORM_CUSTOM_" + Date.now(),
|
||||
roleName: "平台自定义角色",
|
||||
});
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
|
||||
const formRules = ref<FormRules>({
|
||||
roleCode: [{ required: true, message: "请输入角色编码", trigger: "blur" }],
|
||||
roleName: [{ required: true, message: "请输入角色名称", trigger: "blur" }],
|
||||
});
|
||||
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
form.value = {
|
||||
roleCode: "PLATFORM_CUSTOM_" + Date.now(),
|
||||
roleName: "平台自定义角色",
|
||||
};
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const permissionGroups = computed(() => {
|
||||
const grouped: Record<string, any[]> = {};
|
||||
permissions.value.forEach((p) => {
|
||||
const key = p.module || "other";
|
||||
if (!grouped[key]) grouped[key] = [];
|
||||
grouped[key].push(p);
|
||||
});
|
||||
return Object.keys(grouped).map((module) => ({ module, items: grouped[module] }));
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
if (!canRead.value) {
|
||||
rows.value = [];
|
||||
return;
|
||||
}
|
||||
const resp = await fetchPlatformRoles();
|
||||
rows.value = resp?.data?.list || [];
|
||||
} catch (e: any) {
|
||||
rows.value = [];
|
||||
ElMessage.error(e?.response?.data?.message || "平台角色数据加载失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formRef.value) return;
|
||||
const valid = await formRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
try {
|
||||
await createPlatformRole({
|
||||
roleCode: String(form.value.roleCode).trim(),
|
||||
roleName: String(form.value.roleName).trim(),
|
||||
});
|
||||
ElMessage.success("平台角色创建成功");
|
||||
createVisible.value = false;
|
||||
form.value.roleCode = "PLATFORM_CUSTOM_" + Date.now();
|
||||
form.value.roleName = "平台自定义角色";
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "平台角色创建失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async (row: any) => {
|
||||
const dialog = await ElMessageBox.prompt("请输入新的角色名称", "编辑平台角色", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
inputValue: row.roleName,
|
||||
}).catch(() => null);
|
||||
if (!dialog) return;
|
||||
try {
|
||||
await updatePlatformRole(row.id, { roleName: dialog.value });
|
||||
ElMessage.success("平台角色更新成功");
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "平台角色更新失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnable = async (id: number) => {
|
||||
try {
|
||||
await enablePlatformRole(id);
|
||||
ElMessage.success("平台角色已启用");
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "平台角色启用失败");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisable = async (id: number) => {
|
||||
try {
|
||||
await disablePlatformRole(id);
|
||||
ElMessage.success("平台角色已停用");
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "平台角色停用失败");
|
||||
}
|
||||
};
|
||||
|
||||
const openBindPermissions = async (row: any) => {
|
||||
selectedRoleId.value = row.id;
|
||||
const [permResp, rolePermResp] = await Promise.all([fetchPlatformPermissions(), fetchPlatformRolePermissions(row.id)]);
|
||||
permissions.value = permResp?.data?.list || [];
|
||||
selectedPermissionIds.value = rolePermResp?.data || [];
|
||||
bindDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const submitBindPermissions = async () => {
|
||||
if (!selectedRoleId.value) return;
|
||||
try {
|
||||
await bindPlatformRolePermissions(selectedRoleId.value, { permissionIds: selectedPermissionIds.value });
|
||||
ElMessage.success("平台角色权限绑定成功,请重新登录刷新权限");
|
||||
bindDialogVisible.value = false;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "平台角色权限绑定失败");
|
||||
}
|
||||
};
|
||||
|
||||
const openBindMenus = async (row: any) => {
|
||||
selectedRoleId.value = row.id;
|
||||
const [menuResp, roleMenuResp] = await Promise.all([fetchPlatformMenus(), fetchPlatformRoleMenus(row.id)]);
|
||||
menus.value = menuResp?.data?.list || [];
|
||||
selectedMenuIds.value = roleMenuResp?.data || [];
|
||||
bindMenuVisible.value = true;
|
||||
};
|
||||
|
||||
const submitBindMenus = async () => {
|
||||
if (!selectedRoleId.value) return;
|
||||
try {
|
||||
await bindPlatformRoleMenus(selectedRoleId.value, { menuIds: selectedMenuIds.value });
|
||||
ElMessage.success("平台角色菜单绑定成功,请重新登录刷新菜单");
|
||||
bindMenuVisible.value = false;
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || "平台角色菜单绑定失败");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<PageContainer title="平台会话管理">
|
||||
<el-form :inline="true">
|
||||
<el-form-item label="Scope">
|
||||
<el-select v-model="query.scope" clearable class="w-input-sm">
|
||||
<el-option label="租户会话" value="TENANT" />
|
||||
<el-option label="平台会话" value="PLATFORM" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="query.status" clearable class="w-input-sm">
|
||||
<el-option label="ACTIVE" value="ACTIVE" />
|
||||
<el-option label="ROTATED" value="ROTATED" />
|
||||
<el-option label="REVOKED" value="REVOKED" />
|
||||
<el-option label="EXPIRED" value="EXPIRED" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="用户ID">
|
||||
<el-input-number v-model="query.userId" :min="1" :controls="false" class="w-input-sm" />
|
||||
</el-form-item>
|
||||
<el-form-item label="租户ID">
|
||||
<el-input-number v-model="query.tenantId" :min="1" :controls="false" class="w-input-sm" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button v-if="canRead" @click="load">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="rows" v-loading="loading">
|
||||
<el-table-column prop="id" label="会话ID" width="100" />
|
||||
<el-table-column prop="scope" label="Scope" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="100" />
|
||||
<el-table-column prop="userId" label="用户ID" width="100" />
|
||||
<el-table-column prop="userName" label="用户名" width="140" />
|
||||
<el-table-column prop="phone" label="手机号" width="130" />
|
||||
<el-table-column prop="tenantId" label="租户ID" width="100" />
|
||||
<el-table-column prop="tenantName" label="租户名称" width="180" />
|
||||
<el-table-column prop="issuedAt" label="签发时间" width="180" />
|
||||
<el-table-column prop="expiresAt" label="过期时间" width="180" />
|
||||
<el-table-column prop="lastUsedAt" label="最近使用" width="180" />
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="canManage && row.status === 'ACTIVE'"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleRevokePrincipal(row)"
|
||||
>
|
||||
下线会话
|
||||
</el-button>
|
||||
<!-- <el-button
|
||||
v-if="canManage && row.userId"
|
||||
size="small"
|
||||
@click="handleRevokePrincipal(row)"
|
||||
>
|
||||
下线该主体全部
|
||||
</el-button> -->
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import {
|
||||
fetchPlatformAuthSessions,
|
||||
revokePlatformAuthSession,
|
||||
revokePlatformPrincipalSessions,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.platform.sessionRead));
|
||||
const canManage = computed(() => authStore.hasPermission(PERMS.platform.sessionManage));
|
||||
const loading = ref(false);
|
||||
const rows = ref<any[]>([]);
|
||||
const query = reactive<{
|
||||
scope?: "TENANT" | "PLATFORM";
|
||||
status?: "ACTIVE" | "ROTATED" | "REVOKED" | "EXPIRED";
|
||||
userId?: number;
|
||||
tenantId?: number;
|
||||
}>({
|
||||
scope: undefined,
|
||||
status: "ACTIVE",
|
||||
userId: undefined,
|
||||
tenantId: undefined,
|
||||
});
|
||||
|
||||
const load = async () => {
|
||||
if (!canRead.value) {
|
||||
rows.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const resp = await fetchPlatformAuthSessions({
|
||||
scope: query.scope,
|
||||
status: query.status,
|
||||
userId: query.userId,
|
||||
tenantId: query.tenantId,
|
||||
});
|
||||
rows.value = resp?.data || [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (row: any) => {
|
||||
await ElMessageBox.confirm(`确认下线会话 #${row.id} 吗?`, "提示", { type: "warning" });
|
||||
await revokePlatformAuthSession(Number(row.id));
|
||||
ElMessage.success("会话已下线");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleRevokePrincipal = async (row: any) => {
|
||||
const scope = String(row?.scope || "") as "TENANT" | "PLATFORM";
|
||||
const userId = Number(row?.userId || 0);
|
||||
if (!userId || (scope !== "TENANT" && scope !== "PLATFORM")) {
|
||||
ElMessage.error("主体信息不完整,无法下线");
|
||||
return;
|
||||
}
|
||||
await ElMessageBox.confirm("确认下线该主体的全部会话吗?", "提示", { type: "warning" });
|
||||
await revokePlatformPrincipalSessions({
|
||||
userId,
|
||||
scope,
|
||||
tenantId: scope === "TENANT" ? Number(row?.tenantId || 0) : undefined,
|
||||
});
|
||||
ElMessage.success("该主体会话已全部下线");
|
||||
await load();
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -0,0 +1,530 @@
|
||||
<template>
|
||||
<PageContainer title="平台用户管理">
|
||||
<QueryToolbar>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="Search name / phone / email"
|
||||
clearable
|
||||
class="w-input-md"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<el-button @click="handleSearch">Search</el-button>
|
||||
<el-button @click="handleResetSearch">Reset</el-button>
|
||||
<el-button v-if="canManage" @click="openImportDialog">Batch Import</el-button>
|
||||
<el-button v-if="canManage" @click="downloadPlatformUserTemplate">Download Template</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="openCreateDrawer">Create User</el-button>
|
||||
<el-button v-if="canManage" @click="load">Refresh</el-button>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-table :data="rows" class="mt-md" empty-text="No data">
|
||||
<el-table-column prop="userName" label="Name" />
|
||||
<el-table-column prop="phone" label="Phone" />
|
||||
<el-table-column prop="email" label="Email" />
|
||||
<el-table-column prop="status" label="Status" width="120" :formatter="statusFormatter" />
|
||||
<el-table-column label="Validity" width="340">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.validFrom || "-" }} ~ {{ row.validTo || "-" }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Role" width="280">
|
||||
<template #default="{ row }">
|
||||
<el-select v-model="roleMap[row.id]" class="w-input-sm" placeholder="Select role" clearable>
|
||||
<el-option v-for="role in roles" :key="role.id" :label="role.roleName" :value="role.id" />
|
||||
</el-select>
|
||||
<el-button
|
||||
v-if="canManage"
|
||||
size="small"
|
||||
class="ml-sm"
|
||||
:disabled="!roleMap[row.id]"
|
||||
@click="handleAssignRole(row.id)"
|
||||
>
|
||||
Assign
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Actions" width="380">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" @click="openEditDrawer(row)">Edit</el-button>
|
||||
<el-button
|
||||
v-if="canManage"
|
||||
size="small"
|
||||
type="success"
|
||||
:disabled="row.status === 'ENABLED'"
|
||||
@click="handleEnable(row.id)"
|
||||
>
|
||||
Enable
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canManage"
|
||||
size="small"
|
||||
type="warning"
|
||||
:disabled="row.status !== 'ENABLED'"
|
||||
@click="handleDisable(row.id)"
|
||||
>
|
||||
Disable
|
||||
</el-button>
|
||||
<el-button v-if="canManage" size="small" @click="handleResetPassword(row.id)">Reset Password</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-drawer
|
||||
v-model="drawerVisible"
|
||||
:title="isEditMode ? 'Edit Platform User' : 'Create Platform User'"
|
||||
:size="DRAWER_SIZE.md"
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="Name" prop="userName">
|
||||
<el-input v-model="form.userName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Phone" prop="phone">
|
||||
<el-input v-model="form.phone" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="isEditMode ? 'Password (leave blank to keep)' : 'Password'" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
show-password
|
||||
:placeholder="isEditMode ? 'Leave blank to keep current password' : 'Enter password'"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="Email" prop="email">
|
||||
<el-input v-model="form.email" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Valid From">
|
||||
<el-date-picker v-model="form.validFrom" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Valid To">
|
||||
<el-date-picker v-model="form.validTo" type="datetime" value-format="YYYY-MM-DD HH:mm:ss" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="drawerVisible = false">Cancel</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="handleSubmit">
|
||||
{{ isEditMode ? "Save" : "Create" }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="importVisible" title="Batch Import Platform Users" :width="DIALOG_WIDTH.lg" destroy-on-close>
|
||||
<div class="text-secondary mb-sm">
|
||||
Supports `.csv` or tab-delimited text. Header order:
|
||||
`userName, phone, password, email, validFrom, validTo, roleCode`
|
||||
</div>
|
||||
<div v-if="roles.length" class="text-secondary mb-sm">
|
||||
Available role codes: {{ roleCodeText }}
|
||||
</div>
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".csv,.txt"
|
||||
:on-change="handleImportFileChange"
|
||||
>
|
||||
<el-button>Select File</el-button>
|
||||
</el-upload>
|
||||
<div v-if="importFileName" class="mt-sm text-secondary">Selected file: {{ importFileName }}</div>
|
||||
<el-input
|
||||
v-model="importText"
|
||||
type="textarea"
|
||||
:rows="10"
|
||||
class="mt-md"
|
||||
placeholder="You can also paste CSV content here. The first row must contain headers."
|
||||
@input="syncImportPreview"
|
||||
/>
|
||||
<div v-if="importPreview.length" class="mt-md">
|
||||
<div class="mb-sm">Preview first {{ importPreview.length }} rows:</div>
|
||||
<el-table :data="importPreview" size="small" max-height="240">
|
||||
<el-table-column prop="userName" label="Name" />
|
||||
<el-table-column prop="phone" label="Phone" />
|
||||
<el-table-column prop="email" label="Email" />
|
||||
<el-table-column prop="roleCode" label="Role Code" />
|
||||
</el-table>
|
||||
</div>
|
||||
<div v-if="lastImportResult" class="mt-md">
|
||||
<el-alert
|
||||
:title="`Import result: success ${lastImportResult.success}, failed ${lastImportResult.failed}`"
|
||||
:type="lastImportResult.failed ? 'warning' : 'success'"
|
||||
show-icon
|
||||
:closable="false"
|
||||
/>
|
||||
<el-button v-if="lastImportResult.errors?.length" class="mt-sm" @click="downloadImportErrorSnapshot">
|
||||
Download Failed Snapshot
|
||||
</el-button>
|
||||
<el-table v-if="lastImportResult.errors?.length" :data="lastImportResult.errors" size="small" class="mt-sm" max-height="220">
|
||||
<el-table-column prop="rowNo" label="Row" width="90" />
|
||||
<el-table-column prop="identifier" label="Identifier" width="220" />
|
||||
<el-table-column prop="message" label="Reason" />
|
||||
</el-table>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="importVisible = false">Close</el-button>
|
||||
<el-button @click="downloadPlatformUserTemplate">Download Template</el-button>
|
||||
<el-button type="primary" :loading="importLoading" @click="submitImportUsers">Start Import</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
import {
|
||||
assignPlatformUserRole,
|
||||
batchImportPlatformUsers,
|
||||
createPlatformUser,
|
||||
disablePlatformUser,
|
||||
enablePlatformUser,
|
||||
fetchPlatformRoles,
|
||||
fetchPlatformUsers,
|
||||
resetPlatformUserPassword,
|
||||
updatePlatformUser,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
import {
|
||||
downloadCsvRows,
|
||||
downloadCsvTemplate,
|
||||
findMissingHeaders,
|
||||
parseDelimitedText,
|
||||
readTextFile,
|
||||
} from "../../utils/batchImport";
|
||||
|
||||
type PlatformImportRow = {
|
||||
userName: string;
|
||||
phone: string;
|
||||
password: string;
|
||||
email: string;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
roleCode: string;
|
||||
};
|
||||
|
||||
const rows = ref<any[]>([]);
|
||||
const roles = ref<any[]>([]);
|
||||
const roleMap = ref<Record<number, number | undefined>>({});
|
||||
const authStore = useAuthStore();
|
||||
const canManage = computed(() => authStore.hasPermission(PERMS.platform.userManage));
|
||||
const drawerVisible = ref(false);
|
||||
const isEditMode = ref(false);
|
||||
const editingId = ref<number | null>(null);
|
||||
const formRef = ref<FormInstance>();
|
||||
const keyword = ref("");
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const importVisible = ref(false);
|
||||
const importLoading = ref(false);
|
||||
const importText = ref("");
|
||||
const importFileName = ref("");
|
||||
const importPreview = ref<Array<{ userName: string; phone: string; email: string; roleCode: string }>>([]);
|
||||
const lastImportResult = ref<any | null>(null);
|
||||
const lastImportedUsers = ref<PlatformImportRow[] | null>(null);
|
||||
|
||||
const emptyForm = () => ({
|
||||
userName: "",
|
||||
phone: "",
|
||||
password: "",
|
||||
email: "",
|
||||
validFrom: "",
|
||||
validTo: "",
|
||||
});
|
||||
|
||||
const form = ref(emptyForm());
|
||||
|
||||
const formRules = computed<FormRules>(() => ({
|
||||
userName: [{ required: true, message: "Please enter name", trigger: "blur" }],
|
||||
phone: [{ required: true, message: "Please enter phone", trigger: "blur" }],
|
||||
password: isEditMode.value ? [] : [{ required: true, message: "Please enter password", trigger: "blur" }],
|
||||
}));
|
||||
|
||||
const roleCodeText = computed(() => {
|
||||
const codes = roles.value.map((item: any) => item?.roleCode).filter(Boolean);
|
||||
return codes.length ? codes.join(", ") : "-";
|
||||
});
|
||||
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
isEditMode.value = false;
|
||||
editingId.value = null;
|
||||
form.value = emptyForm();
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
const openEditDrawer = (row: any) => {
|
||||
isEditMode.value = true;
|
||||
editingId.value = row.id;
|
||||
form.value = {
|
||||
userName: row.userName || "",
|
||||
phone: row.phone || "",
|
||||
password: "",
|
||||
email: row.email || "",
|
||||
validFrom: row.validFrom || "",
|
||||
validTo: row.validTo || "",
|
||||
};
|
||||
drawerVisible.value = true;
|
||||
};
|
||||
|
||||
const openImportDialog = () => {
|
||||
importVisible.value = true;
|
||||
importText.value = "";
|
||||
importFileName.value = "";
|
||||
importPreview.value = [];
|
||||
lastImportResult.value = null;
|
||||
lastImportedUsers.value = null;
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!canManage.value) {
|
||||
rows.value = [];
|
||||
roles.value = [];
|
||||
return;
|
||||
}
|
||||
const [userResp, roleResp] = await Promise.all([
|
||||
fetchPlatformUsers({ keyword: keyword.value.trim() || undefined }),
|
||||
fetchPlatformRoles(),
|
||||
]);
|
||||
rows.value = userResp?.data?.list || [];
|
||||
roles.value = roleResp?.data?.list || [];
|
||||
};
|
||||
|
||||
const syncKeywordFromRoute = async () => {
|
||||
const nextKeyword = String(route.query.keyword || "").trim();
|
||||
if (keyword.value !== nextKeyword) {
|
||||
keyword.value = nextKeyword;
|
||||
}
|
||||
await load();
|
||||
};
|
||||
|
||||
const pushKeywordQuery = async () => {
|
||||
const nextKeyword = keyword.value.trim();
|
||||
const nextQuery = { ...route.query } as Record<string, any>;
|
||||
if (nextKeyword) {
|
||||
nextQuery.keyword = nextKeyword;
|
||||
} else {
|
||||
delete nextQuery.keyword;
|
||||
}
|
||||
await router.push({ path: route.path, query: nextQuery });
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
await pushKeywordQuery();
|
||||
};
|
||||
|
||||
const handleResetSearch = async () => {
|
||||
keyword.value = "";
|
||||
await pushKeywordQuery();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) {
|
||||
return;
|
||||
}
|
||||
const valid = await formRef.value.validate().catch(() => false);
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEditMode.value && editingId.value) {
|
||||
await updatePlatformUser(editingId.value, form.value);
|
||||
ElMessage.success("Platform user updated");
|
||||
} else {
|
||||
await createPlatformUser(form.value);
|
||||
ElMessage.success("Platform user created");
|
||||
}
|
||||
drawerVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleAssignRole = async (userId: number) => {
|
||||
const roleId = roleMap.value[userId];
|
||||
if (!roleId) {
|
||||
return;
|
||||
}
|
||||
await assignPlatformUserRole({ userId, roleId });
|
||||
ElMessage.success("Role assigned");
|
||||
};
|
||||
|
||||
const handleEnable = async (userId: number) => {
|
||||
await enablePlatformUser(userId);
|
||||
ElMessage.success("Platform user enabled");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDisable = async (userId: number) => {
|
||||
await disablePlatformUser(userId);
|
||||
ElMessage.success("Platform user disabled");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleResetPassword = async (userId: number) => {
|
||||
const dialog = await ElMessageBox.prompt("Enter a new password", "Reset Platform User Password", {
|
||||
confirmButtonText: "Confirm",
|
||||
cancelButtonText: "Cancel",
|
||||
inputValue: "123456",
|
||||
}).catch(() => null);
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
await resetPlatformUserPassword(userId, { newPassword: dialog.value });
|
||||
ElMessage.success("Password reset");
|
||||
};
|
||||
|
||||
const downloadPlatformUserTemplate = () => {
|
||||
downloadCsvTemplate(
|
||||
"platform-user-import-template.csv",
|
||||
["userName", "phone", "password", "email", "validFrom", "validTo", "roleCode"],
|
||||
[["Platform Admin", "13800138000", "Abc12345!", "admin@example.com", "2026-04-20 09:00:00", "2099-12-31 23:59:59", "PLATFORM_ADMIN"]],
|
||||
);
|
||||
};
|
||||
|
||||
const handleImportFileChange = async (uploadFile: any) => {
|
||||
const file = uploadFile?.raw as File | undefined;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
importFileName.value = file.name;
|
||||
importText.value = await readTextFile(file);
|
||||
syncImportPreview();
|
||||
};
|
||||
|
||||
const parseImportRows = (text: string): PlatformImportRow[] => {
|
||||
const rows = parseDelimitedText(text);
|
||||
if (!rows.length) {
|
||||
return [];
|
||||
}
|
||||
const [header, ...dataRows] = rows;
|
||||
const missingHeaders = findMissingHeaders(header, ["userName", "phone", "password"]);
|
||||
if (missingHeaders.length) {
|
||||
return [];
|
||||
}
|
||||
const headerIndex = new Map<string, number>();
|
||||
header.forEach((name, index) => {
|
||||
headerIndex.set(String(name || "").trim(), index);
|
||||
});
|
||||
return dataRows
|
||||
.filter((row) => row.some((cell) => String(cell || "").trim().length > 0))
|
||||
.map((row) => ({
|
||||
userName: row[headerIndex.get("userName") ?? 0] || "",
|
||||
phone: row[headerIndex.get("phone") ?? 1] || "",
|
||||
password: row[headerIndex.get("password") ?? 2] || "",
|
||||
email: row[headerIndex.get("email") ?? 3] || "",
|
||||
validFrom: row[headerIndex.get("validFrom") ?? 4] || "",
|
||||
validTo: row[headerIndex.get("validTo") ?? 5] || "",
|
||||
roleCode: row[headerIndex.get("roleCode") ?? 6] || "",
|
||||
}))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
userName: item.userName.trim(),
|
||||
phone: item.phone.trim(),
|
||||
password: item.password.trim(),
|
||||
email: item.email.trim(),
|
||||
validFrom: item.validFrom.trim(),
|
||||
validTo: item.validTo.trim(),
|
||||
roleCode: item.roleCode.trim(),
|
||||
}));
|
||||
};
|
||||
|
||||
const syncImportPreview = () => {
|
||||
const parsed = parseImportRows(importText.value);
|
||||
importPreview.value = parsed.slice(0, 5).map((item) => ({
|
||||
userName: item.userName,
|
||||
phone: item.phone,
|
||||
email: item.email,
|
||||
roleCode: item.roleCode,
|
||||
}));
|
||||
};
|
||||
|
||||
const validateImportHeaders = (text: string) => {
|
||||
const rows = parseDelimitedText(text);
|
||||
if (!rows.length) {
|
||||
return { ok: false, missingHeaders: ["userName", "phone", "password"] };
|
||||
}
|
||||
const [header] = rows;
|
||||
const missingHeaders = findMissingHeaders(header, ["userName", "phone", "password"]);
|
||||
return { ok: missingHeaders.length === 0, missingHeaders };
|
||||
};
|
||||
|
||||
const submitImportUsers = async () => {
|
||||
const headerValidation = validateImportHeaders(importText.value);
|
||||
if (!headerValidation.ok) {
|
||||
ElMessage.warning(`Missing headers: ${headerValidation.missingHeaders.join(", ")}`);
|
||||
return;
|
||||
}
|
||||
const users = parseImportRows(importText.value);
|
||||
if (!users.length) {
|
||||
ElMessage.warning("Please provide import content first");
|
||||
return;
|
||||
}
|
||||
lastImportedUsers.value = users;
|
||||
importLoading.value = true;
|
||||
try {
|
||||
const resp = await batchImportPlatformUsers({ users });
|
||||
lastImportResult.value = resp?.data || null;
|
||||
if ((resp?.data?.failed || 0) > 0) {
|
||||
ElMessage.warning(`Import completed: success ${resp?.data?.success || 0}, failed ${resp?.data?.failed || 0}`);
|
||||
} else {
|
||||
ElMessage.success(`Import completed: success ${resp?.data?.success || 0}`);
|
||||
}
|
||||
await load();
|
||||
} finally {
|
||||
importLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadImportErrorSnapshot = () => {
|
||||
const errors = lastImportResult.value?.errors || [];
|
||||
const sourceRows = lastImportedUsers.value || [];
|
||||
if (!errors.length || !sourceRows.length) {
|
||||
ElMessage.warning("No failed snapshot available");
|
||||
return;
|
||||
}
|
||||
const exportRows: string[][] = [[
|
||||
"userName",
|
||||
"phone",
|
||||
"password",
|
||||
"email",
|
||||
"validFrom",
|
||||
"validTo",
|
||||
"roleCode",
|
||||
"__error",
|
||||
]];
|
||||
errors.forEach((error: any) => {
|
||||
const rowIndex = Number(error?.rowNo || 0) - 2;
|
||||
const item = sourceRows[rowIndex];
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
exportRows.push([
|
||||
item.userName || "",
|
||||
item.phone || "",
|
||||
item.password || "",
|
||||
item.email || "",
|
||||
item.validFrom || "",
|
||||
item.validTo || "",
|
||||
item.roleCode || "",
|
||||
String(error?.message || ""),
|
||||
]);
|
||||
});
|
||||
downloadCsvRows("platform-user-import-error-snapshot.csv", exportRows);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => route.query.keyword,
|
||||
() => {
|
||||
syncKeywordFromRoute();
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await syncKeywordFromRoute();
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,678 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<!-- <div class="page-header">
|
||||
<h2 class="page-title">个人设置</h2>
|
||||
<p class="page-desc">管理您的基础资料、界面偏好和账户安全。</p>
|
||||
</div> -->
|
||||
|
||||
<div class="profile-layout">
|
||||
<div class="profile-side">
|
||||
<el-card class="profile-card profile-card--profile" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>基础资料</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="avatar-wrapper">
|
||||
<div class="user-avatar-large">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="avatar-text">
|
||||
<h3>{{ userName }}</h3>
|
||||
<p>{{ phone }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="当前租户">{{ tenantName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="租户代码">{{ tenantCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="账号身份">
|
||||
{{ authScope === "TENANT" ? "租户用户" : "平台管理员" }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card class="profile-card profile-card--full-row" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>界面偏好</span>
|
||||
<el-tag size="small" type="info">切换即预览</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-loading="preferencesLoading" class="appearance-content">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="主题模式">
|
||||
<el-radio-group v-model="preferencesForm.themeMode" class="preference-group">
|
||||
<el-radio-button value="SYSTEM">跟随系统</el-radio-button>
|
||||
<el-radio-button value="LIGHT">浅色</el-radio-button>
|
||||
<el-radio-button value="DARK">深色</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="主题方案">
|
||||
<div class="theme-scheme-grid">
|
||||
<label
|
||||
v-for="option in themeSchemeOptions"
|
||||
:key="option.value"
|
||||
class="theme-scheme-card"
|
||||
:class="{ 'is-active': preferencesForm.themeScheme === option.value }"
|
||||
>
|
||||
<input
|
||||
v-model="preferencesForm.themeScheme"
|
||||
class="theme-scheme-input"
|
||||
type="radio"
|
||||
name="themeScheme"
|
||||
:value="option.value"
|
||||
/>
|
||||
<span class="theme-scheme-head">
|
||||
<span class="theme-scheme-name">{{ option.label }}</span>
|
||||
<span class="theme-scheme-swatches">
|
||||
<span
|
||||
v-for="color in option.colors"
|
||||
:key="color"
|
||||
class="theme-scheme-swatch"
|
||||
:style="{ background: color }"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<span class="theme-scheme-description">{{ option.description }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="界面密度">
|
||||
<el-radio-group v-model="preferencesForm.density" class="preference-group">
|
||||
<el-radio-button value="COMFORTABLE">舒适</el-radio-button>
|
||||
<el-radio-button value="COMPACT">紧凑</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="preference-preview">
|
||||
<div class="preview-label">当前已应用</div>
|
||||
<div class="preview-value">
|
||||
<el-tag effect="plain">{{ themeModeLabelMap[themeMode] }}</el-tag>
|
||||
<el-tag effect="plain">{{ themeSchemeLabelMap[themeScheme] }}</el-tag>
|
||||
<el-tag effect="plain">{{ densityLabelMap[density] }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="appearance-actions">
|
||||
<el-button type="primary" :loading="preferencesSaving" @click="handleSavePreferences">
|
||||
保存界面偏好
|
||||
</el-button>
|
||||
<el-button :disabled="preferencesLoading || preferencesSaving" @click="handleResetPreferences">
|
||||
重置
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card class="profile-card profile-card--password" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>修改密码</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="password-content">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
label-position="left"
|
||||
@submit.prevent
|
||||
>
|
||||
<el-form-item label="原密码" prop="oldPassword">
|
||||
<el-input
|
||||
v-model="form.oldPassword"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入原密码"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="新密码" prop="newPassword">
|
||||
<el-input
|
||||
v-model="form.newPassword"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入新密码(至少 6 位)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="确认新密码" prop="confirmPassword">
|
||||
<el-input
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请再次输入新密码"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="submitLoading" @click="handleSubmit">
|
||||
更新密码
|
||||
</el-button>
|
||||
<el-button @click="handleResetPassword">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage, FormInstance, FormRules } from "element-plus";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useRouter } from "vue-router";
|
||||
import {
|
||||
changeMyPassword,
|
||||
fetchProfilePreferences,
|
||||
logoutAllAuth,
|
||||
saveProfilePreferences,
|
||||
} from "../../api/modules";
|
||||
import { useAppearanceStore } from "../../stores/appearance";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { resolveLoginPath } from "../../utils/authNavigation";
|
||||
import {
|
||||
normalizeDensity,
|
||||
normalizeThemeMode,
|
||||
normalizeThemeScheme,
|
||||
} from "../../stores/appearance";
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const appearanceStore = useAppearanceStore();
|
||||
const { scope, tenantCode, tenantName: storedTenantName, userName: storedUserName, phone: storedPhone } = storeToRefs(authStore);
|
||||
const { themeMode, density, themeScheme } = storeToRefs(appearanceStore);
|
||||
|
||||
const authScope = computed(() => scope.value);
|
||||
const tenantCodeDisplay = computed(() => tenantCode.value || "-");
|
||||
const tenantName = computed(() => storedTenantName.value || (authScope.value === "PLATFORM" ? "平台侧" : "尚未关联"));
|
||||
const userName = computed(() => storedUserName.value || "未知用户");
|
||||
const phone = computed(() => storedPhone.value || "未设置");
|
||||
|
||||
const themeModeLabelMap: Record<string, string> = {
|
||||
SYSTEM: "跟随系统",
|
||||
LIGHT: "浅色",
|
||||
DARK: "深色",
|
||||
};
|
||||
|
||||
const densityLabelMap: Record<string, string> = {
|
||||
COMFORTABLE: "舒适",
|
||||
COMPACT: "紧凑",
|
||||
};
|
||||
|
||||
const themeSchemeLabelMap: Record<string, string> = {
|
||||
SLATE: "商务蓝",
|
||||
OCEAN: "海湾青",
|
||||
FOREST: "山岚绿",
|
||||
GRAPHITE: "石墨灰",
|
||||
AMBER: "琥珀棕",
|
||||
RUBY: "酒红棕",
|
||||
MIST: "晨雾蓝",
|
||||
SAGE: "鼠尾草绿",
|
||||
DAWN: "晨曦暖沙",
|
||||
};
|
||||
|
||||
const themeSchemeOptions = [
|
||||
{
|
||||
value: "SLATE",
|
||||
label: "商务蓝",
|
||||
description: "中性稳重,适合作为默认后台主题。",
|
||||
colors: ["#22364b", "#3c6e91", "#5c88a7"],
|
||||
},
|
||||
{
|
||||
value: "OCEAN",
|
||||
label: "海湾青",
|
||||
description: "更清爽明亮,强调信息流动感。",
|
||||
colors: ["#0f3b49", "#0e7490", "#0284c7"],
|
||||
},
|
||||
{
|
||||
value: "FOREST",
|
||||
label: "山岚绿",
|
||||
description: "更柔和克制,视觉压力更低。",
|
||||
colors: ["#24372a", "#4a7c59", "#7c9a4d"],
|
||||
},
|
||||
{
|
||||
value: "GRAPHITE",
|
||||
label: "石墨灰",
|
||||
description: "偏专业工具感,冷静克制,适合长期办公。",
|
||||
colors: ["#1f2937", "#475569", "#64748b"],
|
||||
},
|
||||
{
|
||||
value: "AMBER",
|
||||
label: "琥珀棕",
|
||||
description: "更温暖但不张扬,适合弱化科技模板感。",
|
||||
colors: ["#5b4636", "#9a6b3f", "#c08a52"],
|
||||
},
|
||||
{
|
||||
value: "RUBY",
|
||||
label: "酒红棕",
|
||||
description: "偏正式和品牌感,适合需要辨识度的后台。",
|
||||
colors: ["#4b2a2f", "#7a3e46", "#a95c57"],
|
||||
},
|
||||
{
|
||||
value: "MIST",
|
||||
label: "晨雾蓝",
|
||||
description: "偏轻透的雾蓝浅色方案,整体更亮,适合长时间查看表格和列表。",
|
||||
colors: ["#f2f7fb", "#6f8faf", "#8fb4c8"],
|
||||
},
|
||||
{
|
||||
value: "SAGE",
|
||||
label: "鼠尾草绿",
|
||||
description: "留白更多、颜色更柔,能把现有页面从深色压感里拉出来。",
|
||||
colors: ["#eef5f0", "#6e947d", "#92b29b"],
|
||||
},
|
||||
{
|
||||
value: "DAWN",
|
||||
label: "晨曦暖沙",
|
||||
description: "偏暖米杏的浅色方案,比琥珀棕更轻,适合想要温和观感的后台。",
|
||||
colors: ["#f7efe9", "#ac755c", "#d19b83"],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const submitLoading = ref(false);
|
||||
const preferencesLoading = ref(false);
|
||||
const preferencesSaving = ref(false);
|
||||
const hydratingPreferences = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
oldPassword: "",
|
||||
newPassword: "",
|
||||
confirmPassword: "",
|
||||
});
|
||||
|
||||
const preferencesForm = reactive({
|
||||
themeMode: themeMode.value,
|
||||
density: density.value,
|
||||
themeScheme: themeScheme.value,
|
||||
});
|
||||
|
||||
const syncPreferencesForm = (payload?: { themeMode?: unknown; density?: unknown; themeScheme?: unknown }) => {
|
||||
preferencesForm.themeMode = normalizeThemeMode(payload?.themeMode ?? themeMode.value);
|
||||
preferencesForm.density = normalizeDensity(payload?.density ?? density.value);
|
||||
preferencesForm.themeScheme = normalizeThemeScheme(payload?.themeScheme ?? themeScheme.value);
|
||||
};
|
||||
|
||||
const loadPreferences = async () => {
|
||||
preferencesLoading.value = true;
|
||||
hydratingPreferences.value = true;
|
||||
try {
|
||||
const resp = await fetchProfilePreferences();
|
||||
const nextPreferences = {
|
||||
themeMode: normalizeThemeMode(resp?.data?.themeMode),
|
||||
density: normalizeDensity(resp?.data?.density),
|
||||
themeScheme: normalizeThemeScheme(resp?.data?.themeScheme),
|
||||
};
|
||||
syncPreferencesForm(nextPreferences);
|
||||
appearanceStore.setAppearance(nextPreferences, true);
|
||||
} catch (_e) {
|
||||
syncPreferencesForm();
|
||||
} finally {
|
||||
hydratingPreferences.value = false;
|
||||
preferencesLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePreferences = async () => {
|
||||
const payload = {
|
||||
themeMode: normalizeThemeMode(preferencesForm.themeMode),
|
||||
density: normalizeDensity(preferencesForm.density),
|
||||
themeScheme: normalizeThemeScheme(preferencesForm.themeScheme),
|
||||
};
|
||||
preferencesSaving.value = true;
|
||||
try {
|
||||
await saveProfilePreferences(payload);
|
||||
appearanceStore.setAppearance(payload, true);
|
||||
ElMessage.success("界面偏好已保存");
|
||||
} catch (e: any) {
|
||||
if (e?.response?.data?.message) {
|
||||
ElMessage.error(e.response.data.message);
|
||||
} else {
|
||||
ElMessage.error("保存界面偏好失败");
|
||||
}
|
||||
} finally {
|
||||
preferencesSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPreferences = async () => {
|
||||
await loadPreferences();
|
||||
ElMessage.success("已恢复为当前账号保存的界面偏好");
|
||||
};
|
||||
|
||||
const validateConfirmPassword = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||
if (!value) {
|
||||
callback(new Error("请再次输入新密码"));
|
||||
return;
|
||||
}
|
||||
if (value !== form.newPassword) {
|
||||
callback(new Error("两次输入的新密码不一致"));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
};
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
oldPassword: [{ required: true, message: "请输入原密码", trigger: "blur" }],
|
||||
newPassword: [
|
||||
{ required: true, message: "请输入新密码", trigger: "blur" },
|
||||
{ min: 6, message: "密码长度至少 6 位", trigger: "blur" },
|
||||
],
|
||||
confirmPassword: [{ required: true, validator: validateConfirmPassword, trigger: "blur" }],
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formRef.value) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await formRef.value.validate();
|
||||
} catch (_e) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
await changeMyPassword({
|
||||
oldPassword: form.oldPassword,
|
||||
newPassword: form.newPassword,
|
||||
});
|
||||
ElMessage.success("密码修改成功,请重新登录");
|
||||
try {
|
||||
await logoutAllAuth();
|
||||
} catch (_e) {
|
||||
// ignore
|
||||
}
|
||||
const nextLoginPath = resolveLoginPath(authScope.value, tenantCode.value);
|
||||
await router.replace(nextLoginPath);
|
||||
authStore.clearAuthStorage();
|
||||
} catch (e: any) {
|
||||
if (e?.response?.data?.message) {
|
||||
ElMessage.error(e.response.data.message);
|
||||
} else {
|
||||
ElMessage.error("修改密码失败");
|
||||
}
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = () => {
|
||||
formRef.value?.resetFields();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [preferencesForm.themeMode, preferencesForm.density, preferencesForm.themeScheme],
|
||||
([themeMode, density, themeScheme]) => {
|
||||
if (hydratingPreferences.value) {
|
||||
return;
|
||||
}
|
||||
appearanceStore.setAppearance({ themeMode, density, themeScheme }, false);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
hydratingPreferences.value = true;
|
||||
syncPreferencesForm();
|
||||
hydratingPreferences.value = false;
|
||||
loadPreferences();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container {
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.profile-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 380px) minmax(0, 1fr);
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.profile-side {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.profile-card {
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--wo-card-border);
|
||||
}
|
||||
|
||||
.profile-card--profile {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.profile-card--password {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.profile-card--full-row {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.avatar-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.user-avatar-large {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: var(--wo-profile-avatar-bg);
|
||||
color: var(--wo-profile-avatar-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.15);
|
||||
}
|
||||
|
||||
.user-avatar-large svg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
|
||||
.avatar-text h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.avatar-text p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.appearance-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.preference-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.theme-scheme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.theme-scheme-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 108px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--wo-card-border);
|
||||
border-radius: 12px;
|
||||
background: var(--el-bg-color);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-scheme-card:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(var(--wo-theme-rgb), 0.28);
|
||||
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.theme-scheme-card.is-active {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 0 1px rgba(var(--wo-theme-rgb), 0.16), 0 10px 24px rgba(var(--wo-theme-rgb), 0.12);
|
||||
}
|
||||
|
||||
.theme-scheme-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.theme-scheme-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.theme-scheme-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.theme-scheme-swatches {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.theme-scheme-swatch {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.32);
|
||||
}
|
||||
|
||||
.theme-scheme-description {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.preference-preview {
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.preview-value {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.appearance-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.password-content {
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.profile-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-card--profile,
|
||||
.profile-card--password,
|
||||
.profile-card--full-row {
|
||||
grid-column: 1 / -1;
|
||||
grid-row: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.avatar-wrapper {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.theme-scheme-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.appearance-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.appearance-actions :deep(.el-button) {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,516 @@
|
||||
<template>
|
||||
<PageContainer title="项目管理">
|
||||
<QueryToolbar>
|
||||
<el-form :inline="true" @submit.prevent class="project-page-toolbar">
|
||||
<el-form-item>
|
||||
<el-input v-model="queryName" placeholder="搜索项目名称" clearable class="w-input-md" />
|
||||
</el-form-item>
|
||||
<!-- <el-form-item>
|
||||
<el-checkbox v-model="includeDeleted">包含已删除</el-checkbox>
|
||||
</el-form-item> -->
|
||||
<el-form-item>
|
||||
<el-button v-if="canCreate" type="primary" @click="openCreateDrawer">新建项目</el-button>
|
||||
<el-button @click="load">刷新</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-table
|
||||
:data="filteredRows"
|
||||
row-key="id"
|
||||
lazy
|
||||
:load="loadSubProjects"
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||
class="mt-md project-page-table"
|
||||
empty-text="暂无数据"
|
||||
>
|
||||
<el-table-column prop="name" label="项目名称" width="230"/>
|
||||
<!-- <el-table-column prop="subProjectCount" label="子项目数量" /> -->
|
||||
<el-table-column prop="enterpriseName" label="合作企业" />
|
||||
<!-- <el-table-column label="删除标记" >
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.deleted" type="danger" size="small">已删除</el-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column> -->
|
||||
<el-table-column prop="budgetCent" label="预算(元)" :formatter="budgetFormatter" />
|
||||
<el-table-column label="费用合计(元)" :formatter="projectFeeTotalFormatter" />
|
||||
<el-table-column prop="budgetExecutionRatio" label="预算执行率" :formatter="executionRatioFormatter" />
|
||||
<el-table-column prop="meetingTotal" label="会议总期数" width="100"/>
|
||||
<el-table-column label="项目周期" width="200">
|
||||
<template #default="{ row }">{{ row.startDate || "-" }} ~ {{ row.endDate || "-" }}</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column prop="status" label="状态" :formatter="statusFormatter" width="100"/>
|
||||
<el-table-column label="操作" width="400">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canCreate" size="small" type="primary" @click="openCreateDrawer(row)">添加子项目</el-button>
|
||||
<el-button size="small" @click="openDetailDrawer(row)">详情</el-button>
|
||||
<el-button v-if="canCreate" size="small" type="primary" @click="openEditDrawer(row)">编辑</el-button>
|
||||
<el-button v-if="canCreateMeeting" size="small" type="primary" @click="handleCreateMeeting(row)">新建会议</el-button>
|
||||
<el-button v-if="canFreeze && row.status !== 'FROZEN' && row.status !== 'ARCHIVED'" size="small" type="warning" @click="handleFreeze(row.id)">冻结</el-button>
|
||||
<el-button v-if="canUnfreeze && row.status === 'FROZEN'" size="small" type="success" @click="handleUnfreeze(row.id)">解冻</el-button>
|
||||
<el-button v-if="canArchive && (row.status === 'COMPLETED' || row.status === 'FROZEN')" size="small" type="info" @click="handleArchive(row.id)">归档</el-button>
|
||||
<el-button v-if="canBindUser" size="small" @click="openBindingDialog(row.id)">绑定人员</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<ProjectEditDrawer
|
||||
v-model="projectDrawerVisible"
|
||||
:mode="projectDrawerMode"
|
||||
:source-row="projectDrawerSourceRow"
|
||||
:edit-id="projectDrawerEditId"
|
||||
@saved="load"
|
||||
/>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="项目详情" size="65%">
|
||||
<el-descriptions :column="2" border v-if="currentProject">
|
||||
<el-descriptions-item label="项目名称">{{ currentProject.name || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="子项目数量">{{ currentProject.subProjectCount || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="上级项目ID">{{ currentProject.parentProjectId || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="主办单位">{{ currentProject.hostEnterpriseName || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="主办单位负责人">{{ currentProject.hostOwnerUsers || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="主办单位项目执行人">{{ currentProject.hostExecutorUsers || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="合作企业负责人">{{ currentProject.partnerOwnerUsers || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="合作企业项目执行人">{{ currentProject.partnerExecutorUsers || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目周期">{{ currentProject.startDate || "-" }} ~ {{ currentProject.endDate || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目预算(元)">{{ toYuan(currentProject.budgetCent) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="管理费(元)">{{ toYuan(currentProjectFee.managementFeeCent) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="税费(元)">{{ toYuan(currentProjectFee.taxFeeCent) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="到款金额(元)">{{ toYuan(currentProjectFee.paidAmountCent) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="自定义费用(元)">{{ customFeeText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="会议总期数">{{ currentProject.meetingTotal || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="完成期数">{{ currentProject.meetingCompletedCount || 0 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目状态">{{ toZhStatus(currentProject.status) }}</el-descriptions-item>
|
||||
|
||||
<el-descriptions-item label="预算执行率">{{ formatExecutionRatio(currentProject.budgetExecutionRatio) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-divider v-if="canKeyChangeLogRead">关键变更日志</el-divider>
|
||||
<el-table v-if="canKeyChangeLogRead" :data="keyChangeLogs" style="width: 100%">
|
||||
<el-table-column prop="fieldName" label="字段" width="180" />
|
||||
<el-table-column prop="changeType" label="动作" width="160" :formatter="statusFormatter" />
|
||||
<el-table-column prop="beforeValue" label="变更前" />
|
||||
<el-table-column prop="afterValue" label="变更后" />
|
||||
<el-table-column prop="changeReason" label="变更原因" width="150" />
|
||||
<el-table-column prop="operatorUserName" label="操作人" width="140" />
|
||||
<el-table-column prop="handoverAt" label="交接时间" width="180" />
|
||||
<el-table-column prop="createdAt" label="记录时间" width="180" />
|
||||
</el-table>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="bindingVisible" title="绑定项目人员" :width="DIALOG_WIDTH.lg">
|
||||
<el-form label-position="left" :label-width="LABEL_WIDTH.lg">
|
||||
<el-form-item v-if="!isProjectExecutorRole" label="项目负责人">
|
||||
<div class="binding-select-field">
|
||||
<div v-if="ownerReadonlyBoundUsers.length" class="binding-readonly-block">
|
||||
<div class="binding-readonly-title">他人绑定,不可修改</div>
|
||||
<div class="binding-readonly-tags">
|
||||
<el-tag v-for="item in ownerReadonlyBoundUsers" :key="item.userId" type="info" effect="plain">
|
||||
{{ formatBindingUserLabel(item) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<el-select v-model="ownerEditableUserIds" multiple filterable clearable class="w-full">
|
||||
<el-option v-for="item in visibleOwnerCandidates" :key="item.userId" :label="`${item.userName}(${item.phone})`" :value="item.userId" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!isProjectExecutorRole" label="项目执行人">
|
||||
<div class="binding-select-field">
|
||||
<div v-if="executorReadonlyBoundUsers.length" class="binding-readonly-block">
|
||||
<div class="binding-readonly-title">他人绑定,不可修改</div>
|
||||
<div class="binding-readonly-tags">
|
||||
<el-tag v-for="item in executorReadonlyBoundUsers" :key="item.userId" type="info" effect="plain">
|
||||
{{ formatBindingUserLabel(item) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<el-select v-model="executorEditableUserIds" multiple filterable clearable class="w-full">
|
||||
<el-option v-for="item in visibleExecutorCandidates" :key="item.userId" :label="`${item.userName}(${item.phone})`" :value="item.userId" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="canBindExecutorUser || isProjectExecutorRole" label="执行人(EXECUTOR)">
|
||||
<div class="binding-select-field">
|
||||
<div v-if="legacyExecutorReadonlyBoundUsers.length" class="binding-readonly-block">
|
||||
<div class="binding-readonly-title">他人绑定,不可修改</div>
|
||||
<div class="binding-readonly-tags">
|
||||
<el-tag v-for="item in legacyExecutorReadonlyBoundUsers" :key="item.userId" type="info" effect="plain">
|
||||
{{ formatBindingUserLabel(item) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<el-select v-model="legacyExecutorEditableUserIds" multiple filterable clearable class="w-full">
|
||||
<el-option v-for="item in visibleLegacyExecutorCandidates" :key="item.userId" :label="`${item.userName}(${item.phone})`" :value="item.userId" />
|
||||
</el-select>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="bindingVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSaveBindings">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import ProjectEditDrawer from "./project-page/ProjectEditDrawer.vue";
|
||||
import {
|
||||
createMeeting,
|
||||
fetchMeetings,
|
||||
fetchProjectBindingCandidates,
|
||||
fetchProjectChildren,
|
||||
fetchProjectBindings,
|
||||
fetchProjectKeyChangeLogs,
|
||||
fetchProjects,
|
||||
freezeProject,
|
||||
unfreezeProject,
|
||||
archiveProject,
|
||||
saveProjectBindings,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const rows = ref<any[]>([]);
|
||||
const queryName = ref("");
|
||||
const includeDeleted = ref(false);
|
||||
const projectDrawerVisible = ref(false);
|
||||
const projectDrawerMode = ref<"create" | "edit">("create");
|
||||
const projectDrawerSourceRow = ref<any | null>(null);
|
||||
const projectDrawerEditId = ref<number | null>(null);
|
||||
const detailVisible = ref(false);
|
||||
const bindingVisible = ref(false);
|
||||
const currentProject = ref<any | null>(null);
|
||||
const keyChangeLogs = ref<any[]>([]);
|
||||
const ownerCandidates = ref<any[]>([]);
|
||||
const executorCandidates = ref<any[]>([]);
|
||||
const legacyExecutorCandidates = ref<any[]>([]);
|
||||
const ownerBoundUsers = ref<any[]>([]);
|
||||
const executorBoundUsers = ref<any[]>([]);
|
||||
const legacyExecutorBoundUsers = ref<any[]>([]);
|
||||
const ownerUserIds = ref<number[]>([]);
|
||||
const executorUserIds = ref<number[]>([]);
|
||||
const legacyExecutorUserIds = ref<number[]>([]);
|
||||
const currentBindingProjectId = ref<number | null>(null);
|
||||
const childrenCache = new Map<number, any[]>();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const canCreate = computed(() => authStore.hasPermission(PERMS.project.create));
|
||||
const canCreateMeeting = computed(() => authStore.hasPermission(PERMS.meeting.create));
|
||||
const canFreeze = computed(() => authStore.hasPermission(PERMS.project.freeze));
|
||||
const canUnfreeze = computed(() => authStore.hasPermission(PERMS.project.unfreeze));
|
||||
const canArchive = computed(() => authStore.hasPermission(PERMS.project.archive));
|
||||
const canBindUser = computed(() => authStore.hasPermission(PERMS.project.bindUser));
|
||||
const canBindExecutorUser = computed(() => authStore.hasPermission(PERMS.project.bindExecutorUser));
|
||||
const canKeyChangeLogRead = computed(() => authStore.hasPermission(PERMS.project.keyChangeLogRead));
|
||||
const currentRoles = computed(() => authStore.roles || []);
|
||||
const currentUserId = computed(() => Number(authStore.userId || 0) || null);
|
||||
const isProjectExecutorRole = computed(() => currentRoles.value.includes("PROJECT_EXECUTOR"));
|
||||
const toBindingUserId = (item: any) => Number(item?.userId || 0);
|
||||
const normalizeBindingUserIds = (ids: unknown[]) =>
|
||||
Array.from(new Set((ids || []).map((item) => Number(item || 0)).filter((item) => item > 0)));
|
||||
const isBindingUserEditableForCurrentUser = (item: any) => {
|
||||
if (!isProjectExecutorRole.value) return true;
|
||||
if (!currentUserId.value) return false;
|
||||
return Number(item?.createdBy || 0) === currentUserId.value;
|
||||
};
|
||||
const filterBindingCandidatesForCurrentUser = (candidates: any[]) => {
|
||||
return candidates.filter((item: any) => isBindingUserEditableForCurrentUser(item));
|
||||
};
|
||||
const filterReadonlyBoundUsersForCurrentUser = (users: any[]) => {
|
||||
if (!isProjectExecutorRole.value) return [];
|
||||
return users.filter((item: any) => !isBindingUserEditableForCurrentUser(item));
|
||||
};
|
||||
const buildEditableBindingUserIdsModel = (allUserIds: { value: number[] }, readonlyBoundUsers: { value: any[] }) =>
|
||||
computed<number[]>({
|
||||
get: () => {
|
||||
const readonlyIds = new Set(readonlyBoundUsers.value.map((item: any) => toBindingUserId(item)));
|
||||
return allUserIds.value.filter((item) => !readonlyIds.has(Number(item || 0)));
|
||||
},
|
||||
set: (value) => {
|
||||
allUserIds.value = normalizeBindingUserIds([
|
||||
...readonlyBoundUsers.value.map((item: any) => toBindingUserId(item)),
|
||||
...(value || []),
|
||||
]);
|
||||
},
|
||||
});
|
||||
const formatBindingUserLabel = (item: any) => {
|
||||
const name = String(item?.userName || "").trim();
|
||||
const phone = String(item?.phone || "").trim();
|
||||
const userId = toBindingUserId(item);
|
||||
if (name && phone) return `${name}(${phone})`;
|
||||
if (name) return name;
|
||||
if (phone) return phone;
|
||||
return userId ? `用户#${userId}` : "-";
|
||||
};
|
||||
const visibleOwnerCandidates = computed(() => filterBindingCandidatesForCurrentUser(ownerCandidates.value));
|
||||
const visibleExecutorCandidates = computed(() => filterBindingCandidatesForCurrentUser(executorCandidates.value));
|
||||
const visibleLegacyExecutorCandidates = computed(() => filterBindingCandidatesForCurrentUser(legacyExecutorCandidates.value));
|
||||
const ownerReadonlyBoundUsers = computed(() => filterReadonlyBoundUsersForCurrentUser(ownerBoundUsers.value));
|
||||
const executorReadonlyBoundUsers = computed(() => filterReadonlyBoundUsersForCurrentUser(executorBoundUsers.value));
|
||||
const legacyExecutorReadonlyBoundUsers = computed(() => filterReadonlyBoundUsersForCurrentUser(legacyExecutorBoundUsers.value));
|
||||
const ownerEditableUserIds = buildEditableBindingUserIdsModel(ownerUserIds, ownerReadonlyBoundUsers);
|
||||
const executorEditableUserIds = buildEditableBindingUserIdsModel(executorUserIds, executorReadonlyBoundUsers);
|
||||
const legacyExecutorEditableUserIds = buildEditableBindingUserIdsModel(legacyExecutorUserIds, legacyExecutorReadonlyBoundUsers);
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
const toYuan = (cent: unknown) => Number(((Number(cent) || 0) / 100).toFixed(2));
|
||||
const formatExecutionRatio = (ratio: unknown) => `${((Number(ratio) || 0) * 100).toFixed(2)}%`;
|
||||
const budgetFormatter = (_row: any, _column: any, value: unknown) => toYuan(value);
|
||||
const executionRatioFormatter = (_row: any, _column: any, value: unknown) => formatExecutionRatio(value);
|
||||
const projectFeeTotalFormatter = (row: any) => {
|
||||
const fee = parseProjectFee(row?.projectFeeJson);
|
||||
const total = (fee.managementFeeCent || 0)
|
||||
+ (fee.taxFeeCent || 0)
|
||||
+ (fee.paidAmountCent || 0)
|
||||
+ (fee.customFees || []).reduce((acc: number, cur: { amountCent: number }) => acc + (cur.amountCent || 0), 0);
|
||||
return toYuan(total);
|
||||
};
|
||||
const parseProjectFee = (projectFeeJson: unknown) => {
|
||||
const defaults = {
|
||||
managementFeeCent: 0,
|
||||
taxFeeCent: 0,
|
||||
paidAmountCent: 0,
|
||||
customFees: [] as Array<{ name: string; amountCent: number; remark?: string }>,
|
||||
};
|
||||
if (!projectFeeJson) return defaults;
|
||||
try {
|
||||
const parsed = JSON.parse(String(projectFeeJson));
|
||||
return {
|
||||
managementFeeCent: Math.max(0, Number(parsed?.managementFeeCent) || 0),
|
||||
taxFeeCent: Math.max(0, Number(parsed?.taxFeeCent) || 0),
|
||||
paidAmountCent: Math.max(0, Number(parsed?.paidAmountCent) || 0),
|
||||
customFees: Array.isArray(parsed?.customFees)
|
||||
? parsed.customFees
|
||||
.map((item: any) => ({
|
||||
name: String(item?.name || "").trim(),
|
||||
amountCent: Math.max(0, Number(item?.amountCent) || 0),
|
||||
remark: String(item?.remark || ""),
|
||||
}))
|
||||
.filter((item: { name: string }) => !!item.name)
|
||||
: [],
|
||||
};
|
||||
} catch (_e) {
|
||||
return defaults;
|
||||
}
|
||||
};
|
||||
const currentProjectFee = computed(() => parseProjectFee(currentProject.value?.projectFeeJson));
|
||||
const customFeeText = computed(() =>
|
||||
currentProjectFee.value.customFees.length
|
||||
? currentProjectFee.value.customFees.map((item: { name: string; amountCent: number }) => `${item.name}:${toYuan(item.amountCent)}元`).join(";")
|
||||
: "-",
|
||||
);
|
||||
const normalizeProjectRow = (row: any) => ({
|
||||
...row,
|
||||
hasChildren: Number(row?.subProjectCount || 0) > 0,
|
||||
});
|
||||
const filteredRows = computed(() =>
|
||||
rows.value
|
||||
.map((row) => normalizeProjectRow(row))
|
||||
.filter((r) => !queryName.value || String(r.name || "").includes(queryName.value)),
|
||||
);
|
||||
|
||||
const parseDateOnly = (value: unknown) => {
|
||||
const dateText = String(value || "").trim();
|
||||
if (!dateText) return null;
|
||||
const parsed = new Date(`${dateText}T00:00:00`);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
};
|
||||
|
||||
const formatDateOnly = (value: Date) => {
|
||||
const y = value.getFullYear();
|
||||
const m = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(value.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const resp = await fetchProjects({ parentOnly: true });
|
||||
rows.value = resp?.data?.list || [];
|
||||
childrenCache.clear();
|
||||
};
|
||||
|
||||
const loadSubProjects = async (row: any, _treeNode: unknown, resolve: (data: any[]) => void) => {
|
||||
const projectId = Number(row?.id || 0);
|
||||
if (!projectId) {
|
||||
resolve([]);
|
||||
return;
|
||||
}
|
||||
if (childrenCache.has(projectId)) {
|
||||
resolve(childrenCache.get(projectId) || []);
|
||||
return;
|
||||
}
|
||||
const resp = await fetchProjectChildren(projectId);
|
||||
const children = (resp?.data || []).map((item: any) => normalizeProjectRow(item));
|
||||
childrenCache.set(projectId, children);
|
||||
resolve(children);
|
||||
};
|
||||
|
||||
const openCreateDrawer = (parentRow?: any) => {
|
||||
projectDrawerMode.value = "create";
|
||||
projectDrawerSourceRow.value = parentRow || null;
|
||||
projectDrawerEditId.value = null;
|
||||
projectDrawerVisible.value = true;
|
||||
};
|
||||
|
||||
const openEditDrawer = (row: any) => {
|
||||
projectDrawerMode.value = "edit";
|
||||
projectDrawerSourceRow.value = row || null;
|
||||
projectDrawerEditId.value = Number(row?.id || 0) || null;
|
||||
projectDrawerVisible.value = true;
|
||||
};
|
||||
|
||||
const openDetailDrawer = async (row: any) => {
|
||||
currentProject.value = row;
|
||||
detailVisible.value = true;
|
||||
if (!canKeyChangeLogRead.value) {
|
||||
keyChangeLogs.value = [];
|
||||
return;
|
||||
}
|
||||
const resp = await fetchProjectKeyChangeLogs(row.id);
|
||||
keyChangeLogs.value = resp?.data || [];
|
||||
};
|
||||
|
||||
const handleCreateMeeting = async (row: any) => {
|
||||
const meetingTotal = Math.floor(Number(row?.meetingTotal) || 0);
|
||||
if (meetingTotal <= 0) {
|
||||
ElMessage.warning("请先设置项目会议期数后再新建会议");
|
||||
return;
|
||||
}
|
||||
const projectStart = parseDateOnly(row?.startDate);
|
||||
const projectEnd = parseDateOnly(row?.endDate);
|
||||
if (!projectStart || !projectEnd || projectStart.getTime() > projectEnd.getTime()) {
|
||||
ElMessage.warning("请先完善项目周期后再新建会议");
|
||||
return;
|
||||
}
|
||||
|
||||
const meetingResp = await fetchMeetings({ projectId: row.id });
|
||||
const existingMeetingCount = (meetingResp?.data?.list || []).length;
|
||||
if (existingMeetingCount >= meetingTotal) {
|
||||
ElMessage.warning(`该项目会议已达到总期数(${meetingTotal}期),无法继续新建`);
|
||||
return;
|
||||
}
|
||||
|
||||
const fee = parseProjectFee(row?.projectFeeJson);
|
||||
const customFeeCent = (fee.customFees || []).reduce((acc: number, cur: { amountCent: number }) => acc + (Number(cur?.amountCent) || 0), 0);
|
||||
const distributableBudgetCent = (Number(row?.budgetCent) || 0) - (Number(fee.managementFeeCent) || 0) - (Number(fee.taxFeeCent) || 0) - customFeeCent;
|
||||
const defaultMeetingBudgetCent = Math.floor(distributableBudgetCent / meetingTotal);
|
||||
if (defaultMeetingBudgetCent <= 0) {
|
||||
ElMessage.warning("项目可分配会议预算不足,请先检查项目预算或费用设置");
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const meetingDate = now >= projectStart && now <= projectEnd ? now : projectStart;
|
||||
const meetingDateText = formatDateOnly(meetingDate);
|
||||
const currentPeriod = existingMeetingCount + 1;
|
||||
await createMeeting({
|
||||
projectId: row.id,
|
||||
topic: `${row.name || "会议"}-第${currentPeriod}期`,
|
||||
budgetCent: defaultMeetingBudgetCent,
|
||||
laborRatio: Number(row?.laborFeeRatio) || 0,
|
||||
meetingCategory: "学术会",
|
||||
meetingForm: "线下",
|
||||
location: "线下",
|
||||
startTime: `${meetingDateText} 09:00:00`,
|
||||
endTime: `${meetingDateText} 18:00:00`,
|
||||
});
|
||||
ElMessage.success("会议创建成功");
|
||||
};
|
||||
|
||||
const handleFreeze = async (id: number) => {
|
||||
const reason = await ElMessageBox.prompt("请输入冻结原因", "冻结项目", { confirmButtonText: "确定", cancelButtonText: "取消" }).catch(() => null);
|
||||
if (!reason) return;
|
||||
await freezeProject(id, reason.value);
|
||||
ElMessage.success("项目已冻结");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleUnfreeze = async (id: number) => {
|
||||
const reason = await ElMessageBox.prompt("请输入解冻原因", "解冻项目", { confirmButtonText: "确定", cancelButtonText: "取消" }).catch(() => null);
|
||||
if (!reason) return;
|
||||
await unfreezeProject(id, { reason: reason.value });
|
||||
ElMessage.success("项目已解冻");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleArchive = async (id: number) => {
|
||||
await ElMessageBox.confirm("归档后项目将不可编辑,确认归档?", "归档项目", { type: "warning" }).catch(() => null);
|
||||
try {
|
||||
await archiveProject(id);
|
||||
ElMessage.success("项目已归档");
|
||||
await load();
|
||||
} catch (_e) {
|
||||
// 用户取消或接口异常
|
||||
}
|
||||
};
|
||||
|
||||
const ensureBindingCandidates = async () => {
|
||||
if (ownerCandidates.value.length > 0 || executorCandidates.value.length > 0 || legacyExecutorCandidates.value.length > 0) return;
|
||||
const resp = await fetchProjectBindingCandidates();
|
||||
ownerCandidates.value = resp?.data?.ownerUsers || [];
|
||||
executorCandidates.value = resp?.data?.executorUsers || [];
|
||||
legacyExecutorCandidates.value = resp?.data?.legacyExecutorUsers || [];
|
||||
};
|
||||
|
||||
const openBindingDialog = async (projectId: number) => {
|
||||
await ensureBindingCandidates();
|
||||
const resp = await fetchProjectBindings(projectId);
|
||||
ownerBoundUsers.value = resp?.data?.ownerUsers || [];
|
||||
executorBoundUsers.value = resp?.data?.executorUsers || [];
|
||||
legacyExecutorBoundUsers.value = resp?.data?.legacyExecutorUsers || [];
|
||||
ownerUserIds.value = ownerBoundUsers.value.map((item: any) => item.userId);
|
||||
executorUserIds.value = executorBoundUsers.value.map((item: any) => item.userId);
|
||||
legacyExecutorUserIds.value = legacyExecutorBoundUsers.value.map((item: any) => item.userId);
|
||||
currentBindingProjectId.value = projectId;
|
||||
bindingVisible.value = true;
|
||||
};
|
||||
|
||||
const handleSaveBindings = async () => {
|
||||
if (!currentBindingProjectId.value) return;
|
||||
await saveProjectBindings(currentBindingProjectId.value, {
|
||||
ownerUserIds: ownerUserIds.value,
|
||||
executorUserIds: executorUserIds.value,
|
||||
legacyExecutorUserIds: (canBindExecutorUser.value || isProjectExecutorRole.value) ? legacyExecutorUserIds.value : [],
|
||||
});
|
||||
ElMessage.success("绑定保存成功");
|
||||
bindingVisible.value = false;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* :deep(.project-page-toolbar .el-form-item:nth-child(2)) {
|
||||
display: none;
|
||||
} */
|
||||
|
||||
.binding-select-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.binding-readonly-block {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.binding-readonly-title {
|
||||
margin-bottom: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.binding-readonly-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<PageContainer title="角色管理">
|
||||
<QueryToolbar>
|
||||
<el-button
|
||||
v-if="canCreateRole"
|
||||
type="primary"
|
||||
@click="openCreateDrawer"
|
||||
>
|
||||
新增角色
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canReadRole"
|
||||
@click="load"
|
||||
>
|
||||
刷新
|
||||
</el-button>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-table :data="rows" class="mt-md" empty-text="暂无数据">
|
||||
<el-table-column prop="roleCode" label="角色编码" />
|
||||
<el-table-column prop="roleName" label="角色名称" />
|
||||
<el-table-column prop="status" label="状态" width="120" :formatter="statusFormatter" />
|
||||
<el-table-column label="操作" width="280">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canUpdateRole" size="small" @click="handleRename(row)">改名</el-button>
|
||||
<el-button v-if="canBindPermissions" size="small" @click="openBindPermissions(row)">绑权限</el-button>
|
||||
<el-button v-if="canBindPermissions" size="small" @click="openBindMenus(row)">绑菜单</el-button>
|
||||
<el-button
|
||||
v-if="canDisableRole && row.status === 'ENABLED'"
|
||||
size="small"
|
||||
type="warning"
|
||||
@click="handleDisable(row.id)"
|
||||
>
|
||||
停用
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else-if="canEnableRole"
|
||||
size="small"
|
||||
type="success"
|
||||
@click="handleEnable(row.id)"
|
||||
>
|
||||
启用
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canDeleteRole && row.status === 'DISABLED'"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDeleteRole(row.id, row.roleName)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="mt-md"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="totalCount"
|
||||
v-model:current-page="pageNo"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@size-change="load"
|
||||
@current-change="load"
|
||||
/>
|
||||
|
||||
<el-drawer v-model="createVisible" title="新增角色" :size="DRAWER_SIZE.sm" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="角色编码" prop="roleCode">
|
||||
<el-input v-model="form.roleCode" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色名称" prop="roleName">
|
||||
<el-input v-model="form.roleName" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button v-if="canCreateRole" type="primary" @click="handleCreate">创建</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="bindDialogVisible" title="角色权限绑定" :width="DIALOG_WIDTH.md">
|
||||
<div v-if="permissionGroups.length === 0">暂无可绑定权限</div>
|
||||
<div v-for="group in permissionGroups" :key="group.module" class="mb-md">
|
||||
<div class="text-title mb-xs">{{ group.module }}</div>
|
||||
<el-checkbox-group v-model="selectedPermissionIds">
|
||||
<el-checkbox
|
||||
v-for="perm in group.items"
|
||||
:key="perm.id"
|
||||
:label="perm.id"
|
||||
class="mr-lg"
|
||||
>
|
||||
{{ perm.permissionName }}({{ perm.permissionCode }})
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="bindDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitBindPermissions">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="bindMenuVisible" title="角色菜单绑定" :width="DIALOG_WIDTH.md">
|
||||
<div v-if="menus.length === 0">暂无可绑定菜单</div>
|
||||
<el-checkbox-group v-model="selectedMenuIds">
|
||||
<el-checkbox v-for="menu in menus" :key="menu.id" :label="menu.id" class="mr-lg mb-xs">
|
||||
{{ menu.menuName }}({{ menu.routePath }})
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
<template #footer>
|
||||
<el-button @click="bindMenuVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitBindMenus">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import {
|
||||
bindRoleMenus,
|
||||
bindRolePermissions,
|
||||
createRole,
|
||||
deleteRole,
|
||||
disableRole,
|
||||
enableRole,
|
||||
fetchMenus,
|
||||
fetchPermissions,
|
||||
fetchRoleMenus,
|
||||
fetchRolePermissions,
|
||||
fetchRoles,
|
||||
updateRole,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const rows = ref<any[]>([]);
|
||||
const pageNo = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const totalCount = ref(0);
|
||||
const authStore = useAuthStore();
|
||||
const canReadRole = computed(() => authStore.hasPermission(PERMS.role.read));
|
||||
const canCreateRole = computed(() => authStore.hasPermission(PERMS.role.create));
|
||||
const canUpdateRole = computed(() => authStore.hasPermission(PERMS.role.update));
|
||||
const canEnableRole = computed(() => authStore.hasPermission(PERMS.role.enable));
|
||||
const canDisableRole = computed(() => authStore.hasPermission(PERMS.role.disable));
|
||||
const canDeleteRole = computed(() => authStore.hasPermission(PERMS.role.delete));
|
||||
const canBindPermissions = computed(() => authStore.hasPermission(PERMS.role.permissionBind));
|
||||
const canReadPermissions = computed(() => authStore.hasPermission(PERMS.permission.read));
|
||||
const createVisible = ref(false);
|
||||
const form = ref({
|
||||
roleCode: "CUSTOM_" + Date.now(),
|
||||
roleName: "自定义角色",
|
||||
});
|
||||
|
||||
const formRef = ref<FormInstance>();
|
||||
const formRules = {
|
||||
roleCode: [{ required: true, message: "请输入角色编码", trigger: "blur" }],
|
||||
roleName: [{ required: true, message: "请输入角色名称", trigger: "blur" }],
|
||||
};
|
||||
|
||||
const bindDialogVisible = ref(false);
|
||||
const bindMenuVisible = ref(false);
|
||||
const selectedRoleId = ref<number | null>(null);
|
||||
const selectedPermissionIds = ref<number[]>([]);
|
||||
const permissions = ref<any[]>([]);
|
||||
const menus = ref<any[]>([]);
|
||||
const selectedMenuIds = ref<number[]>([]);
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
form.value = {
|
||||
roleCode: "CUSTOM_" + Date.now(),
|
||||
roleName: "自定义角色",
|
||||
};
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!canReadRole.value) {
|
||||
rows.value = [];
|
||||
return;
|
||||
}
|
||||
const resp = await fetchRoles({ pageNo: pageNo.value, pageSize: pageSize.value });
|
||||
rows.value = resp?.data?.list || [];
|
||||
totalCount.value = Number(resp?.data?.total || 0);
|
||||
};
|
||||
|
||||
const permissionGroups = computed(() => {
|
||||
const grouped: Record<string, any[]> = {};
|
||||
permissions.value.forEach((p) => {
|
||||
const key = p.module || "other";
|
||||
if (!grouped[key]) {
|
||||
grouped[key] = [];
|
||||
}
|
||||
grouped[key].push(p);
|
||||
});
|
||||
return Object.keys(grouped).map((module) => ({ module, items: grouped[module] }));
|
||||
});
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formRef.value) return;
|
||||
const valid = await formRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
await createRole(form.value);
|
||||
ElMessage.success("角色创建成功");
|
||||
createVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleRename = async (row: any) => {
|
||||
const dialog = await ElMessageBox.prompt("请输入新的角色名称", "编辑角色", {
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
inputValue: row.roleName,
|
||||
}).catch(() => null);
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
await updateRole(row.id, { roleName: dialog.value });
|
||||
ElMessage.success("角色更新成功");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleEnable = async (id: number) => {
|
||||
await enableRole(id);
|
||||
ElMessage.success("角色已启用");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDisable = async (id: number) => {
|
||||
await disableRole(id);
|
||||
ElMessage.success("角色已停用");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDeleteRole = async (id: number, roleName: string) => {
|
||||
await ElMessageBox.confirm(`确认删除角色「${roleName}」?删除后不可恢复。`, "删除角色", {
|
||||
type: "warning",
|
||||
confirmButtonText: "确认删除",
|
||||
cancelButtonText: "取消",
|
||||
});
|
||||
await deleteRole(id);
|
||||
ElMessage.success("角色已删除");
|
||||
await load();
|
||||
};
|
||||
|
||||
const openBindPermissions = async (row: any) => {
|
||||
if (!canReadPermissions.value) {
|
||||
ElMessage.warning("当前账号没有 permission.read 权限");
|
||||
return;
|
||||
}
|
||||
selectedRoleId.value = row.id;
|
||||
const [permResp, rolePermResp] = await Promise.all([
|
||||
fetchPermissions(),
|
||||
fetchRolePermissions(row.id),
|
||||
]);
|
||||
permissions.value = permResp?.data?.list || [];
|
||||
selectedPermissionIds.value = rolePermResp?.data || [];
|
||||
bindDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const submitBindPermissions = async () => {
|
||||
if (!selectedRoleId.value) {
|
||||
return;
|
||||
}
|
||||
await bindRolePermissions(selectedRoleId.value, { permissionIds: selectedPermissionIds.value });
|
||||
ElMessage.success("权限绑定成功,请重新登录以刷新权限");
|
||||
bindDialogVisible.value = false;
|
||||
};
|
||||
|
||||
const openBindMenus = async (row: any) => {
|
||||
selectedRoleId.value = row.id;
|
||||
const [menuResp, roleMenuResp] = await Promise.all([fetchMenus(), fetchRoleMenus(row.id)]);
|
||||
menus.value = menuResp?.data?.list || [];
|
||||
const validMenuIds = new Set((menus.value || []).map((m: any) => Number(m.id)));
|
||||
selectedMenuIds.value = (roleMenuResp?.data || []).filter((id: number) => validMenuIds.has(Number(id)));
|
||||
bindMenuVisible.value = true;
|
||||
};
|
||||
|
||||
const submitBindMenus = async () => {
|
||||
if (!selectedRoleId.value) {
|
||||
return;
|
||||
}
|
||||
const validMenuIds = new Set((menus.value || []).map((m: any) => Number(m.id)));
|
||||
const filteredMenuIds = (selectedMenuIds.value || []).filter((id: number) => validMenuIds.has(Number(id)));
|
||||
await bindRoleMenus(selectedRoleId.value, { menuIds: filteredMenuIds });
|
||||
ElMessage.success("菜单绑定成功,请重新登录以刷新菜单");
|
||||
bindMenuVisible.value = false;
|
||||
};
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
@@ -0,0 +1,625 @@
|
||||
<template>
|
||||
<PageContainer title="模板查看下载">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="模板列表" name="templates">
|
||||
<el-form :inline="true" :model="templateQuery" class="wo-query-form">
|
||||
<el-form-item label="模板名称">
|
||||
<el-input v-model="templateQuery.templateName" clearable placeholder="请输入模板名称" class="w-input-md" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板类型">
|
||||
<el-select v-model="templateQuery.templateType" clearable placeholder="全部" class="w-input-md">
|
||||
<el-option
|
||||
v-for="item in templateTypeOptions"
|
||||
:key="item.typeCode"
|
||||
:label="item.typeName"
|
||||
:value="item.typeCode"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="模板状态">
|
||||
<el-select v-model="templateQuery.status" clearable placeholder="全部" class="w-input-md">
|
||||
<el-option v-for="item in templateStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="水印下载">
|
||||
<el-select v-model="templateQuery.watermarkEnabled" clearable placeholder="全部" class="w-input-md">
|
||||
<el-option v-for="item in watermarkFilterOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button v-if="canRead" type="primary" @click="handleTemplateSearch">查询</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="resetTemplateQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="templateRows" empty-text="暂无模板数据">
|
||||
<el-table-column prop="templateName" label="模板名称" min-width="180" />
|
||||
<el-table-column label="模板类型" width="130">
|
||||
<template #default="{ row }">
|
||||
{{ getTemplateTypeLabel(row.templateType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="适用范围" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ getScopeTypeLabel(row.scopeType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="bizScene" label="业务场景" width="140" :formatter="bizSceneFormatter" />
|
||||
<el-table-column prop="currentVersionNo" label="当前版本" width="100" />
|
||||
<el-table-column prop="status" label="状态" width="110" :formatter="statusFormatter" />
|
||||
<el-table-column label="生效时间" min-width="220">
|
||||
<template #default="{ row }">
|
||||
{{ formatEffectiveTime(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="水印" width="90">
|
||||
<template #default="{ row }">
|
||||
{{ row.watermarkEnabled ? "开启" : "关闭" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="downloadRateLimitPerHour" label="限流/小时" width="110" />
|
||||
<el-table-column prop="updatedAt" label="最近更新" width="180" />
|
||||
<el-table-column label="操作" min-width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" v-if="canRead" @click="openDetailDrawer(row)">查看详情</el-button>
|
||||
<el-button size="small" v-if="canRead" @click="focusLogs(row)">
|
||||
{{ canReadAllLogs ? "查看下载日志" : "查看我的下载日志" }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
v-if="canDownload"
|
||||
:disabled="isDownloadBlocked(row)"
|
||||
@click="handleDownload(row)"
|
||||
>
|
||||
下载
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
type="primary"
|
||||
v-if="canDownload"
|
||||
:disabled="isWatermarkDownloadBlocked(row)"
|
||||
@click="handleDownloadWatermark(row)"
|
||||
>
|
||||
水印下载
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="mt-md"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="templateTotalCount"
|
||||
v-model:current-page="templatePageNo"
|
||||
v-model:page-size="templatePageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@size-change="loadTemplates"
|
||||
@current-change="loadTemplates"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="canReadAllLogs ? '下载日志' : '我的下载日志'" name="logs">
|
||||
<div class="text-secondary mb-sm">
|
||||
{{ canReadAllLogs ? "支持按模板、下载人和时间范围查看全部下载记录。" : "当前仅展示当前账号的下载日志。" }}
|
||||
</div>
|
||||
|
||||
<el-form :inline="true" :model="logQuery" class="wo-query-form">
|
||||
<el-form-item label="模板名称">
|
||||
<el-input v-model="logQuery.templateName" clearable placeholder="请输入模板名称" class="w-input-md" />
|
||||
</el-form-item>
|
||||
<el-form-item label="模板ID">
|
||||
<el-input-number v-model="logQuery.templateId" :min="1" class="w-input-sm" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="canReadAllLogs" label="下载人">
|
||||
<el-input v-model="logQuery.userKeyword" clearable placeholder="姓名或手机号" class="w-input-md" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="canReadAllLogs" label="用户ID">
|
||||
<el-input-number v-model="logQuery.userId" :min="1" class="w-input-sm" />
|
||||
</el-form-item>
|
||||
<el-form-item label="版本号">
|
||||
<el-input-number v-model="logQuery.versionNo" :min="1" class="w-input-sm" />
|
||||
</el-form-item>
|
||||
<el-form-item label="下载方式">
|
||||
<el-select v-model="logQuery.downloadType" clearable placeholder="全部" class="w-input-md">
|
||||
<el-option label="普通下载" value="NORMAL" />
|
||||
<el-option label="水印下载" value="WATERMARK" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="canReadAllLogs" label="IP">
|
||||
<el-input v-model="logQuery.ip" clearable placeholder="请输入 IP" class="w-input-md" />
|
||||
</el-form-item>
|
||||
<el-form-item label="下载时间">
|
||||
<el-date-picker
|
||||
v-model="logQuery.dateRange"
|
||||
type="datetimerange"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
range-separator="至"
|
||||
value-format="YYYY-MM-DD HH:mm:ss"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button v-if="canRead" type="primary" @click="handleLogSearch">查询</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="resetLogQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="logRows" empty-text="暂无下载记录">
|
||||
<el-table-column prop="templateName" label="模板名称" min-width="180">
|
||||
<template #default="{ row }">
|
||||
{{ row.templateName || `模板#${row.templateId}` }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="versionNo" label="版本号" width="90" />
|
||||
<el-table-column label="下载方式" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ getDownloadTypeLabel(row.downloadType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="canReadAllLogs" label="下载人" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.userName || row.userPhone">
|
||||
{{ row.userName || "-" }}<span v-if="row.userPhone"> / {{ row.userPhone }}</span>
|
||||
</span>
|
||||
<span v-else>{{ `用户#${row.userId}` }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="watermarkText" label="水印文案" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="objectKey" label="ObjectKey" min-width="260" show-overflow-tooltip />
|
||||
<el-table-column prop="ip" label="IP" width="140" />
|
||||
<el-table-column prop="downloadedAt" label="下载时间" width="180" />
|
||||
<el-table-column prop="userAgent" label="终端信息" min-width="220" show-overflow-tooltip />
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
class="mt-md"
|
||||
background
|
||||
layout="total, prev, pager, next, sizes"
|
||||
:total="logTotalCount"
|
||||
v-model:current-page="logPageNo"
|
||||
v-model:page-size="logPageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
@size-change="loadLogs"
|
||||
@current-change="loadLogs"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="模板详情" :size="DRAWER_SIZE.lg" destroy-on-close>
|
||||
<template v-if="detailRow">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="模板名称">{{ detailRow.templateName || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="模板状态">{{ getStatusLabel(detailRow.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="模板类型">{{ getTemplateTypeLabel(detailRow.templateType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="业务场景">{{ getBizSceneLabel(detailRow.bizScene) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="适用范围">{{ getScopeTypeLabel(detailRow.scopeType) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="当前版本">V{{ detailRow.currentVersionNo || 1 }}</el-descriptions-item>
|
||||
<el-descriptions-item label="生效时间">{{ formatEffectiveTime(detailRow) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="水印下载">{{ detailRow.watermarkEnabled ? "开启" : "关闭" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下载限流/小时">{{ detailRow.downloadRateLimitPerHour ?? "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近更新">{{ detailRow.updatedAt || "-" }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下载状态">
|
||||
{{ isDownloadBlocked(detailRow) ? "当前不可下载" : "允许下载" }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="mt-md flex items-center gap-sm">
|
||||
<el-button v-if="canRead" @click="focusLogs(detailRow)">
|
||||
{{ canReadAllLogs ? "查看下载日志" : "查看我的下载日志" }}
|
||||
</el-button>
|
||||
<el-button v-if="canDownload" type="primary" :disabled="isDownloadBlocked(detailRow)" @click="handleDownload(detailRow)">
|
||||
下载
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="canDownload"
|
||||
type="primary"
|
||||
:disabled="isWatermarkDownloadBlocked(detailRow)"
|
||||
@click="handleDownloadWatermark(detailRow)"
|
||||
>
|
||||
水印下载
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-descriptions v-if="currentVersionDetail" class="mt-md" :column="2" border>
|
||||
<el-descriptions-item label="当前版本 ObjectKey">
|
||||
{{ currentVersionDetail.objectKey || detailRow.currentObjectKey || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="当前版本状态">
|
||||
{{ getStatusLabel(currentVersionDetail.versionStatus) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="当前版本变更说明">
|
||||
{{ currentVersionDetail.changeLog || "-" }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="当前版本创建时间">
|
||||
{{ currentVersionDetail.createdAt || "-" }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-tabs class="mt-md">
|
||||
<el-tab-pane label="版本历史">
|
||||
<el-table :data="detailVersionRows" empty-text="暂无版本记录">
|
||||
<el-table-column prop="versionNo" label="版本号" width="90" />
|
||||
<el-table-column prop="versionStatus" label="状态" width="100" :formatter="statusFormatter" />
|
||||
<el-table-column label="当前生效" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.effective ? "是" : "否" }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="objectKey" label="ObjectKey" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column prop="changeLog" label="变更说明" min-width="180" />
|
||||
<el-table-column prop="rollbackReason" label="回滚原因" min-width="180" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="180" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="canReadAllLogs ? '最近下载' : '我的最近下载'">
|
||||
<el-table :data="detailDownloadRows" empty-text="暂无下载记录">
|
||||
<el-table-column prop="downloadedAt" label="下载时间" width="180" />
|
||||
<el-table-column label="下载方式" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ getDownloadTypeLabel(row.downloadType) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="canReadAllLogs" label="下载人" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.userName || row.userPhone">
|
||||
{{ row.userName || "-" }}<span v-if="row.userPhone"> / {{ row.userPhone }}</span>
|
||||
</span>
|
||||
<span v-else>{{ `用户#${row.userId}` }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="watermarkText" label="水印文案" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="ip" label="IP" width="140" />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import {
|
||||
downloadTemplate,
|
||||
downloadTemplateWatermark,
|
||||
fetchTemplateDownloadLogs,
|
||||
fetchTemplateTypeOptions,
|
||||
fetchTemplateVersions,
|
||||
fetchTemplates,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { DRAWER_SIZE } from "../../constants/ui";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
|
||||
type TemplateTypeCode = "AGENDA" | "SIGN_IN" | "INVITATION" | "OTHER";
|
||||
type BizSceneCode = "MEETING_RECOMMEND" | "AUDIT_NOTIFY" | "SETTLEMENT";
|
||||
type ScopeTypeCode = "ALL" | "PROJECT" | "MEETING";
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
ENABLED: "启用",
|
||||
DISABLED: "停用",
|
||||
DRAFT: "草稿",
|
||||
PUBLISHED: "已发布",
|
||||
ARCHIVED: "已归档",
|
||||
};
|
||||
|
||||
const BIZ_SCENE_LABELS: Record<string, string> = {
|
||||
MEETING_RECOMMEND: "会议推荐",
|
||||
AUDIT_NOTIFY: "审核通知",
|
||||
SETTLEMENT: "结算",
|
||||
};
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const route = useRoute();
|
||||
|
||||
const canRead = computed(() => authStore.hasPermission(PERMS.template.read));
|
||||
const canDownload = computed(() => authStore.hasPermission(PERMS.template.download));
|
||||
const canReadAllLogs = computed(() => authStore.hasPermission(PERMS.template.downloadLogReadAll));
|
||||
|
||||
const activeTab = ref<"templates" | "logs">("templates");
|
||||
|
||||
const templateRows = ref<any[]>([]);
|
||||
const templatePageNo = ref(1);
|
||||
const templatePageSize = ref(20);
|
||||
const templateTotalCount = ref(0);
|
||||
|
||||
const logRows = ref<any[]>([]);
|
||||
const logPageNo = ref(1);
|
||||
const logPageSize = ref(20);
|
||||
const logTotalCount = ref(0);
|
||||
|
||||
const detailVisible = ref(false);
|
||||
const detailRow = ref<any | null>(null);
|
||||
const detailVersionRows = ref<any[]>([]);
|
||||
const detailDownloadRows = ref<any[]>([]);
|
||||
|
||||
const templateTypeOptions = ref<Array<{ typeCode: TemplateTypeCode; typeName: string; status: "ENABLED" | "DISABLED" }>>([]);
|
||||
|
||||
const templateStatusOptions = [
|
||||
{ label: "草稿", value: "DRAFT" },
|
||||
{ label: "已发布", value: "PUBLISHED" },
|
||||
{ label: "已停用", value: "DISABLED" },
|
||||
{ label: "已归档", value: "ARCHIVED" },
|
||||
];
|
||||
|
||||
const scopeTypeOptions = [
|
||||
{ label: "全部", value: "ALL" },
|
||||
{ label: "项目级", value: "PROJECT" },
|
||||
{ label: "会议级", value: "MEETING" },
|
||||
];
|
||||
|
||||
const watermarkFilterOptions = [
|
||||
{ label: "开启", value: "true" },
|
||||
{ label: "关闭", value: "false" },
|
||||
];
|
||||
|
||||
const templateQuery = ref({
|
||||
templateName: "",
|
||||
templateType: "" as TemplateTypeCode | "",
|
||||
status: "",
|
||||
watermarkEnabled: "" as "" | "true" | "false",
|
||||
});
|
||||
|
||||
const logQuery = ref({
|
||||
templateId: undefined as number | undefined,
|
||||
templateName: "",
|
||||
userId: undefined as number | undefined,
|
||||
userKeyword: "",
|
||||
versionNo: undefined as number | undefined,
|
||||
downloadType: "" as "" | "NORMAL" | "WATERMARK",
|
||||
ip: "",
|
||||
dateRange: [] as string[],
|
||||
});
|
||||
|
||||
const currentVersionDetail = computed(() => {
|
||||
if (!detailVersionRows.value.length) {
|
||||
return null;
|
||||
}
|
||||
const currentVersionNo = Number(detailRow.value?.currentVersionNo || 0);
|
||||
return (
|
||||
detailVersionRows.value.find((item) => Number(item.versionNo) === currentVersionNo) ||
|
||||
detailVersionRows.value.find((item) => Boolean(item.effective)) ||
|
||||
detailVersionRows.value[0]
|
||||
);
|
||||
});
|
||||
|
||||
const getStatusLabel = (value?: unknown) => {
|
||||
const key = String(value ?? "").trim();
|
||||
return STATUS_LABELS[key] || key || "-";
|
||||
};
|
||||
|
||||
const getBizSceneLabel = (value?: unknown) => {
|
||||
const key = String(value ?? "").trim();
|
||||
return BIZ_SCENE_LABELS[key] || key || "-";
|
||||
};
|
||||
|
||||
const getTemplateTypeLabel = (code: string) =>
|
||||
templateTypeOptions.value.find((item) => item.typeCode === code)?.typeName || code || "-";
|
||||
|
||||
const getScopeTypeLabel = (code: ScopeTypeCode | string) =>
|
||||
scopeTypeOptions.find((item) => item.value === code)?.label || code || "-";
|
||||
|
||||
const getDownloadTypeLabel = (value?: unknown) => (String(value ?? "").trim() === "WATERMARK" ? "水印下载" : "普通下载");
|
||||
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => getStatusLabel(value);
|
||||
const bizSceneFormatter = (_row: any, _column: any, value: unknown) => getBizSceneLabel(value);
|
||||
|
||||
const formatEffectiveTime = (row: any) => {
|
||||
const start = String(row?.effectiveFrom || "").trim();
|
||||
const end = String(row?.effectiveTo || "").trim();
|
||||
if (!start && !end) {
|
||||
return "长期有效";
|
||||
}
|
||||
return `${start || "未设置"} ~ ${end || "未设置"}`;
|
||||
};
|
||||
|
||||
const buildTemplateName = (row: any) => row?.templateName || `模板#${row?.id ?? "-"}`;
|
||||
const isDownloadBlocked = (row?: any) => ["DISABLED", "ARCHIVED"].includes(String(row?.status || "").trim().toUpperCase());
|
||||
const isWatermarkDownloadBlocked = (row?: any) => isDownloadBlocked(row) || !row?.watermarkEnabled;
|
||||
|
||||
const confirmAction = async (message: string, title: string) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(message, title, {
|
||||
type: "warning",
|
||||
confirmButtonText: "确定",
|
||||
cancelButtonText: "取消",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const buildTemplateParams = () => ({
|
||||
templateName: templateQuery.value.templateName || undefined,
|
||||
templateType: templateQuery.value.templateType || undefined,
|
||||
status: templateQuery.value.status || undefined,
|
||||
watermarkEnabled: templateQuery.value.watermarkEnabled ? templateQuery.value.watermarkEnabled === "true" : undefined,
|
||||
pageNo: templatePageNo.value,
|
||||
pageSize: templatePageSize.value,
|
||||
});
|
||||
|
||||
const buildLogParams = () => ({
|
||||
templateId: logQuery.value.templateId,
|
||||
templateName: logQuery.value.templateName || undefined,
|
||||
userId: canReadAllLogs.value ? logQuery.value.userId : undefined,
|
||||
userKeyword: canReadAllLogs.value ? logQuery.value.userKeyword || undefined : undefined,
|
||||
versionNo: logQuery.value.versionNo,
|
||||
downloadType: logQuery.value.downloadType || undefined,
|
||||
ip: canReadAllLogs.value ? logQuery.value.ip || undefined : undefined,
|
||||
downloadedFrom: logQuery.value.dateRange?.[0] || undefined,
|
||||
downloadedTo: logQuery.value.dateRange?.[1] || undefined,
|
||||
pageNo: logPageNo.value,
|
||||
pageSize: logPageSize.value,
|
||||
});
|
||||
|
||||
const initQueryFromRoute = () => {
|
||||
const routeTemplateId = Number(route.query.templateId || 0);
|
||||
const routeTemplateName = String(route.query.templateName || "").trim();
|
||||
if (routeTemplateId > 0) {
|
||||
logQuery.value.templateId = routeTemplateId;
|
||||
activeTab.value = "logs";
|
||||
}
|
||||
if (routeTemplateName) {
|
||||
logQuery.value.templateName = routeTemplateName;
|
||||
}
|
||||
};
|
||||
|
||||
const loadTemplateTypeOptions = async () => {
|
||||
if (!canRead.value) {
|
||||
templateTypeOptions.value = [];
|
||||
return;
|
||||
}
|
||||
const resp = await fetchTemplateTypeOptions();
|
||||
templateTypeOptions.value = resp?.data || [];
|
||||
};
|
||||
|
||||
const loadTemplates = async () => {
|
||||
if (!canRead.value) {
|
||||
templateRows.value = [];
|
||||
templateTotalCount.value = 0;
|
||||
return;
|
||||
}
|
||||
const resp = await fetchTemplates(buildTemplateParams());
|
||||
templateRows.value = resp?.data?.list || [];
|
||||
templateTotalCount.value = Number(resp?.data?.total || 0);
|
||||
};
|
||||
|
||||
const loadLogs = async () => {
|
||||
if (!canRead.value) {
|
||||
logRows.value = [];
|
||||
logTotalCount.value = 0;
|
||||
return;
|
||||
}
|
||||
const resp = await fetchTemplateDownloadLogs(buildLogParams());
|
||||
logRows.value = resp?.data?.list || [];
|
||||
logTotalCount.value = Number(resp?.data?.total || 0);
|
||||
};
|
||||
|
||||
const refreshDetailData = async () => {
|
||||
if (!detailRow.value?.id || !canRead.value) {
|
||||
detailVersionRows.value = [];
|
||||
detailDownloadRows.value = [];
|
||||
return;
|
||||
}
|
||||
const [versionsResp, logsResp] = await Promise.all([
|
||||
fetchTemplateVersions(detailRow.value.id),
|
||||
fetchTemplateDownloadLogs({
|
||||
templateId: detailRow.value.id,
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
}),
|
||||
]);
|
||||
detailVersionRows.value = versionsResp?.data || [];
|
||||
detailDownloadRows.value = logsResp?.data?.list || [];
|
||||
};
|
||||
|
||||
const openDetailDrawer = async (row: any) => {
|
||||
detailRow.value = { ...row };
|
||||
detailVisible.value = true;
|
||||
await refreshDetailData();
|
||||
};
|
||||
|
||||
const handleTemplateSearch = async () => {
|
||||
templatePageNo.value = 1;
|
||||
await loadTemplates();
|
||||
};
|
||||
|
||||
const handleLogSearch = async () => {
|
||||
logPageNo.value = 1;
|
||||
await loadLogs();
|
||||
};
|
||||
|
||||
const resetTemplateQuery = async () => {
|
||||
templateQuery.value = {
|
||||
templateName: "",
|
||||
templateType: "",
|
||||
status: "",
|
||||
watermarkEnabled: "",
|
||||
};
|
||||
templatePageNo.value = 1;
|
||||
templatePageSize.value = 20;
|
||||
await loadTemplates();
|
||||
};
|
||||
|
||||
const resetLogQuery = async () => {
|
||||
logQuery.value = {
|
||||
templateId: undefined,
|
||||
templateName: "",
|
||||
userId: undefined,
|
||||
userKeyword: "",
|
||||
versionNo: undefined,
|
||||
downloadType: "",
|
||||
ip: "",
|
||||
dateRange: [],
|
||||
};
|
||||
logPageNo.value = 1;
|
||||
logPageSize.value = 20;
|
||||
await loadLogs();
|
||||
};
|
||||
|
||||
const focusLogs = async (row?: any) => {
|
||||
if (row?.id) {
|
||||
logQuery.value.templateId = Number(row.id);
|
||||
logQuery.value.templateName = row.templateName || "";
|
||||
}
|
||||
activeTab.value = "logs";
|
||||
logPageNo.value = 1;
|
||||
await loadLogs();
|
||||
};
|
||||
|
||||
const handleDownload = async (row: any) => {
|
||||
if (isDownloadBlocked(row)) {
|
||||
ElMessage.warning("该模板已停用或归档,当前不可下载");
|
||||
return;
|
||||
}
|
||||
const resp = await downloadTemplate(row.id);
|
||||
const url = resp?.data?.signedUrl;
|
||||
if (!url) {
|
||||
ElMessage.error("获取下载链接失败");
|
||||
return;
|
||||
}
|
||||
window.open(url, "_blank");
|
||||
ElMessage.success("已生成下载链接并记录下载日志");
|
||||
await Promise.all([loadLogs(), refreshDetailData()]);
|
||||
};
|
||||
|
||||
const handleDownloadWatermark = async (row: any) => {
|
||||
if (isWatermarkDownloadBlocked(row)) {
|
||||
ElMessage.warning(isDownloadBlocked(row) ? "该模板已停用或归档,当前不可下载" : "该模板未开启水印下载");
|
||||
return;
|
||||
}
|
||||
const confirmed = await confirmAction(
|
||||
`确认对「${buildTemplateName(row)}」执行水印下载?系统会记录本次下载日志。`,
|
||||
"水印下载",
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
const resp = await downloadTemplateWatermark(row.id, { watermarkText: "仅限内部使用" });
|
||||
const url = resp?.data?.signedUrl;
|
||||
if (!url) {
|
||||
ElMessage.error("获取水印下载链接失败");
|
||||
return;
|
||||
}
|
||||
window.open(url, "_blank");
|
||||
ElMessage.success("已生成水印下载链接并记录下载日志");
|
||||
await Promise.all([loadLogs(), refreshDetailData()]);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
initQueryFromRoute();
|
||||
await loadTemplateTypeOptions();
|
||||
await Promise.all([loadTemplates(), loadLogs()]);
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<div class="dashboard-page">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">工作台</h2>
|
||||
<p class="page-desc">欢迎回来,{{ userName }}!这里是您的工作概览与待办事项。</p>
|
||||
</div>
|
||||
|
||||
<!-- 概览卡片区 -->
|
||||
<el-row :gutter="24" class="stat-cards">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="hover" class="stat-card stat-pending">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-title">待我审批的资料</div>
|
||||
<div class="stat-value">{{ pendingAuditCount }}</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<!-- 这里预留其他卡片位置,比如进行中的会议、待财务确认等,根据权限展示 -->
|
||||
<el-col :span="8" v-if="canCreateMeeting">
|
||||
<el-card shadow="hover" class="stat-card stat-meetings">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" 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>
|
||||
<div class="stat-info">
|
||||
<div class="stat-title">活跃会议</div>
|
||||
<div class="stat-value">{{ activeMeetingCount }}</div>
|
||||
<div class="stat-desc">前往会议管理查看详情</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :span="8" v-if="canViewFinance">
|
||||
<el-card shadow="hover" class="stat-card stat-finance">
|
||||
<div class="stat-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="1" x2="12" y2="23"/>
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<div class="stat-title">待财务确认账单</div>
|
||||
<div class="stat-value">{{ pendingFinanceCount }}</div>
|
||||
<div class="stat-desc">前往财务管理处理</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 待办任务区 -->
|
||||
<el-card class="todo-list-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>待办审批</span>
|
||||
<el-button type="primary" link @click="router.push('/audits')">查看全部</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="myPendingTasks"
|
||||
style="width: 100%"
|
||||
stripe
|
||||
:empty-text="loading ? '加载中...' : '暂无待审批任务'"
|
||||
>
|
||||
<el-table-column prop="id" label="任务ID" width="80" />
|
||||
<el-table-column prop="meetingId" label="关联会议ID" width="100" />
|
||||
<el-table-column prop="moduleCode" label="模块" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ formatModuleCode(row.moduleCode) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="nodeStatus" label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
size="small"
|
||||
:type="row.nodeStatus === 'PENDING' ? 'warning' : 'info'"
|
||||
>
|
||||
{{ row.nodeStatus === 'PENDING' ? '待处理' : row.nodeStatus }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="到达时间" width="180" />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="goToAudit(row.meetingId)"
|
||||
>
|
||||
去处理
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { fetchAuditTasks, fetchDashboardStats } from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhModuleCode } from "../../utils/status";
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const userName = computed(() => authStore.userName || "用户");
|
||||
const canCreateMeeting = computed(() => authStore.hasPermission(PERMS.meeting.manage));
|
||||
const canViewFinance = computed(() => authStore.hasPermission(PERMS.finance.reconciliation));
|
||||
|
||||
const loading = ref(false);
|
||||
const myPendingTasks = ref<any[]>([]);
|
||||
const pendingAuditCount = ref(0);
|
||||
const activeMeetingCount = ref(0);
|
||||
const pendingFinanceCount = ref(0);
|
||||
|
||||
const formatModuleCode = (code: string) => toZhModuleCode(code);
|
||||
|
||||
const goToAudit = (meetingId?: number) => {
|
||||
if (meetingId) {
|
||||
router.push({ path: '/audits', query: { meetingId: String(meetingId) } });
|
||||
} else {
|
||||
router.push('/audits');
|
||||
}
|
||||
};
|
||||
|
||||
const loadMyTasks = async () => {
|
||||
if (!authStore.hasPermission(PERMS.audit.read)) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const resp = await fetchAuditTasks({ mine: true, pageNo: 1, pageSize: 50 });
|
||||
const list = resp?.data?.list || [];
|
||||
// 过滤出PENDING的任务以供展示
|
||||
const pendings = list.filter((x: any) => x.nodeStatus === "PENDING" || x.nodeStatus === "CLAIMED");
|
||||
myPendingTasks.value = pendings.slice(0, 10); // 工作台只展示Top 10
|
||||
pendingAuditCount.value = pendings.length;
|
||||
} catch (e) {
|
||||
// ignore
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadMyTasks();
|
||||
loadDashboardStats();
|
||||
});
|
||||
|
||||
const loadDashboardStats = async () => {
|
||||
try {
|
||||
const resp = await fetchDashboardStats();
|
||||
const stats = resp?.data || {};
|
||||
activeMeetingCount.value = stats.activeMeetingCount || 0;
|
||||
pendingFinanceCount.value = stats.pendingFinanceCount || 0;
|
||||
} catch (_e) {
|
||||
// 接口未就绪时静默失败
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-page {
|
||||
padding: 24px;
|
||||
background-color: var(--el-bg-color-page);
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-desc {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.stat-cards {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.stat-card :deep(.el-card__body) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.stat-icon svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.stat-pending .stat-icon {
|
||||
background: rgba(245, 108, 108, 0.1);
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.stat-meetings .stat-icon {
|
||||
background: rgba(64, 158, 255, 0.1);
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.stat-finance .stat-icon {
|
||||
background: rgba(103, 194, 58, 0.1);
|
||||
color: #67c23a;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.stat-desc {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.todo-list-card {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,628 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<!-- Animated background -->
|
||||
<div class="bg-gradient"></div>
|
||||
<div class="bg-orbs">
|
||||
<div class="orb orb-1"></div>
|
||||
<div class="orb orb-2"></div>
|
||||
<div class="orb orb-3"></div>
|
||||
<div class="orb orb-4"></div>
|
||||
</div>
|
||||
|
||||
<!-- Login card -->
|
||||
<div class="login-card" :class="{ 'card-visible': cardVisible }">
|
||||
<!-- Header section -->
|
||||
<div class="card-header">
|
||||
<div class="logo-icon">
|
||||
<svg viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="40" height="40" rx="10" fill="url(#logo-grad-t)" />
|
||||
<path d="M13 16h14M13 20h14M13 24h10" stroke="white" stroke-width="2.2" stroke-linecap="round"/>
|
||||
<defs>
|
||||
<linearGradient id="logo-grad-t" x1="0" y1="0" x2="40" y2="40">
|
||||
<stop stop-color="#0ea5e9"/>
|
||||
<stop offset="1" stop-color="#6366f1"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 class="card-title">核销管理系统</h1>
|
||||
<p class="card-subtitle">租户登录</p>
|
||||
</div>
|
||||
|
||||
<!-- Tenant code badge -->
|
||||
<div class="tenant-badge">
|
||||
<svg viewBox="0 0 20 20" fill="none" style="width:14px;height:14px;flex-shrink:0">
|
||||
<path d="M10 2L3 6v4c0 4.42 2.98 8.56 7 9.6 4.02-1.04 7-5.18 7-9.6V6l-7-4z" fill="currentColor" opacity="0.8"/>
|
||||
</svg>
|
||||
<span>租户编码:<strong>{{ tenantCode }}</strong></span>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<el-form :model="form" class="login-form" @keyup.enter="handleLogin">
|
||||
<div class="input-group">
|
||||
<label class="input-label">手机号</label>
|
||||
<el-input
|
||||
v-model="form.phone"
|
||||
placeholder="请输入手机号"
|
||||
size="large"
|
||||
:prefix-icon="PhoneIcon"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label class="input-label">密码</label>
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
size="large"
|
||||
:prefix-icon="LockIcon"
|
||||
show-password
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="input-group">
|
||||
<label class="input-label">验证码</label>
|
||||
<div class="captcha-row">
|
||||
<el-input
|
||||
v-model="form.captchaCode"
|
||||
placeholder="请输入验证码"
|
||||
size="large"
|
||||
clearable
|
||||
class="captcha-input"
|
||||
/>
|
||||
<img
|
||||
v-if="captchaImg"
|
||||
:src="captchaImg"
|
||||
class="captcha-img"
|
||||
@click="loadCaptcha"
|
||||
title="点击刷新验证码"
|
||||
/>
|
||||
<div v-else class="captcha-placeholder" @click="loadCaptcha">加载中</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 登录错误提示 -->
|
||||
<div v-if="lockCountdown > 0" class="login-alert login-alert-danger">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" style="width:16px;height:16px;flex-shrink:0">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span>账号已锁定,请 {{ formatCountdown(lockCountdown) }} 后重试</span>
|
||||
</div>
|
||||
<div v-else-if="remainingAttempts !== null && remainingAttempts <= 3" class="login-alert login-alert-warning">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor" style="width:16px;height:16px;flex-shrink:0">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.72-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span>您还有 {{ remainingAttempts }} 次尝试机会</span>
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="login-btn"
|
||||
:loading="loading"
|
||||
:disabled="lockCountdown > 0"
|
||||
@click="handleLogin"
|
||||
>
|
||||
<span v-if="!loading">登 录</span>
|
||||
</el-button>
|
||||
</el-form>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="card-footer">
|
||||
<span class="footer-text">© {{ new Date().getFullYear() }} 核销管理系统</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, onMounted, onUnmounted, h } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { login, fetchCaptcha } from "../../api/modules";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
|
||||
// Simple icon render functions for prefix-icon
|
||||
const PhoneIcon = h("i", { class: "login-icon" }, [
|
||||
h(
|
||||
"svg",
|
||||
{
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
"stroke-width": "2",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
style: "width:16px;height:16px",
|
||||
},
|
||||
[
|
||||
h("path", {
|
||||
d: "M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z",
|
||||
}),
|
||||
]
|
||||
),
|
||||
]);
|
||||
|
||||
const LockIcon = h("i", { class: "login-icon" }, [
|
||||
h(
|
||||
"svg",
|
||||
{
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
"stroke-width": "2",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
style: "width:16px;height:16px",
|
||||
},
|
||||
[
|
||||
h("rect", { x: "3", y: "11", width: "18", height: "11", rx: "2", ry: "2" }),
|
||||
h("path", { d: "M7 11V7a5 5 0 0110 0v4" }),
|
||||
]
|
||||
),
|
||||
]);
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const loading = ref(false);
|
||||
const cardVisible = ref(false);
|
||||
const tenantCode = computed(() => String(route.params.tenantCode || "").trim());
|
||||
|
||||
const captchaId = ref("");
|
||||
const captchaImg = ref("");
|
||||
const remainingAttempts = ref<number | null>(null);
|
||||
const lockCountdown = ref(0);
|
||||
let lockTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const form = reactive({
|
||||
phone: "13800000000",
|
||||
password: "123456",
|
||||
captchaCode: "",
|
||||
});
|
||||
|
||||
const loadCaptcha = async () => {
|
||||
try {
|
||||
const resp = await fetchCaptcha();
|
||||
captchaId.value = resp?.data?.captchaId || "";
|
||||
captchaImg.value = resp?.data?.image || "";
|
||||
} catch {
|
||||
captchaImg.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const formatCountdown = (seconds: number): string => {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const startLockCountdown = (seconds: number) => {
|
||||
if (lockTimer) clearInterval(lockTimer);
|
||||
lockCountdown.value = seconds;
|
||||
lockTimer = setInterval(() => {
|
||||
lockCountdown.value--;
|
||||
if (lockCountdown.value <= 0) {
|
||||
if (lockTimer) clearInterval(lockTimer);
|
||||
lockTimer = null;
|
||||
remainingAttempts.value = null;
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
cardVisible.value = true;
|
||||
}, 100);
|
||||
loadCaptcha();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (lockTimer) clearInterval(lockTimer);
|
||||
});
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (loading.value || lockCountdown.value > 0) return;
|
||||
if (!tenantCode.value) {
|
||||
ElMessage.error("租户编码不能为空");
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
authStore.clearAuthStorage();
|
||||
const resp = await login({
|
||||
tenantCode: tenantCode.value,
|
||||
phone: form.phone,
|
||||
password: form.password,
|
||||
captchaId: captchaId.value,
|
||||
captchaCode: form.captchaCode,
|
||||
});
|
||||
remainingAttempts.value = null;
|
||||
authStore.saveAuthPayload({
|
||||
...(resp?.data || {}),
|
||||
tenantCode: resp?.data?.tenantCode || tenantCode.value,
|
||||
scope: "TENANT",
|
||||
});
|
||||
ElMessage.success("登录成功");
|
||||
await router.push("/projects");
|
||||
} catch (e: any) {
|
||||
const data = e?.response?.data;
|
||||
const errors = data?.errors || {};
|
||||
// 处理锁定
|
||||
if (errors.lockRemainingSeconds) {
|
||||
startLockCountdown(Number(errors.lockRemainingSeconds));
|
||||
}
|
||||
// 处理剩余次数
|
||||
if (errors.remainingAttempts !== undefined) {
|
||||
remainingAttempts.value = Number(errors.remainingAttempts);
|
||||
}
|
||||
ElMessage.error(data?.message || e?.message || "登录失败");
|
||||
// 刷新验证码
|
||||
form.captchaCode = "";
|
||||
await loadCaptcha();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ===== Page layout ===== */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-family: "Inter", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
/* ===== Animated gradient background ===== */
|
||||
.bg-gradient {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: var(--wo-login-bg-tenant);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* ===== Floating orbs ===== */
|
||||
.bg-orbs {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
opacity: 0.45;
|
||||
animation: float 20s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.orb-1 {
|
||||
width: 380px;
|
||||
height: 380px;
|
||||
background: radial-gradient(circle, var(--wo-login-orb-tenant-1), transparent 70%);
|
||||
top: -80px;
|
||||
right: -60px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.orb-2 {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
background: radial-gradient(circle, var(--wo-login-orb-tenant-2), transparent 70%);
|
||||
bottom: -100px;
|
||||
left: -80px;
|
||||
animation-delay: -5s;
|
||||
animation-duration: 25s;
|
||||
}
|
||||
|
||||
.orb-3 {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
background: radial-gradient(circle, var(--wo-login-orb-tenant-3), transparent 70%);
|
||||
top: 40%;
|
||||
left: 15%;
|
||||
animation-delay: -10s;
|
||||
animation-duration: 18s;
|
||||
}
|
||||
|
||||
.orb-4 {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: radial-gradient(circle, var(--wo-login-orb-tenant-4), transparent 70%);
|
||||
top: 20%;
|
||||
right: 25%;
|
||||
animation-delay: -15s;
|
||||
animation-duration: 22s;
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||||
25% { transform: translate(30px, -40px) scale(1.05); }
|
||||
50% { transform: translate(-20px, 20px) scale(0.95); }
|
||||
75% { transform: translate(40px, 10px) scale(1.02); }
|
||||
}
|
||||
|
||||
/* ===== Glass card ===== */
|
||||
.login-card {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
width: 420px;
|
||||
padding: 48px 40px 36px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
backdrop-filter: blur(24px) saturate(1.4);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(1.4);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 20px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
|
||||
|
||||
/* Entrance animation */
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.96);
|
||||
transition: all 0.7s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.login-card.card-visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
|
||||
/* ===== Header ===== */
|
||||
.card-header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.logo-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
margin-bottom: 16px;
|
||||
animation: logo-pop 0.6s 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
|
||||
}
|
||||
|
||||
.logo-icon svg {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
filter: drop-shadow(0 4px 12px rgba(14, 165, 233, 0.4));
|
||||
}
|
||||
|
||||
@keyframes logo-pop {
|
||||
from { transform: scale(0); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin: 0 0 6px 0;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.card-subtitle {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ===== Tenant badge ===== */
|
||||
.tenant-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 24px;
|
||||
background: rgba(14, 165, 233, 0.1);
|
||||
border: 1px solid rgba(14, 165, 233, 0.2);
|
||||
border-radius: 10px;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tenant-badge strong {
|
||||
color: #38bdf8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ===== Form ===== */
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
/* Element Plus input overrides */
|
||||
.login-form :deep(.el-input__wrapper) {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
box-shadow: none;
|
||||
transition: all 0.3s ease;
|
||||
padding: 4px 14px;
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__wrapper:hover) {
|
||||
border-color: rgba(14, 165, 233, 0.4);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__wrapper.is-focus) {
|
||||
border-color: #0ea5e9;
|
||||
background: rgba(14, 165, 233, 0.08);
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.15);
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__inner) {
|
||||
color: #ffffff;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__inner::placeholder) {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__prefix) {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.login-form :deep(.el-input__suffix) {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.login-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ===== Login button ===== */
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
margin-top: 8px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 4px;
|
||||
background: linear-gradient(135deg, #0ea5e9 0%, #6366f1 50%, #8b5cf6 100%);
|
||||
background-size: 200% 200%;
|
||||
animation: btn-shimmer 3s ease-in-out infinite;
|
||||
box-shadow: 0 4px 16px rgba(14, 165, 233, 0.35);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 24px rgba(14, 165, 233, 0.5);
|
||||
}
|
||||
|
||||
.login-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@keyframes btn-shimmer {
|
||||
0%, 100% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
}
|
||||
|
||||
/* ===== Footer ===== */
|
||||
.card-footer {
|
||||
text-align: center;
|
||||
margin-top: 32px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* ===== Responsive ===== */
|
||||
@media (max-width: 480px) {
|
||||
.login-card {
|
||||
width: calc(100vw - 32px);
|
||||
padding: 36px 24px 28px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== Captcha ===== */
|
||||
.captcha-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.captcha-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.captcha-img {
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.captcha-img:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.captcha-placeholder {
|
||||
width: 130px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ===== Login alerts ===== */
|
||||
.login-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
font-size: 13px;
|
||||
animation: alert-in 0.3s ease;
|
||||
}
|
||||
|
||||
.login-alert-warning {
|
||||
background: rgba(234, 179, 8, 0.15);
|
||||
border: 1px solid rgba(234, 179, 8, 0.3);
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.login-alert-danger {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
@keyframes alert-in {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.login-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,505 @@
|
||||
<template>
|
||||
<PageContainer title="租户管理">
|
||||
<QueryToolbar>
|
||||
<el-button v-if="canCreate" type="primary" @click="openCreateDrawer">新增租户</el-button>
|
||||
<el-button v-if="canManage" @click="load">刷新</el-button>
|
||||
</QueryToolbar>
|
||||
|
||||
<el-table :data="rows" class="mt-md" empty-text="暂无数据">
|
||||
<el-table-column prop="tenantCode" label="租户编码" width="220" />
|
||||
<el-table-column prop="tenantName" label="租户名称" />
|
||||
<el-table-column label="Logo" width="180">
|
||||
<template #default="{ row }">
|
||||
<img
|
||||
v-if="tenantLogoMap[row.id]"
|
||||
:src="tenantLogoMap[row.id]"
|
||||
alt="tenant-logo"
|
||||
style="max-height: 36px; max-width: 140px; object-fit: contain;"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="120" :formatter="statusFormatter" />
|
||||
<el-table-column prop="createdAt" label="创建时间" width="190" />
|
||||
<el-table-column label="操作" width="520">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="canManage" size="small" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button
|
||||
v-if="canToggleStatus && row.status !== 'ENABLED'"
|
||||
size="small"
|
||||
type="success"
|
||||
@click="handleEnable(row.id)"
|
||||
>
|
||||
启用
|
||||
</el-button>
|
||||
<el-button
|
||||
v-else-if="canToggleStatus"
|
||||
size="small"
|
||||
type="warning"
|
||||
@click="handleDisable(row.id)"
|
||||
>
|
||||
停用
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isPlatform && canManage"
|
||||
size="small"
|
||||
@click="openSetAdmin(row)"
|
||||
>
|
||||
设置管理员
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isPlatform && canManage"
|
||||
size="small"
|
||||
type="primary"
|
||||
plain
|
||||
@click="handleInitBaseline(row)"
|
||||
>
|
||||
初始化基线
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="isPlatform && canManage && row.status === 'DISABLED'"
|
||||
size="small"
|
||||
type="danger"
|
||||
@click="handleDeleteTenant(row.id, row.tenantName)"
|
||||
>
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-drawer v-model="createVisible" title="新增租户" :size="DRAWER_SIZE.sm" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" label-position="left" :label-width="LABEL_WIDTH.md" :rules="formRules">
|
||||
<el-form-item label="租户编码" prop="tenantCode">
|
||||
<el-input v-model="form.tenantCode" placeholder="如 TENANT_BJ_XINXIN" />
|
||||
</el-form-item>
|
||||
<el-form-item label="租户名称" prop="tenantName">
|
||||
<el-input v-model="form.tenantName" placeholder="如 北京欣欣" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Logo地址">
|
||||
<el-input v-model="form.logoUrl" placeholder="可选:OSSKey 或 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="上传Logo">
|
||||
<el-upload accept="image/*" :show-file-list="false" :http-request="uploadCreateLogo">
|
||||
<el-button>选择并自动上传</el-button>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="createLogoPreviewUrl" label="Logo预览">
|
||||
<img :src="createLogoPreviewUrl" alt="logo-preview" style="max-height: 48px; max-width: 220px; object-fit: contain;" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="handleCreate">创建</el-button>
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑租户" :width="DIALOG_WIDTH.md">
|
||||
<el-form ref="editFormRef" :model="editForm" label-position="left" :label-width="LABEL_WIDTH.md" :rules="editFormRules">
|
||||
<el-form-item label="租户编码">
|
||||
<el-input v-model="editForm.tenantCode" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="租户名称" prop="tenantName">
|
||||
<el-input v-model="editForm.tenantName" placeholder="如 北京欣欣" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Logo地址">
|
||||
<el-input v-model="editForm.logoUrl" placeholder="可选:OSSKey 或 URL" />
|
||||
</el-form-item>
|
||||
<el-form-item label="上传Logo">
|
||||
<el-upload accept="image/*" :show-file-list="false" :http-request="uploadEditLogo">
|
||||
<el-button>选择并自动上传</el-button>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editLogoPreviewUrl" label="Logo预览">
|
||||
<img :src="editLogoPreviewUrl" alt="logo-preview" style="max-height: 48px; max-width: 220px; object-fit: contain;" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button v-if="canManage" type="primary" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="adminDialogVisible" title="设置租户管理员" :width="DIALOG_WIDTH.md">
|
||||
<div class="mb-md text-regular">
|
||||
当前租户:{{ selectedTenant?.tenantName }}({{ selectedTenant?.tenantCode }})
|
||||
</div>
|
||||
<el-form ref="adminFormRef" :model="adminForm" label-position="left" :label-width="LABEL_WIDTH.md" :rules="adminFormRules">
|
||||
<el-form-item label="管理员姓名" prop="userName">
|
||||
<el-input v-model="adminForm.userName" />
|
||||
</el-form-item>
|
||||
<el-form-item label="管理员手机号" prop="phone">
|
||||
<el-input v-model="adminForm.phone" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱" prop="email">
|
||||
<el-input v-model="adminForm.email" />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色编码">
|
||||
<el-input v-model="adminForm.roleCode" placeholder="默认 TENANT_ADMIN" />
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="系统会向该邮箱发送一次性首次设密链接,不再发送明文初始密码。"
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="adminDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSetAdmin">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</PageContainer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import PageContainer from "../../components/PageContainer.vue";
|
||||
import QueryToolbar from "../../components/QueryToolbar.vue";
|
||||
import { DRAWER_SIZE, DIALOG_WIDTH, LABEL_WIDTH } from "../../constants/ui";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import type { FormInstance, FormRules } from "element-plus";
|
||||
import {
|
||||
createPlatformTenant,
|
||||
createTenant,
|
||||
deletePlatformTenant,
|
||||
disablePlatformTenant,
|
||||
disableTenant,
|
||||
enablePlatformTenant,
|
||||
enableTenant,
|
||||
fetchFilePresignDownload,
|
||||
fetchPlatformTenantAdmin,
|
||||
fetchPlatformTenantLogoUploadSign,
|
||||
fetchPlatformTenants,
|
||||
fetchTenantLogoUploadSign,
|
||||
fetchTenants,
|
||||
initPlatformTenantBaseline,
|
||||
setPlatformTenantAdmin,
|
||||
updatePlatformTenant,
|
||||
updateTenant,
|
||||
} from "../../api/modules";
|
||||
import { PERMS } from "../../constants/permissions";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { toZhStatus } from "../../utils/status";
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const isPlatform = computed(() => authStore.scope === "PLATFORM");
|
||||
const canManage = computed(() =>
|
||||
isPlatform.value ? authStore.hasPermission(PERMS.platform.tenantManage) : authStore.hasPermission(PERMS.tenant.manage),
|
||||
);
|
||||
const canCreate = computed(() => isPlatform.value && canManage.value);
|
||||
const canToggleStatus = computed(() => isPlatform.value && canManage.value);
|
||||
const rows = ref<any[]>([]);
|
||||
const tenantLogoMap = ref<Record<number, string>>({});
|
||||
const createVisible = ref(false);
|
||||
const createLogoPreviewUrl = ref("");
|
||||
const editVisible = ref(false);
|
||||
const editId = ref<number | null>(null);
|
||||
const editLogoPreviewUrl = ref("");
|
||||
const adminDialogVisible = ref(false);
|
||||
const selectedTenant = ref<any>(null);
|
||||
const adminForm = ref({
|
||||
userName: "",
|
||||
phone: "",
|
||||
email: "",
|
||||
roleCode: "TENANT_ADMIN",
|
||||
});
|
||||
const form = ref({
|
||||
tenantCode: "",
|
||||
tenantName: "",
|
||||
logoUrl: "",
|
||||
});
|
||||
const editForm = ref({
|
||||
tenantCode: "",
|
||||
tenantName: "",
|
||||
logoUrl: "",
|
||||
});
|
||||
const formRef = ref<FormInstance>();
|
||||
const editFormRef = ref<FormInstance>();
|
||||
const adminFormRef = ref<FormInstance>();
|
||||
const formRules = ref<FormRules>({
|
||||
tenantCode: [{ required: true, message: "请输入租户编码", trigger: "blur" }],
|
||||
tenantName: [{ required: true, message: "请输入租户名称", trigger: "blur" }],
|
||||
});
|
||||
const editFormRules = ref<FormRules>({
|
||||
tenantName: [{ required: true, message: "请输入租户名称", trigger: "blur" }],
|
||||
});
|
||||
const adminFormRules = ref<FormRules>({
|
||||
userName: [{ required: true, message: "请输入管理员姓名", trigger: "blur" }],
|
||||
phone: [{ required: true, message: "请输入管理员手机号", trigger: "blur" }],
|
||||
email: [{ required: true, message: "请输入邮箱", trigger: "blur" }],
|
||||
});
|
||||
const statusFormatter = (_row: any, _column: any, value: unknown) => toZhStatus(value);
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
form.value = {
|
||||
tenantCode: "",
|
||||
tenantName: "",
|
||||
logoUrl: "",
|
||||
};
|
||||
createLogoPreviewUrl.value = "";
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const resolveLogoPreviewUrl = async (logoValue: unknown) => {
|
||||
const normalizedLogo = String(logoValue || "").trim();
|
||||
if (!normalizedLogo) {
|
||||
return "";
|
||||
}
|
||||
if (normalizedLogo.startsWith("http://") || normalizedLogo.startsWith("https://")) {
|
||||
return normalizedLogo;
|
||||
}
|
||||
const previewResp = await fetchFilePresignDownload({ objectKey: normalizedLogo });
|
||||
return String(previewResp?.data?.signedUrl || "");
|
||||
};
|
||||
|
||||
const putToSignedUrl = async (url: string, file: File, contentType: string) => {
|
||||
const result = await fetch(url, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": contentType || "application/octet-stream",
|
||||
},
|
||||
body: file,
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(`上传失败: ${result.status}`);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadCreateLogo = async (options: any) => {
|
||||
const file = options?.file as File;
|
||||
if (!file) {
|
||||
ElMessage.warning("请选择 Logo 文件");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const signApi = isPlatform.value ? fetchPlatformTenantLogoUploadSign : fetchTenantLogoUploadSign;
|
||||
const resp = await signApi({
|
||||
fileName: file.name,
|
||||
contentType: file.type || "application/octet-stream",
|
||||
});
|
||||
const uploadUrl = resp?.data?.uploadUrl;
|
||||
const objectKey = resp?.data?.objectKey;
|
||||
const contentType = resp?.data?.contentType || file.type || "application/octet-stream";
|
||||
if (!uploadUrl || !objectKey) {
|
||||
ElMessage.error("获取 Logo 上传签名失败");
|
||||
return;
|
||||
}
|
||||
await putToSignedUrl(uploadUrl, file, contentType);
|
||||
form.value.logoUrl = objectKey;
|
||||
createLogoPreviewUrl.value = await resolveLogoPreviewUrl(objectKey);
|
||||
ElMessage.success("Logo 上传成功");
|
||||
} catch (_e: any) {
|
||||
ElMessage.error(_e?.response?.data?.message || "Logo 上传失败");
|
||||
}
|
||||
};
|
||||
|
||||
const uploadEditLogo = async (options: any) => {
|
||||
const file = options?.file as File;
|
||||
if (!file) {
|
||||
ElMessage.warning("请选择 Logo 文件");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const signApi = isPlatform.value ? fetchPlatformTenantLogoUploadSign : fetchTenantLogoUploadSign;
|
||||
const resp = await signApi({
|
||||
fileName: file.name,
|
||||
contentType: file.type || "application/octet-stream",
|
||||
});
|
||||
const uploadUrl = resp?.data?.uploadUrl;
|
||||
const objectKey = resp?.data?.objectKey;
|
||||
const contentType = resp?.data?.contentType || file.type || "application/octet-stream";
|
||||
if (!uploadUrl || !objectKey) {
|
||||
ElMessage.error("获取 Logo 上传签名失败");
|
||||
return;
|
||||
}
|
||||
await putToSignedUrl(uploadUrl, file, contentType);
|
||||
editForm.value.logoUrl = objectKey;
|
||||
editLogoPreviewUrl.value = await resolveLogoPreviewUrl(objectKey);
|
||||
ElMessage.success("Logo 上传成功");
|
||||
} catch (_e: any) {
|
||||
ElMessage.error(_e?.response?.data?.message || "Logo 上传失败");
|
||||
}
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
const resp = isPlatform.value ? await fetchPlatformTenants() : await fetchTenants();
|
||||
rows.value = resp?.data?.list || [];
|
||||
const nextMap: Record<number, string> = {};
|
||||
await Promise.all(
|
||||
rows.value.map(async (item: any) => {
|
||||
const id = Number(item?.id || 0);
|
||||
const logoKey = String(item?.logoUrl || "").trim();
|
||||
if (!id || !logoKey) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const signedUrl = await resolveLogoPreviewUrl(logoKey);
|
||||
if (signedUrl) {
|
||||
nextMap[id] = signedUrl;
|
||||
}
|
||||
} catch (_e) {
|
||||
// ignore single row logo load failure
|
||||
}
|
||||
}),
|
||||
);
|
||||
tenantLogoMap.value = nextMap;
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formRef.value) return;
|
||||
const valid = await formRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
const createApi = isPlatform.value ? createPlatformTenant : createTenant;
|
||||
await createApi({
|
||||
tenantCode: form.value.tenantCode.trim(),
|
||||
tenantName: form.value.tenantName.trim(),
|
||||
logoUrl: form.value.logoUrl.trim() || undefined,
|
||||
});
|
||||
ElMessage.success("租户创建成功");
|
||||
createVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const openEdit = async (row: any) => {
|
||||
editId.value = Number(row?.id || 0) || null;
|
||||
editForm.value = {
|
||||
tenantCode: String(row?.tenantCode || "").trim(),
|
||||
tenantName: String(row?.tenantName || "").trim(),
|
||||
logoUrl: String(row?.logoUrl || "").trim(),
|
||||
};
|
||||
try {
|
||||
editLogoPreviewUrl.value = await resolveLogoPreviewUrl(row?.logoUrl);
|
||||
} catch (_e) {
|
||||
editLogoPreviewUrl.value = "";
|
||||
}
|
||||
editVisible.value = true;
|
||||
};
|
||||
|
||||
const syncCurrentTenantProfile = (tenant: any) => {
|
||||
const currentTenantId = Number(authStore.tenantId || 0);
|
||||
const updatedTenantId = Number(tenant?.id || editId.value || 0);
|
||||
if (authStore.scope !== "TENANT" || !currentTenantId || currentTenantId !== updatedTenantId) {
|
||||
return;
|
||||
}
|
||||
authStore.updateTenantProfile({
|
||||
tenantName: String(tenant?.tenantName || editForm.value.tenantName || "").trim(),
|
||||
logoUrl: String(tenant?.logoUrl || editForm.value.logoUrl || "").trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editId.value) return;
|
||||
if (!editFormRef.value) return;
|
||||
const valid = await editFormRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
const updateApi = isPlatform.value ? updatePlatformTenant : updateTenant;
|
||||
const resp = await updateApi(editId.value, {
|
||||
tenantName: editForm.value.tenantName.trim(),
|
||||
logoUrl: editForm.value.logoUrl.trim() || undefined,
|
||||
});
|
||||
syncCurrentTenantProfile(resp?.data || null);
|
||||
ElMessage.success("租户更新成功");
|
||||
editVisible.value = false;
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleEnable = async (id: number) => {
|
||||
const enableApi = isPlatform.value ? enablePlatformTenant : enableTenant;
|
||||
await enableApi(id);
|
||||
ElMessage.success("租户已启用");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDisable = async (id: number) => {
|
||||
const disableApi = isPlatform.value ? disablePlatformTenant : disableTenant;
|
||||
await disableApi(id);
|
||||
ElMessage.success("租户已停用");
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleDeleteTenant = async (id: number, name: string) => {
|
||||
await ElMessageBox.confirm(`确认删除租户「${name}」?删除后不可恢复。`, "删除租户", {
|
||||
type: "warning",
|
||||
confirmButtonText: "确认删除",
|
||||
cancelButtonText: "取消",
|
||||
});
|
||||
await deletePlatformTenant(id);
|
||||
ElMessage.success("租户已删除");
|
||||
await load();
|
||||
};
|
||||
|
||||
const openSetAdmin = async (row: any) => {
|
||||
selectedTenant.value = row;
|
||||
const defaultForm = {
|
||||
userName: `${row.tenantName || "租户"}管理员`,
|
||||
phone: "",
|
||||
email: "",
|
||||
roleCode: "TENANT_ADMIN",
|
||||
};
|
||||
try {
|
||||
const resp = await fetchPlatformTenantAdmin(row.id);
|
||||
const data = resp?.data;
|
||||
adminForm.value = data
|
||||
? {
|
||||
userName: String(data.userName || defaultForm.userName),
|
||||
phone: String(data.phone || ""),
|
||||
email: String(data.email || ""),
|
||||
roleCode: String(data.roleCode || "TENANT_ADMIN"),
|
||||
}
|
||||
: defaultForm;
|
||||
} catch (_e) {
|
||||
adminForm.value = defaultForm;
|
||||
}
|
||||
adminDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleSetAdmin = async () => {
|
||||
if (!selectedTenant.value?.id) return;
|
||||
if (!adminFormRef.value) return;
|
||||
const valid = await adminFormRef.value.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
const resp = await setPlatformTenantAdmin(selectedTenant.value.id, {
|
||||
userName: adminForm.value.userName.trim(),
|
||||
phone: adminForm.value.phone.trim(),
|
||||
email: adminForm.value.email.trim(),
|
||||
roleCode: adminForm.value.roleCode.trim() || undefined,
|
||||
});
|
||||
const action = String(resp?.data?.action || "");
|
||||
ElMessage.success(
|
||||
action === "UPDATED"
|
||||
? "租户管理员更新成功,首次设密链接已发送到邮箱"
|
||||
: "租户管理员创建成功,首次设密链接已发送到邮箱",
|
||||
);
|
||||
adminDialogVisible.value = false;
|
||||
};
|
||||
|
||||
const handleInitBaseline = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将为租户 ${row.tenantName}(${row.tenantCode})补齐菜单、角色、角色权限和角色菜单,可重复执行。是否继续?`,
|
||||
"初始化确认",
|
||||
{ type: "warning", confirmButtonText: "继续", cancelButtonText: "取消" },
|
||||
);
|
||||
const resp = await initPlatformTenantBaseline(row.id);
|
||||
const data = resp?.data || {};
|
||||
ElMessage.success(
|
||||
`初始化完成:菜单 +${data.menuInitialized || 0},角色 +${data.roleInitialized || 0},角色权限 +${data.rolePermissionInitialized || 0},角色菜单 +${data.roleMenuInitialized || 0}`,
|
||||
);
|
||||
} catch (_e) {
|
||||
const message = (_e as any)?.response?.data?.message;
|
||||
if (message) {
|
||||
ElMessage.error(String(message));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (canManage.value) {
|
||||
await load();
|
||||
}
|
||||
});
|
||||
</script>
|
||||