This commit is contained in:
haomingming
2026-05-20 18:21:39 +08:00
commit 815aa04fe8
564 changed files with 82601 additions and 0 deletions
+158
View File
@@ -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>
+1873
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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"
}
}
+13
View File
@@ -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>
+148
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+62
View File
@@ -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>
+64
View File
@@ -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>
+15
View File
@@ -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>
+23
View File
@@ -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>
+155
View File
@@ -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;
+50
View File
@@ -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;
+124
View File
@@ -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();
});
+151
View File
@@ -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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+241
View File
@@ -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();
},
},
});
+273
View File
@@ -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();
},
},
});
+4
View File
@@ -0,0 +1,4 @@
import { createPinia } from "pinia";
export const pinia = createPinia();
+139
View File
@@ -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();
},
},
});
+212
View File
@@ -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);
},
},
});
+677
View File
@@ -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;
}
+135
View File
@@ -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; }
+58
View File
@@ -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;
}
+58
View File
@@ -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;
}
};
+11
View File
@@ -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";
};
+94
View File
@@ -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;
};
+42
View File
@@ -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);
},
});
});
};
+111
View 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);
+826
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
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>
+197
View File
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -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>
+144
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+291
View File
@@ -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>
+678
View File
@@ -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>
+516
View File
@@ -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>
+300
View File
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -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>
+505
View File
@@ -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>

Some files were not shown because too many files have changed in this diff Show More