feat: optimize frontend layout, pagination and diagnosis page UI

pull/49/head
hnu202326010131 2 weeks ago
parent 2b976d4745
commit 3fb76642cc

@ -0,0 +1,55 @@
{
"hash": "c2271604",
"configHash": "8580b9f5",
"lockfileHash": "dd153584",
"browserHash": "993d84e5",
"optimized": {
"axios": {
"src": "../../node_modules/axios/index.js",
"file": "axios.js",
"fileHash": "a104a4ea",
"needsInterop": false
},
"echarts": {
"src": "../../node_modules/echarts/index.js",
"file": "echarts.js",
"fileHash": "44e77827",
"needsInterop": false
},
"pinia": {
"src": "../../node_modules/pinia/dist/pinia.mjs",
"file": "pinia.js",
"fileHash": "3fc07cf1",
"needsInterop": false
},
"vue": {
"src": "../../node_modules/vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "2b79ef78",
"needsInterop": false
},
"vue-router": {
"src": "../../node_modules/vue-router/dist/vue-router.mjs",
"file": "vue-router.js",
"fileHash": "e3b745fd",
"needsInterop": false
},
"marked": {
"src": "../../node_modules/marked/lib/marked.esm.js",
"file": "marked.js",
"fileHash": "3006f58c",
"needsInterop": false
}
},
"chunks": {
"chunk-AYVSL3LM": {
"file": "chunk-AYVSL3LM.js"
},
"chunk-5J33X7OV": {
"file": "chunk-5J33X7OV.js"
},
"chunk-UVKRO5ER": {
"file": "chunk-UVKRO5ER.js"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

@ -0,0 +1,162 @@
// node_modules/@vue/devtools-api/lib/esm/env.js
function getDevtoolsGlobalHook() {
return getTarget().__VUE_DEVTOOLS_GLOBAL_HOOK__;
}
function getTarget() {
return typeof navigator !== "undefined" && typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : {};
}
var isProxyAvailable = typeof Proxy === "function";
// node_modules/@vue/devtools-api/lib/esm/const.js
var HOOK_SETUP = "devtools-plugin:setup";
var HOOK_PLUGIN_SETTINGS_SET = "plugin:settings:set";
// node_modules/@vue/devtools-api/lib/esm/time.js
var supported;
var perf;
function isPerformanceSupported() {
var _a;
if (supported !== void 0) {
return supported;
}
if (typeof window !== "undefined" && window.performance) {
supported = true;
perf = window.performance;
} else if (typeof globalThis !== "undefined" && ((_a = globalThis.perf_hooks) === null || _a === void 0 ? void 0 : _a.performance)) {
supported = true;
perf = globalThis.perf_hooks.performance;
} else {
supported = false;
}
return supported;
}
function now() {
return isPerformanceSupported() ? perf.now() : Date.now();
}
// node_modules/@vue/devtools-api/lib/esm/proxy.js
var ApiProxy = class {
constructor(plugin, hook) {
this.target = null;
this.targetQueue = [];
this.onQueue = [];
this.plugin = plugin;
this.hook = hook;
const defaultSettings = {};
if (plugin.settings) {
for (const id in plugin.settings) {
const item = plugin.settings[id];
defaultSettings[id] = item.defaultValue;
}
}
const localSettingsSaveId = `__vue-devtools-plugin-settings__${plugin.id}`;
let currentSettings = Object.assign({}, defaultSettings);
try {
const raw = localStorage.getItem(localSettingsSaveId);
const data = JSON.parse(raw);
Object.assign(currentSettings, data);
} catch (e) {
}
this.fallbacks = {
getSettings() {
return currentSettings;
},
setSettings(value) {
try {
localStorage.setItem(localSettingsSaveId, JSON.stringify(value));
} catch (e) {
}
currentSettings = value;
},
now() {
return now();
}
};
if (hook) {
hook.on(HOOK_PLUGIN_SETTINGS_SET, (pluginId, value) => {
if (pluginId === this.plugin.id) {
this.fallbacks.setSettings(value);
}
});
}
this.proxiedOn = new Proxy({}, {
get: (_target, prop) => {
if (this.target) {
return this.target.on[prop];
} else {
return (...args) => {
this.onQueue.push({
method: prop,
args
});
};
}
}
});
this.proxiedTarget = new Proxy({}, {
get: (_target, prop) => {
if (this.target) {
return this.target[prop];
} else if (prop === "on") {
return this.proxiedOn;
} else if (Object.keys(this.fallbacks).includes(prop)) {
return (...args) => {
this.targetQueue.push({
method: prop,
args,
resolve: () => {
}
});
return this.fallbacks[prop](...args);
};
} else {
return (...args) => {
return new Promise((resolve) => {
this.targetQueue.push({
method: prop,
args,
resolve
});
});
};
}
}
});
}
async setRealTarget(target) {
this.target = target;
for (const item of this.onQueue) {
this.target.on[item.method](...item.args);
}
for (const item of this.targetQueue) {
item.resolve(await this.target[item.method](...item.args));
}
}
};
// node_modules/@vue/devtools-api/lib/esm/index.js
function setupDevtoolsPlugin(pluginDescriptor, setupFn) {
const descriptor = pluginDescriptor;
const target = getTarget();
const hook = getDevtoolsGlobalHook();
const enableProxy = isProxyAvailable && descriptor.enableEarlyProxy;
if (hook && (target.__VUE_DEVTOOLS_PLUGIN_API_AVAILABLE__ || !enableProxy)) {
hook.emit(HOOK_SETUP, pluginDescriptor, setupFn);
} else {
const proxy = enableProxy ? new ApiProxy(descriptor, hook) : null;
const list = target.__VUE_DEVTOOLS_PLUGINS__ = target.__VUE_DEVTOOLS_PLUGINS__ || [];
list.push({
pluginDescriptor: descriptor,
setupFn,
proxy
});
if (proxy) {
setupFn(proxy.proxiedTarget);
}
}
}
export {
setupDevtoolsPlugin
};
//# sourceMappingURL=chunk-AYVSL3LM.js.map

File diff suppressed because one or more lines are too long

@ -0,0 +1,13 @@
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
export {
__export,
__publicField
};
//# sourceMappingURL=chunk-UVKRO5ER.js.map

@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

@ -0,0 +1,348 @@
import {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBaseVNode,
createBlock,
createCommentVNode,
createElementBlock,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
} from "./chunk-5J33X7OV.js";
import "./chunk-UVKRO5ER.js";
export {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBlock,
createCommentVNode,
createElementBlock,
createBaseVNode as createElementVNode,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
};
//# sourceMappingURL=vue.js.map

@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

@ -0,0 +1,55 @@
{
"hash": "c2271604",
"configHash": "8580b9f5",
"lockfileHash": "dd153584",
"browserHash": "993d84e5",
"optimized": {
"axios": {
"src": "../../node_modules/axios/index.js",
"file": "axios.js",
"fileHash": "27a2f759",
"needsInterop": false
},
"echarts": {
"src": "../../node_modules/echarts/index.js",
"file": "echarts.js",
"fileHash": "490adcd2",
"needsInterop": false
},
"pinia": {
"src": "../../node_modules/pinia/dist/pinia.mjs",
"file": "pinia.js",
"fileHash": "cb34899d",
"needsInterop": false
},
"vue": {
"src": "../../node_modules/vue/dist/vue.runtime.esm-bundler.js",
"file": "vue.js",
"fileHash": "64652676",
"needsInterop": false
},
"vue-router": {
"src": "../../node_modules/vue-router/dist/vue-router.mjs",
"file": "vue-router.js",
"fileHash": "fb4518da",
"needsInterop": false
},
"marked": {
"src": "../../node_modules/marked/lib/marked.esm.js",
"file": "marked.js",
"fileHash": "a2dd25f2",
"needsInterop": false
}
},
"chunks": {
"chunk-AYVSL3LM": {
"file": "chunk-AYVSL3LM.js"
},
"chunk-5J33X7OV": {
"file": "chunk-5J33X7OV.js"
},
"chunk-UVKRO5ER": {
"file": "chunk-UVKRO5ER.js"
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

@ -0,0 +1,162 @@
// node_modules/@vue/devtools-api/lib/esm/env.js
function getDevtoolsGlobalHook() {
return getTarget().__VUE_DEVTOOLS_GLOBAL_HOOK__;
}
function getTarget() {
return typeof navigator !== "undefined" && typeof window !== "undefined" ? window : typeof globalThis !== "undefined" ? globalThis : {};
}
var isProxyAvailable = typeof Proxy === "function";
// node_modules/@vue/devtools-api/lib/esm/const.js
var HOOK_SETUP = "devtools-plugin:setup";
var HOOK_PLUGIN_SETTINGS_SET = "plugin:settings:set";
// node_modules/@vue/devtools-api/lib/esm/time.js
var supported;
var perf;
function isPerformanceSupported() {
var _a;
if (supported !== void 0) {
return supported;
}
if (typeof window !== "undefined" && window.performance) {
supported = true;
perf = window.performance;
} else if (typeof globalThis !== "undefined" && ((_a = globalThis.perf_hooks) === null || _a === void 0 ? void 0 : _a.performance)) {
supported = true;
perf = globalThis.perf_hooks.performance;
} else {
supported = false;
}
return supported;
}
function now() {
return isPerformanceSupported() ? perf.now() : Date.now();
}
// node_modules/@vue/devtools-api/lib/esm/proxy.js
var ApiProxy = class {
constructor(plugin, hook) {
this.target = null;
this.targetQueue = [];
this.onQueue = [];
this.plugin = plugin;
this.hook = hook;
const defaultSettings = {};
if (plugin.settings) {
for (const id in plugin.settings) {
const item = plugin.settings[id];
defaultSettings[id] = item.defaultValue;
}
}
const localSettingsSaveId = `__vue-devtools-plugin-settings__${plugin.id}`;
let currentSettings = Object.assign({}, defaultSettings);
try {
const raw = localStorage.getItem(localSettingsSaveId);
const data = JSON.parse(raw);
Object.assign(currentSettings, data);
} catch (e) {
}
this.fallbacks = {
getSettings() {
return currentSettings;
},
setSettings(value) {
try {
localStorage.setItem(localSettingsSaveId, JSON.stringify(value));
} catch (e) {
}
currentSettings = value;
},
now() {
return now();
}
};
if (hook) {
hook.on(HOOK_PLUGIN_SETTINGS_SET, (pluginId, value) => {
if (pluginId === this.plugin.id) {
this.fallbacks.setSettings(value);
}
});
}
this.proxiedOn = new Proxy({}, {
get: (_target, prop) => {
if (this.target) {
return this.target.on[prop];
} else {
return (...args) => {
this.onQueue.push({
method: prop,
args
});
};
}
}
});
this.proxiedTarget = new Proxy({}, {
get: (_target, prop) => {
if (this.target) {
return this.target[prop];
} else if (prop === "on") {
return this.proxiedOn;
} else if (Object.keys(this.fallbacks).includes(prop)) {
return (...args) => {
this.targetQueue.push({
method: prop,
args,
resolve: () => {
}
});
return this.fallbacks[prop](...args);
};
} else {
return (...args) => {
return new Promise((resolve) => {
this.targetQueue.push({
method: prop,
args,
resolve
});
});
};
}
}
});
}
async setRealTarget(target) {
this.target = target;
for (const item of this.onQueue) {
this.target.on[item.method](...item.args);
}
for (const item of this.targetQueue) {
item.resolve(await this.target[item.method](...item.args));
}
}
};
// node_modules/@vue/devtools-api/lib/esm/index.js
function setupDevtoolsPlugin(pluginDescriptor, setupFn) {
const descriptor = pluginDescriptor;
const target = getTarget();
const hook = getDevtoolsGlobalHook();
const enableProxy = isProxyAvailable && descriptor.enableEarlyProxy;
if (hook && (target.__VUE_DEVTOOLS_PLUGIN_API_AVAILABLE__ || !enableProxy)) {
hook.emit(HOOK_SETUP, pluginDescriptor, setupFn);
} else {
const proxy = enableProxy ? new ApiProxy(descriptor, hook) : null;
const list = target.__VUE_DEVTOOLS_PLUGINS__ = target.__VUE_DEVTOOLS_PLUGINS__ || [];
list.push({
pluginDescriptor: descriptor,
setupFn,
proxy
});
if (proxy) {
setupFn(proxy.proxiedTarget);
}
}
}
export {
setupDevtoolsPlugin
};
//# sourceMappingURL=chunk-AYVSL3LM.js.map

File diff suppressed because one or more lines are too long

@ -0,0 +1,13 @@
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
export {
__export,
__publicField
};
//# sourceMappingURL=chunk-UVKRO5ER.js.map

@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

@ -0,0 +1,348 @@
import {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBaseVNode,
createBlock,
createCommentVNode,
createElementBlock,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
} from "./chunk-5J33X7OV.js";
import "./chunk-UVKRO5ER.js";
export {
BaseTransition,
BaseTransitionPropsValidators,
Comment,
DeprecationTypes,
EffectScope,
ErrorCodes,
ErrorTypeStrings,
Fragment,
KeepAlive,
ReactiveEffect,
Static,
Suspense,
Teleport,
Text,
TrackOpTypes,
Transition,
TransitionGroup,
TriggerOpTypes,
VueElement,
assertNumber,
callWithAsyncErrorHandling,
callWithErrorHandling,
camelize,
capitalize,
cloneVNode,
compatUtils,
compile,
computed,
createApp,
createBlock,
createCommentVNode,
createElementBlock,
createBaseVNode as createElementVNode,
createHydrationRenderer,
createPropsRestProxy,
createRenderer,
createSSRApp,
createSlots,
createStaticVNode,
createTextVNode,
createVNode,
customRef,
defineAsyncComponent,
defineComponent,
defineCustomElement,
defineEmits,
defineExpose,
defineModel,
defineOptions,
defineProps,
defineSSRCustomElement,
defineSlots,
devtools,
effect,
effectScope,
getCurrentInstance,
getCurrentScope,
getCurrentWatcher,
getTransitionRawChildren,
guardReactiveProps,
h,
handleError,
hasInjectionContext,
hydrate,
hydrateOnIdle,
hydrateOnInteraction,
hydrateOnMediaQuery,
hydrateOnVisible,
initCustomFormatter,
initDirectivesForSSR,
inject,
isMemoSame,
isProxy,
isReactive,
isReadonly,
isRef,
isRuntimeOnly,
isShallow,
isVNode,
markRaw,
mergeDefaults,
mergeModels,
mergeProps,
nextTick,
nodeOps,
normalizeClass,
normalizeProps,
normalizeStyle,
onActivated,
onBeforeMount,
onBeforeUnmount,
onBeforeUpdate,
onDeactivated,
onErrorCaptured,
onMounted,
onRenderTracked,
onRenderTriggered,
onScopeDispose,
onServerPrefetch,
onUnmounted,
onUpdated,
onWatcherCleanup,
openBlock,
patchProp,
popScopeId,
provide,
proxyRefs,
pushScopeId,
queuePostFlushCb,
reactive,
readonly,
ref,
registerRuntimeCompiler,
render,
renderList,
renderSlot,
resolveComponent,
resolveDirective,
resolveDynamicComponent,
resolveFilter,
resolveTransitionHooks,
setBlockTracking,
setDevtoolsHook,
setTransitionHooks,
shallowReactive,
shallowReadonly,
shallowRef,
ssrContextKey,
ssrUtils,
stop,
toDisplayString,
toHandlerKey,
toHandlers,
toRaw,
toRef,
toRefs,
toValue,
transformVNodeArgs,
triggerRef,
unref,
useAttrs,
useCssModule,
useCssVars,
useHost,
useId,
useModel,
useSSRContext,
useShadowRoot,
useSlots,
useTemplateRef,
useTransitionState,
vModelCheckbox,
vModelDynamic,
vModelRadio,
vModelSelect,
vModelText,
vShow,
version,
warn,
watch,
watchEffect,
watchPostEffect,
watchSyncEffect,
withAsyncContext,
withCtx,
withDefaults,
withDirectives,
withKeys,
withMemo,
withModifiers,
withScopeId
};
//# sourceMappingURL=vue.js.map

@ -0,0 +1,7 @@
{
"version": 3,
"sources": [],
"sourcesContent": [],
"mappings": "",
"names": []
}

@ -1,87 +0,0 @@
# 执行日志 (Exec Logs) 后端联调指南
本文档旨在指导后端开发人员如何对接 `frontend-vue` 项目中的“执行日志”页面。该页面已移除所有静态兜底数据,完全依赖后端 API。
## 1. 基础信息
- **页面路径**: `/#/exec-logs`
- **前端组件**: `src/app/views/ExecLogs.vue`
- **基础路径 (Base URL)**: `/api` (由 Vite 代理转发)
---
## 2. 接口定义
### 2.1 获取执行日志列表
用于初始化页面表格及刷新数据。
- **请求方式**: `GET`
- **端点**: `/v1/exec-logs`
- **认证**: 需要在 Header 中携带 `Authorization: Bearer <token>`
- **响应格式 (JSON)**:
前端支持以下两种结构之一:
```json
{
"exec_logs": [
{
"exec_id": "EXE-001",
"fault_id": "FLT-001",
"command_type": "shell",
"execution_status": "success",
"start_time": "2025-11-07T10:20:03Z",
"end_time": "2025-11-07T10:22:35Z",
"exit_code": 0
}
]
}
```
或者:
```json
{
"items": [ ... ]
}
```
### 2.2 新增执行日志
- **请求方式**: `POST`
- **端点**: `/v1/exec-logs`
- **请求体 (JSON)**:
```json
{
"exec_id": "string",
"fault_id": "string",
"command_type": "shell | hdfs | yarn",
"execution_status": "running | success | failed",
"start_time": "ISO8601 String",
"end_time": "ISO8601 String | null",
"exit_code": "number | null"
}
```
### 2.3 更新执行日志
- **请求方式**: `PUT`
- **端点**: `/v1/exec-logs/{exec_id}`
- **请求体 (JSON)**: 同 POST不含 `exec_id`)。
### 2.4 删除执行日志
- **请求方式**: `DELETE`
- **端点**: `/v1/exec-logs/{exec_id}`
---
## 3. 注意事项
1. **时间格式**: 后端返回的时间字符串应符合 ISO8601 格式,前端会自动截取前 19 位并替换 `T` 为空格进行展示。
2. **错误处理**:
- 如果接口返回非 2xx 状态码,前端会解析 `response.data.detail` 作为错误消息显示在 UI 上。
- 若无 `detail` 字段,则回退显示 `e.message` 或“网络错误”。
3. **字段映射**: 前端代码中做了容错映射(如 `exec_id``id`),但建议优先使用 `exec_id` 以保持一致。
4. **状态类型**: `execution_status` 必须是 `running`, `success`, `failed` 之一,否则前端样式可能无法正确渲染。
---
## 4. 联调测试建议
1. 使用 Postman 或 cURL 先行验证 `/api/v1/exec-logs` 是否能正确返回数据。
2. 检查 Vite 配置文件中的代理目标 (`target`) 是否指向了正确的后端服务端口。
3. 确保登录后获取的 Token 已正确存储在 LocalStorage 中,否则请求会因缺少 Header 而失败。

@ -39,9 +39,8 @@
| 前端展示字段 | 推荐后端字段 | 备用适配字段 |
| :----------- | :----------- | :----------------------- |
| 唯一标识 | `id` | `operation_id` |
| 时间戳 | `timestamp` | `created_at`, `time` |
| 时间戳 | `timestamp` | `operation_time`, `time` |
| 操作用户 | `user` | `username`, `operator` |
| 动作名称 | `action` | `operation` |
| 详情描述 | `detail` | `description`, `content` |
## 3. 前端处理细节

@ -0,0 +1,95 @@
# 用户管理模块后端联调指南
## 1. 模块概述
用户管理模块主要用于管理员对系统用户的生命周期管理,包括查询用户列表、新增用户、修改用户角色/状态以及删除用户。
## 2. 认证说明
所有接口均需在 Header 中携带 Bearer Token 进行身份校验,且当前登录用户必须拥有 `admin` 权限。
- **Header**: `Authorization: Bearer <your_token>`
## 3. 接口列表
### 3.1 获取用户列表
- **URL**: `/api/v1/users`
- **Method**: `GET`
- **说明**: 获取系统中所有注册用户的列表。
- **响应格式 (推荐)**:
```json
{
"users": [
{
"username": "admin",
"full_name": "系统管理员",
"email": "admin@example.com",
"role": "admin",
"status": "enabled"
}
]
}
```
- **前端兼容性**: 前端已做适配,支持直接返回数组 `[]`,并兼容 `user_name`/`name` 等字段名变体。
### 3.2 新增用户
- **URL**: `/api/v1/users`
- **Method**: `POST`
- **Body 格式**: `application/json`
- **Payload 示例**:
```json
{
"username": "new_user",
"full_name": "新用户姓名",
"email": "user@example.com",
"password": "password123",
"role": "operator",
"status": "enabled"
}
```
- **关键点**: 前端已补全 `full_name` 字段,请后端确保接收并存储该字段。
### 3.3 修改用户信息 (状态/角色)
- **URL**: `/api/v1/users/{username}`
- **Method**: `PATCH`
- **说明**: 用于修改用户的角色或启用/禁用状态。
- **Payload 示例 (修改状态)**:
```json
{ "status": "disabled" }
```
- **Payload 示例 (修改角色)**:
```json
{ "role": "admin" }
```
### 3.4 删除用户
- **URL**: `/api/v1/users/{username}`
- **Method**: `DELETE`
- **说明**: 从系统中永久删除指定用户。
## 4. 数据字典
### 4.1 用户角色 (Role)
| 标识符 | 名称 | 说明 |
| :--- | :--- | :--- |
| `admin` | 管理员 | 拥有最高管理权限,可进入用户管理界面 |
| `operator` | 操作员 | 拥有集群操作权限 |
| `observer` | 观察员 | 仅拥有查看权限 |
### 4.2 用户状态 (Status)
| 标识符 | 名称 | 说明 |
| :--- | :--- | :--- |
| `enabled` | 启用 | 正常登录使用 |
| `pending` | 待审核 | 注册后尚未激活 |
| `disabled` | 禁用 | 账号被封禁,无法登录 |
## 5. 错误处理规范
前端会解析后端返回的错误详情并展示给用户。建议后端在出错时返回如下结构:
- **状态码**: `400` (参数错误), `401` (未授权), `403` (权限不足), `500` (服务器异常)
- **Body**:
```json
{
"detail": "简短的错误摘要",
"errors": [
{ "message": "具体的错误原因1" },
{ "message": "具体的错误原因2" }
]
}
```

@ -0,0 +1,77 @@
# 账号管理后端联调指南
本指南详细说明了如何将“账号管理”前端页面与 FastAPI 后端进行联调,特别是修改密码功能的实现。
## 1. 后端接口说明
### 1.1 修改密码接口
- **路径**: `/api/v1/user/password`
- **方法**: `PATCH`
- **认证**: 需要 JWT Token放在请求头的 `Authorization: Bearer {token}` 中)
- **请求体 (JSON)**:
```json
{
"currentPassword": "当前旧密码",
"newPassword": "新密码"
}
```
- **验证规则**:
- `newPassword`: 长度 8-128 位,必须包含大写字母、小写字母和数字。
- **响应格式**:
- 成功: `{"ok": true}`
- 失败: 返回 400 错误及详细错误信息(如 `invalid_current_password`, `weak_new_password` 等)。
### 1.2 获取用户信息接口 (参考)
- **路径**: `/api/v1/user/me`
- **方法**: `GET`
- **功能**: 用于在账号管理页面显示当前登录的用户名等信息。
## 2. 前端实现细节
### 2.1 API 调用工具
前端统一使用 `src/app/lib/api.ts` 中定义的 axios 实例进行请求。
```typescript
import api from '../lib/api'
// 示例调用
const response = await api.patch('/v1/user/password', {
currentPassword: '...',
newPassword: '...'
}, {
headers: { Authorization: `Bearer ${token}` }
})
```
### 2.2 状态管理
使用 Pinia store (`src/app/stores/auth.ts`) 管理用户登录状态和 Token。
```typescript
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
// 使用 auth.token 获取当前 Token
```
### 2.3 错误处理逻辑
`Account.vue` 中,我们根据后端返回的 `detail` 字段进行友好的错误提示:
- `invalid_current_password`: 提示“当前密码错误”。
- `weak_new_password`: 提示“新密码太弱(需包含大小写字母和数字)”。
- `demo_user_cannot_change_password`: 提示“演示账号不允许修改密码”。
- 其他错误: 提示“服务器错误,请稍后再试”。
## 3. 联调注意事项
1. **跨域配置**: 后端 `main.py` 已配置 `CORSMiddleware` 允许所有来源 (`*`),但在生产环境下建议限制为前端域名。
2. **演示账号限制**: 系统内置的演示账号(如 `admin`, `ops`, `obs`)在 `auth.py` 中被拦截,不允许修改密码,以保护公共演示环境的安全。
3. **数据模型**: 修改密码操作会直接更新数据库中 `users` 表的 `password_hash` 字段,并同步更新 `updated_at` 时间戳。
## 4. 后续扩展
- **双因素认证 (2FA)**: 目前前端预留了位置,后端尚未实现相关接口。
- **头像上传**: 建议后续增加 `/user/avatar` 接口支持。

@ -0,0 +1,88 @@
# 集群操作日志 (Cluster Operation Logs) 后端联调指南
本文档旨在指导后端开发人员如何对接 `frontend-vue` 项目中的“集群操作日志”页面。该页面已根据数据库重构进行了适配,完全依赖后端 API。
## 1. 基础信息
- **页面路径**: `/#/hadoop-exec-logs`
- **前端组件**: `src/app/views/ExecLogs.vue`
- **基础路径 (Base URL)**: `/api` (由 Vite 代理转发)
---
## 2. 接口定义
### 2.1 获取集群操作日志列表
用于初始化页面表格及刷新数据。
- **请求方式**: `GET`
- **端点**: `/v1/exec-logs`
- **认证**: 需要在 Header 中携带 `Authorization: Bearer <token>`
- **响应格式 (JSON)**:
前端支持以下两种结构之一:
```json
{
"exec_logs": [
{
"id": 1,
"cluster_name": "hadoop-cluster-01",
"username": "admin",
"description": "执行HDFS清理脚本",
"execution_status": "success",
"start_time": "2025-11-07T10:20:03Z",
"end_time": "2025-11-07T10:22:35Z"
}
]
}
```
或者:
```json
{
"items": [ ... ],
"total": 100
}
```
### 2.2 新增集群操作日志
- **请求方式**: `POST`
- **端点**: `/v1/exec-logs`
- **请求体 (JSON)**:
```json
{
"from_user_id": 1,
"cluster_name": "string",
"description": "string",
"execution_status": "running | success | failed",
"start_time": "ISO8601 String (e.g., 2023-10-27T10:00:00Z)",
"end_time": "ISO8601 String | null"
}
```
### 2.3 更新集群操作日志
- **请求方式**: `PUT`
- **端点**: `/v1/exec-logs/{id}`
- **请求体 (JSON)**: 同 POST。
### 2.4 删除集群操作日志
- **请求方式**: `DELETE`
- **端点**: `/v1/exec-logs/{id}`
---
## 3. 注意事项
1. **主键字段**: 主键已由 `exec_id` (String) 统一改为 `id` (Integer)。
2. **新增必填字段**: `from_user_id` (Integer) 和 `cluster_name` (String)。前端会自动从当前登录用户信息中获取 `from_user_id`
3. **时间格式**: 后端返回的时间字符串应符合 ISO8601 格式,前端展示时会进行格式化。
4. **状态类型**: `execution_status` 必须是 `running`, `success`, `failed` 之一。
---
## 4. 联调测试建议
1. 使用 Postman 验证 `/api/v1/exec-logs`
2. 确保登录后获取的 Token 包含正确的用户 ID。

@ -22,11 +22,11 @@
## 3. 日志与监控
- **系统日志 (Logs)**
- **故障日志 (Fault Logs)**
- **路径**: `/logs`
- **功能**: 汇总展示集群及节点的运行日志,支持按级别和关键词搜索。
- **执行日志 (Exec Logs)**
- **路径**: `/exec-logs`
- **集群操作日志 (Cluster Operation Logs)**
- **路径**: `/hadoop-exec-logs`
- **功能**: 专门展示自动化脚本或运维指令的执行历史及详细输出结果。
- **操作日志 (Operation Logs)**
- **路径**: `/operation-logs` (仅管理员)

@ -10,11 +10,13 @@
"dependencies": {
"axios": "^1.7.7",
"echarts": "^5.5.0",
"marked": "^17.0.1",
"pinia": "^2.1.7",
"vue": "^3.4.38",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@types/marked": "^5.0.2",
"@vitejs/plugin-vue": "^5.1.2",
"typescript": "^5.6.2",
"vite": "^5.4.10"
@ -778,6 +780,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/marked": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/marked/-/marked-5.0.2.tgz",
"integrity": "sha512-OucS4KMHhFzhz27KxmWg7J+kIYqyqoW5kdIEI319hqARQQUTqhao3M/F+uFnDXD0Rg72iDDZxZNxq5gvctmLlg==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-vue": {
"version": "5.2.4",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@ -1238,6 +1247,18 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/marked": {
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@ -1411,7 +1432,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -1426,7 +1446,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@ -1486,7 +1505,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",

@ -11,14 +11,15 @@
"dependencies": {
"axios": "^1.7.7",
"echarts": "^5.5.0",
"marked": "^17.0.1",
"pinia": "^2.1.7",
"vue": "^3.4.38",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@types/marked": "^5.0.2",
"@vitejs/plugin-vue": "^5.1.2",
"typescript": "^5.6.2",
"vite": "^5.4.10"
}
}

@ -19,6 +19,9 @@
</main>
</div>
<!-- 锁屏组件 -->
<LockScreen />
<!-- 侧边栏遮罩层移动端使用 -->
<div
class="layout__sidebar-overlay"
@ -33,6 +36,8 @@
import HeaderNav from "./components/HeaderNav.vue";
//
import Sidebar from "./components/Sidebar.vue";
//
import LockScreen from "./components/LockScreen.vue";
// UI Store
import { useUIStore } from "./stores/ui";
//

@ -2,25 +2,25 @@
<table class="dashboard__table">
<thead>
<tr>
<th>执行ID</th>
<th>故障ID</th>
<th>命令类型</th>
<th>ID</th>
<th>集群</th>
<th>用户</th>
<th>描述</th>
<th>状态</th>
<th>开始时间</th>
<th>结束时间</th>
<th>退出码</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="r in records" :key="r.id" class="dashboard__table-row" :class="{ 'row--selected': selectedId===r.id }" @click="$emit('select', r)">
<td>{{ r.id }}</td>
<td>{{ r.faultId }}</td>
<td>{{ r.cmdType }}</td>
<td>{{ r.clusterName }}</td>
<td>{{ r.username }}</td>
<td>{{ r.description }}</td>
<td><span :class="statusClass(r.status)">{{ r.status }}</span></td>
<td>{{ r.start }}</td>
<td>{{ r.end || '-' }}</td>
<td>{{ r.code ?? '-' }}</td>
<td>
<button class="btn u-text-sm" type="button" @click.stop="$emit('edit', r)">编辑</button>
<button class="btn u-text-sm u-ml-1" type="button" @click.stop="$emit('delete', r.id)">删除</button>
@ -31,8 +31,8 @@
</template>
<script setup lang="ts">
type RecordItem = { id:string; faultId:string; cmdType:string; status:'running'|'success'|'failed'; start:string; end:string|''; code:number|null }
defineProps<{ records: RecordItem[]; selectedId: string }>()
type RecordItem = { id:number; clusterName:string; username:string; description:string; faultId:string; cmdType:string; status:'running'|'success'|'failed'; start:string; end:string|''; code:number|null }
defineProps<{ records: RecordItem[]; selectedId: number | null }>()
function statusClass(s:'running'|'success'|'failed'){ return s==='running'?'status--running': s==='success'?'status--success':'status--failed' }
</script>
@ -41,4 +41,5 @@ function statusClass(s:'running'|'success'|'failed'){ return s==='running'?'stat
.status--running{ color:#2563eb }
.status--success{ color:#16a34a }
.status--failed{ color:#dc2626 }
.dashboard__table th { white-space: nowrap; }
</style>

@ -118,21 +118,48 @@
role="menuitem"
>账号管理</RouterLink
>
<!-- From Uiverse.io by vinodjangid07 -->
<button class="Btn logout-btn" @click.prevent="onLogout">
<div class="sign">
<svg viewBox="0 0 512 512">
<path
d="M377.9 105.9L500.7 228.7c7.2 7.2 11.3 17.1 11.3 27.3s-4.1 20.1-11.3 27.3L377.9 406.1c-6.4 6.4-15 9.9-24 9.9c-18.7 0-33.9-15.2-33.9-33.9l0-62.1-128 0c-17.7 0-32-14.3-32-32l0-64c0-17.7 14.3-32 32-32l128 0 0-62.1c0-18.7 15.2-33.9 33.9-33.9c9 0 17.6 3.6 24 9.9zM160 96L96 96c-17.7 0-32 14.3-32 32l0 256c0 17.7 14.3 32 32 32l64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32l-64 0c-53 0-96-43-96-96L0 128C0 75 43 32 96 32l64 0c17.7 0 32 14.3 32 32s-14.3 32-32 32z"
></path>
</svg>
</div>
<div class="text">退出登录</div>
</button>
<a
class="header__user-dropdown-item"
href="javascript:;"
role="menuitem"
@click.prevent="showLockModal = true"
>锁定屏幕</a
>
<a
class="header__user-dropdown-item"
href="javascript:;"
role="menuitem"
@click.prevent="onLogout"
>退出登录</a
>
</div>
</div>
</div>
</header>
<!-- 锁屏密码设置弹窗 -->
<Transition name="fade">
<div v-if="showLockModal" class="modal-overlay" @click.self="showLockModal = false">
<div class="modal-content">
<h3 class="modal-title">设置锁屏密码</h3>
<input
v-model="lockPasswordInput"
type="password"
class="modal-input"
placeholder="请输入锁屏密码"
@keyup.enter="handleLock"
/>
<div class="modal-actions">
<button class="modal-btn modal-btn--secondary" @click="showLockModal = false">
取消
</button>
<button class="modal-btn modal-btn--primary" @click="handleLock">
锁定
</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
@ -159,6 +186,17 @@ function onLogout() {
router.replace({ name: "login" });
}
const ui = useUIStore();
const showLockModal = ref(false);
const lockPasswordInput = ref('');
function handleLock() {
if (lockPasswordInput.value) {
ui.lock(lockPasswordInput.value);
showLockModal.value = false;
lockPasswordInput.value = '';
}
}
function toggleSidebar() {
ui.toggleSidebar();
}
@ -531,82 +569,94 @@ function closeAll() {}
background: var(--hover);
}
/* From Uiverse.io by kennyotsu-monochromia */
.logout-btn {
margin: 8px 12px; /* 增加一些外边距以适应菜单 */
}
.Btn {
--black: #000000;
--ch-black: #141414;
--eer-black: #1b1b1b;
--night-rider: #2e2e2e;
--white: #ffffff;
--af-white: #f3f3f3;
--ch-white: #e1e1e1;
/* 弹窗样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: flex-start;
width: 45px;
height: 45px;
border: none;
border-radius: 5px;
cursor: pointer;
position: relative;
overflow: hidden;
transition-duration: 0.3s;
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.199);
background-color: var(--af-white);
justify-content: center;
z-index: 2000;
}
/* plus sign */
.sign {
.modal-content {
background: var(--surface, #fff);
padding: 24px;
border-radius: 12px;
width: 100%;
transition-duration: 0.3s;
max-width: 360px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.modal-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 20px;
color: var(--text-primary);
}
.modal-input {
width: 100%;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid var(--border, #e5e7eb);
background: var(--bg-light, #f9fafb);
margin-bottom: 24px;
outline: none;
transition: all 0.2s;
}
.modal-input:focus {
border-color: var(--accent, #58bc82);
box-shadow: 0 0 0 3px rgba(88, 188, 130, 0.1);
}
.modal-actions {
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-end;
gap: 12px;
}
.sign svg {
width: 17px;
.modal-btn {
padding: 8px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.sign svg path {
fill: var(--night-rider);
.modal-btn--secondary {
background: var(--bg-light, #f3f4f6);
color: var(--text-secondary, #4b5563);
}
/* text */
.text {
position: absolute;
right: 0%;
width: 0%;
opacity: 0;
color: var(--night-rider);
font-size: 1.2em;
font-weight: 600;
transition-duration: 0.3s;
white-space: nowrap;
.modal-btn--secondary:hover {
background: var(--hover, #e5e7eb);
}
/* hover effect on button width */
.Btn:hover {
width: 125px;
border-radius: 5px;
transition-duration: 0.3s;
.modal-btn--primary {
background: var(--accent, #58bc82);
color: white;
}
.Btn:hover .sign {
width: 30%;
transition-duration: 0.3s;
padding-left: 20px;
.modal-btn--primary:hover {
filter: brightness(1.1);
}
/* hover effect button's text */
.Btn:hover .text {
opacity: 1;
width: 70%;
transition-duration: 0.3s;
padding-right: 10px;
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
/* button click effect*/
.Btn:active {
transform: translate(2px, 2px);
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

@ -0,0 +1,174 @@
<template>
<Transition name="fade">
<div v-if="ui.isLocked" class="lock-screen" role="dialog" aria-modal="true">
<div class="lock-screen__content">
<div class="lock-screen__avatar">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1024 1024"
width="64"
height="64"
>
<path
fill="currentColor"
d="M512 512a192 192 0 1 0 0-384 192 192 0 0 0 0 384m0 64a256 256 0 1 1 0-512 256 256 0 0 1 0 512m320 320v-96a96 96 0 0 0-96-96H288a96 96 0 0 0-96 96v96a32 32 0 1 1-64 0v-96a160 160 0 0 1 160-160h448a160 160 0 0 1 160 160v96a32 32 0 1 1-64 0"
></path>
</svg>
</div>
<h2 class="lock-screen__title">屏幕已锁定</h2>
<div class="lock-screen__form">
<input
v-model="password"
type="password"
class="lock-screen__input"
placeholder="请输入解锁密码"
@keyup.enter="onUnlock"
/>
<button class="lock-screen__btn lock-screen__btn--primary" @click="onUnlock">
解锁
</button>
<button class="lock-screen__btn lock-screen__btn--link" @click="onBackToLogin">
返回登录
</button>
</div>
<p v-if="error" class="lock-screen__error">{{ error }}</p>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useUIStore } from '../stores/ui';
import { useAuthStore } from '../stores/auth';
const ui = useUIStore();
const auth = useAuthStore();
const router = useRouter();
const password = ref('');
const error = ref('');
function onUnlock() {
if (password.value === ui.lockPassword) {
ui.unlock();
password.value = '';
error.value = '';
} else {
error.value = '密码错误';
password.value = '';
}
}
function onBackToLogin() {
ui.unlock();
auth.logout();
router.replace({ name: 'login' });
}
</script>
<style scoped>
.lock-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(10px);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
color: white;
}
.lock-screen__content {
width: 100%;
max-width: 320px;
text-align: center;
}
.lock-screen__avatar {
margin-bottom: 24px;
color: var(--accent, #58bc82);
display: flex;
justify-content: center;
align-items: center;
}
.lock-screen__title {
font-size: 24px;
margin-bottom: 32px;
font-weight: 600;
}
.lock-screen__form {
display: flex;
flex-direction: column;
gap: 16px;
}
.lock-screen__input {
padding: 12px 16px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
color: white;
font-size: 16px;
outline: none;
transition: all 0.3s;
}
.lock-screen__input:focus {
border-color: var(--accent, #58bc82);
background: rgba(255, 255, 255, 0.15);
}
.lock-screen__btn {
padding: 12px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
border: none;
}
.lock-screen__btn--primary {
background: var(--accent, #58bc82);
color: white;
}
.lock-screen__btn--primary:hover {
filter: brightness(1.1);
}
.lock-screen__btn--link {
background: transparent;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
text-decoration: underline;
}
.lock-screen__btn--link:hover {
color: white;
}
.lock-screen__error {
color: #f43f5e;
margin-top: 16px;
font-size: 14px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

@ -13,6 +13,17 @@
<span class="sidebar__text">ClusterManager</span>
</span>
</div>
<RouterLink
v-if="can([Roles.admin, Roles.operator])"
class="sidebar__item"
to="/diagnosis"
:class="{ 'is-active': isActive('/diagnosis') }"
title="故障诊断"
>
<i class="fas fa-microscope sidebar__icon"></i>
<span class="sidebar__text">故障诊断</span>
</RouterLink>
<RouterLink
class="sidebar__item"
to="/cluster-list"
@ -30,62 +41,19 @@
title="故障日志"
>
<i class="fas fa-clipboard-list sidebar__icon"></i>
<span class="sidebar__text">故障日志</span>
<span class="sidebar__text">集群日志</span>
</RouterLink>
<RouterLink
class="sidebar__item"
to="/exec-logs"
:class="{ 'is-active': isActive('/exec-logs') }"
title="执行日志"
to="/hadoop-exec-logs"
:class="{ 'is-active': isActive('/hadoop-exec-logs') }"
title="集群操作日志"
>
<i class="fas fa-terminal sidebar__icon"></i>
<span class="sidebar__text">执行日志</span>
</RouterLink>
<RouterLink
v-if="can([Roles.admin, Roles.operator])"
class="sidebar__item"
to="/diagnosis"
:class="{ 'is-active': isActive('/diagnosis') }"
title="故障诊断"
>
<i class="fas fa-microscope sidebar__icon"></i>
<span class="sidebar__text">故障诊断</span>
<span class="sidebar__text">集群操作日志</span>
</RouterLink>
<!-- 系统配置 (子菜单) -->
<div
v-if="can([Roles.admin, Roles.operator])"
class="sidebar__sub-menu"
:class="{ 'is-opened': isOpened('1') && !ui.sidebarHidden }"
>
<div
class="sidebar__sub-menu-title"
@click="toggleMenu('1')"
title="系统配置"
>
<i class="fas fa-sliders-h sidebar__icon"></i>
<span class="sidebar__text">系统配置</span>
<i
v-if="!ui.sidebarHidden"
class="fas fa-chevron-down sidebar__arrow"
:class="{ 'is-rotated': isOpened('1') }"
></i>
</div>
<div class="sidebar__sub-menu-content">
<RouterLink
class="sidebar__item sidebar__item--sub"
to="/alert-config"
:class="{ 'is-active': isActive('/alert-config') }"
title="告警配置"
>
<i class="fas fa-bell sidebar__icon"></i>
<span class="sidebar__text">告警配置</span>
</RouterLink>
</div>
</div>
<!-- 角色权限控制 (子菜单) -->
<div
v-if="can([Roles.admin, Roles.operator])"
@ -121,10 +89,10 @@
class="sidebar__item sidebar__item--sub"
to="/operation-logs"
:class="{ 'is-active': isActive('/operation-logs') }"
title="操作日志"
title="系统操作日志"
>
<i class="fas fa-history sidebar__icon"></i>
<span class="sidebar__text">操作日志</span>
<span class="sidebar__text">系统操作日志</span>
</RouterLink>
</div>
</div>

@ -3,15 +3,14 @@ import { useAuthStore } from '../stores/auth'
import { Roles, AllRoles } from '../constants/roles'
const routes: RouteRecordRaw[] = [
{ path: '/', redirect: '/cluster-list' },
{ path: '/', redirect: '/diagnosis' },
{ path: '/login', name: 'login', component: () => import('../views/Login.vue'), meta: { requiresAuth: false, hideSidebar: true } },
{ path: '/register', name: 'register', component: () => import('../views/Register.vue'), meta: { requiresAuth: false, hideSidebar: true } },
{ path: '/cluster-list', name: 'cluster-list', component: () => import('../views/ClusterList.vue'), meta: { requiresAuth: true, roles: AllRoles } },
{ path: '/dashboard', name: 'dashboard', component: () => import('../views/Dashboard.vue'), meta: { requiresAuth: true, roles: AllRoles } },
{ path: '/logs', name: 'logs', component: () => import('../views/Logs.vue'), meta: { requiresAuth: true, roles: AllRoles } },
{ path: '/diagnosis', name: 'diagnosis', component: () => import('../views/Diagnosis.vue'), meta: { requiresAuth: true, roles: [Roles.admin, Roles.operator] } },
{ path: '/exec-logs', name: 'exec-logs', component: () => import('../views/ExecLogs.vue'), meta: { requiresAuth: true, roles: AllRoles } },
{ path: '/alert-config', name: 'alert-config', component: () => import('../views/AlertConfig.vue'), meta: { requiresAuth: true, roles: [Roles.admin, Roles.operator] } },
{ path: '/hadoop-exec-logs', name: 'hadoop-exec-logs', component: () => import('../views/ExecLogs.vue'), meta: { requiresAuth: true, roles: AllRoles } },
{ path: '/profile', name: 'profile', component: () => import('../views/Profile.vue'), meta: { requiresAuth: true, roles: AllRoles } },
{ path: '/account', name: 'account', component: () => import('../views/Account.vue'), meta: { requiresAuth: true, roles: AllRoles } },
{ path: '/user-management', name: 'user-management', component: () => import('../views/UserManagement.vue'), meta: { requiresAuth: true, roles: [Roles.admin] } },

@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import api from '../lib/api'
type User = { username: string; role: 'admin'|'operator'|'observer' }
type User = { id: number; username: string; role: 'admin'|'operator'|'observer' }
function makeDemoToken() {
const payload = { sub: 'demo-admin', role: 'admin', iat: Date.now(), exp: Date.now() + 12 * 60 * 60 * 1000 }
@ -25,8 +25,7 @@ export const useAuthStore = defineStore('auth', {
role: (s) => s.user?.role || null,
defaultPage: (s) => {
const r = s.user?.role
if (r === 'admin') return 'cluster-list'
if (r === 'operator') return 'cluster-list'
if (r === 'admin' || r === 'operator') return 'diagnosis'
if (r === 'observer') return 'cluster-list'
return 'login'
}
@ -55,7 +54,7 @@ export const useAuthStore = defineStore('auth', {
if (username === '123' && password === '123') {
const role: 'admin' = 'admin'
const token = makeDemoToken()
this.user = { username: 'demo-admin', role }
this.user = { id: 1, username: 'demo-admin', role }
this.token = token
this.persist()
return { ok: true, role }
@ -63,13 +62,15 @@ export const useAuthStore = defineStore('auth', {
try {
const r = await api.post('/v1/user/login', { username, password })
const token = r?.data?.token
const backendRoleRaw = (r?.data?.user?.role || r?.data?.role || '') as string
const userId = r?.data?.user?.id || r?.data?.id || 0
const backendRoles = (r?.data?.roles || []) as string[]
const backendRoleRaw = (r?.data?.user?.role || r?.data?.role || r?.data?.role_key || (backendRoles.length > 0 ? backendRoles[0] : '')) as string
const backendRole = normalizeRole(backendRoleRaw)
const role: 'admin'|'operator'|'observer' = backendRole || (username === 'admin' ? 'admin' : username === 'ops' ? 'operator' : username === 'obs' ? 'observer' : 'observer')
const role: 'admin'|'operator'|'observer' = backendRole || (username === 'admin' || username === 'administrator' ? 'admin' : (username === 'ops' || username === 'operator') ? 'operator' : 'observer')
if (!token) {
return { ok: false, message: '登录失败' }
}
this.user = { username, role }
this.user = { id: userId, username, role }
this.token = token
this.persist()
return { ok: true, role }
@ -85,10 +86,12 @@ export const useAuthStore = defineStore('auth', {
async register(username: string, email: string, password: string, fullName: string) {
try {
const r = await api.post('/v1/user/register', { username, email, password, fullName })
const backendRoleRaw = (r?.data?.user?.role || r?.data?.role || '') as string
const userId = r?.data?.user?.id || r?.data?.id || 0
const backendRoles = (r?.data?.roles || []) as string[]
const backendRoleRaw = (r?.data?.user?.role || r?.data?.role || r?.data?.role_key || (backendRoles.length > 0 ? backendRoles[0] : '')) as string
const backendRole = normalizeRole(backendRoleRaw)
const role: 'admin'|'operator'|'observer' = backendRole || (username === 'admin' ? 'admin' : username === 'ops' ? 'operator' : username === 'obs' ? 'observer' : 'observer')
this.user = { username, role }
const role: 'admin'|'operator'|'observer' = backendRole || (username === 'admin' || username === 'administrator' ? 'admin' : (username === 'ops' || username === 'operator') ? 'operator' : 'observer')
this.user = { id: userId, username, role }
this.token = r?.data?.token || null
this.persist()
return { ok: true, role }

@ -1,11 +1,27 @@
import { defineStore } from 'pinia'
export const useUIStore = defineStore('ui', {
state: () => ({ sidebarHidden: false }),
state: () => ({
sidebarHidden: false,
isLocked: localStorage.getItem('cm_locked') === 'true',
lockPassword: localStorage.getItem('cm_lock_pwd') || ''
}),
actions: {
toggleSidebar() { this.sidebarHidden = !this.sidebarHidden },
hideSidebar() { this.sidebarHidden = true },
showSidebar() { this.sidebarHidden = false }
showSidebar() { this.sidebarHidden = false },
lock(password: string) {
this.isLocked = true;
this.lockPassword = password;
localStorage.setItem('cm_locked', 'true');
localStorage.setItem('cm_lock_pwd', password);
},
unlock() {
this.isLocked = false;
this.lockPassword = '';
localStorage.removeItem('cm_locked');
localStorage.removeItem('cm_lock_pwd');
}
}
})

@ -2,10 +2,10 @@
<section class="layout__section">
<div class="layout__page-header exec-header">
<div>
<h2 class="layout__page-title">账号管理原型</h2>
<h2 class="layout__page-title">账号管理</h2>
<div class="layout__page-subtitle">修改密码设置双因素认证等</div>
</div>
<div class="layout__page-actions"><button class="btn btn--primary" type="button" @click="save"></button></div>
<div class="layout__page-actions"><button class="btn btn--primary" :disabled="loading" type="button" @click="save"></button></div>
</div>
<article class="layout__card u-mt-2">
@ -33,14 +33,46 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import api from '../lib/api'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const form = reactive({ current:'', next:'', confirm:'' })
const err = ref('')
function save(){
const loading = ref(false)
async function save(){
err.value = ''
if (!form.current || !form.next || !form.confirm) { err.value = '请填写完整密码信息'; return }
if (form.next.length < 8) { err.value = '新密码至少8位'; return }
if (form.next !== form.confirm) { err.value = '两次输入的新密码不一致'; return }
err.value = '已保存(示例界面,未接入后端)'
loading.value = true
try {
const r = await api.patch('/v1/user/password', {
currentPassword: form.current,
newPassword: form.next
}, {
headers: { Authorization: `Bearer ${auth.token}` }
})
if (r.data?.ok) {
err.value = '密码修改成功'
form.current = ''
form.next = ''
form.confirm = ''
} else {
err.value = r.data?.detail || '修改失败'
}
} catch (e: any) {
const detail = e.response?.data?.detail
if (detail === 'invalid_current_password') err.value = '当前密码错误'
else if (detail === 'weak_new_password') err.value = '新密码太弱(需包含大小写字母和数字)'
else if (detail === 'demo_user_cannot_change_password') err.value = '演示账号不允许修改密码'
else err.value = '服务器错误,请稍后再试'
} finally {
loading.value = false
}
}
</script>

@ -1,225 +0,0 @@
<template>
<section class="layout__section">
<div class="layout__page-header exec-header">
<div>
<h2 class="layout__page-title">告警配置</h2>
<div class="layout__page-subtitle">设置告警规则通知渠道与阈值</div>
</div>
<div class="header-actions"><button class="btn btn--primary" type="button" @click="open=true"></button></div>
</div>
<article class="layout__card u-mt-2">
<div class="layout__card-header" style="display: flex; justify-content: space-between; align-items: center;">
<h3 class="layout__card-title">通知与阈值</h3>
<button class="btn btn--primary u-text-sm" @click="saveSettings" :disabled="loading">保存全局设置</button>
</div>
<div class="layout__card-body">
<div v-if="loading" class="u-text-center u-p-4 u-text-gray-500">...</div>
<div v-else-if="err && !open" class="u-p-4 u-text-center u-text-error">{{ err }}</div>
<form v-else class="layout__grid layout__grid--3" @submit.prevent>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">告警严重级别</label>
<select v-model="severity" class="u-w-full u-p-2 u-border u-rounded u-mt-1">
<option value="INFO">INFO</option>
<option value="WARN">WARN</option>
<option value="ERROR">ERROR</option>
</select>
<div class="u-mt-2">
<label class="u-text-sm u-font-medium u-text-gray-700"><input type="checkbox" v-model="enableEmail" class="u-mr-1" />启用邮件通知</label>
</div>
<div class="u-mt-1">
<label class="u-text-sm u-font-medium u-text-gray-700"><input type="checkbox" v-model="enableSms" class="u-mr-1" />启用短信通知</label>
</div>
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">邮件接收人</label>
<input v-model.trim="email" class="u-w-full u-p-2 u-border u-rounded u-mt-1" placeholder="ops@example.com" />
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700">Webhook 地址</label>
<input v-model.trim="webhook" class="u-w-full u-p-2 u-border u-rounded u-mt-1" placeholder="https://hooks.example.com/alert" />
<div class="u-mt-2">
<label class="u-text-sm u-font-medium u-text-gray-700"><input type="checkbox" v-model="enableWebhook" class="u-mr-1" />启用 Webhook</label>
</div>
</div>
</form>
</div>
</article>
<article class="layout__card u-mt-3">
<div class="layout__card-header"><h3 class="layout__card-title">告警规则列表</h3></div>
<div class="layout__card-body u-p-0">
<table class="dashboard__table">
<thead><tr><th>规则名称</th><th>条件</th><th>级别</th><th>通知渠道</th><th>操作</th></tr></thead>
<tbody>
<tr v-for="r in rules" :key="r.name" class="dashboard__table-row">
<td>{{ r.name }}</td>
<td>{{ r.cond }}</td>
<td><span :class="levelClass(r.level)">{{ r.level }}</span></td>
<td>{{ r.channel }}</td>
<td><button class="btn u-text-sm" type="button" @click="edit(r)"></button><button class="btn u-text-sm u-ml-1" type="button" @click="del(r.name)"></button></td>
</tr>
</tbody>
</table>
</div>
</article>
<article class="layout__card u-mt-3" v-show="open">
<div class="layout__card-header"><h3 class="layout__card-title">新增配置</h3></div>
<div class="layout__card-body">
<form @submit.prevent="save">
<div class="form-grid">
<input v-model.trim="form.name" class="header__search-input" placeholder="规则名称" />
<input v-model.trim="form.cond" class="header__search-input" placeholder="条件描述" />
<select v-model="form.level" class="header__search-input"><option value="INFO">INFO</option><option value="WARN">WARN</option><option value="ERROR">ERROR</option></select>
<input v-model.trim="form.channel" class="header__search-input" placeholder="通知渠道,如 邮件 或 邮件+Webhook" />
</div>
<div class="u-mt-2"><button class="btn btn--primary" type="submit">保存</button><button class="btn u-ml-1" type="button" @click="open=false"></button></div>
<div class="u-text-sm u-text-gray-700 u-mt-1">{{ err }}</div>
</form>
</div>
</article>
</section>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import api from '../lib/api'
import { useAuthStore } from '../stores/auth'
const auth = useAuthStore()
const severity = ref<'INFO'|'WARN'|'ERROR'>('INFO')
const enableEmail = ref(false)
const enableSms = ref(false)
const enableWebhook = ref(false)
const email = ref('')
const webhook = ref('')
const rules = reactive<{ name:string; cond:string; level:'INFO'|'WARN'|'ERROR'; channel:string }[]>([])
const open = ref(false)
const err = ref('')
const form = reactive<{ name:string; cond:string; level:'INFO'|'WARN'|'ERROR'; channel:string }>({ name:'', cond:'', level:'INFO', channel:'' })
const loading = ref(false)
function getErrorMessage(e: any, prefix: string) {
const status = e.response?.status
const detail = e.response?.data?.detail
let msg = ''
if (status === 401) {
msg = '会话已过期或未登录,请重新登录。'
} else if (status === 403) {
msg = '权限不足:您没有权限执行此操作。'
} else if (status === 404) {
msg = '接口不存在 (404),请检查后端服务版本。'
} else if (status === 422) {
msg = `参数校验失败:${detail || '请检查输入格式'}`
} else if (status >= 500) {
msg = `后端服务器错误 (${status}),请稍后再试。`
} else {
msg = detail || e.message || '未知错误'
}
return `${prefix}${msg}`
}
onMounted(() => {
loadAll()
})
async function loadAll() {
loading.value = true
err.value = ''
try {
const [settingsRes, rulesRes] = await Promise.all([
api.get('/v1/alert/settings', { headers: { Authorization: `Bearer ${auth.token}` } }),
api.get('/v1/alert/rules', { headers: { Authorization: `Bearer ${auth.token}` } })
])
const s = settingsRes.data
severity.value = s.severity || 'INFO'
enableEmail.value = !!s.enableEmail
enableSms.value = !!s.enableSms
enableWebhook.value = !!s.enableWebhook
email.value = s.email || ''
webhook.value = s.webhook || ''
const r = rulesRes.data?.rules || []
rules.splice(0, rules.length, ...r)
} catch (e: any) {
err.value = getErrorMessage(e, '加载配置失败')
} finally {
loading.value = false
}
}
async function saveSettings() {
err.value = ''
try {
await api.post('/v1/alert/settings', {
severity: severity.value,
enableEmail: enableEmail.value,
enableSms: enableSms.value,
enableWebhook: enableWebhook.value,
email: email.value,
webhook: webhook.value
}, { headers: { Authorization: `Bearer ${auth.token}` } })
alert('全局设置已保存')
} catch (e: any) {
err.value = getErrorMessage(e, '保存全局设置失败')
alert(err.value)
}
}
function levelClass(l:'INFO'|'WARN'|'ERROR'){ return l==='ERROR'?'level--error': l==='WARN'?'level--warn':'level--info' }
async function save(){
if (!form.name || !form.cond) { err.value='请填写规则名称与条件'; return }
err.value = ''
try {
await api.post('/v1/alert/rules', { ...form }, { headers: { Authorization: `Bearer ${auth.token}` } })
open.value = false
form.name = ''
form.cond = ''
form.level = 'INFO'
form.channel = ''
await loadAll()
} catch (e: any) {
err.value = getErrorMessage(e, '保存规则失败')
}
}
async function del(n:string){
if (!confirm(`确定要删除规则 ${n} 吗?`)) return
err.value = ''
try {
await api.delete(`/v1/alert/rules/${encodeURIComponent(n)}`, { headers: { Authorization: `Bearer ${auth.token}` } })
await loadAll()
} catch (e: any) {
err.value = getErrorMessage(e, '删除失败')
alert(err.value)
}
}
function edit(r:any){
open.value = true
form.name = r.name
form.cond = r.cond
form.level = r.level
form.channel = r.channel
}
</script>
<style scoped>
.exec-header{ display:flex; justify-content:space-between; align-items:center }
.layout__page-subtitle{ color:#6b7280; font-size:13px }
.layout__card{ background:#ffffff; border:1px solid #e5e7eb; border-radius:12px; box-shadow:0 8px 24px rgba(16,24,40,0.06) }
.layout__card-header{ padding:12px 16px; border-bottom:1px solid #e5e7eb }
.layout__card-title{ font-size:14px; font-weight:600 }
.layout__card-body{ padding:16px }
.layout__grid{ display:grid; gap:16px }
.layout__grid--3{ grid-template-columns: 1fr 1fr 1fr }
.form-grid{ display:grid; grid-template-columns: repeat(4, 1fr); gap:12px }
.level--info{ color:#2563eb }
.level--warn{ color:#f59e0b }
.level--error{ color:#dc2626 }
</style>

@ -93,10 +93,6 @@
<button class="btn u-text-sm u-ml-1" :disabled="c.health_status==='healthy'" @click.stop="startCluster(c.uuid)">启动集群</button>
<button class="btn u-text-sm u-ml-1" :disabled="c.health_status!=='healthy'" @click.stop="stopCluster(c.uuid)">关闭集群</button>
<button class="btn u-text-sm u-ml-1" @click.stop="unregister(c.uuid)">注销集群</button>
<button class="btn u-text-sm u-ml-1" @click.stop="discover(c.uuid)">发现角色</button>
<button class="btn u-text-sm u-ml-1" :disabled="c.health_status==='healthy'" @click.stop="startClusterNew(c.uuid)">按集群启动</button>
<button class="btn u-text-sm u-ml-1" :disabled="c.health_status!=='healthy'" @click.stop="stopClusterNew(c.uuid)">按集群停止</button>
<button class="btn u-text-sm u-ml-1" @click.stop="syncHosts(c.uuid)">同步 hosts</button>
</td>
</tr>
</tbody>
@ -319,18 +315,18 @@ async function unregister(id: string) {
}
catch(e:any){ err.value = formatError(e, '注销失败') }
}
// POST /api/v1/clusters/{id}/start
// POST /api/v1/ops/clusters/{id}/start
async function startCluster(id: string) {
try{
await api.post(`/v1/clusters/${encodeURIComponent(id)}/start`, {}, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
await api.post(`/v1/ops/clusters/${encodeURIComponent(id)}/start`, {}, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
await load()
}
catch(e:any){ err.value = formatError(e, '启动失败') }
}
// POST /api/v1/clusters/{id}/stop
// POST /api/v1/ops/clusters/{id}/stop
async function stopCluster(id: string) {
try{
await api.post(`/v1/clusters/${encodeURIComponent(id)}/stop`, {}, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
await api.post(`/v1/ops/clusters/${encodeURIComponent(id)}/stop`, {}, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
await load()
}
catch(e:any){ err.value = formatError(e, '关闭失败') }
@ -342,46 +338,4 @@ function toDashboard(c: any) {
router.push({ name: 'dashboard' })
}
onMounted(()=>{ load() })
// Hadoop /// hosts
// summary + warnings
async function discover(id:string){
try{
const r = await api.post('/v1/hadoop/cluster/discover', {}, { params: { cluster: id }, headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
const warnings: string[] = Array.isArray(r.data?.warnings) ? r.data.warnings : []
err.value = (r.data?.summary || '') + (warnings.length? ' | '+warnings.join('') : '')
}catch(e:any){
const d = e?.response?.data; const w = Array.isArray(d?.warnings)? d.warnings : []
err.value = (d?.summary || d?.detail || '发现失败') + (w.length? ' | '+w.join('') : '')
}
}
async function startClusterNew(id:string){
try{
const r = await api.post('/v1/hadoop/cluster/start', {}, { params: { cluster: id }, headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
const warnings: string[] = Array.isArray(r.data?.warnings) ? r.data.warnings : []
err.value = (r.data?.summary || '启动完成') + (warnings.length? ' | '+warnings.join('') : '')
}catch(e:any){
const d = e?.response?.data; const w = Array.isArray(d?.warnings)? d.warnings : []
err.value = (d?.summary || d?.detail || '启动失败') + (w.length? ' | '+w.join('') : '')
} finally { await load() }
}
async function stopClusterNew(id:string){
try{
const r = await api.post('/v1/hadoop/cluster/stop', {}, { params: { cluster: id }, headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
const warnings: string[] = Array.isArray(r.data?.warnings) ? r.data.warnings : []
err.value = (r.data?.summary || '停止完成') + (warnings.length? ' | '+warnings.join('') : '')
}catch(e:any){
const d = e?.response?.data; const w = Array.isArray(d?.warnings)? d.warnings : []
err.value = (d?.summary || d?.detail || '停止失败') + (w.length? ' | '+w.join('') : '')
} finally { await load() }
}
async function syncHosts(id:string){
try{
const r = await api.post('/v1/hadoop/cluster/sync-hosts', {}, { params: { cluster: id }, headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
const warnings: string[] = Array.isArray(r.data?.warnings) ? r.data.warnings : []
err.value = (r.data?.summary || '同步完成') + (warnings.length? ' | '+warnings.join('') : '')
}catch(e:any){
const d = e?.response?.data; const w = Array.isArray(d?.warnings)? d.warnings : []
err.value = (d?.summary || d?.detail || '同步失败') + (w.length? ' | '+w.join('') : '')
}
}
</script>

@ -5,78 +5,80 @@
<h2 class="layout__page-title">故障诊断</h2>
</div>
</div>
<div class="diag-layout">
<aside class="diag-sidebar">
<div class="diag-filter">
<form class="diag-filter-grid">
<div>
<label class="u-text-sm u-font-medium u-text-gray-700"
>日志级别</label
>
<select
v-model="filters.level"
class="u-w-full u-p-2 u-border u-rounded u-mt-1"
>
<option value="">全部级别</option>
<option value="debug">DEBUG</option>
<option value="info">INFO</option>
<option value="warn">WARN</option>
<option value="error">ERROR</option>
</select>
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700"
>来源集群</label
>
<select
v-model="filters.cluster"
class="u-w-full u-p-2 u-border u-rounded u-mt-1"
>
<option value="">全部集群</option>
<option v-for="c in clusterOptions" :key="c" :value="c">
{{ c }}
</option>
</select>
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700"
>来源节点</label
>
<select
v-model="filters.node"
class="u-w-full u-p-2 u-border u-rounded u-mt-1"
>
<option value="">全部节点</option>
<option v-for="n in nodesOptions" :key="n" :value="n">
{{ n }}
</option>
</select>
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700"
>时间范围</label
>
<select
v-model="filters.timeRange"
class="u-w-full u-p-2 u-border u-rounded u-mt-1"
>
<option value="">全部时间</option>
<option value="1h">最近1小时</option>
<option value="6h">最近6小时</option>
<option value="24h">最近24小时</option>
<option value="7d">最近7天</option>
</select>
</div>
<div class="filter-actions">
<button type="button" class="btn btn-link" @click="clearFilters">
清除筛选
</button>
</div>
</form>
<div class="u-text-sm u-text-gray-700 u-mt-2">
{{ filterSummary }}
<!-- 筛选与资源导航调整为上方全宽显示 -->
<aside class="diag-sidebar u-mb-4">
<div class="diag-filter">
<form class="diag-filter-grid">
<div>
<label class="u-text-sm u-font-medium u-text-gray-700"
>日志级别</label
>
<select
v-model="filters.level"
class="u-w-full u-p-2 u-border u-rounded u-mt-1"
>
<option value="">全部级别</option>
<option value="debug">DEBUG</option>
<option value="info">INFO</option>
<option value="warn">WARN</option>
<option value="error">ERROR</option>
</select>
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700"
>来源集群</label
>
<select
v-model="filters.cluster"
class="u-w-full u-p-2 u-border u-rounded u-mt-1"
>
<option value="">全部集群</option>
<option v-for="c in clusterOptions" :key="c" :value="c">
{{ c }}
</option>
</select>
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700"
>来源节点</label
>
<select
v-model="filters.node"
class="u-w-full u-p-2 u-border u-rounded u-mt-1"
>
<option value="">全部节点</option>
<option v-for="n in nodesOptions" :key="n" :value="n">
{{ n }}
</option>
</select>
</div>
<div>
<label class="u-text-sm u-font-medium u-text-gray-700"
>时间范围</label
>
<select
v-model="filters.timeRange"
class="u-w-full u-p-2 u-border u-rounded u-mt-1"
>
<option value="">全部时间</option>
<option value="1h">最近1小时</option>
<option value="6h">最近6小时</option>
<option value="24h">最近24小时</option>
<option value="7d">最近7天</option>
</select>
</div>
<div class="filter-actions">
<button type="button" class="btn btn-link" @click="clearFilters">
清除筛选
</button>
</div>
</form>
<div class="u-text-sm u-text-gray-700 u-mt-2">
{{ filterSummary }}
</div>
</div>
<div class="diag-resources-row">
<div class="diag-group" v-for="g in filteredGroups" :key="g.id">
<button
class="diag-group-toggle"
@ -103,47 +105,27 @@
</li>
</ul>
</div>
<div class="diag-tabs">
<button
:class="['btn', tab === 'live' ? 'btn--primary' : '']"
type="button"
@click="tab = 'live'"
>
实时日志
</button>
<button
:class="['btn', tab === 'auto' ? 'btn--primary' : '']"
type="button"
@click="tab = 'auto'"
>
自动刷新中
</button>
</div>
<div class="diag-tip">请选择集群或节点以显示相关日志</div>
<article class="layout__card u-mt-2">
<div class="layout__card-header">
<h3 class="layout__card-title">故障信息</h3>
</div>
<div class="layout__card-body">
<div class="fault-row">
<span class="fault-key">故障代码</span
><span class="fault-val">{{ fault?.code || "—" }}</span>
</div>
<div class="fault-row">
<span class="fault-key">发生时间</span
><span class="fault-val">{{ fault?.time || "—" }}</span>
</div>
<div class="fault-row">
<span class="fault-key">影响范围</span
><span class="fault-val">{{ fault?.scope || "—" }}</span>
</div>
<div class="u-text-sm u-text-error u-mt-1" v-if="faultErr">
{{ faultErr }}
</div>
</div>
</article>
</aside>
</div>
<div class="diag-tabs">
<button
:class="['btn', tab === 'live' ? 'btn--primary' : '']"
type="button"
@click="tab = 'live'"
>
实时日志
</button>
<button
:class="['btn', tab === 'auto' ? 'btn--primary' : '']"
type="button"
@click="tab = 'auto'"
>
自动刷新中
</button>
</div>
<div class="diag-tip">请选择集群或节点以显示相关日志</div>
</aside>
<div class="diag-layout">
<aside class="diag-preview">
<article class="layout__card">
<div class="layout__card-header">
@ -157,7 +139,7 @@
<div class="preview-meta">
当前节点<strong>{{ selectedNode }}</strong>
</div>
<div class="u-overflow-x-auto u-mt-2">
<div class="u-mt-2 table-container">
<table class="dashboard__table">
<thead class="dashboard__table-head">
<tr>
@ -243,7 +225,7 @@
<summary>推理过程</summary>
<pre style="white-space: pre-wrap">{{ m.reasoning }}</pre>
</details>
<div>{{ m.content }}</div>
<div style="white-space: pre-wrap">{{ m.content }}</div>
</div>
</div>
</div>
@ -347,9 +329,6 @@ type Group = {
};
const groups = reactive<Group[]>([]);
const loadingSidebar = ref(false);
type FaultInfo = { code: string; time: string; scope: string };
const fault = ref<FaultInfo | null>(null);
const faultErr = ref("");
const selectedNode = ref("");
const clusterOptions = computed(() => groups.map((g) => g.name));
const nodesOptions = computed(() => {
@ -456,33 +435,6 @@ async function toggleGroup(g: Group) {
g.open = !g.open;
if (g.open && g.nodes.length === 0) await loadNodesFor(g.uuid);
}
async function loadFaultInfo() {
faultErr.value = "";
fault.value = null;
const params: any = {};
if (selectedNode.value) params.node = selectedNode.value;
else if (filters.cluster) {
const g = groups.find(x => x.name === filters.cluster);
params.cluster = g ? g.uuid : filters.cluster;
}
try {
const r = await api.get("/v1/faults/summary", {
params,
headers: auth.token
? { Authorization: `Bearer ${auth.token}` }
: undefined,
});
const d = r?.data?.fault || r?.data?.data || null;
if (d)
fault.value = {
code: String(d.code || ""),
time: String(d.time || ""),
scope: String(d.scope || ""),
};
} catch (e: any) {
faultErr.value = formatError(e, "故障信息加载失败");
}
}
function selectNode(n: string) {
selectedNode.value = n;
}
@ -541,11 +493,11 @@ async function loadPreviewLogs() {
? r.data.logs
: [];
previewLogs.value = items.map((d: any, i: number) => ({
id: d.id || i,
time: d.time || new Date().toISOString(),
id: d.log_id || d.id || i,
time: d.log_time || d.time || d.timestamp || new Date().toISOString(),
level: String(d.level || "info").toLowerCase(),
source: String(d.source || d.node || ""),
message: d.message || "",
source: String(d.title || d.source || d.node_host || d.node || d.host || ""),
message: d.info || d.message || "",
}));
} catch (e: any) {
previewLogs.value = [];
@ -725,11 +677,9 @@ async function generateReport() {
onMounted(async () => {
await loadClusters();
await loadHistory();
await loadFaultInfo();
});
watch(selectedNode, () => {
loadHistory();
loadFaultInfo();
loadPreviewLogs();
});
watch(
@ -747,7 +697,7 @@ watch(
watch(
() => filters.cluster,
() => {
loadFaultInfo();
//
}
);
function formatError(e: any, def: string) {
@ -808,21 +758,21 @@ function formatError(e: any, def: string) {
}
.diag-layout {
display: grid;
grid-template-columns: var(--diag-sidebar-width, 30%) 1fr var(
--diag-assistant-width,
30%
);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
align-items: stretch;
}
.diag-preview, .diag-assistant {
min-width: 0;
}
.diag-sidebar {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
padding: 16px;
display: flex;
flex-direction: column;
overflow-x: hidden;
margin-bottom: 16px;
}
.diag-filter {
padding-bottom: 12px;
@ -830,8 +780,8 @@ function formatError(e: any, def: string) {
}
.diag-filter-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 16px;
margin-top: 8px;
}
.diag-filter-grid > div {
@ -852,8 +802,18 @@ function formatError(e: any, def: string) {
justify-content: flex-end;
margin-top: 8px;
}
.dashboard__table th { white-space: nowrap; }
.diag-resources-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.diag-group {
margin-top: 8px;
flex: 0 0 auto;
min-width: 200px;
}
.diag-group-toggle {
width: 100%;
@ -926,22 +886,6 @@ function formatError(e: any, def: string) {
color: var(--text-muted);
font-size: 12px;
}
.fault-row {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px dashed var(--border);
}
.fault-row:last-child {
border-bottom: none;
}
.fault-key {
color: var(--text-muted);
font-size: 12px;
}
.fault-val {
font-weight: 600;
}
.diag-preview {
display: flex;
@ -966,6 +910,19 @@ function formatError(e: any, def: string) {
color: var(--text-muted);
font-size: 14px;
}
.table-container {
max-height: 580px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 8px;
}
.dashboard__table thead th {
position: sticky;
top: 0;
z-index: 10;
background: var(--active);
box-shadow: 0 1px 0 var(--border);
}
.preview-body {
background: var(--hover);
border: 1px solid var(--border);
@ -977,7 +934,6 @@ function formatError(e: any, def: string) {
.diag-assistant {
display: flex;
flex-direction: column;
margin-right: 16px;
}
.diag-assistant .layout__card {
display: flex;
@ -999,7 +955,7 @@ function formatError(e: any, def: string) {
}
.assist-row {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.assist-field {

@ -2,14 +2,14 @@
<section class="layout__section">
<div class="layout__page-header exec-header">
<div>
<h2 class="layout__page-title">执行日志</h2>
<h2 class="layout__page-title">集群操作日志</h2>
<div class="layout__page-subtitle">查看与管理修复执行记录支持完整后端同步</div>
</div>
<div class="header-actions">
<button class="btn" type="button" @click="refresh"></button>
<button class="btn btn--primary u-ml-1" type="button" @click="openCreate=true"></button>
<button class="btn u-ml-1" type="button" :disabled="!selected" @click="openEdit()"></button>
<button class="btn u-ml-1" type="button" :disabled="!selected" @click="delSelected()"></button>
<button class="btn u-ml-1" type="button" :disabled="selected === null" @click="openEdit()"></button>
<button class="btn u-ml-1" type="button" :disabled="selected === null" @click="delSelected()"></button>
</div>
</div>
@ -25,13 +25,12 @@
<div class="layout__card-body">
<form @submit.prevent="save">
<div class="form-grid">
<input v-model.trim="form.id" placeholder="执行ID" class="header__search-input" />
<input v-model.trim="form.faultId" placeholder="故障ID" class="header__search-input" />
<select v-model="form.cmdType" class="header__search-input"><option>shell</option><option>hdfs</option><option>yarn</option></select>
<input v-model.trim="form.clusterName" placeholder="集群名称" class="header__search-input" />
<input v-model.trim="form.username" placeholder="用户" class="header__search-input" />
<input v-model.trim="form.description" placeholder="描述" class="header__search-input" />
<select v-model="form.status" class="header__search-input"><option>running</option><option>success</option><option>failed</option></select>
<input v-model.trim="form.start" placeholder="开始时间 如 2025-11-07 10:20:03" class="header__search-input" />
<input v-model.trim="form.end" placeholder="结束时间 如 2025-11-07 10:22:35 或留空" class="header__search-input" />
<input v-model.number="form.code" placeholder="退出码 如 0 或留空" class="header__search-input" />
</div>
<div class="u-mt-2">
<button class="btn btn--primary" type="submit">保存</button>
@ -49,74 +48,60 @@ import { reactive, ref, onMounted } from 'vue'
import api from '../lib/api'
import { useAuthStore } from '../stores/auth'
import ExecLogsTable from '../components/ExecLogsTable.vue'
type RecordItem = { id:string; faultId:string; cmdType:string; status:'running'|'success'|'failed'; start:string; end:string|''; code:number|null }
type RecordItem = { id:number; clusterName:string; username:string; description:string; faultId:string; cmdType:string; status:'running'|'success'|'failed'; start:string; end:string|''; code:number|null }
const auth = useAuthStore()
const records = reactive<RecordItem[]>([])
const selected = ref('')
const selected = ref<number|null>(null)
const openCreate = ref(false)
const openEditForm = ref(false)
const err = ref('')
const loading = ref(false)
const form = reactive<RecordItem>({ id:'', faultId:'', cmdType:'shell', status:'running', start:'', end:'', code:null })
const form = reactive<RecordItem>({ id:0, clusterName:'', username:'', description:'', faultId:'', cmdType:'shell', status:'running', start:'', end:'', code:null })
function select(r: RecordItem){ selected.value = r.id }
function editRow(r: RecordItem){ selected.value = r.id; openCreate.value=false; openEditForm.value=true; Object.assign(form, r) }
function openEdit(){ const r = records.find(x=>x.id===selected.value); if (r) editRow(r) }
function delSelected(){ if (selected.value) del(selected.value) }
function delSelected(){ if (selected.value !== null) del(selected.value) }
async function del(id:string){
async function del(id:number){
try{
await api.delete(`/v1/exec-logs/${encodeURIComponent(id)}`, {
await api.delete(`/v1/exec-logs/${id}`, {
headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined
})
const i = records.findIndex(x=>x.id===id)
if (i>=0) {
records.splice(i,1)
if (selected.value===id) selected.value=''
if (selected.value===id) selected.value=null
}
}catch(e:any){
err.value = '删除失败:' + (e.response?.data?.detail || e.message || '网络错误')
}
}
function cancelForm(){ openCreate.value=false; openEditForm.value=false; err.value='' }
function cancelForm(){ openCreate.value=false; openEditForm.value=false; err.value=''; Object.assign(form, { id:0, clusterName:'', username:'', description:'', faultId:'', cmdType:'shell', status:'running', start:'', end:'', code:null }) }
async function save(){
err.value=''
if (!form.id || !form.faultId || !form.cmdType || !form.status || !form.start) { err.value='请完整填写信息'; return }
const exists = records.find(x=>x.id===form.id)
if (!form.clusterName || !form.start) { err.value='请完整填写信息'; return }
const payload = {
id: form.id,
faultId: form.faultId,
cmdType: form.cmdType,
status: form.status,
start: form.start,
end: form.end,
code: form.code
from_user_id: auth.user?.id || 0,
cluster_name: form.clusterName,
description: form.description,
fault_id: form.faultId,
command_type: form.cmdType,
execution_status: form.status,
start_time: form.start.replace(' ', 'T'),
end_time: form.end ? form.end.replace(' ', 'T') : null,
exit_code: form.code
}
try{
if (openCreate.value) {
if (exists) { err.value='执行ID已存在'; return }
await api.post('/v1/exec-logs', {
exec_id: payload.id,
fault_id: payload.faultId,
command_type: payload.cmdType,
execution_status: payload.status,
start_time: payload.start,
end_time: payload.end || null,
exit_code: payload.code
}, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
await api.post('/v1/exec-logs', payload, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
} else if (openEditForm.value) {
if (!exists) { err.value='目标记录不存在'; return }
await api.put(`/v1/exec-logs/${encodeURIComponent(form.id)}`, {
fault_id: payload.faultId,
command_type: payload.cmdType,
execution_status: payload.status,
start_time: payload.start,
end_time: payload.end || null,
exit_code: payload.code
}, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
if (selected.value === null) { err.value='目标记录不存在'; return }
await api.put(`/v1/exec-logs/${selected.value}`, payload, { headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
}
await load()
cancelForm()
@ -136,17 +121,20 @@ async function load(){
})
const items = Array.isArray(r.data?.items) ? r.data.items : (Array.isArray(r.data?.exec_logs) ? r.data.exec_logs : [])
const normalized: RecordItem[] = items.map((d:any)=>({
id: d.exec_id || d.id,
faultId: d.fault_id,
cmdType: d.command_type || d.cmdType,
status: d.execution_status || d.status,
start: (d.start_time || d.start || '').replace('T',' ').slice(0,19),
id: d.id,
clusterName: d.cluster_name || '',
username: d.username || d.user_name || d.user?.username || '',
description: d.description || '',
faultId: d.fault_id || '',
cmdType: d.command_type || '',
status: d.execution_status || 'running',
start: (d.start_time || '').replace('T',' ').slice(0,19),
end: d.end_time ? String(d.end_time).replace('T',' ').slice(0,19) : '',
code: d.exit_code ?? null
}))
records.splice(0, records.length, ...normalized)
}catch(e:any){
err.value = '加载执行日志失败:' + (e.response?.data?.detail || e.message || '网络错误')
err.value = '加载集群操作日志失败:' + (e.response?.data?.detail || e.message || '网络错误')
records.splice(0, records.length)
} finally {
loading.value = false

@ -1,6 +1,6 @@
<template>
<section class="layout__section">
<div class="layout__page-header"><h2 class="layout__page-title">日志查询</h2></div>
<div class="layout__page-header"><h2 class="layout__page-title">集群日志</h2></div>
<article class="layout__card">
<div class="layout__card-header"><h3 class="layout__card-title">搜索条件</h3></div>
<div class="layout__card-body">
@ -74,11 +74,16 @@
</tr>
</tbody>
</table>
<div class="u-mt-2">
<button id="log-prev" class="btn" @click="prev"></button>
<button id="log-next" class="btn u-ml-1" @click="next"></button>
<select id="log-page-size" v-model.number="size" class="header__search-input u-ml-1"><option :value="10">10</option><option :value="20">20</option><option :value="50">50</option></select>
<span id="log-page-info" class="u-ml-1"> {{ page }} </span>
<div class="u-mt-2 pagination-bar">
<button id="log-prev" class="btn" :disabled="page <= 1" @click="prev"></button>
<input type="number" v-model.number="page" min="1" :max="maxPage" class="header__search-input u-ml-1" style="width: 80px; text-align: center;" />
<button id="log-next" class="btn u-ml-1" :disabled="page >= maxPage" @click="next"></button>
<select id="log-page-size" v-model.number="size" class="header__search-input u-ml-1">
<option :value="10">10/</option>
<option :value="20">20/</option>
<option :value="50">50/</option>
</select>
<span id="log-page-info" class="u-ml-1"> {{ maxPage }} </span>
</div>
</section>
</template>
@ -117,7 +122,16 @@ async function load(){
if (q.timeRange) params.time_from = rangeFromNow(q.timeRange)
const r = await api.get('/v1/logs', { params, headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined })
const items = Array.isArray(r.data?.items) ? r.data.items : (Array.isArray(r.data?.logs)? r.data.logs : [])
const normalized = items.map((d:any)=>({ ...d, source: String((d?.source ?? d?.user ?? '') || '') }))
const normalized = items.map((d:any)=>({
id: d.log_id || d.id,
time: d.log_time || d.timestamp || '',
level: d.level || 'info',
cluster: d.cluster_name || d.cluster || '',
node: d.node_host || d.node || d.host || '',
op: d.op || '',
source: d.title || d.source || d.service || '',
message: d.info || d.message || ''
}))
data.value = normalized
total.value = Number(r.data?.total ?? items.length)
if (!clustersOpts.value.length) clustersOpts.value = Array.from(new Set(items.map((d:any)=>d.cluster).filter(Boolean)))
@ -129,16 +143,29 @@ async function load(){
}
function apply(manual=false) { page.value = 1 }
function clear() { q.level=''; q.cluster=''; q.node=''; q.op=''; q.source=''; q.timeRange=''; page.value=1 }
function prev() { if (page.value>1) { page.value-=1; load() } }
function next() { const max = Math.max(1, Math.ceil(total.value/size.value)); if (page.value<max) { page.value+=1; load() } }
const maxPage = computed(() => Math.max(1, Math.ceil(total.value / size.value)))
function prev() { if (page.value > 1) page.value -= 1 }
function next() { if (page.value < maxPage.value) page.value += 1 }
const pageData = computed(() => {
const s = q.source.trim().toLowerCase()
let list = data.value
if (s) list = list.filter(d => String(d.source || '').toLowerCase().includes(s))
return list
})
watch(() => ({...q}), () => { apply(); load() }, { deep: true })
watch(size, () => { page.value = 1; load() })
watch(() => ({...q}), () => {
if (page.value === 1) load()
else page.value = 1
}, { deep: true })
watch(page, (val) => {
if (typeof val !== 'number' || isNaN(val)) return
if (val < 1) { page.value = 1; return }
if (val > maxPage.value) { page.value = maxPage.value; return }
load()
})
watch(size, () => {
if (page.value === 1) load()
else page.value = 1
})
onMounted(()=>{ load() })
const summary = computed(() => {
const parts = [] as string[]
@ -153,35 +180,14 @@ const summary = computed(() => {
</script>
<style scoped>
.layout__card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 8px 24px rgba(16,24,40,0.06) }
.layout__card-header { padding: 12px 16px; border-bottom: 1px solid var(--border) }
.layout__card-title { font-size: 14px; font-weight: 600; color: var(--text-primary) }
.layout__card { background: #ffffff; border: 1px solid #e5e7eb; border-radius: 12px; box-shadow: 0 8px 24px rgba(16,24,40,0.06) }
.layout__card-header { padding: 12px 16px; border-bottom: 1px solid #e5e7eb }
.layout__card-title { font-size: 14px; font-weight: 600 }
.layout__card-body { padding: 16px }
.layout__grid { display: grid; gap: 16px }
.layout__grid--3 { grid-template-columns: 1fr 1fr 1fr }
.btn-link { background: transparent; border-color: transparent; color: var(--accent) }
.btn-link { background: transparent; border-color: transparent; color: #2563eb }
.filter-actions { display: flex; justify-content: flex-end; align-items: center; }
</style>
<style>
/* 夜间模式特定样式:黑底白字 */
.dark-mode .layout__card {
background: #000000 !important;
color: #ffffff !important;
border-color: #334155;
}
.dark-mode .layout__card-header {
border-bottom-color: #334155;
}
.dark-mode .layout__card-title {
color: #ffffff !important;
}
.dark-mode select.u-border {
background: #1a1a1a;
color: #ffffff;
border-color: #334155;
}
.dark-mode #log-filter-summary {
color: #cccccc !important;
}
.pagination-bar { display: flex; justify-content: center; align-items: center; }
.dashboard__table th { white-space: nowrap; }
</style>

@ -2,7 +2,7 @@
<section class="layout__section" aria-labelledby="operation-logs-title">
<header class="layout__page-header">
<div>
<h2 id="operation-logs-title" class="layout__page-title">操作日志</h2>
<h2 id="operation-logs-title" class="layout__page-title">系统操作日志</h2>
<p class="layout__page-subtitle">记录用户操作与系统事件</p>
</div>
<div class="layout__page-actions">
@ -24,7 +24,6 @@
<tr>
<th class="dashboard__table-th" scope="col">时间</th>
<th class="dashboard__table-th" scope="col">用户</th>
<th class="dashboard__table-th" scope="col">动作</th>
<th class="dashboard__table-th" scope="col">详情</th>
</tr>
</thead>
@ -32,11 +31,10 @@
<tr v-for="log in logs" :key="log.id" class="dashboard__table-row">
<td class="dashboard__table-td"><time :datetime="log.timestamp">{{ formatTime(log.timestamp) }}</time></td>
<td class="dashboard__table-td">{{ log.user }}</td>
<td class="dashboard__table-td">{{ log.action }}</td>
<td class="dashboard__table-td">{{ log.detail }}</td>
</tr>
<tr v-if="logs.length === 0" class="dashboard__table-row">
<td colspan="4" class="dashboard__table-td u-text-center">暂无操作记录</td>
<td colspan="3" class="dashboard__table-td u-text-center">暂无操作记录</td>
</tr>
</tbody>
</table>
@ -55,7 +53,6 @@ interface OperationLogItem {
id: string
timestamp: string
user: string
action: string
detail: string
}
@ -77,18 +74,17 @@ async function loadLogs() {
loading.value = true
err.value = ''
try {
const r = await api.get('/v1/operation-logs', {
const r = await api.get('/v1/sys-exec-logs', {
headers: auth.token ? { Authorization: `Bearer ${auth.token}` } : undefined
})
//
//
const items = Array.isArray(r.data?.items) ? r.data.items : (Array.isArray(r.data?.operation_logs) ? r.data.operation_logs : [])
const normalized: OperationLogItem[] = items.map((d: any) => ({
id: d.id || d.operation_id,
timestamp: d.timestamp || d.created_at || d.time,
user: d.user || d.username || d.operator,
action: d.action || d.operation,
id: d.operation_id || d.id,
timestamp: d.operation_time || d.timestamp || d.created_at || d.time,
user: d.user || d.username || d.operator || `User ${d.user_id}`,
detail: d.detail || d.description || d.content
}))

@ -9,18 +9,30 @@
<option value="observer">观察员</option>
</select>
</td><td>{{ statusName(u.status) }}</td><td><button class="btn u-text-sm" @click="ban(u.username)"></button><button class="btn u-text-sm u-ml-1" @click="unban(u.username)"></button><button class="btn u-text-sm u-ml-1" @click="del(u.username)"></button></td></tr></tbody></table>
<div v-show="open" class="u-mt-2">
<form @submit.prevent="save">
<input v-model.trim="form.username" placeholder="用户名" class="header__search-input" />
<input v-model.trim="form.email" placeholder="邮箱" class="header__search-input" />
<div v-if="err" class="u-text-sm u-text-error u-mb-2">{{ err }}</div>
<div v-show="open" class="u-mt-2 layout__card u-p-4">
<h3 class="u-mb-2 u-text-sm u-font-bold">新增系统用户</h3>
<form @submit.prevent="save" class="layout__grid layout__grid--2">
<input v-model.trim="form.username" placeholder="用户名 (Username)" class="header__search-input" />
<input v-model.trim="form.fullName" placeholder="姓名 (Full Name)" class="header__search-input" />
<input v-model.trim="form.email" placeholder="邮箱 (Email)" class="header__search-input" />
<input v-model.trim="form.password" type="password" placeholder="输入密码" class="header__search-input" />
<input v-model.trim="form.confirmPassword" type="password" placeholder="确认密码" class="header__search-input" />
<select v-model="form.role" class="header__search-input"><option value="admin">管理员</option><option value="operator">操作员</option><option value="observer"></option></select>
<select v-model="form.status" class="header__search-input"><option value="enabled">启用</option><option value="pending">待审核</option><option value="disabled"></option></select>
<button class="btn">保存</button>
<button class="btn u-ml-1" type="button" @click="cancel"></button>
<select v-model="form.role" class="header__search-input">
<option value="admin">管理员</option>
<option value="operator">操作员</option>
<option value="observer">观察员</option>
</select>
<select v-model="form.status" class="header__search-input">
<option value="enabled">启用</option>
<option value="pending">待审核</option>
<option value="disabled">禁用</option>
</select>
<div class="u-col-span-2 u-mt-2">
<button class="btn btn--primary" :disabled="saving">{{ saving ? '提交中...' : '保存用户' }}</button>
<button class="btn u-ml-1" type="button" @click="cancel" :disabled="saving">取消</button>
</div>
</form>
<div class="u-text-sm u-text-gray-700">{{ err }}</div>
</div>
</section>
</template>
@ -33,26 +45,57 @@ const auth = useAuthStore()
const users = reactive<{ username:string; email:string; role:string; status:string }[]>([])
const open = ref(false)
const err = ref('')
const saving = ref(false)
const changingRoleUser = ref('')
const form = reactive({ username:'', email:'', password:'', confirmPassword:'', role:'operator', status:'enabled' })
function roleName(r:string){ if(r==='admin')return '管理员'; if(r==='operator')return '操作员'; if(r==='observer')return '观察员'; return r }
const form = reactive({ username:'', fullName:'', email:'', password:'', confirmPassword:'', role:'operator', status:'enabled' })
function normalizeRole(r: string) {
const v = String(r || '').trim().toLowerCase()
if (v === 'admin' || v === 'administrator') return 'admin'
if (v === 'operator' || v === 'ops' || v === 'op') return 'operator'
if (v === 'observer' || v === 'obs' || v === 'view') return 'observer'
return v || 'observer'
}
function roleName(r:string){
const normalized = normalizeRole(r)
if(normalized==='admin')return '管理员';
if(normalized==='operator')return '操作员';
if(normalized==='observer')return '观察员';
return normalized
}
function statusName(s:string){ if(s==='enabled')return '启用'; if(s==='pending')return '待审核'; if(s==='disabled')return '禁用'; return s }
async function load(){ try{ const r = await api.get('/v1/users',{ headers: auth.token?{ Authorization:`Bearer ${auth.token}` }:undefined }); users.splice(0,users.length,...(r.data?.users||[])) } catch(e:any){ err.value = e?.response?.data?.detail || '加载失败' } }
async function load(){
try{
const r = await api.get('/v1/users',{ headers: auth.token?{ Authorization:`Bearer ${auth.token}` }:undefined });
const data = r.data?.users || r.data;
const list = Array.isArray(data) ? data.map((u: any) => ({
username: u.username || u.user_name || u.name,
email: u.email || u.mail,
role: u.role,
status: u.status
})) : [];
users.splice(0,users.length,...list);
} catch(e:any){
err.value = e?.response?.data?.detail || e?.message || '加载失败'
}
}
async function save(){
if(!form.username||!form.email||!form.role||!form.status){ err.value='请填写完整信息'; return }
if(!form.username||!form.fullName||!form.email||!form.role||!form.status){ err.value='请填写完整信息'; return }
if(!form.password||!form.confirmPassword){ err.value='请输入密码并确认'; return }
if(form.password!==form.confirmPassword){ err.value='两次密码不一致'; return }
try{
const payload = { username: form.username, email: form.email, role: form.role, status: form.status, password: form.password }
saving.value = true; err.value = ''
const payload = { username: form.username, full_name: form.fullName, email: form.email, role: form.role, status: form.status, password: form.password }
await api.post('/v1/users', payload, { headers: auth.token?{ Authorization:`Bearer ${auth.token}` }:undefined })
await load(); cancel()
} catch(e:any){
const d=e?.response?.data; const errs=d?.detail?.errors;
if(Array.isArray(errs)&&errs.length){ err.value = errs.map((x:any)=>x?.message||'').filter(Boolean).join('') }
else { err.value = d?.detail || '保存失败' }
} finally {
saving.value = false
}
}
function cancel(){ open.value=false; err.value=''; form.username=''; form.email=''; form.password=''; form.confirmPassword=''; form.role='operator'; form.status='enabled' }
function cancel(){ open.value=false; err.value=''; form.username=''; form.fullName=''; form.email=''; form.password=''; form.confirmPassword=''; form.role='operator'; form.status='enabled' }
async function ban(u:string){ try{ await api.patch(`/v1/users/${encodeURIComponent(u)}`, { status:'disabled' }, { headers: auth.token?{ Authorization:`Bearer ${auth.token}` }:undefined }); await load() } catch(e:any){ err.value = e?.response?.data?.detail || '操作失败' } }
async function unban(u:string){ try{ await api.patch(`/v1/users/${encodeURIComponent(u)}`, { status:'enabled' }, { headers: auth.token?{ Authorization:`Bearer ${auth.token}` }:undefined }); await load() } catch(e:any){ err.value = e?.response?.data?.detail || '操作失败' } }
async function del(u:string){ try{ await api.delete(`/v1/users/${encodeURIComponent(u)}`, { headers: auth.token?{ Authorization:`Bearer ${auth.token}` }:undefined }); await load() } catch(e:any){ err.value = e?.response?.data?.detail || '删除失败' } }

Loading…
Cancel
Save