初始化
This commit is contained in:
7
src/views/_builtin/403/index.vue
Normal file
7
src/views/_builtin/403/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<ExceptionBase type="403" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
7
src/views/_builtin/404/index.vue
Normal file
7
src/views/_builtin/404/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<ExceptionBase type="404" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
7
src/views/_builtin/500/index.vue
Normal file
7
src/views/_builtin/500/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<ExceptionBase type="500" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
15
src/views/_builtin/iframe-page/[url].vue
Normal file
15
src/views/_builtin/iframe-page/[url].vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
url: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<iframe id="iframePage" class="size-full" :src="url"></iframe>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
85
src/views/_builtin/login/index.vue
Normal file
85
src/views/_builtin/login/index.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import { getPaletteColorByNumber, mixColor } from '@sa/color';
|
||||
import { loginModuleRecord } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import PwdLogin from './modules/pwd-login.vue';
|
||||
import ResetPwd from './modules/reset-pwd.vue';
|
||||
|
||||
defineOptions({ name: 'LoginPage' });
|
||||
|
||||
interface Props {
|
||||
/** The login module */
|
||||
module?: UnionKey.LoginModule;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
interface LoginModule {
|
||||
label: App.I18n.I18nKey;
|
||||
component: Component;
|
||||
}
|
||||
|
||||
const moduleMap: Record<UnionKey.LoginModule, LoginModule> = {
|
||||
'pwd-login': { label: loginModuleRecord['pwd-login'], component: PwdLogin },
|
||||
'reset-pwd': { label: loginModuleRecord['reset-pwd'], component: ResetPwd }
|
||||
};
|
||||
|
||||
const activeModule = computed(() => moduleMap[props.module || 'pwd-login']);
|
||||
|
||||
const bgThemeColor = computed(() =>
|
||||
themeStore.darkMode ? getPaletteColorByNumber(themeStore.themeColor, 600) : themeStore.themeColor
|
||||
);
|
||||
|
||||
const bgColor = computed(() => {
|
||||
const COLOR_WHITE = '#ffffff';
|
||||
|
||||
const ratio = themeStore.darkMode ? 0.5 : 0.2;
|
||||
|
||||
return mixColor(COLOR_WHITE, themeStore.themeColor, ratio);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative size-full flex-center overflow-hidden" :style="{ backgroundColor: bgColor }">
|
||||
<WaveBg :theme-color="bgThemeColor" />
|
||||
<ElCard class="relative z-4 w-auto rd-12px">
|
||||
<div class="w-400px lt-sm:w-300px">
|
||||
<header class="flex-y-center justify-between">
|
||||
<SystemLogo class="text-64px text-primary lt-sm:text-48px" />
|
||||
<h3 class="text-28px text-primary font-500 lt-sm:text-22px">{{ $t('system.title') }}</h3>
|
||||
<div class="i-flex-col">
|
||||
<ThemeSchemaSwitch
|
||||
:theme-schema="themeStore.themeScheme"
|
||||
:show-tooltip="false"
|
||||
class="text-20px lt-sm:text-18px"
|
||||
@switch="themeStore.toggleThemeScheme"
|
||||
/>
|
||||
<LangSwitch
|
||||
v-if="themeStore.header.multilingual.visible"
|
||||
:lang="appStore.locale"
|
||||
:lang-options="appStore.localeOptions"
|
||||
:show-tooltip="false"
|
||||
@change-lang="appStore.changeLocale"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<main class="pt-15px">
|
||||
<div class="pt-15px">
|
||||
<Transition :name="themeStore.page.animateMode" mode="out-in" appear>
|
||||
<component :is="activeModule.component" />
|
||||
</Transition>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
85
src/views/_builtin/login/index.vue.bak
Normal file
85
src/views/_builtin/login/index.vue.bak
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
import { getPaletteColorByNumber, mixColor } from '@sa/color';
|
||||
import { loginModuleRecord } from '@/constants/app';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { $t } from '@/locales';
|
||||
import PwdLogin from './modules/pwd-login.vue';
|
||||
import ResetPwd from './modules/reset-pwd.vue';
|
||||
|
||||
defineOptions({ name: 'LoginPage' });
|
||||
|
||||
interface Props {
|
||||
/** The login module */
|
||||
module?: UnionKey.LoginModule;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const appStore = useAppStore();
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
interface LoginModule {
|
||||
label: App.I18n.I18nKey;
|
||||
component: Component;
|
||||
}
|
||||
|
||||
const moduleMap: Record<UnionKey.LoginModule, LoginModule> = {
|
||||
'pwd-login': { label: loginModuleRecord['pwd-login'], component: PwdLogin },
|
||||
'reset-pwd': { label: loginModuleRecord['reset-pwd'], component: ResetPwd }
|
||||
};
|
||||
|
||||
const activeModule = computed(() => moduleMap[props.module || 'pwd-login']);
|
||||
|
||||
const bgThemeColor = computed(() =>
|
||||
themeStore.darkMode ? getPaletteColorByNumber(themeStore.themeColor, 600) : themeStore.themeColor
|
||||
);
|
||||
|
||||
const bgColor = computed(() => {
|
||||
const COLOR_WHITE = '#ffffff';
|
||||
|
||||
const ratio = themeStore.darkMode ? 0.5 : 0.2;
|
||||
|
||||
return mixColor(COLOR_WHITE, themeStore.themeColor, ratio);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative size-full flex-center overflow-hidden" :style="{ backgroundColor: bgColor }">
|
||||
<WaveBg :theme-color="bgThemeColor" />
|
||||
<ElCard class="relative z-4 w-auto rd-12px">
|
||||
<div class="w-400px lt-sm:w-300px">
|
||||
<header class="flex-y-center justify-between">
|
||||
<SystemLogo class="text-64px text-primary lt-sm:text-48px" />
|
||||
<h3 class="text-28px text-primary font-500 lt-sm:text-22px">{{ $t('system.title') }}</h3>
|
||||
<div class="i-flex-col">
|
||||
<ThemeSchemaSwitch
|
||||
:theme-schema="themeStore.themeScheme"
|
||||
:show-tooltip="false"
|
||||
class="text-20px lt-sm:text-18px"
|
||||
@switch="themeStore.toggleThemeScheme"
|
||||
/>
|
||||
<LangSwitch
|
||||
v-if="themeStore.header.multilingual.visible"
|
||||
:lang="appStore.locale"
|
||||
:lang-options="appStore.localeOptions"
|
||||
:show-tooltip="false"
|
||||
@change-lang="appStore.changeLocale"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<main class="pt-15px">
|
||||
<div class="pt-15px">
|
||||
<Transition :name="themeStore.page.animateMode" mode="out-in" appear>
|
||||
<component :is="activeModule.component" />
|
||||
</Transition>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
60
src/views/_builtin/login/modules/pwd-login.vue
Normal file
60
src/views/_builtin/login/modules/pwd-login.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'PwdLogin' });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { formRef, validate } = useForm();
|
||||
|
||||
interface FormModel {
|
||||
userName: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const model = ref<FormModel>({
|
||||
userName: 'admin',
|
||||
password: 'admin123'
|
||||
});
|
||||
|
||||
const rules = computed<Record<keyof FormModel, App.Global.FormRule[]>>(() => {
|
||||
// inside computed to make locale ref, if not apply i18n, you can define it without computed
|
||||
const { formRules } = useFormRules();
|
||||
|
||||
return {
|
||||
userName: formRules.userName,
|
||||
password: formRules.pwd
|
||||
};
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
await authStore.login(model.value.userName, model.value.password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" size="large" :show-label="false" @keyup.enter="handleSubmit">
|
||||
<ElFormItem prop="userName">
|
||||
<ElInput v-model="model.userName" :placeholder="$t('page.login.common.userNamePlaceholder')" />
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
v-model="model.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="$t('page.login.common.passwordPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElSpace direction="vertical" :size="24" class="w-full" fill>
|
||||
<ElCheckbox>{{ $t('page.login.pwdLogin.rememberMe') }}</ElCheckbox>
|
||||
<ElButton type="primary" size="large" round block :loading="authStore.loginLoading" @click="handleSubmit">
|
||||
{{ $t('common.confirm') }}
|
||||
</ElButton>
|
||||
</ElSpace>
|
||||
</ElForm>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
80
src/views/_builtin/login/modules/reset-pwd.vue
Normal file
80
src/views/_builtin/login/modules/reset-pwd.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'ResetPwd' });
|
||||
|
||||
const { toggleLoginModule } = useRouterPush();
|
||||
const { formRef, validate } = useForm();
|
||||
|
||||
interface FormModel {
|
||||
phone: string;
|
||||
code: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
const model = ref<FormModel>({
|
||||
phone: '',
|
||||
code: '',
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
type RuleRecord = Partial<Record<keyof FormModel, App.Global.FormRule[]>>;
|
||||
|
||||
const rules = computed<RuleRecord>(() => {
|
||||
const { formRules, createConfirmPwdRule } = useFormRules();
|
||||
|
||||
return {
|
||||
phone: formRules.phone,
|
||||
password: formRules.pwd,
|
||||
confirmPassword: createConfirmPwdRule(model.value.password)
|
||||
};
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
// request to reset password
|
||||
window.$message?.success($t('page.login.common.validateSuccess'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" size="large" :show-label="false" @keyup.enter="handleSubmit">
|
||||
<ElFormItem prop="phone">
|
||||
<ElInput v-model="model.phone" :placeholder="$t('page.login.common.phonePlaceholder')" />
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="code">
|
||||
<ElInput v-model="model.code" :placeholder="$t('page.login.common.codePlaceholder')" />
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="password">
|
||||
<ElInput
|
||||
v-model="model.password"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="$t('page.login.common.passwordPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem prop="confirmPassword">
|
||||
<ElInput
|
||||
v-model="model.confirmPassword"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="$t('page.login.common.confirmPasswordPlaceholder')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElSpace direction="vertical" fill :size="18" class="w-full">
|
||||
<ElButton type="primary" size="large" round @click="handleSubmit">
|
||||
{{ $t('common.confirm') }}
|
||||
</ElButton>
|
||||
<ElButton size="large" round @click="toggleLoginModule('pwd-login')">
|
||||
{{ $t('page.login.common.back') }}
|
||||
</ElButton>
|
||||
</ElSpace>
|
||||
</ElForm>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
7
src/views/function/hide-child/one/index.vue
Normal file
7
src/views/function/hide-child/one/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<LookForward />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
7
src/views/function/hide-child/three/index.vue
Normal file
7
src/views/function/hide-child/three/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<LookForward />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
7
src/views/function/hide-child/two/index.vue
Normal file
7
src/views/function/hide-child/two/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<LookForward />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
24
src/views/function/multi-tab/index.vue
Normal file
24
src/views/function/multi-tab/index.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
const route = useRoute();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const routeQuery = computed(() => JSON.stringify(route.query));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<LookForward>
|
||||
<div>
|
||||
<ElButton @click="routerPushByKey('function_tab')">{{ $t('page.function.multiTab.backTab') }}</ElButton>
|
||||
<div class="py-24px">{{ $t('page.function.multiTab.routeParam') }}: {{ routeQuery }}</div>
|
||||
</div>
|
||||
</LookForward>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
57
src/views/function/request/index.vue
Normal file
57
src/views/function/request/index.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { fetchCustomBackendError } from '@/service/api';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
async function logout() {
|
||||
await fetchCustomBackendError('8888', $t('request.logoutMsg'));
|
||||
}
|
||||
|
||||
async function logoutWithModal() {
|
||||
await fetchCustomBackendError('7777', $t('request.logoutWithModalMsg'));
|
||||
}
|
||||
|
||||
async function refreshToken() {
|
||||
await fetchCustomBackendError('9999', $t('request.tokenExpired'));
|
||||
}
|
||||
|
||||
async function handleRepeatedMessageError() {
|
||||
await Promise.all([
|
||||
fetchCustomBackendError('2222', $t('page.function.request.repeatedErrorMsg1')),
|
||||
fetchCustomBackendError('2222', $t('page.function.request.repeatedErrorMsg1')),
|
||||
fetchCustomBackendError('2222', $t('page.function.request.repeatedErrorMsg1')),
|
||||
fetchCustomBackendError('3333', $t('page.function.request.repeatedErrorMsg2')),
|
||||
fetchCustomBackendError('3333', $t('page.function.request.repeatedErrorMsg2')),
|
||||
fetchCustomBackendError('3333', $t('page.function.request.repeatedErrorMsg2'))
|
||||
]);
|
||||
}
|
||||
|
||||
async function handleRepeatedModalError() {
|
||||
await Promise.all([
|
||||
fetchCustomBackendError('7777', $t('request.logoutWithModalMsg')),
|
||||
fetchCustomBackendError('7777', $t('request.logoutWithModalMsg')),
|
||||
fetchCustomBackendError('7777', $t('request.logoutWithModalMsg'))
|
||||
]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSpace direction="vertical" fill :size="16">
|
||||
<ElCard :header="$t('request.logout')" class="card-wrapper">
|
||||
<ElButton @click="logout">{{ $t('common.trigger') }}</ElButton>
|
||||
</ElCard>
|
||||
<ElCard :header="$t('request.logoutWithModal')" class="card-wrapper">
|
||||
<ElButton @click="logoutWithModal">{{ $t('common.trigger') }}</ElButton>
|
||||
</ElCard>
|
||||
<ElCard :header="$t('request.refreshToken')" class="card-wrapper">
|
||||
<ElButton @click="refreshToken">{{ $t('common.trigger') }}</ElButton>
|
||||
</ElCard>
|
||||
<ElCard :header="$t('page.function.request.repeatedErrorOccurOnce')" class="card-wrapper">
|
||||
<ElButton @click="handleRepeatedMessageError">{{ $t('page.function.request.repeatedError') }}(Message)</ElButton>
|
||||
<ElButton class="ml-12px" @click="handleRepeatedModalError">
|
||||
{{ $t('page.function.request.repeatedError') }}(Modal)
|
||||
</ElButton>
|
||||
</ElCard>
|
||||
</ElSpace>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
7
src/views/function/super-page/index.vue
Normal file
7
src/views/function/super-page/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<LookForward />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
62
src/views/function/tab/index.vue
Normal file
62
src/views/function/tab/index.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { useTabStore } from '@/store/modules/tab';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'TabPage' });
|
||||
|
||||
const tabStore = useTabStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const tabLabel = ref('');
|
||||
|
||||
function changeTabLabel() {
|
||||
tabStore.setTabLabel(tabLabel.value);
|
||||
}
|
||||
|
||||
function resetTabLabel() {
|
||||
tabStore.resetTabLabel();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSpace direction="vertical" fill :size="16">
|
||||
<ElCard :header="$t('page.function.tab.tabOperate.title')" class="card-wrapper">
|
||||
<ElDivider content-position="left">{{ $t('page.function.tab.tabOperate.addTab') }}</ElDivider>
|
||||
<ElButton @click="routerPushByKey('system_user')">{{ $t('page.function.tab.tabOperate.addTabDesc') }}</ElButton>
|
||||
<ElDivider content-position="left">{{ $t('page.function.tab.tabOperate.closeTab') }}</ElDivider>
|
||||
<ElSpace>
|
||||
<ElButton @click="tabStore.removeActiveTab">
|
||||
{{ $t('page.function.tab.tabOperate.closeCurrentTab') }}
|
||||
</ElButton>
|
||||
<ElButton @click="tabStore.removeTabByRouteName('system_user')">
|
||||
{{ $t('page.function.tab.tabOperate.closeAboutTab') }}
|
||||
</ElButton>
|
||||
</ElSpace>
|
||||
<ElDivider content-position="left">{{ $t('page.function.tab.tabOperate.addMultiTab') }}</ElDivider>
|
||||
<ElSpace>
|
||||
<ElButton @click="routerPushByKey('function_multi-tab')">
|
||||
{{ $t('page.function.tab.tabOperate.addMultiTabDesc1') }}
|
||||
</ElButton>
|
||||
<ElButton @click="routerPushByKey('function_multi-tab', { query: { a: '1' } })">
|
||||
{{ $t('page.function.tab.tabOperate.addMultiTabDesc2') }}
|
||||
</ElButton>
|
||||
</ElSpace>
|
||||
</ElCard>
|
||||
<ElCard :header="$t('page.function.tab.tabTitle.title')" class="card-wrapper">
|
||||
<ElDivider content-position="left">{{ $t('page.function.tab.tabTitle.changeTitle') }}</ElDivider>
|
||||
<ElInput v-model="tabLabel" class="max-w-240px">
|
||||
<template #append>
|
||||
<ElButton type="primary" @click="changeTabLabel">{{ $t('page.function.tab.tabTitle.change') }}</ElButton>
|
||||
</template>
|
||||
</ElInput>
|
||||
<ElDivider content-position="left">{{ $t('page.function.tab.tabTitle.resetTitle') }}</ElDivider>
|
||||
<ElButton type="danger" plain class="w-80px" @click="resetTabLabel">
|
||||
{{ $t('page.function.tab.tabTitle.reset') }}
|
||||
</ElButton>
|
||||
</ElCard>
|
||||
</ElSpace>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
99
src/views/function/toggle-auth/index.vue
Normal file
99
src/views/function/toggle-auth/index.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useLoading } from '@sa/hooks';
|
||||
import { useAppStore } from '@/store/modules/app';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useTabStore } from '@/store/modules/tab';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'ToggleAuth' });
|
||||
|
||||
const route = useRoute();
|
||||
const appStore = useAppStore();
|
||||
const authStore = useAuthStore();
|
||||
const tabStore = useTabStore();
|
||||
const { hasAuth } = useAuth();
|
||||
const { loading, startLoading, endLoading } = useLoading();
|
||||
|
||||
type AccountKey = 'super' | 'admin' | 'user';
|
||||
|
||||
interface Account {
|
||||
key: AccountKey;
|
||||
label: string;
|
||||
userName: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const accounts = computed<Account[]>(() => [
|
||||
{
|
||||
key: 'super',
|
||||
label: $t('page.login.pwdLogin.superAdmin'),
|
||||
userName: 'Super',
|
||||
password: '123456'
|
||||
},
|
||||
{
|
||||
key: 'admin',
|
||||
label: $t('page.login.pwdLogin.admin'),
|
||||
userName: 'Admin',
|
||||
password: '123456'
|
||||
},
|
||||
{
|
||||
key: 'user',
|
||||
label: $t('page.login.pwdLogin.user'),
|
||||
userName: 'User',
|
||||
password: '123456'
|
||||
}
|
||||
]);
|
||||
|
||||
const loginAccount = ref<AccountKey>('super');
|
||||
|
||||
async function handleToggleAccount(account: Account) {
|
||||
loginAccount.value = account.key;
|
||||
|
||||
startLoading();
|
||||
await authStore.login(account.userName, account.password, false);
|
||||
tabStore.initTabStore(route);
|
||||
endLoading();
|
||||
appStore.reloadPage();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSpace direction="vertical" fill :size="16">
|
||||
<ElCard :header="$t('route.function_toggle-auth')" class="card-wrapper">
|
||||
<ElDescriptions direction="vertical" border :column="1">
|
||||
<ElDescriptionsItem :label="$t('page.system.user.userRole')">
|
||||
<ElSpace>
|
||||
<ElTag v-for="role in authStore.userInfo.roles" :key="role">{{ role }}</ElTag>
|
||||
</ElSpace>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem ions-item :label="$t('page.function.toggleAuth.toggleAccount')">
|
||||
<ElSpace>
|
||||
<ElButton
|
||||
v-for="account in accounts"
|
||||
:key="account.key"
|
||||
:loading="loading && loginAccount === account.key"
|
||||
:disabled="loading && loginAccount !== account.key"
|
||||
@click="handleToggleAccount(account)"
|
||||
>
|
||||
{{ account.label }}
|
||||
</ElButton>
|
||||
</ElSpace>
|
||||
</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
</ElCard>
|
||||
<ElCard :header="$t('page.function.toggleAuth.authHook')" class="card-wrapper">
|
||||
<ElSpace>
|
||||
<ElButton v-if="hasAuth('B_CODE1')">{{ $t('page.function.toggleAuth.superAdminVisible') }}</ElButton>
|
||||
<ElButton v-if="hasAuth('B_CODE2')">{{ $t('page.function.toggleAuth.adminVisible') }}</ElButton>
|
||||
<ElButton v-if="hasAuth('B_CODE3')">
|
||||
{{ $t('page.function.toggleAuth.adminOrUserVisible') }}
|
||||
</ElButton>
|
||||
</ElSpace>
|
||||
</ElCard>
|
||||
</ElSpace>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
116
src/views/plugin/barcode/index.vue
Normal file
116
src/views/plugin/barcode/index.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue';
|
||||
import JsBarcode from 'jsbarcode';
|
||||
import type { Options } from 'jsbarcode';
|
||||
|
||||
defineOptions({ name: 'BarcodePage' });
|
||||
|
||||
const text = 'CN-RDMS';
|
||||
|
||||
interface CodeConfig {
|
||||
id: string;
|
||||
title: string;
|
||||
text: string;
|
||||
options: Options;
|
||||
}
|
||||
|
||||
const codes: CodeConfig[] = [
|
||||
{
|
||||
id: 'code39',
|
||||
title: 'CODE 39 正常尺寸',
|
||||
text: 'Hello',
|
||||
options: { format: 'code39' }
|
||||
},
|
||||
{
|
||||
id: 'code128',
|
||||
title: 'CODE 128 正常尺寸',
|
||||
text,
|
||||
options: {}
|
||||
},
|
||||
{
|
||||
id: 'ean-13',
|
||||
title: 'ENA-13 商品条形码',
|
||||
text: '1234567890128',
|
||||
options: { format: 'ean13' }
|
||||
},
|
||||
{
|
||||
id: 'upc-a',
|
||||
title: 'UPC-A 商品条形码',
|
||||
text: '123456789012',
|
||||
options: { format: 'upc' }
|
||||
},
|
||||
{
|
||||
id: 'barcode',
|
||||
title: '不一样的高度,不一样的颜色',
|
||||
text: 'Hello',
|
||||
options: {
|
||||
height: 30,
|
||||
lineColor: '#9ca3af'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'barcode1',
|
||||
title: '加个背景色',
|
||||
text,
|
||||
options: {
|
||||
background: '#9ca3af',
|
||||
lineColor: '#ffffff'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'barcode2',
|
||||
title: '字体好大',
|
||||
text,
|
||||
options: {
|
||||
fontSize: 40
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'barcode3',
|
||||
title: '粗狂的条码,文字离远点',
|
||||
text: 'Hi',
|
||||
options: {
|
||||
textMargin: 30,
|
||||
width: 4
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'barcode4',
|
||||
title: '字体跑上面来,还是粗体',
|
||||
text,
|
||||
options: {
|
||||
textPosition: 'top',
|
||||
fontOptions: 'bold'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
function generateBarcode() {
|
||||
codes.forEach(code => {
|
||||
JsBarcode(`#${code.id}`, code.text, code.options);
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
generateBarcode();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<ElCard header="条形码" class="h-full card-wrapper">
|
||||
<ElScrollbar class="h-full">
|
||||
<ElRow :gutter="12" class="w-[calc(100%-12px)]">
|
||||
<ElCol v-for="item in codes" :key="item.id" :lg="8" :md="12" :sm="24" class="mb-24px">
|
||||
<div class="flex-col-center">
|
||||
<h3>{{ item.title }}</h3>
|
||||
<svg :id="item.id" class="h-130px" />
|
||||
</div>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElScrollbar>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
60
src/views/plugin/charts/antv/data.ts
Normal file
60
src/views/plugin/charts/antv/data.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { CustomGraphData } from './modules/types';
|
||||
|
||||
// 日期可以自己随便设置,就是字符串展示,也可以修改为业务需要的字段
|
||||
export function getFlowData(): CustomGraphData {
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
id: 'NS',
|
||||
name: 'Start',
|
||||
status: 'COMPLETED',
|
||||
startDate: '2024-10-01',
|
||||
endDate: '2024-10-07',
|
||||
actualStartDate: '2024-10-01',
|
||||
actualEndDate: '2024-10-07'
|
||||
},
|
||||
{
|
||||
id: 'N1',
|
||||
name: 'Node1',
|
||||
status: 'COMPLETED_EARLY',
|
||||
startDate: '2024-10-08',
|
||||
endDate: '2024-10-10',
|
||||
actualStartDate: '2024-10-08',
|
||||
actualEndDate: '2024-10-09',
|
||||
milestone: true
|
||||
},
|
||||
{
|
||||
id: 'N2',
|
||||
name: 'Node2',
|
||||
status: 'COMPLETED_EARLY',
|
||||
startDate: '2024-10-11',
|
||||
endDate: '2024-10-13',
|
||||
actualStartDate: '2024-10-11',
|
||||
actualEndDate: '2024-10-12'
|
||||
},
|
||||
{ id: 'N3', name: 'Node3', status: 'IN_PROGRESS', isDeleted: true },
|
||||
{ id: 'N4', name: 'Node4', status: 'COMPLETED_LATE' },
|
||||
{ id: 'N5', name: 'Node5', status: 'DELAYED', isDelayed: true, milestone: true },
|
||||
{ id: 'N6', name: 'Node6', status: 'PAUSED' },
|
||||
{ id: 'N7', name: 'Node7', status: 'NOT_STARTED' },
|
||||
{ id: 'N8', name: 'Node8', status: 'NOT_STARTED' },
|
||||
{ id: 'N9', name: 'End', status: 'NOT_STARTED' },
|
||||
{ id: 'NX', name: 'NodeX', status: 'NOT_STARTED', isDeleted: true }
|
||||
],
|
||||
edges: [
|
||||
{ id: 'E1', source: 'NS', target: 'N1' },
|
||||
{ id: 'E2', source: 'N1', target: 'N2' },
|
||||
{ id: 'E3', source: 'N1', target: 'N3', isDeleted: true },
|
||||
{ id: 'E4', source: 'N1', target: 'N4' },
|
||||
{ id: 'E5', source: 'N2', target: 'N5' },
|
||||
{ id: 'E6', source: 'N3', target: 'N5', isDeleted: true },
|
||||
{ id: 'E7', source: 'N4', target: 'N5' },
|
||||
{ id: 'E8', source: 'N5', target: 'N6' },
|
||||
{ id: 'E9', source: 'N6', target: 'N7' },
|
||||
{ id: 'E10', source: 'N6', target: 'N8' },
|
||||
{ id: 'E11', source: 'N7', target: 'N9' },
|
||||
{ id: 'EX', source: 'N8', target: 'N9' },
|
||||
{ id: 'EO', source: 'N5', target: 'NX', isDeleted: true }
|
||||
]
|
||||
};
|
||||
}
|
||||
67
src/views/plugin/charts/antv/index.vue
Normal file
67
src/views/plugin/charts/antv/index.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, onMounted, ref, useTemplateRef } from 'vue';
|
||||
import type { Ref } from 'vue';
|
||||
import type { CustomBehaviorOption, IPointerEvent } from '@antv/g6';
|
||||
import AntvFlow from './modules/antv-flow.vue';
|
||||
import type { CustomGraphData } from './modules/types';
|
||||
import { getFlowData } from './data';
|
||||
|
||||
defineOptions({ name: 'AntVCharts' });
|
||||
|
||||
const antvFlowRef = useTemplateRef('antvFlowRef');
|
||||
|
||||
const flowData = ref({
|
||||
nodes: [],
|
||||
edges: []
|
||||
}) as Ref<CustomGraphData>;
|
||||
|
||||
const selectedNode = ref<string | undefined>('N2');
|
||||
|
||||
const behaviors: CustomBehaviorOption[] = [
|
||||
{
|
||||
type: 'click-select',
|
||||
enable: (event: IPointerEvent) => event.targetType === 'node',
|
||||
onClick: (event: IPointerEvent) => {
|
||||
const node = event.target as unknown as HTMLElement;
|
||||
const nodeData = flowData.value.nodes.find(item => item.id === node.id);
|
||||
selectedNode.value = nodeData?.id;
|
||||
window.$message?.success(`选中节点:[${node.id}]${nodeData?.name}`);
|
||||
}
|
||||
}
|
||||
];
|
||||
const hasNodeN = computed(() => flowData.value.nodes.some(node => node.id === 'NN'));
|
||||
|
||||
function addNode() {
|
||||
const { nodes, edges } = flowData.value;
|
||||
|
||||
nodes.push({ id: 'NN', name: 'New node', status: 'NOT_STARTED' });
|
||||
edges.push({ id: 'EN', source: 'N5', target: 'NN' });
|
||||
flowData.value = { nodes, edges };
|
||||
}
|
||||
|
||||
function removeNode(id: string) {
|
||||
const { nodes, edges } = flowData.value;
|
||||
// 删除node的同时,也需要删除包含NX的edge
|
||||
flowData.value = {
|
||||
nodes: nodes.filter(node => node.id !== id),
|
||||
edges: edges.filter(edge => edge.source !== id && edge.target !== id)
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
flowData.value = getFlowData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<ElCard header="AntV G6 Next" class="h-full card-wrapper">
|
||||
<AntvFlow ref="antvFlowRef" :data="flowData" :selected="selectedNode" :behaviors="behaviors" />
|
||||
<ElDivider />
|
||||
<ElButton @click="selectedNode = 'N5'">选中节点N5(需要自行处理选中事件,不会触发元素点击)</ElButton>
|
||||
<ElButton v-if="!hasNodeN" @click="addNode">添加节点并与Node5连线</ElButton>
|
||||
<ElButton v-else @click="() => removeNode('NN')">删除新添加的节点</ElButton>
|
||||
<ElButton @click="() => removeNode('NX')">删除NodeX</ElButton>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
135
src/views/plugin/charts/antv/modules/antv-flow.vue
Normal file
135
src/views/plugin/charts/antv/modules/antv-flow.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<script setup lang="tsx">
|
||||
import { shallowRef, useTemplateRef, watch } from 'vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { vResizeObserver } from '@vueuse/components';
|
||||
import type { CustomBehaviorOption, Graph } from '@antv/g6';
|
||||
import { useAntFlow } from './antv-g6-flow';
|
||||
import { nodeStatus } from './status';
|
||||
import type { CustomGraphData } from './types';
|
||||
|
||||
defineOptions({ name: 'AntvFLow' });
|
||||
|
||||
interface Props {
|
||||
behaviors?: CustomBehaviorOption[];
|
||||
data: CustomGraphData;
|
||||
selected?: string;
|
||||
height?: string;
|
||||
autoFit?: 'view' | 'center';
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const containerRef = useTemplateRef('containerRef');
|
||||
const graphRef = shallowRef<Graph | null>(null);
|
||||
|
||||
// 监听容器尺寸变化,调整画布大小为图容器大小
|
||||
const onContainerResize = useDebounceFn(() => {
|
||||
if (graphRef.value) {
|
||||
graphRef.value.resize();
|
||||
}
|
||||
}, 5);
|
||||
|
||||
async function draw() {
|
||||
if (graphRef.value) {
|
||||
graphRef.value.destroy();
|
||||
}
|
||||
const { graph } = useAntFlow({
|
||||
container: 'antv-flow',
|
||||
data: props.data,
|
||||
behaviors: props.behaviors,
|
||||
autoFit: props.autoFit
|
||||
});
|
||||
graphRef.value = graph;
|
||||
await selectNode();
|
||||
}
|
||||
|
||||
async function selectNode() {
|
||||
if (props.selected && graphRef.value) {
|
||||
try {
|
||||
await graphRef.value.setElementState(props.selected, 'selected');
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
graphRef.value?.zoomBy(0.9);
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
graphRef.value?.zoomBy(1.1);
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
graphRef.value?.zoomTo(1);
|
||||
graphRef.value?.fitCenter();
|
||||
}
|
||||
|
||||
function fitZoom() {
|
||||
graphRef.value?.fitView();
|
||||
graphRef.value?.fitCenter();
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.data, () => props.selected],
|
||||
() => {
|
||||
draw();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
defineExpose({ selectNode, graph: graphRef });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- canvas toolbar -->
|
||||
<div class="absolute left-0 right-0 z-1 flex items-center items-stretch justify-between">
|
||||
<ElButtonGroup size="small" class="bg-white!">
|
||||
<ElButton @click="zoomOut">
|
||||
<icon-mingcute:zoom-out-line />
|
||||
</ElButton>
|
||||
<ElButton @click="zoomIn">
|
||||
<icon-mingcute:zoom-in-line />
|
||||
</ElButton>
|
||||
<ElButton @click="resetZoom">
|
||||
<icon-icon-park-outline:equal-ratio />
|
||||
</ElButton>
|
||||
<ElButton @click="fitZoom">
|
||||
<icon-gg:ratio />
|
||||
</ElButton>
|
||||
</ElButtonGroup>
|
||||
<div class="flex-center gap-12px">
|
||||
<ElPopover placement="bottom-end" :width="200" :animated="false">
|
||||
<template #reference>
|
||||
<ElButton size="small" class="bg-white!">
|
||||
<icon-fe:question />
|
||||
</ElButton>
|
||||
</template>
|
||||
<div class="flex-col gap-8px">
|
||||
<div span="2" class="text-12px font-bold">节点图例</div>
|
||||
<ElRow>
|
||||
<ElCol v-for="(config, status) in nodeStatus" :key="status" :span="12" class="mb-8px flex-center">
|
||||
<ElTag size="small" round :bordered="false">
|
||||
<template #default>
|
||||
<icon-f7:flag-circle-fill v-if="status === 'MILESTONE'" :style="{ color: config.color }" />
|
||||
<icon-f7:circle-fill v-else :style="{ color: config.color }" />
|
||||
{{ config.type }}
|
||||
</template>
|
||||
</ElTag>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</div>
|
||||
</div>
|
||||
<!-- canvas container -->
|
||||
<div
|
||||
id="antv-flow"
|
||||
ref="containerRef"
|
||||
v-resize-observer="onContainerResize"
|
||||
class="w-full"
|
||||
:style="{ height: props.height || '300px' }"
|
||||
@contextmenu="event => event.preventDefault()"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
170
src/views/plugin/charts/antv/modules/antv-g6-flow.ts
Normal file
170
src/views/plugin/charts/antv/modules/antv-g6-flow.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { Graph } from '@antv/g6';
|
||||
import type { CustomBehaviorOption, IPointerEvent } from '@antv/g6';
|
||||
import type { Canvas } from '@antv/g6/lib/runtime/canvas';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { getNodeIcon, nodeStatus } from './status';
|
||||
import type { CustomEdgeData, CustomGraphData, CustomNodeData } from './types';
|
||||
|
||||
interface AntFlowConfig {
|
||||
container: string | HTMLElement | Canvas;
|
||||
data: CustomGraphData;
|
||||
behaviors?: CustomBehaviorOption[];
|
||||
autoFit?: 'view' | 'center';
|
||||
}
|
||||
|
||||
export function useAntFlow(config: AntFlowConfig) {
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
const baseColor = 'rgb(158 163 171)';
|
||||
|
||||
const { container, autoFit = 'center', data, behaviors = [] } = config;
|
||||
|
||||
const graph = new Graph({
|
||||
container,
|
||||
animation: false,
|
||||
padding: 16,
|
||||
theme: 'light',
|
||||
autoFit,
|
||||
data,
|
||||
node: {
|
||||
type: 'rect',
|
||||
|
||||
style: (node: CustomNodeData) => {
|
||||
const iconS = getNodeIcon(node);
|
||||
let labelFill = '#000000';
|
||||
if (node.taskState === 'NOT_STARTED') {
|
||||
labelFill = '#787878';
|
||||
}
|
||||
|
||||
return {
|
||||
labelText: node.name as string,
|
||||
size: [120, 26],
|
||||
radius: 99,
|
||||
fill: '#FFFFFF',
|
||||
stroke: node.isDeleted ? themeStore.otherColor.error : baseColor,
|
||||
lineDash: node.isDeleted ? 4 : 0,
|
||||
lineWidth: 1,
|
||||
labelFill,
|
||||
labelX: 2,
|
||||
labelY: 2,
|
||||
labelTextBaseline: 'middle',
|
||||
labelTextAlign: 'center',
|
||||
labelLineHeight: 13,
|
||||
labelWordWrap: true,
|
||||
labelMaxWidth: 72,
|
||||
iconSrc: iconS,
|
||||
iconWidth: 16,
|
||||
iconHeight: 16,
|
||||
iconX: -45,
|
||||
labelFontSize: 12,
|
||||
labelPlacement: 'center',
|
||||
badgeLineWidth: 6,
|
||||
badgeFontSize: 8,
|
||||
badges: [
|
||||
{ text: '延期', placement: 'top', offsetY: -11, visibility: node.isDelayed ? 'visible' : 'hidden' },
|
||||
{ text: '已删除', placement: 'bottom', offsetY: 11, visibility: node.isDeleted ? 'visible' : 'hidden' }
|
||||
],
|
||||
badgePalette: [themeStore.otherColor.error, themeStore.otherColor.error],
|
||||
ports: [{ placement: 'left' }, { placement: 'right' }]
|
||||
};
|
||||
},
|
||||
state: {
|
||||
selected: {
|
||||
lineWidth: 2,
|
||||
stroke: themeStore.themeColor,
|
||||
labelFill: themeStore.themeColor,
|
||||
halo: true,
|
||||
haloStroke: themeStore.themeColor,
|
||||
haloLineWidth: 6
|
||||
},
|
||||
active: (node: CustomNodeData) => ({
|
||||
halo: true,
|
||||
haloStroke: node.isDeleted ? themeStore.otherColor.error : themeStore.themeColor,
|
||||
haloLineWidth: 6,
|
||||
zIndex: 2
|
||||
})
|
||||
}
|
||||
},
|
||||
edge: {
|
||||
type: 'cubic-horizontal',
|
||||
style: (node: CustomEdgeData) => ({
|
||||
curveOffset: 10,
|
||||
curvePosition: 0.5,
|
||||
stroke: node.isDeleted ? themeStore.otherColor.error : baseColor,
|
||||
lineDash: node.isDeleted ? 4 : 0
|
||||
}),
|
||||
state: {
|
||||
active: (node: CustomEdgeData) => ({
|
||||
lineWidth: 2,
|
||||
stroke: node.isDeleted ? themeStore.otherColor.error : themeStore.themeColor,
|
||||
halo: true,
|
||||
haloStroke: node.isDeleted ? themeStore.otherColor.error : themeStore.themeColor,
|
||||
haloLineWidth: 6,
|
||||
zIndex: 2
|
||||
})
|
||||
}
|
||||
},
|
||||
layout: {
|
||||
type: 'antv-dagre',
|
||||
rankdir: 'LR',
|
||||
ranksep: 20,
|
||||
nodesep: -20,
|
||||
controlPoints: true
|
||||
},
|
||||
behaviors: [
|
||||
{
|
||||
key: 'hover-activate',
|
||||
type: 'hover-activate',
|
||||
degree: 1,
|
||||
direction: 'both'
|
||||
},
|
||||
'drag-canvas',
|
||||
...behaviors
|
||||
],
|
||||
plugins: [
|
||||
{
|
||||
type: 'tooltip',
|
||||
enable: (event: IPointerEvent) => event.targetType === 'node',
|
||||
getContent: (_event: IPointerEvent, items?: CustomNodeData[]) => {
|
||||
let result = '<div style="display: flex; flex-direction: column; gap: 8px;">';
|
||||
|
||||
// 弹出提示可以自定义各种内容,但是这里很奇怪,有的class不跟随unocss的样式
|
||||
items?.forEach(item => {
|
||||
result += `
|
||||
<h3 style="display: flex; align-items: center; gap: 8px;">${item.name}</h3>
|
||||
<div style="display: flex;">
|
||||
<b>状态:</b>
|
||||
<div style="display: flex; gap: 4px;">
|
||||
<img src="${getNodeIcon(item)}" />
|
||||
<span style="font-weight: 400 !important;">${nodeStatus[item.status as keyof typeof nodeStatus].type}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 32px; row-gap: 4px;">
|
||||
<div style="display: flex; flex-direction: column;"><div style="color: rgb(156 163 175);">预计开始</div>
|
||||
<div style="font-weight: 700;">${item.startDate || '-'}</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<div style="color: rgb(156 163 175);">预计结束</div>
|
||||
<div style="font-weight: 700;">${item.endDate || '-'}</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<div style="color: rgb(156 163 175);">实际开始</div>
|
||||
<div style="font-weight: 700;">${item.actualStartDate || '-'}</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<div style="color: rgb(156 163 175);">实际结束</div>
|
||||
<div style="font-weight: 700;">${item.actualEndDate || '-'}</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
result += '</div>';
|
||||
return result;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
graph.render();
|
||||
|
||||
return { graph };
|
||||
}
|
||||
95
src/views/plugin/charts/antv/modules/status.ts
Normal file
95
src/views/plugin/charts/antv/modules/status.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { h } from 'vue';
|
||||
import { ElTag } from 'element-plus';
|
||||
import type { TagProps } from 'element-plus';
|
||||
import type { CustomNodeData, NodeStatus } from './types';
|
||||
|
||||
interface NodeStatusConfig {
|
||||
type: string;
|
||||
color: string;
|
||||
textColor: string;
|
||||
base64: string;
|
||||
flag64: string;
|
||||
}
|
||||
|
||||
export const nodeStatus: Record<NodeStatus, NodeStatusConfig> = {
|
||||
MILESTONE: {
|
||||
type: '里程碑',
|
||||
color: '#5b5b5b',
|
||||
textColor: '',
|
||||
base64: '',
|
||||
flag64: ''
|
||||
},
|
||||
NOT_STARTED: {
|
||||
type: '未开始',
|
||||
color: '#CCCDD0',
|
||||
textColor: '#5b5b5b',
|
||||
base64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNDQ0NERDAiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjgyOCAyMy45MDYtMjMuOTA2YzAtMTMuMDU1LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45NDUgNC4wOTUgMjhjMCAxMy4wNzggMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2Ii8+PC9zdmc+`,
|
||||
flag64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNDQ0NERDAiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjg1MSAyMy45MDYtMjMuOTA2YzAtMTMuMDc4LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45MjIgNC4wOTUgMjhjMCAxMy4wNTUgMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2bS05LjA3LTExLjExYy0uNTg2IDAtMS4xMDItLjUxNS0xLjEwMi0xLjA3N1YxOS44MmMwLTEuMDA4LjQ5Mi0xLjc1OCAxLjQ1My0yLjE4Yy44NDQtLjM3NCAxLjU3LS41ODUgMy4zNzUtLjU4NWM0LjI0MiAwIDYuODkgMi4wODYgMTAuODc1IDIuMDg2YzEuOTIyIDAgMi45My0uNDkzIDMuNTQtLjQ5M2MuNzk2IDAgMS40MDYuNDkzIDEuNDA2IDEuMTcydjExLjU1NWMwIDEuMDU1LS40NDYgMS43MzQtMS40NTQgMi4xOGMtLjg2Ny4zOTgtMS42MTcuNjA5LTMuMzc1LjYwOWMtNC4wNzggMC02LjY4LTIuMDYyLTEwLjg3NS0yLjA2MmMtMS40MyAwLTIuMzY3LjI4LTIuNzg5LjQ2OHY3LjE0OWMwIC41ODYtLjQ0NSAxLjA3OC0xLjA1NCAxLjA3OCIvPjwvc3ZnPg==`
|
||||
},
|
||||
DELAYED: {
|
||||
type: '已延期',
|
||||
color: '#B81111',
|
||||
textColor: '#dccbcb',
|
||||
base64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNCODExMTEiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjgyOCAyMy45MDYtMjMuOTA2YzAtMTMuMDU1LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45NDUgNC4wOTUgMjhjMCAxMy4wNzggMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2Ii8+PC9zdmc+`,
|
||||
flag64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNCODExMTEiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjg1MSAyMy45MDYtMjMuOTA2YzAtMTMuMDc4LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45MjIgNC4wOTUgMjhjMCAxMy4wNTUgMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2bS05LjA3LTExLjExYy0uNTg2IDAtMS4xMDItLjUxNS0xLjEwMi0xLjA3N1YxOS44MmMwLTEuMDA4LjQ5Mi0xLjc1OCAxLjQ1My0yLjE4Yy44NDQtLjM3NCAxLjU3LS41ODUgMy4zNzUtLjU4NWM0LjI0MiAwIDYuODkgMi4wODYgMTAuODc1IDIuMDg2YzEuOTIyIDAgMi45My0uNDkzIDMuNTQtLjQ5M2MuNzk2IDAgMS40MDYuNDkzIDEuNDA2IDEuMTcydjExLjU1NWMwIDEuMDU1LS40NDYgMS43MzQtMS40NTQgMi4xOGMtLjg2Ny4zOTgtMS42MTcuNjA5LTMuMzc1LjYwOWMtNC4wNzggMC02LjY4LTIuMDYyLTEwLjg3NS0yLjA2MmMtMS40MyAwLTIuMzY3LjI4LTIuNzg5LjQ2OHY3LjE0OWMwIC41ODYtLjQ0NSAxLjA3OC0xLjA1NCAxLjA3OCIvPjwvc3ZnPg==`
|
||||
},
|
||||
PAUSED: {
|
||||
type: '已暂停',
|
||||
color: '#0E42D2',
|
||||
textColor: '#dae0f0',
|
||||
base64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiMwRTQyRDIiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjgyOCAyMy45MDYtMjMuOTA2YzAtMTMuMDU1LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45NDUgNC4wOTUgMjhjMCAxMy4wNzggMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2Ii8+PC9zdmc+`,
|
||||
flag64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiMwRTQyRDIiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjg1MSAyMy45MDYtMjMuOTA2YzAtMTMuMDc4LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45MjIgNC4wOTUgMjhjMCAxMy4wNTUgMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2bS05LjA3LTExLjExYy0uNTg2IDAtMS4xMDItLjUxNS0xLjEwMi0xLjA3N1YxOS44MmMwLTEuMDA4LjQ5Mi0xLjc1OCAxLjQ1My0yLjE4Yy44NDQtLjM3NCAxLjU3LS41ODUgMy4zNzUtLjU4NWM0LjI0MiAwIDYuODkgMi4wODYgMTAuODc1IDIuMDg2YzEuOTIyIDAgMi45My0uNDkzIDMuNTQtLjQ5M2MuNzk2IDAgMS40MDYuNDkzIDEuNDA2IDEuMTcydjExLjU1NWMwIDEuMDU1LS40NDYgMS43MzQtMS40NTQgMi4xOGMtLjg2Ny4zOTgtMS42MTcuNjA5LTMuMzc1LjYwOWMtNC4wNzggMC02LjY4LTIuMDYyLTEwLjg3NS0yLjA2MmMtMS40MyAwLTIuMzY3LjI4LTIuNzg5LjQ2OHY3LjE0OWMwIC41ODYtLjQ0NSAxLjA3OC0xLjA1NCAxLjA3OCIvPjwvc3ZnPg==`
|
||||
},
|
||||
IN_PROGRESS: {
|
||||
type: '进行中',
|
||||
color: '#E1BE0D',
|
||||
textColor: '#4f4304',
|
||||
base64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNFMUJFMEQiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjgyOCAyMy45MDYtMjMuOTA2YzAtMTMuMDU1LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45NDUgNC4wOTUgMjhjMCAxMy4wNzggMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2Ii8+PC9zdmc+`,
|
||||
flag64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNFMUJFMEQiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjg1MSAyMy45MDYtMjMuOTA2YzAtMTMuMDc4LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45MjIgNC4wOTUgMjhjMCAxMy4wNTUgMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2bS05LjA3LTExLjExYy0uNTg2IDAtMS4xMDItLjUxNS0xLjEwMi0xLjA3N1YxOS44MmMwLTEuMDA4LjQ5Mi0xLjc1OCAxLjQ1My0yLjE4Yy44NDQtLjM3NCAxLjU3LS41ODUgMy4zNzUtLjU4NWM0LjI0MiAwIDYuODkgMi4wODYgMTAuODc1IDIuMDg2YzEuOTIyIDAgMi45My0uNDkzIDMuNTQtLjQ5M2MuNzk2IDAgMS40MDYuNDkzIDEuNDA2IDEuMTcydjExLjU1NWMwIDEuMDU1LS40NDYgMS43MzQtMS40NTQgMi4xOGMtLjg2Ny4zOTgtMS42MTcuNjA5LTMuMzc1LjYwOWMtNC4wNzggMC02LjY4LTIuMDYyLTEwLjg3NS0yLjA2MmMtMS40MyAwLTIuMzY3LjI4LTIuNzg5LjQ2OHY3LjE0OWMwIC41ODYtLjQ0NSAxLjA3OC0xLjA1NCAxLjA3OCIvPjwvc3ZnPg==`
|
||||
},
|
||||
COMPLETED: {
|
||||
type: '已完成',
|
||||
color: '#33C73D',
|
||||
textColor: '#084e0c',
|
||||
base64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiMzM0M3M0QiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjgyOCAyMy45MDYtMjMuOTA2YzAtMTMuMDU1LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45NDUgNC4wOTUgMjhjMCAxMy4wNzggMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2Ii8+PC9zdmc+`,
|
||||
flag64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiMzM0M3M0QiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjg1MSAyMy45MDYtMjMuOTA2YzAtMTMuMDc4LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45MjIgNC4wOTUgMjhjMCAxMy4wNTUgMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2bS05LjA3LTExLjExYy0uNTg2IDAtMS4xMDItLjUxNS0xLjEwMi0xLjA3N1YxOS44MmMwLTEuMDA4LjQ5Mi0xLjc1OCAxLjQ1My0yLjE4Yy44NDQtLjM3NCAxLjU3LS41ODUgMy4zNzUtLjU4NWM0LjI0MiAwIDYuODkgMi4wODYgMTAuODc1IDIuMDg2YzEuOTIyIDAgMi45My0uNDkzIDMuNTQtLjQ5M2MuNzk2IDAgMS40MDYuNDkzIDEuNDA2IDEuMTcydjExLjU1NWMwIDEuMDU1LS40NDYgMS43MzQtMS40NTQgMi4xOGMtLjg2Ny4zOTgtMS42MTcuNjA5LTMuMzc1LjYwOWMtNC4wNzggMC02LjY4LTIuMDYyLTEwLjg3NS0yLjA2MmMtMS40MyAwLTIuMzY3LjI4LTIuNzg5LjQ2OHY3LjE0OWMwIC41ODYtLjQ0NSAxLjA3OC0xLjA1NCAxLjA3OCIvPjwvc3ZnPg==`
|
||||
},
|
||||
COMPLETED_EARLY: {
|
||||
type: '提前完成',
|
||||
color: '#CCFF99',
|
||||
textColor: '#42681d',
|
||||
base64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNDQ0ZGOTkiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjgyOCAyMy45MDYtMjMuOTA2YzAtMTMuMDU1LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45NDUgNC4wOTUgMjhjMCAxMy4wNzggMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2Ii8+PC9zdmc+`,
|
||||
flag64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNDQ0ZGOTkiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjg1MSAyMy45MDYtMjMuOTA2YzAtMTMuMDc4LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45MjIgNC4wOTUgMjhjMCAxMy4wNTUgMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2bS05LjA3LTExLjExYy0uNTg2IDAtMS4xMDItLjUxNS0xLjEwMi0xLjA3N1YxOS44MmMwLTEuMDA4LjQ5Mi0xLjc1OCAxLjQ1My0yLjE4Yy44NDQtLjM3NCAxLjU3LS41ODUgMy4zNzUtLjU4NWM0LjI0MiAwIDYuODkgMi4wODYgMTAuODc1IDIuMDg2YzEuOTIyIDAgMi45My0uNDkzIDMuNTQtLjQ5M2MuNzk2IDAgMS40MDYuNDkzIDEuNDA2IDEuMTcydjExLjU1NWMwIDEuMDU1LS40NDYgMS43MzQtMS40NTQgMi4xOGMtLjg2Ny4zOTgtMS42MTcuNjA5LTMuMzc1LjYwOWMtNC4wNzggMC02LjY4LTIuMDYyLTEwLjg3NS0yLjA2MmMtMS40MyAwLTIuMzY3LjI4LTIuNzg5LjQ2OHY3LjE0OWMwIC41ODYtLjQ0NSAxLjA3OC0xLjA1NCAxLjA3OCIvPjwvc3ZnPg==`
|
||||
},
|
||||
COMPLETED_LATE: {
|
||||
type: '延期完成',
|
||||
color: '#CC6699',
|
||||
textColor: '#4b092a',
|
||||
base64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNDQzY2OTkiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjgyOCAyMy45MDYtMjMuOTA2YzAtMTMuMDU1LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45NDUgNC4wOTUgMjhjMCAxMy4wNzggMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2Ii8+PC9zdmc+`,
|
||||
flag64: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgNTYgNTYiPjxwYXRoIGZpbGw9IiNDQzY2OTkiIGQ9Ik0yOCA1MS45MDZjMTMuMDU1IDAgMjMuOTA2LTEwLjg1MSAyMy45MDYtMjMuOTA2YzAtMTMuMDc4LTEwLjg3NS0yMy45MDYtMjMuOTMtMjMuOTA2QzE0Ljg5OSA0LjA5NCA0LjA5NSAxNC45MjIgNC4wOTUgMjhjMCAxMy4wNTUgMTAuODI4IDIzLjkwNiAyMy45MDYgMjMuOTA2bS05LjA3LTExLjExYy0uNTg2IDAtMS4xMDItLjUxNS0xLjEwMi0xLjA3N1YxOS44MmMwLTEuMDA4LjQ5Mi0xLjc1OCAxLjQ1My0yLjE4Yy44NDQtLjM3NCAxLjU3LS41ODUgMy4zNzUtLjU4NWM0LjI0MiAwIDYuODkgMi4wODYgMTAuODc1IDIuMDg2YzEuOTIyIDAgMi45My0uNDkzIDMuNTQtLjQ5M2MuNzk2IDAgMS40MDYuNDkzIDEuNDA2IDEuMTcydjExLjU1NWMwIDEuMDU1LS40NDYgMS43MzQtMS40NTQgMi4xOGMtLjg2Ny4zOTgtMS42MTcuNjA5LTMuMzc1LjYwOWMtNC4wNzggMC02LjY4LTIuMDYyLTEwLjg3NS0yLjA2MmMtMS40MyAwLTIuMzY3LjI4LTIuNzg5LjQ2OHY3LjE0OWMwIC41ODYtLjQ0NSAxLjA3OC0xLjA1NCAxLjA3OCIvPjwvc3ZnPg==`
|
||||
}
|
||||
};
|
||||
|
||||
export function getNodeIcon(node: CustomNodeData) {
|
||||
if (!node.status) return '';
|
||||
|
||||
const type = node.milestone ? 'flag64' : 'base64';
|
||||
|
||||
return nodeStatus[node.status][type];
|
||||
}
|
||||
|
||||
export function getNodeStatusTag(state: NodeStatus, tagProperty?: TagProps) {
|
||||
const { color, type } = nodeStatus[state] || {};
|
||||
|
||||
return h(
|
||||
ElTag,
|
||||
{
|
||||
color,
|
||||
size: 'small',
|
||||
...tagProperty
|
||||
},
|
||||
{
|
||||
default: () => type
|
||||
}
|
||||
);
|
||||
}
|
||||
28
src/views/plugin/charts/antv/modules/types.ts
Normal file
28
src/views/plugin/charts/antv/modules/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { EdgeData, GraphData, NodeData } from '@antv/g6';
|
||||
|
||||
export type NodeStatus =
|
||||
| 'MILESTONE'
|
||||
| 'NOT_STARTED'
|
||||
| 'DELAYED'
|
||||
| 'PAUSED'
|
||||
| 'IN_PROGRESS'
|
||||
| 'COMPLETED'
|
||||
| 'COMPLETED_EARLY'
|
||||
| 'COMPLETED_LATE';
|
||||
|
||||
export interface CustomNodeData extends NodeData {
|
||||
isDelayed?: boolean;
|
||||
isDeleted?: boolean;
|
||||
milestone?: boolean;
|
||||
status?: NodeStatus;
|
||||
}
|
||||
|
||||
export interface CustomEdgeData extends EdgeData {
|
||||
isDelayed?: boolean;
|
||||
isDeleted?: boolean;
|
||||
}
|
||||
|
||||
export interface CustomGraphData extends GraphData {
|
||||
nodes: CustomNodeData[];
|
||||
edges: CustomEdgeData[];
|
||||
}
|
||||
706
src/views/plugin/charts/echarts/data.ts
Normal file
706
src/views/plugin/charts/echarts/data.ts
Normal file
@@ -0,0 +1,706 @@
|
||||
import { graphic } from 'echarts';
|
||||
import type { ScatterSeriesOption } from 'echarts/charts';
|
||||
import type { SingleAxisComponentOption, TitleComponentOption } from 'echarts/components';
|
||||
import type { ECOption } from '@/hooks/common/echarts';
|
||||
|
||||
export const pieOptions: ECOption = {
|
||||
legend: {},
|
||||
toolbox: {
|
||||
show: true,
|
||||
feature: {
|
||||
mark: { show: true },
|
||||
dataView: { show: true, readOnly: false },
|
||||
restore: { show: true },
|
||||
saveAsImage: { show: true }
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'Nightingale Chart',
|
||||
type: 'pie',
|
||||
radius: [50, 150],
|
||||
center: ['50%', '50%'],
|
||||
roseType: 'area',
|
||||
itemStyle: {
|
||||
borderRadius: 8
|
||||
},
|
||||
data: [
|
||||
{ value: 40, name: 'rose 1' },
|
||||
{ value: 38, name: 'rose 2' },
|
||||
{ value: 32, name: 'rose 3' },
|
||||
{ value: 30, name: 'rose 4' },
|
||||
{ value: 28, name: 'rose 5' },
|
||||
{ value: 26, name: 'rose 6' },
|
||||
{ value: 22, name: 'rose 7' },
|
||||
{ value: 18, name: 'rose 8' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const lineOptions: ECOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
label: {
|
||||
backgroundColor: '#6a7985'
|
||||
}
|
||||
}
|
||||
},
|
||||
title: {
|
||||
text: 'Stacked Line'
|
||||
},
|
||||
legend: {
|
||||
data: ['Email', 'Union Ads', 'Video Ads', 'Direct', 'Search Engine']
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true
|
||||
},
|
||||
toolbox: {
|
||||
feature: {
|
||||
saveAsImage: {}
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
color: '#37a2da',
|
||||
name: 'Email',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
stack: 'Total',
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0.25,
|
||||
color: '#37a2da'
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: '#fff'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
data: [120, 132, 101, 134, 90, 230, 210]
|
||||
},
|
||||
{
|
||||
color: '#9fe6b8',
|
||||
name: 'Union Ads',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
stack: 'Total',
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0.25,
|
||||
color: '#9fe6b8'
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: '#fff'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
data: [220, 182, 191, 234, 290, 330, 310]
|
||||
},
|
||||
{
|
||||
color: '#fedb5c',
|
||||
name: 'Video Ads',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
stack: 'Total',
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0.25,
|
||||
color: '#fedb5c'
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: '#fff'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
data: [150, 232, 201, 154, 190, 330, 410]
|
||||
},
|
||||
{
|
||||
color: '#fb7293',
|
||||
name: 'Direct',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
stack: 'Total',
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0.25,
|
||||
color: '#fb7293'
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: '#fff'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
data: [320, 332, 301, 334, 390, 330, 320]
|
||||
},
|
||||
{
|
||||
color: '#e7bcf3',
|
||||
name: 'Search Engine',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
stack: 'Total',
|
||||
areaStyle: {
|
||||
color: {
|
||||
type: 'linear',
|
||||
x: 0,
|
||||
y: 0,
|
||||
x2: 0,
|
||||
y2: 1,
|
||||
colorStops: [
|
||||
{
|
||||
offset: 0.25,
|
||||
color: '#e7bcf3'
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: '#fff'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
emphasis: {
|
||||
focus: 'series'
|
||||
},
|
||||
data: [820, 932, 901, 934, 1290, 1330, 1320]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const barOptions: ECOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
label: {
|
||||
backgroundColor: '#6a7985'
|
||||
}
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: [
|
||||
{
|
||||
data: [120, 200, 150, 80, 70, 110, 130],
|
||||
type: 'bar',
|
||||
color: '#8378ea',
|
||||
showBackground: true,
|
||||
barGap: 100,
|
||||
itemStyle: {
|
||||
borderRadius: [40, 40, 0, 0]
|
||||
},
|
||||
backgroundStyle: {
|
||||
color: 'rgba(180, 180, 180, 0.2)'
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export function getPictorialBarOption(): ECOption {
|
||||
const category: string[] = [];
|
||||
let dottedBase = Number(new Date());
|
||||
const lineData: number[] = [];
|
||||
const barData: number[] = [];
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
const date = new Date((dottedBase += 3600 * 24 * 1000));
|
||||
category.push([date.getFullYear(), date.getMonth() + 1, date.getDate()].join('-'));
|
||||
const b = Math.random() * 200;
|
||||
const d = Math.random() * 200;
|
||||
barData.push(b);
|
||||
lineData.push(d + b);
|
||||
}
|
||||
|
||||
const options: ECOption = {
|
||||
backgroundColor: '#0f375f',
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
data: ['line', 'bar'],
|
||||
textStyle: {
|
||||
color: '#ccc'
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
data: category,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#ccc'
|
||||
}
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
splitLine: { show: false },
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
color: '#ccc'
|
||||
}
|
||||
}
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: 'line',
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
showAllSymbol: true,
|
||||
symbol: 'emptyCircle',
|
||||
symbolSize: 15,
|
||||
data: lineData
|
||||
},
|
||||
{
|
||||
name: 'bar',
|
||||
type: 'bar',
|
||||
barWidth: 10,
|
||||
itemStyle: {
|
||||
borderRadius: 5,
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: '#14c8d4' },
|
||||
{ offset: 1, color: '#43eec6' }
|
||||
])
|
||||
},
|
||||
data: barData
|
||||
},
|
||||
{
|
||||
name: 'line',
|
||||
type: 'bar',
|
||||
barGap: '-100%',
|
||||
barWidth: 10,
|
||||
itemStyle: {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(20,200,212,0.5)' },
|
||||
{ offset: 0.2, color: 'rgba(20,200,212,0.2)' },
|
||||
{ offset: 1, color: 'rgba(20,200,212,0)' }
|
||||
])
|
||||
},
|
||||
z: -12,
|
||||
data: lineData
|
||||
},
|
||||
{
|
||||
name: 'dotted',
|
||||
type: 'pictorialBar',
|
||||
symbol: 'rect',
|
||||
itemStyle: {
|
||||
color: '#0f375f'
|
||||
},
|
||||
symbolRepeat: true,
|
||||
symbolSize: [12, 4],
|
||||
symbolMargin: 1,
|
||||
z: -10,
|
||||
data: lineData
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
export function getScatterOption() {
|
||||
// prettier-ignore
|
||||
const hours = ['12a', '1a', '2a', '3a', '4a', '5a', '6a', '7a', '8a', '9a','10a','11a', '12p', '1p', '2p', '3p', '4p', '5p', '6p', '7p', '8p', '9p', '10p', '11p'];
|
||||
|
||||
// prettier-ignore
|
||||
const days = ['Saturday', 'Friday', 'Thursday', 'Wednesday', 'Tuesday', 'Monday', 'Sunday'];
|
||||
|
||||
// prettier-ignore
|
||||
const data: [number, number, number][] = [[0,0,5],[0,1,1],[0,2,0],[0,3,0],[0,4,0],[0,5,0],[0,6,0],[0,7,0],[0,8,0],[0,9,0],[0,10,0],[0,11,2],[0,12,4],[0,13,1],[0,14,1],[0,15,3],[0,16,4],[0,17,6],[0,18,4],[0,19,4],[0,20,3],[0,21,3],[0,22,2],[0,23,5],[1,0,7],[1,1,0],[1,2,0],[1,3,0],[1,4,0],[1,5,0],[1,6,0],[1,7,0],[1,8,0],[1,9,0],[1,10,5],[1,11,2],[1,12,2],[1,13,6],[1,14,9],[1,15,11],[1,16,6],[1,17,7],[1,18,8],[1,19,12],[1,20,5],[1,21,5],[1,22,7],[1,23,2],[2,0,1],[2,1,1],[2,2,0],[2,3,0],[2,4,0],[2,5,0],[2,6,0],[2,7,0],[2,8,0],[2,9,0],[2,10,3],[2,11,2],[2,12,1],[2,13,9],[2,14,8],[2,15,10],[2,16,6],[2,17,5],[2,18,5],[2,19,5],[2,20,7],[2,21,4],[2,22,2],[2,23,4],[3,0,7],[3,1,3],[3,2,0],[3,3,0],[3,4,0],[3,5,0],[3,6,0],[3,7,0],[3,8,1],[3,9,0],[3,10,5],[3,11,4],[3,12,7],[3,13,14],[3,14,13],[3,15,12],[3,16,9],[3,17,5],[3,18,5],[3,19,10],[3,20,6],[3,21,4],[3,22,4],[3,23,1],[4,0,1],[4,1,3],[4,2,0],[4,3,0],[4,4,0],[4,5,1],[4,6,0],[4,7,0],[4,8,0],[4,9,2],[4,10,4],[4,11,4],[4,12,2],[4,13,4],[4,14,4],[4,15,14],[4,16,12],[4,17,1],[4,18,8],[4,19,5],[4,20,3],[4,21,7],[4,22,3],[4,23,0],[5,0,2],[5,1,1],[5,2,0],[5,3,3],[5,4,0],[5,5,0],[5,6,0],[5,7,0],[5,8,2],[5,9,0],[5,10,4],[5,11,1],[5,12,5],[5,13,10],[5,14,5],[5,15,7],[5,16,11],[5,17,6],[5,18,0],[5,19,5],[5,20,3],[5,21,4],[5,22,2],[5,23,0],[6,0,1],[6,1,0],[6,2,0],[6,3,0],[6,4,0],[6,5,0],[6,6,0],[6,7,0],[6,8,0],[6,9,0],[6,10,1],[6,11,0],[6,12,2],[6,13,1],[6,14,3],[6,15,4],[6,16,0],[6,17,0],[6,18,0],[6,19,0],[6,20,1],[6,21,2],[6,22,2],[6,23,6]];
|
||||
|
||||
const title: TitleComponentOption[] = [];
|
||||
const singleAxis: SingleAxisComponentOption[] = [];
|
||||
const series: ScatterSeriesOption[] = [];
|
||||
|
||||
days.forEach((day, idx) => {
|
||||
title.push({
|
||||
textBaseline: 'middle',
|
||||
top: `${((idx + 0.5) * 100) / 7}%`,
|
||||
text: day
|
||||
});
|
||||
singleAxis.push({
|
||||
left: 150,
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: hours,
|
||||
top: `${(idx * 100) / 7 + 5}%`,
|
||||
height: `${100 / 7 - 10}%`,
|
||||
axisLabel: {
|
||||
interval: 2
|
||||
}
|
||||
});
|
||||
series.push({
|
||||
singleAxisIndex: idx,
|
||||
coordinateSystem: 'singleAxis',
|
||||
type: 'scatter',
|
||||
data: [],
|
||||
symbolSize(dataItem) {
|
||||
return dataItem[1] * 4;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
data.forEach(dataItem => {
|
||||
(series as any)[dataItem[0]].data.push([dataItem[1], dataItem[2]]);
|
||||
});
|
||||
|
||||
const option: ECOption = {
|
||||
tooltip: {
|
||||
position: 'top'
|
||||
},
|
||||
title,
|
||||
singleAxis,
|
||||
series: series as any
|
||||
};
|
||||
|
||||
return option;
|
||||
}
|
||||
|
||||
export const radarOptions: ECOption = {
|
||||
title: {
|
||||
text: 'Multiple Radar'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
legend: {
|
||||
left: 'center',
|
||||
data: ['A Software', 'A Phone', 'Another Phone', 'Precipitation', 'Evaporation']
|
||||
},
|
||||
radar: [
|
||||
{
|
||||
indicator: [
|
||||
{ name: 'Brand', max: 100 },
|
||||
{ name: 'Content', max: 100 },
|
||||
{ name: 'Usability', max: 100 },
|
||||
{ name: 'Function', max: 100 }
|
||||
],
|
||||
center: ['25%', '40%'],
|
||||
radius: 80
|
||||
},
|
||||
{
|
||||
indicator: [
|
||||
{ name: 'Look', max: 100 },
|
||||
{ name: 'Photo', max: 100 },
|
||||
{ name: 'System', max: 100 },
|
||||
{ name: 'Performance', max: 100 },
|
||||
{ name: 'Screen', max: 100 }
|
||||
],
|
||||
radius: 80,
|
||||
center: ['50%', '60%']
|
||||
},
|
||||
{
|
||||
indicator: (() => {
|
||||
const res = [];
|
||||
for (let i = 1; i <= 12; i += 1) {
|
||||
res.push({ name: `${i}月`, max: 100 });
|
||||
}
|
||||
return res;
|
||||
})(),
|
||||
center: ['75%', '40%'],
|
||||
radius: 80
|
||||
}
|
||||
],
|
||||
series: [
|
||||
{
|
||||
type: 'radar',
|
||||
tooltip: {
|
||||
trigger: 'item'
|
||||
},
|
||||
areaStyle: {},
|
||||
data: [
|
||||
{
|
||||
value: [60, 73, 85, 40],
|
||||
name: 'A Software'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'radar',
|
||||
radarIndex: 1,
|
||||
areaStyle: {},
|
||||
data: [
|
||||
{
|
||||
value: [85, 90, 90, 95, 95],
|
||||
name: 'A Phone'
|
||||
},
|
||||
{
|
||||
value: [95, 80, 95, 90, 93],
|
||||
name: 'Another Phone'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'radar',
|
||||
radarIndex: 2,
|
||||
areaStyle: {},
|
||||
data: [
|
||||
{
|
||||
name: 'Precipitation',
|
||||
value: [2.6, 5.9, 9.0, 26.4, 28.7, 70.7, 75.6, 82.2, 48.7, 18.8, 6.0, 2.3]
|
||||
},
|
||||
{
|
||||
name: 'Evaporation',
|
||||
value: [2.0, 4.9, 7.0, 23.2, 25.6, 76.7, 35.6, 62.2, 32.6, 20.0, 6.4, 3.3]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const gaugeOptions: ECOption = {
|
||||
series: [
|
||||
{
|
||||
name: 'hour',
|
||||
type: 'gauge',
|
||||
startAngle: 90,
|
||||
endAngle: -270,
|
||||
min: 0,
|
||||
max: 12,
|
||||
splitNumber: 12,
|
||||
clockwise: true,
|
||||
axisLine: {
|
||||
lineStyle: {
|
||||
width: 15,
|
||||
color: [[1, 'rgba(0,0,0,0.7)']],
|
||||
shadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
shadowBlur: 15
|
||||
}
|
||||
},
|
||||
splitLine: {
|
||||
lineStyle: {
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
shadowBlur: 3,
|
||||
shadowOffsetX: 1,
|
||||
shadowOffsetY: 2
|
||||
}
|
||||
},
|
||||
axisLabel: {
|
||||
fontSize: 50,
|
||||
distance: 25,
|
||||
formatter(value) {
|
||||
if (value === 0) {
|
||||
return '';
|
||||
}
|
||||
return `${value}`;
|
||||
}
|
||||
},
|
||||
anchor: {
|
||||
show: true,
|
||||
icon: 'path://M532.8,70.8C532.8,70.8,532.8,70.8,532.8,70.8L532.8,70.8C532.7,70.8,532.8,70.8,532.8,70.8z M456.1,49.6c-2.2-6.2-8.1-10.6-15-10.6h-37.5v10.6h37.5l0,0c2.9,0,5.3,2.4,5.3,5.3c0,2.9-2.4,5.3-5.3,5.3v0h-22.5c-1.5,0.1-3,0.4-4.3,0.9c-4.5,1.6-8.1,5.2-9.7,9.8c-0.6,1.7-0.9,3.4-0.9,5.3v16h10.6v-16l0,0l0,0c0-2.7,2.1-5,4.7-5.3h10.3l10.4,21.2h11.8l-10.4-21.2h0c6.9,0,12.8-4.4,15-10.6c0.6-1.7,0.9-3.5,0.9-5.3C457,53,456.7,51.2,456.1,49.6z M388.9,92.1h11.3L381,39h-3.6h-11.3L346.8,92v0h11.3l3.9-10.7h7.3h7.7l3.9-10.6h-7.7h-7.3l7.7-21.2v0L388.9,92.1z M301,38.9h-10.6v53.1H301V70.8h28.4l3.7-10.6H301V38.9zM333.2,38.9v10.6v10.7v31.9h10.6V38.9H333.2z M249.5,81.4L249.5,81.4L249.5,81.4c-2.9,0-5.3-2.4-5.3-5.3h0V54.9h0l0,0c0-2.9,2.4-5.3,5.3-5.3l0,0l0,0h33.6l3.9-10.6h-37.5c-1.9,0-3.6,0.3-5.3,0.9c-4.5,1.6-8.1,5.2-9.7,9.7c-0.6,1.7-0.9,3.5-0.9,5.3l0,0v21.3c0,1.9,0.3,3.6,0.9,5.3c1.6,4.5,5.2,8.1,9.7,9.7c1.7,0.6,3.5,0.9,5.3,0.9h33.6l3.9-10.6H249.5z M176.8,38.9v10.6h49.6l3.9-10.6H176.8z M192.7,81.4L192.7,81.4L192.7,81.4c-2.9,0-5.3-2.4-5.3-5.3l0,0v-5.3h38.9l3.9-10.6h-53.4v10.6v5.3l0,0c0,1.9,0.3,3.6,0.9,5.3c1.6,4.5,5.2,8.1,9.7,9.7c1.7,0.6,3.4,0.9,5.3,0.9h23.4h10.2l3.9-10.6l0,0H192.7z M460.1,38.9v10.6h21.4v42.5h10.6V49.6h17.5l3.8-10.6H460.1z M541.6,68.2c-0.2,0.1-0.4,0.3-0.7,0.4C541.1,68.4,541.4,68.3,541.6,68.2L541.6,68.2z M554.3,60.2h-21.6v0l0,0c-2.9,0-5.3-2.4-5.3-5.3c0-2.9,2.4-5.3,5.3-5.3l0,0l0,0h33.6l3.8-10.6h-37.5l0,0c-6.9,0-12.8,4.4-15,10.6c-0.6,1.7-0.9,3.5-0.9,5.3c0,1.9,0.3,3.7,0.9,5.3c2.2,6.2,8.1,10.6,15,10.6h21.6l0,0c2.9,0,5.3,2.4,5.3,5.3c0,2.9-2.4,5.3-5.3,5.3l0,0h-37.5v10.6h37.5c6.9,0,12.8-4.4,15-10.6c0.6-1.7,0.9-3.5,0.9-5.3c0-1.9-0.3-3.7-0.9-5.3C567.2,64.6,561.3,60.2,554.3,60.2z',
|
||||
showAbove: false,
|
||||
offsetCenter: [0, '-35%'],
|
||||
size: 120,
|
||||
keepAspect: true,
|
||||
itemStyle: {
|
||||
color: '#707177'
|
||||
}
|
||||
},
|
||||
pointer: {
|
||||
icon: 'path://M2.9,0.7L2.9,0.7c1.4,0,2.6,1.2,2.6,2.6v115c0,1.4-1.2,2.6-2.6,2.6l0,0c-1.4,0-2.6-1.2-2.6-2.6V3.3C0.3,1.9,1.4,0.7,2.9,0.7z',
|
||||
width: 12,
|
||||
length: '55%',
|
||||
offsetCenter: [0, '8%'],
|
||||
itemStyle: {
|
||||
color: '#C0911F',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
shadowBlur: 8,
|
||||
shadowOffsetX: 2,
|
||||
shadowOffsetY: 4
|
||||
}
|
||||
},
|
||||
detail: {
|
||||
show: false
|
||||
},
|
||||
title: {
|
||||
offsetCenter: [0, '30%']
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'minute',
|
||||
type: 'gauge',
|
||||
startAngle: 90,
|
||||
endAngle: -270,
|
||||
min: 0,
|
||||
max: 60,
|
||||
clockwise: true,
|
||||
axisLine: {
|
||||
show: false
|
||||
},
|
||||
splitLine: {
|
||||
show: false
|
||||
},
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
show: false
|
||||
},
|
||||
pointer: {
|
||||
icon: 'path://M2.9,0.7L2.9,0.7c1.4,0,2.6,1.2,2.6,2.6v115c0,1.4-1.2,2.6-2.6,2.6l0,0c-1.4,0-2.6-1.2-2.6-2.6V3.3C0.3,1.9,1.4,0.7,2.9,0.7z',
|
||||
width: 8,
|
||||
length: '70%',
|
||||
offsetCenter: [0, '8%'],
|
||||
itemStyle: {
|
||||
color: '#C0911F',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
shadowBlur: 8,
|
||||
shadowOffsetX: 2,
|
||||
shadowOffsetY: 4
|
||||
}
|
||||
},
|
||||
anchor: {
|
||||
show: true,
|
||||
size: 20,
|
||||
showAbove: false,
|
||||
itemStyle: {
|
||||
borderWidth: 15,
|
||||
borderColor: '#C0911F',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
shadowBlur: 8,
|
||||
shadowOffsetX: 2,
|
||||
shadowOffsetY: 4
|
||||
}
|
||||
},
|
||||
detail: {
|
||||
show: false
|
||||
},
|
||||
title: {
|
||||
offsetCenter: ['0%', '-40%']
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'second',
|
||||
type: 'gauge',
|
||||
startAngle: 90,
|
||||
endAngle: -270,
|
||||
min: 0,
|
||||
max: 60,
|
||||
animationEasingUpdate: 'bounceOut',
|
||||
clockwise: true,
|
||||
axisLine: {
|
||||
show: false
|
||||
},
|
||||
splitLine: {
|
||||
show: false
|
||||
},
|
||||
axisTick: {
|
||||
show: false
|
||||
},
|
||||
axisLabel: {
|
||||
show: false
|
||||
},
|
||||
pointer: {
|
||||
icon: 'path://M2.9,0.7L2.9,0.7c1.4,0,2.6,1.2,2.6,2.6v115c0,1.4-1.2,2.6-2.6,2.6l0,0c-1.4,0-2.6-1.2-2.6-2.6V3.3C0.3,1.9,1.4,0.7,2.9,0.7z',
|
||||
width: 4,
|
||||
length: '85%',
|
||||
offsetCenter: [0, '8%'],
|
||||
itemStyle: {
|
||||
color: '#C0911F',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
shadowBlur: 8,
|
||||
shadowOffsetX: 2,
|
||||
shadowOffsetY: 4
|
||||
}
|
||||
},
|
||||
anchor: {
|
||||
show: true,
|
||||
size: 15,
|
||||
showAbove: true,
|
||||
itemStyle: {
|
||||
color: '#C0911F',
|
||||
shadowColor: 'rgba(0, 0, 0, 0.3)',
|
||||
shadowBlur: 8,
|
||||
shadowOffsetX: 2,
|
||||
shadowOffsetY: 4
|
||||
}
|
||||
},
|
||||
detail: {
|
||||
show: false
|
||||
},
|
||||
title: {
|
||||
offsetCenter: ['0%', '-40%']
|
||||
},
|
||||
data: [
|
||||
{
|
||||
value: 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
93
src/views/plugin/charts/echarts/index.vue
Normal file
93
src/views/plugin/charts/echarts/index.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { onUnmounted } from 'vue';
|
||||
import { useEcharts } from '@/hooks/common/echarts';
|
||||
import {
|
||||
barOptions,
|
||||
gaugeOptions,
|
||||
getPictorialBarOption,
|
||||
getScatterOption,
|
||||
lineOptions,
|
||||
pieOptions,
|
||||
radarOptions
|
||||
} from './data';
|
||||
|
||||
defineOptions({ name: 'EchartsDemo' });
|
||||
|
||||
const { domRef: pieRef } = useEcharts(() => pieOptions, { onRender() {} });
|
||||
const { domRef: lineRef } = useEcharts(() => lineOptions, { onRender() {} });
|
||||
const { domRef: barRef } = useEcharts(() => barOptions, { onRender() {} });
|
||||
const { domRef: pictorialBarRef } = useEcharts(() => getPictorialBarOption(), { onRender() {} });
|
||||
const { domRef: radarRef } = useEcharts(() => radarOptions, { onRender() {} });
|
||||
const { domRef: scatterRef } = useEcharts(() => getScatterOption(), { onRender() {} });
|
||||
const { domRef: gaugeRef, setOptions: setGaugeOptions } = useEcharts(() => gaugeOptions, { onRender() {} });
|
||||
|
||||
let intervalId: NodeJS.Timeout;
|
||||
|
||||
function initGaugeChart() {
|
||||
intervalId = setInterval(() => {
|
||||
const date = new Date();
|
||||
const second = date.getSeconds();
|
||||
const minute = date.getMinutes() + second / 60;
|
||||
const hour = (date.getHours() % 12) + minute / 60;
|
||||
|
||||
setGaugeOptions({
|
||||
animationDurationUpdate: 300,
|
||||
series: [
|
||||
{
|
||||
name: 'hour',
|
||||
animation: hour !== 0,
|
||||
data: [{ value: hour }]
|
||||
},
|
||||
{
|
||||
name: 'minute',
|
||||
animation: minute !== 0,
|
||||
data: [{ value: minute }]
|
||||
},
|
||||
{
|
||||
animation: second !== 0,
|
||||
name: 'second',
|
||||
data: [{ value: second }]
|
||||
}
|
||||
]
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function clearGaugeChart() {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
|
||||
initGaugeChart();
|
||||
|
||||
onUnmounted(() => {
|
||||
clearGaugeChart();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSpace fill :size="16">
|
||||
<ElCard class="card-wrapper">
|
||||
<div ref="pieRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard class="card-wrapper">
|
||||
<div ref="lineRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard class="card-wrapper">
|
||||
<div ref="barRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard class="card-wrapper">
|
||||
<div ref="radarRef" class="h-400px"></div>
|
||||
</ElCard>
|
||||
<ElCard class="card-wrapper">
|
||||
<div ref="scatterRef" class="h-600px"></div>
|
||||
</ElCard>
|
||||
<ElCard class="card-wrapper">
|
||||
<div ref="pictorialBarRef" class="h-600px" />
|
||||
</ElCard>
|
||||
<ElCard class="card-wrapper">
|
||||
<div ref="gaugeRef" class="h-640px" />
|
||||
</ElCard>
|
||||
</ElSpace>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
872
src/views/plugin/charts/vchart/data.ts
Normal file
872
src/views/plugin/charts/vchart/data.ts
Normal file
@@ -0,0 +1,872 @@
|
||||
import type {
|
||||
IAnimationConfig,
|
||||
IAreaChartSpec,
|
||||
IBarChartSpec,
|
||||
ICircularProgressChartSpec,
|
||||
IHistogramChartSpec,
|
||||
IIndicatorSpec,
|
||||
ILiquidChartSpec,
|
||||
IWordCloudChartSpec
|
||||
} from '@visactor/vchart';
|
||||
|
||||
export const shapeWordCloudSpec: IWordCloudChartSpec = {
|
||||
type: 'wordCloud',
|
||||
maskShape: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/log.jpeg',
|
||||
nameField: 'challenge_name',
|
||||
valueField: 'sum_count',
|
||||
seriesField: 'challenge_name',
|
||||
data: [
|
||||
{
|
||||
name: 'data',
|
||||
values: [
|
||||
{
|
||||
challenge_name: '刘浩存',
|
||||
sum_count: 957
|
||||
},
|
||||
{
|
||||
challenge_name: '刘昊然',
|
||||
sum_count: 942
|
||||
},
|
||||
{
|
||||
challenge_name: '喜欢',
|
||||
sum_count: 842
|
||||
},
|
||||
{
|
||||
challenge_name: '真的',
|
||||
sum_count: 828
|
||||
},
|
||||
{
|
||||
challenge_name: '四海',
|
||||
sum_count: 665
|
||||
},
|
||||
{
|
||||
challenge_name: '好看',
|
||||
sum_count: 627
|
||||
},
|
||||
{
|
||||
challenge_name: '评论',
|
||||
sum_count: 574
|
||||
},
|
||||
{
|
||||
challenge_name: '好像',
|
||||
sum_count: 564
|
||||
},
|
||||
{
|
||||
challenge_name: '沈腾',
|
||||
sum_count: 554
|
||||
},
|
||||
{
|
||||
challenge_name: '不像',
|
||||
sum_count: 540
|
||||
},
|
||||
{
|
||||
challenge_name: '多少钱',
|
||||
sum_count: 513
|
||||
},
|
||||
{
|
||||
challenge_name: '韩寒',
|
||||
sum_count: 513
|
||||
},
|
||||
{
|
||||
challenge_name: '不知道',
|
||||
sum_count: 499
|
||||
},
|
||||
{
|
||||
challenge_name: '感觉',
|
||||
sum_count: 499
|
||||
},
|
||||
{
|
||||
challenge_name: '尹正',
|
||||
sum_count: 495
|
||||
},
|
||||
{
|
||||
challenge_name: '不看',
|
||||
sum_count: 487
|
||||
},
|
||||
{
|
||||
challenge_name: '奥特之父',
|
||||
sum_count: 484
|
||||
},
|
||||
{
|
||||
challenge_name: '阿姨',
|
||||
sum_count: 482
|
||||
},
|
||||
{
|
||||
challenge_name: '支持',
|
||||
sum_count: 482
|
||||
},
|
||||
{
|
||||
challenge_name: '父母',
|
||||
sum_count: 479
|
||||
},
|
||||
{
|
||||
challenge_name: '一条',
|
||||
sum_count: 462
|
||||
},
|
||||
{
|
||||
challenge_name: '女主',
|
||||
sum_count: 456
|
||||
},
|
||||
{
|
||||
challenge_name: '确实',
|
||||
sum_count: 456
|
||||
},
|
||||
{
|
||||
challenge_name: '票房',
|
||||
sum_count: 456
|
||||
},
|
||||
{
|
||||
challenge_name: '无语',
|
||||
sum_count: 443
|
||||
},
|
||||
{
|
||||
challenge_name: '干干净净',
|
||||
sum_count: 443
|
||||
},
|
||||
{
|
||||
challenge_name: '为啥',
|
||||
sum_count: 426
|
||||
},
|
||||
{
|
||||
challenge_name: '爱情',
|
||||
sum_count: 425
|
||||
},
|
||||
{
|
||||
challenge_name: '喜剧',
|
||||
sum_count: 422
|
||||
},
|
||||
{
|
||||
challenge_name: '春节',
|
||||
sum_count: 414
|
||||
},
|
||||
{
|
||||
challenge_name: '剧情',
|
||||
sum_count: 414
|
||||
},
|
||||
{
|
||||
challenge_name: '人生',
|
||||
sum_count: 409
|
||||
},
|
||||
{
|
||||
challenge_name: '风格',
|
||||
sum_count: 408
|
||||
},
|
||||
{
|
||||
challenge_name: '演员',
|
||||
sum_count: 403
|
||||
},
|
||||
{
|
||||
challenge_name: '成长',
|
||||
sum_count: 403
|
||||
},
|
||||
{
|
||||
challenge_name: '玩意',
|
||||
sum_count: 402
|
||||
},
|
||||
{
|
||||
challenge_name: '文学',
|
||||
sum_count: 397
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const circularProgressTickSpec: ICircularProgressChartSpec & { indicator: IIndicatorSpec } = {
|
||||
type: 'circularProgress',
|
||||
data: [
|
||||
{
|
||||
id: 'id0',
|
||||
values: [
|
||||
{
|
||||
type: 'Tradition Industries',
|
||||
value: 0.795,
|
||||
text: '79.5%'
|
||||
},
|
||||
{
|
||||
type: 'Business Companies',
|
||||
value: 0.5,
|
||||
text: '50%'
|
||||
},
|
||||
{
|
||||
type: 'Customer-facing Companies',
|
||||
value: 0.25,
|
||||
text: '25%'
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
color: ['rgb(255, 222, 0)', 'rgb(171, 205, 5)', 'rgb(0, 154, 68)'],
|
||||
valueField: 'value',
|
||||
categoryField: 'type',
|
||||
seriesField: 'type',
|
||||
radius: 0.8,
|
||||
innerRadius: 0.4,
|
||||
tickMask: {
|
||||
visible: true,
|
||||
angle: 10,
|
||||
offsetAngle: 0,
|
||||
forceAlign: true,
|
||||
style: {
|
||||
cornerRadius: 15
|
||||
}
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
visible: false,
|
||||
type: 'linear',
|
||||
orient: 'angle'
|
||||
},
|
||||
{
|
||||
visible: false,
|
||||
type: 'band',
|
||||
orient: 'radius'
|
||||
}
|
||||
],
|
||||
indicator: {
|
||||
visible: true,
|
||||
trigger: 'hover',
|
||||
title: {
|
||||
visible: true,
|
||||
field: 'type',
|
||||
autoLimit: true,
|
||||
style: {
|
||||
fontSize: 20,
|
||||
fill: 'black'
|
||||
}
|
||||
},
|
||||
content: [
|
||||
{
|
||||
visible: true,
|
||||
field: 'text',
|
||||
style: {
|
||||
fontSize: 16,
|
||||
fill: 'gray'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
legends: {
|
||||
visible: true,
|
||||
orient: 'bottom',
|
||||
title: {
|
||||
visible: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const liquidChartSmartInvertSpec: ILiquidChartSpec & { indicator: IIndicatorSpec } = {
|
||||
type: 'liquid',
|
||||
valueField: 'value',
|
||||
data: {
|
||||
id: 'data',
|
||||
values: [
|
||||
{
|
||||
value: 0.8
|
||||
}
|
||||
]
|
||||
},
|
||||
maskShape: 'drop', // 水滴
|
||||
// maskShape: 'circle',
|
||||
// maskShape: 'star',
|
||||
indicatorSmartInvert: true,
|
||||
indicator: {
|
||||
visible: true,
|
||||
title: {
|
||||
visible: true,
|
||||
style: {
|
||||
text: '进度'
|
||||
}
|
||||
},
|
||||
content: [
|
||||
{
|
||||
visible: true,
|
||||
style: {
|
||||
fill: 'black',
|
||||
text: '80%'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
liquidBackground: {
|
||||
style: {
|
||||
fill: 'blue'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const goldenMedals: Record<number, any[]> = {
|
||||
2000: [
|
||||
{ country: 'USA', value: 37 },
|
||||
{ country: 'Russia', value: 32 },
|
||||
{ country: 'China', value: 28 },
|
||||
{ country: 'Australia', value: 16 },
|
||||
{ country: 'Germany', value: 13 },
|
||||
{ country: 'France', value: 13 },
|
||||
{ country: 'Italy', value: 13 },
|
||||
{ country: 'Netherlands', value: 12 },
|
||||
{ country: 'Cuba', value: 11 },
|
||||
{ country: 'U.K.', value: 11 }
|
||||
],
|
||||
2004: [
|
||||
{ country: 'USA', value: 36 },
|
||||
{ country: 'China', value: 32 },
|
||||
{ country: 'Russia', value: 28 },
|
||||
{ country: 'Australia', value: 17 },
|
||||
{ country: 'Japan', value: 16 },
|
||||
{ country: 'Germany', value: 13 },
|
||||
{ country: 'France', value: 11 },
|
||||
{ country: 'Italy', value: 10 },
|
||||
{ country: 'South Korea', value: 9 },
|
||||
{ country: 'U.K.', value: 9 }
|
||||
],
|
||||
2008: [
|
||||
{ country: 'China', value: 48 },
|
||||
{ country: 'USA', value: 36 },
|
||||
{ country: 'Russia', value: 24 },
|
||||
{ country: 'U.K.', value: 19 },
|
||||
{ country: 'Germany', value: 16 },
|
||||
{ country: 'Australia', value: 14 },
|
||||
{ country: 'South Korea', value: 13 },
|
||||
{ country: 'Japan', value: 9 },
|
||||
{ country: 'Italy', value: 8 },
|
||||
{ country: 'France', value: 7 }
|
||||
],
|
||||
2012: [
|
||||
{ country: 'USA', value: 46 },
|
||||
{ country: 'China', value: 39 },
|
||||
{ country: 'U.K.', value: 29 },
|
||||
{ country: 'Russia', value: 19 },
|
||||
{ country: 'South Korea', value: 13 },
|
||||
{ country: 'Germany', value: 11 },
|
||||
{ country: 'France', value: 11 },
|
||||
{ country: 'Australia', value: 8 },
|
||||
{ country: 'Italy', value: 8 },
|
||||
{ country: 'Hungary', value: 8 }
|
||||
],
|
||||
2016: [
|
||||
{ country: 'USA', value: 46 },
|
||||
{ country: 'U.K.', value: 27 },
|
||||
{ country: 'China', value: 26 },
|
||||
{ country: 'Russia', value: 19 },
|
||||
{ country: 'Germany', value: 17 },
|
||||
{ country: 'Japan', value: 12 },
|
||||
{ country: 'France', value: 10 },
|
||||
{ country: 'South Korea', value: 9 },
|
||||
{ country: 'Italy', value: 8 },
|
||||
{ country: 'Australia', value: 8 }
|
||||
],
|
||||
2020: [
|
||||
{ country: 'USA', value: 39 },
|
||||
{ country: 'China', value: 38 },
|
||||
{ country: 'Japan', value: 27 },
|
||||
{ country: 'U.K.', value: 22 },
|
||||
{ country: 'Russian Olympic Committee', value: 20 },
|
||||
{ country: 'Australia', value: 17 },
|
||||
{ country: 'Netherlands', value: 10 },
|
||||
{ country: 'France', value: 10 },
|
||||
{ country: 'Germany', value: 10 },
|
||||
{ country: 'Italy', value: 10 }
|
||||
]
|
||||
};
|
||||
|
||||
const colors = {
|
||||
China: '#d62728',
|
||||
USA: '#1664FF',
|
||||
Russia: '#B2CFFF',
|
||||
'U.K.': '#1AC6FF',
|
||||
Australia: '#94EFFF',
|
||||
Japan: '#FF8A00',
|
||||
Cuba: '#FFCE7A',
|
||||
Germany: '#3CC780',
|
||||
France: '#B9EDCD',
|
||||
Italy: '#7442D4',
|
||||
'South Korea': '#DDC5FA',
|
||||
'Russian Olympic Committee': '#B2CFFF',
|
||||
Netherlands: '#FFC400',
|
||||
Hungary: '#FAE878'
|
||||
};
|
||||
|
||||
const dataSpecs = Object.keys(goldenMedals).map(year => {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
id: 'id',
|
||||
values: (goldenMedals[year as unknown as number] as any)
|
||||
.sort((a: any, b: any) => b.value - a.value)
|
||||
.map((v: any) => {
|
||||
return { ...v, fill: (colors as any)[v.country] };
|
||||
})
|
||||
},
|
||||
{
|
||||
id: 'year',
|
||||
values: [{ year }]
|
||||
}
|
||||
]
|
||||
};
|
||||
});
|
||||
const duration = 1000;
|
||||
const exchangeDuration = 600;
|
||||
|
||||
export const rankingBarSpec: IBarChartSpec = {
|
||||
type: 'bar',
|
||||
padding: {
|
||||
top: 12,
|
||||
right: 100,
|
||||
bottom: 12
|
||||
},
|
||||
data: dataSpecs[0].data,
|
||||
direction: 'horizontal',
|
||||
yField: 'country',
|
||||
xField: 'value',
|
||||
seriesField: 'country',
|
||||
bar: {
|
||||
style: {
|
||||
fill: (datum: any) => datum.fill
|
||||
}
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
animation: true,
|
||||
orient: 'bottom',
|
||||
type: 'linear',
|
||||
visible: true,
|
||||
max: 50,
|
||||
grid: {
|
||||
visible: true
|
||||
}
|
||||
},
|
||||
{
|
||||
animation: true,
|
||||
id: 'axis-left',
|
||||
orient: 'left',
|
||||
width: 130,
|
||||
tick: { visible: false },
|
||||
label: { visible: true },
|
||||
type: 'band'
|
||||
}
|
||||
],
|
||||
title: {
|
||||
visible: true,
|
||||
text: 'Top 10 Olympic Gold Medals by Country Since 2000'
|
||||
},
|
||||
animationUpdate: {
|
||||
bar: [
|
||||
{
|
||||
type: 'update',
|
||||
options: { excludeChannels: ['y'] },
|
||||
easing: 'linear',
|
||||
duration
|
||||
},
|
||||
{
|
||||
channel: ['y'],
|
||||
easing: 'circInOut',
|
||||
duration: exchangeDuration
|
||||
}
|
||||
],
|
||||
axis: {
|
||||
duration: exchangeDuration,
|
||||
easing: 'circInOut'
|
||||
}
|
||||
} as Record<string, IAnimationConfig>,
|
||||
animationEnter: {
|
||||
bar: [
|
||||
{
|
||||
type: 'moveIn',
|
||||
duration: exchangeDuration,
|
||||
easing: 'circInOut',
|
||||
options: {
|
||||
direction: 'y',
|
||||
orient: 'negative'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
animationExit: {
|
||||
bar: [
|
||||
{
|
||||
type: 'fadeOut',
|
||||
duration: exchangeDuration
|
||||
}
|
||||
]
|
||||
},
|
||||
customMark: [
|
||||
{
|
||||
type: 'text',
|
||||
dataId: 'year',
|
||||
style: {
|
||||
textBaseline: 'bottom',
|
||||
fontSize: 200,
|
||||
textAlign: 'right',
|
||||
fontFamily: 'PingFang SC',
|
||||
fontWeight: 600,
|
||||
text: (datum: any) => datum.year,
|
||||
x: (_datum: any, ctx: any) => {
|
||||
return ctx.vchart.getChart().getCanvasRect()?.width - 50;
|
||||
},
|
||||
y: (_datum: any, ctx: any) => {
|
||||
return ctx.vchart.getChart().getCanvasRect()?.height - 50;
|
||||
},
|
||||
fill: 'grey',
|
||||
fillOpacity: 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
player: {
|
||||
type: 'continuous',
|
||||
orient: 'bottom',
|
||||
auto: true,
|
||||
loop: true,
|
||||
dx: 80,
|
||||
position: 'middle',
|
||||
interval: duration,
|
||||
specs: dataSpecs,
|
||||
slider: {
|
||||
railStyle: {
|
||||
height: 6
|
||||
}
|
||||
},
|
||||
controller: {
|
||||
backward: {
|
||||
style: {
|
||||
size: 12
|
||||
}
|
||||
},
|
||||
forward: {
|
||||
style: {
|
||||
size: 12
|
||||
}
|
||||
},
|
||||
start: {
|
||||
order: 1,
|
||||
position: 'end'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const stackedDashAreaSpec: IAreaChartSpec = {
|
||||
type: 'area',
|
||||
data: {
|
||||
values: [
|
||||
{ month: 'Jan', country: 'Africa', value: 4229 },
|
||||
{ month: 'Jan', country: 'EU', value: 4376 },
|
||||
{ month: 'Jan', country: 'China', value: 3054 },
|
||||
{ month: 'Jan', country: 'USA', value: 12814 },
|
||||
{ month: 'Feb', country: 'Africa', value: 3932 },
|
||||
{ month: 'Feb', country: 'EU', value: 3987 },
|
||||
{ month: 'Feb', country: 'China', value: 5067 },
|
||||
{ month: 'Feb', country: 'USA', value: 13012 },
|
||||
{ month: 'Mar', country: 'Africa', value: 5221 },
|
||||
{ month: 'Mar', country: 'EU', value: 3574 },
|
||||
{ month: 'Mar', country: 'China', value: 7004 },
|
||||
{ month: 'Mar', country: 'USA', value: 11624 },
|
||||
{ month: 'Apr', country: 'Africa', value: 9256 },
|
||||
{ month: 'Apr', country: 'EU', value: 4376 },
|
||||
{ month: 'Apr', country: 'China', value: 9054 },
|
||||
{ month: 'Apr', country: 'USA', value: 8814 },
|
||||
{ month: 'May', country: 'Africa', value: 3308 },
|
||||
{ month: 'May', country: 'EU', value: 4572 },
|
||||
{ month: 'May', country: 'China', value: 12043 },
|
||||
{ month: 'May', country: 'USA', value: 12998 },
|
||||
{ month: 'Jun', country: 'Africa', value: 5432 },
|
||||
{ month: 'Jun', country: 'EU', value: 3417 },
|
||||
{ month: 'Jun', country: 'China', value: 15067 },
|
||||
{ month: 'Jun', country: 'USA', value: 12321 },
|
||||
{ month: 'Jul', country: 'Africa', value: 13701 },
|
||||
{ month: 'Jul', country: 'EU', value: 5231 },
|
||||
{ month: 'Jul', country: 'China', value: 10119 },
|
||||
{ month: 'Jul', country: 'USA', value: 10342 },
|
||||
{ month: 'Aug', country: 'Africa', value: 4008, forecast: true },
|
||||
{ month: 'Aug', country: 'EU', value: 4572, forecast: true },
|
||||
{ month: 'Aug', country: 'China', value: 12043, forecast: true },
|
||||
{ month: 'Aug', country: 'USA', value: 22998, forecast: true },
|
||||
{ month: 'Sept', country: 'Africa', value: 18712, forecast: true },
|
||||
{ month: 'Sept', country: 'EU', value: 6134, forecast: true },
|
||||
{ month: 'Sept', country: 'China', value: 10419, forecast: true },
|
||||
{ month: 'Sept', country: 'USA', value: 11261, forecast: true }
|
||||
]
|
||||
},
|
||||
stack: true,
|
||||
xField: 'month',
|
||||
yField: 'value',
|
||||
seriesField: 'country',
|
||||
point: {
|
||||
style: {
|
||||
size: 0
|
||||
},
|
||||
state: {
|
||||
dimension_hover: {
|
||||
size: 10,
|
||||
outerBorder: {
|
||||
distance: 0,
|
||||
lineWidth: 6,
|
||||
strokeOpacity: 0.2
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
line: {
|
||||
style: {
|
||||
// Configure the lineDash attribute based on the forecast field value of the data
|
||||
lineDash: (data: any) => {
|
||||
if (data.forecast) {
|
||||
return [5, 5];
|
||||
}
|
||||
return [0];
|
||||
}
|
||||
}
|
||||
},
|
||||
area: {
|
||||
style: {
|
||||
fillOpacity: 0.5,
|
||||
textureColor: '#fff',
|
||||
textureSize: 14,
|
||||
// Configure the texture attribute based on the forecast field value of the data
|
||||
texture: (data: any) => {
|
||||
if (data.forecast) {
|
||||
return 'bias-rl';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
},
|
||||
legends: [{ visible: true, position: 'middle', orient: 'bottom' }],
|
||||
crosshair: {
|
||||
xField: {
|
||||
visible: true,
|
||||
line: {
|
||||
type: 'line'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const barMarkPointSpec: IBarChartSpec = {
|
||||
type: 'bar',
|
||||
height: 300,
|
||||
data: [
|
||||
{
|
||||
id: 'barData',
|
||||
values: [
|
||||
{ time: '10:20', cost: 2 },
|
||||
{ time: '10:30', cost: 1 },
|
||||
{ time: '10:40', cost: 1 },
|
||||
{ time: '10:50', cost: 2 },
|
||||
{ time: '11:00', cost: 2 },
|
||||
{ time: '11:10', cost: 2 },
|
||||
{ time: '11:20', cost: 1 },
|
||||
{ time: '11:30', cost: 1 },
|
||||
{ time: '11:40', cost: 2 },
|
||||
{ time: '11:50', cost: 1 }
|
||||
]
|
||||
}
|
||||
],
|
||||
xField: 'time',
|
||||
yField: 'cost',
|
||||
crosshair: {
|
||||
xField: {
|
||||
visible: true,
|
||||
line: {
|
||||
type: 'rect',
|
||||
style: {
|
||||
fill: 'rgb(85,208,93)',
|
||||
fillOpacity: 0.1
|
||||
}
|
||||
},
|
||||
bindingAxesIndex: [1],
|
||||
defaultSelect: {
|
||||
axisIndex: 1,
|
||||
datum: '10:20'
|
||||
}
|
||||
}
|
||||
},
|
||||
label: {
|
||||
visible: true,
|
||||
animation: false,
|
||||
formatMethod: (datum: any) => `${datum}分钟`,
|
||||
style: {
|
||||
fill: 'rgb(155,155,155)'
|
||||
}
|
||||
},
|
||||
bar: {
|
||||
style: {
|
||||
fill: 'rgb(85,208,93)',
|
||||
cornerRadius: [4, 4, 0, 0],
|
||||
width: 30
|
||||
}
|
||||
},
|
||||
markPoint: [
|
||||
{
|
||||
coordinate: {
|
||||
time: '10:20',
|
||||
cost: 2
|
||||
},
|
||||
itemContent: {
|
||||
type: 'text',
|
||||
// autoRotate: false,
|
||||
offsetY: -10,
|
||||
text: {
|
||||
dy: 14,
|
||||
text: '2分钟',
|
||||
style: {
|
||||
fill: 'white',
|
||||
fontSize: 14
|
||||
},
|
||||
labelBackground: {
|
||||
padding: [5, 10, 5, 10],
|
||||
style: {
|
||||
fill: '#000',
|
||||
cornerRadius: 5
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
itemLine: {
|
||||
endSymbol: {
|
||||
visible: true,
|
||||
style: {
|
||||
angle: Math.PI,
|
||||
scaleY: 0.4,
|
||||
fill: '#000',
|
||||
dy: 4,
|
||||
stroke: '#000'
|
||||
}
|
||||
},
|
||||
startSymbol: { visible: false },
|
||||
line: {
|
||||
style: {
|
||||
visible: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
animationUpdate: false,
|
||||
axes: [
|
||||
{
|
||||
orient: 'left',
|
||||
max: 10,
|
||||
label: { visible: false },
|
||||
grid: {
|
||||
style: { lineDash: [4, 4] }
|
||||
}
|
||||
},
|
||||
{
|
||||
orient: 'bottom',
|
||||
label: {
|
||||
formatMethod: (datum: any) => {
|
||||
return datum === '10:20' ? '当前' : datum;
|
||||
},
|
||||
style: (datum: any) => {
|
||||
return {
|
||||
fontSize: datum === '10:20' ? 14 : 12,
|
||||
fill: datum === '10:20' ? 'black' : 'grey'
|
||||
};
|
||||
}
|
||||
},
|
||||
paddingOuter: 0.5,
|
||||
paddingInner: 0,
|
||||
grid: {
|
||||
visible: true,
|
||||
alignWithLabel: false,
|
||||
style: { lineDash: [4, 4] }
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const histogramDifferentBinSpec: IHistogramChartSpec = {
|
||||
type: 'histogram',
|
||||
xField: 'from',
|
||||
x2Field: 'to',
|
||||
yField: 'profit',
|
||||
seriesField: 'type',
|
||||
bar: {
|
||||
style: {
|
||||
stroke: 'white',
|
||||
lineWidth: 1
|
||||
}
|
||||
},
|
||||
title: {
|
||||
text: 'Profit',
|
||||
textStyle: {
|
||||
align: 'center',
|
||||
height: 50,
|
||||
lineWidth: 3,
|
||||
fill: '#333',
|
||||
fontSize: 25,
|
||||
fontFamily: 'Times New Roman'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
visible: true,
|
||||
mark: {
|
||||
title: {
|
||||
key: 'title',
|
||||
value: 'profit'
|
||||
},
|
||||
content: [
|
||||
{
|
||||
key: (datum?: Record<string, any>) => `${datum?.from}~${datum?.to}`,
|
||||
value: (datum?: Record<string, any>) => datum?.profit
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
axes: [
|
||||
{
|
||||
orient: 'bottom',
|
||||
nice: false
|
||||
}
|
||||
],
|
||||
data: [
|
||||
{
|
||||
name: 'data1',
|
||||
values: [
|
||||
{
|
||||
from: 0,
|
||||
to: 10,
|
||||
profit: 2,
|
||||
type: 'A'
|
||||
},
|
||||
{
|
||||
from: 10,
|
||||
to: 16,
|
||||
profit: 3,
|
||||
type: 'B'
|
||||
},
|
||||
{
|
||||
from: 16,
|
||||
to: 18,
|
||||
profit: 15,
|
||||
type: 'C'
|
||||
},
|
||||
{
|
||||
from: 18,
|
||||
to: 26,
|
||||
profit: 12,
|
||||
type: 'D'
|
||||
},
|
||||
{
|
||||
from: 26,
|
||||
to: 32,
|
||||
profit: 22,
|
||||
type: 'E'
|
||||
},
|
||||
{
|
||||
from: 32,
|
||||
to: 56,
|
||||
profit: 7,
|
||||
type: 'F'
|
||||
},
|
||||
{
|
||||
from: 56,
|
||||
to: 62,
|
||||
profit: 17,
|
||||
type: 'G'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
51
src/views/plugin/charts/vchart/index.vue
Normal file
51
src/views/plugin/charts/vchart/index.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { useVChart } from '@/hooks/common/vchart';
|
||||
import {
|
||||
barMarkPointSpec,
|
||||
circularProgressTickSpec,
|
||||
histogramDifferentBinSpec,
|
||||
liquidChartSmartInvertSpec,
|
||||
rankingBarSpec,
|
||||
shapeWordCloudSpec,
|
||||
stackedDashAreaSpec
|
||||
} from './data';
|
||||
|
||||
const { domRef: stackedDashAreaRef } = useVChart(() => stackedDashAreaSpec);
|
||||
const { domRef: barMarkPointRef } = useVChart(() => barMarkPointSpec);
|
||||
const { domRef: histogramDifferentBinRef } = useVChart(() => histogramDifferentBinSpec);
|
||||
const { domRef: rankingBarRef } = useVChart(() => rankingBarSpec);
|
||||
const { domRef: shapeWordCloudRef } = useVChart(() => shapeWordCloudSpec);
|
||||
const { domRef: circularProgressTickRef } = useVChart(() => circularProgressTickSpec);
|
||||
const { domRef: liquidChartSmartInvertRef } = useVChart(() => liquidChartSmartInvertSpec);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSpace direction="vertical" fill :size="16">
|
||||
<ElCard header="VChart" class="h-full card-wrapper">
|
||||
<WebSiteLink label="More Demos: " link="https://www.visactor.com/vchart/example" />
|
||||
</ElCard>
|
||||
<ElCard header="Stacked Dash Area Chart" class="h-full card-wrapper">
|
||||
<div ref="stackedDashAreaRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard header="Bar Mark Point Chart" class="h-full card-wrapper">
|
||||
<div ref="barMarkPointRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard header="Histogram Different Bin Chart" class="h-full card-wrapper">
|
||||
<div ref="histogramDifferentBinRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard header="Ranking Bar Chart" class="h-full card-wrapper">
|
||||
<div ref="rankingBarRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard header="Circular Progress Tick Chart" class="h-full card-wrapper">
|
||||
<div ref="circularProgressTickRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard header="Liquid Chart Smart Invert Chart" class="h-full card-wrapper">
|
||||
<div ref="liquidChartSmartInvertRef" class="h-400px" />
|
||||
</ElCard>
|
||||
<ElCard header="Shape Word Cloud Chart" class="h-full card-wrapper">
|
||||
<div ref="shapeWordCloudRef" class="h-400px" />
|
||||
</ElCard>
|
||||
</ElSpace>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
37
src/views/plugin/copy/index.vue
Normal file
37
src/views/plugin/copy/index.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { useClipboard } from '@vueuse/core';
|
||||
|
||||
defineOptions({ name: 'CopyPage' });
|
||||
|
||||
const { copy, isSupported } = useClipboard();
|
||||
|
||||
const source = ref('');
|
||||
|
||||
async function handleCopy() {
|
||||
if (!isSupported) {
|
||||
window.$message?.error('您的浏览器不支持Clipboard API');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!source.value) {
|
||||
window.$message?.error('请输入要复制的内容');
|
||||
return;
|
||||
}
|
||||
|
||||
await copy(source.value);
|
||||
window.$message?.success(`复制成功:${source.value}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<ElCard header="文本复制" class="h-full card-wrapper">
|
||||
<ElInput v-model="source" placeholder="请输入要复制的内容吧">
|
||||
<template #append>
|
||||
<ElButton type="primary" @click="handleCopy">复制</ElButton>
|
||||
</template>
|
||||
</ElInput>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
52
src/views/plugin/editor/markdown/index.vue
Normal file
52
src/views/plugin/editor/markdown/index.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import Vditor from 'vditor';
|
||||
import 'vditor/dist/index.css';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
|
||||
defineOptions({ name: 'MarkdownPage' });
|
||||
|
||||
const theme = useThemeStore();
|
||||
|
||||
const vditor = ref<Vditor>();
|
||||
const domRef = ref<HTMLElement>();
|
||||
|
||||
function renderVditor() {
|
||||
if (!domRef.value) return;
|
||||
vditor.value = new Vditor(domRef.value, {
|
||||
minHeight: 400,
|
||||
theme: theme.darkMode ? 'dark' : 'classic',
|
||||
icon: 'material',
|
||||
cache: { enable: false }
|
||||
});
|
||||
}
|
||||
|
||||
const stopHandle = watch(
|
||||
() => theme.darkMode,
|
||||
newValue => {
|
||||
const themeMode = newValue ? 'dark' : 'classic';
|
||||
vditor.value?.setTheme(themeMode);
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
renderVditor();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopHandle();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<ElCard header="markdown插件" class="card-wrapper">
|
||||
<div ref="domRef"></div>
|
||||
<template #footer>
|
||||
<GithubLink link="https://github.com/Vanessa219/vditor" />
|
||||
</template>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
47
src/views/plugin/editor/quill/index.vue
Normal file
47
src/views/plugin/editor/quill/index.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import WangEditor from 'wangeditor';
|
||||
|
||||
defineOptions({ name: 'QuillPage' });
|
||||
|
||||
const editor = ref<WangEditor>();
|
||||
const domRef = ref<HTMLElement>();
|
||||
|
||||
function renderWangEditor() {
|
||||
editor.value = new WangEditor(domRef.value);
|
||||
setEditorConfig();
|
||||
editor.value.create();
|
||||
}
|
||||
|
||||
function setEditorConfig() {
|
||||
if (editor.value?.config?.zIndex) {
|
||||
editor.value.config.zIndex = 10;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
renderWangEditor();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<ElCard header="富文本插件" class="card-wrapper">
|
||||
<div ref="domRef" class="bg-white dark:bg-dark"></div>
|
||||
<template #footer>
|
||||
<GithubLink link="https://github.com/wangeditor-team/wangEditor" />
|
||||
</template>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.w-e-toolbar) {
|
||||
background: inherit !important;
|
||||
border-color: var(--el-border-color) !important;
|
||||
}
|
||||
:deep(.w-e-text-container) {
|
||||
background: inherit;
|
||||
border-color: var(--el-border-color) !important;
|
||||
}
|
||||
</style>
|
||||
171
src/views/plugin/excel/index.vue
Normal file
171
src/views/plugin/excel/index.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<script setup lang="tsx">
|
||||
import { reactive } from 'vue';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import type { FlatResponseData } from '@sa/axios';
|
||||
import { utils, writeFile } from 'xlsx';
|
||||
import { commonStatusRecord, userGenderRecord } from '@/constants/business';
|
||||
import { fetchGetUserList } from '@/service/api';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'ExcelPage' });
|
||||
|
||||
const searchParams: Api.SystemManage.UserSearchParams = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
status: undefined,
|
||||
username: undefined,
|
||||
mobile: undefined,
|
||||
deptId: undefined,
|
||||
roleId: undefined
|
||||
});
|
||||
|
||||
const { columns, data, loading } = useUIPaginatedTable<
|
||||
FlatResponseData<any, Api.SystemManage.UserList>,
|
||||
Api.SystemManage.User
|
||||
>({
|
||||
api: () => fetchGetUserList(searchParams),
|
||||
transform: response => {
|
||||
if (!response.error) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: searchParams.pageNo ?? 1,
|
||||
pageSize: searchParams.pageSize ?? 10,
|
||||
total: response.data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
};
|
||||
},
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage;
|
||||
searchParams.pageSize = params.pageSize;
|
||||
},
|
||||
columns: () => [
|
||||
{ type: 'selection', width: 48 },
|
||||
{ type: 'index', label: $t('common.index'), width: 64 },
|
||||
{ prop: 'username', label: $t('page.system.user.userName'), minWidth: 100 },
|
||||
{
|
||||
prop: 'sex',
|
||||
label: $t('page.system.user.userGender'),
|
||||
width: 100,
|
||||
formatter: row => {
|
||||
const tagMap: Record<Api.SystemManage.UserGender, UI.ThemeColor> = {
|
||||
0: 'info',
|
||||
1: 'primary',
|
||||
2: 'danger'
|
||||
};
|
||||
const value = row.sex ?? 0;
|
||||
|
||||
const label = $t(userGenderRecord[value]);
|
||||
|
||||
return <ElTag type={tagMap[value]}>{label}</ElTag>;
|
||||
}
|
||||
},
|
||||
{ prop: 'nickname', label: $t('page.system.user.nickName'), minWidth: 100 },
|
||||
{ prop: 'mobile', label: $t('page.system.user.userPhone'), width: 120 },
|
||||
{ prop: 'email', label: $t('page.system.user.userEmail'), minWidth: 200 },
|
||||
{
|
||||
prop: 'status',
|
||||
label: $t('page.system.user.userStatus'),
|
||||
width: 100,
|
||||
formatter: row => {
|
||||
const tagMap: Record<Api.SystemManage.CommonStatus, UI.ThemeColor> = {
|
||||
0: 'success',
|
||||
1: 'warning'
|
||||
};
|
||||
|
||||
const label = $t(commonStatusRecord[row.status]);
|
||||
|
||||
return <ElTag type={tagMap[row.status]}>{label}</ElTag>;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
function exportExcel() {
|
||||
const exportColumns = columns.value.slice(2);
|
||||
|
||||
const excelList = data.value.map(item => exportColumns.map(col => getTableValue(col, item)));
|
||||
|
||||
const titleList = exportColumns.map(col => (isTableColumnHasTitle(col) && col.label) || undefined);
|
||||
|
||||
excelList.unshift(titleList);
|
||||
|
||||
const workBook = utils.book_new();
|
||||
|
||||
const workSheet = utils.aoa_to_sheet(excelList);
|
||||
|
||||
workSheet['!cols'] = exportColumns.map(item => ({
|
||||
width: Math.round(Number(item.width) / 10 || 20)
|
||||
}));
|
||||
|
||||
utils.book_append_sheet(workBook, workSheet, '用户列表');
|
||||
|
||||
writeFile(workBook, '用户数据.xlsx');
|
||||
}
|
||||
|
||||
function getTableValue(col: UI.TableColumn<Api.SystemManage.User>, item: Api.SystemManage.User) {
|
||||
if (!isTableColumnHasKey(col)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { prop } = col;
|
||||
|
||||
if (prop === 'operate' || prop === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (prop === 'status') {
|
||||
return $t(commonStatusRecord[item.status]);
|
||||
}
|
||||
|
||||
if (prop === 'sex') {
|
||||
return $t(userGenderRecord[item.sex ?? 0]);
|
||||
}
|
||||
|
||||
if (prop in item) {
|
||||
return item[prop as keyof Api.SystemManage.User];
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function isTableColumnHasKey<T>(column: UI.TableColumn<T>): boolean {
|
||||
return Boolean((column as UI.TableColumnWithKey<T>).prop);
|
||||
}
|
||||
|
||||
function isTableColumnHasTitle<T>(column: UI.TableColumn<T>): boolean {
|
||||
return Boolean((column as UI.TableColumnWithKey<T>).label);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||
<ElCard class="card-wrapper sm:flex-1-hidden">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<p>Excel导出</p>
|
||||
<ElButton plain type="primary" @click="exportExcel">
|
||||
<template #icon>
|
||||
<icon-file-icons:microsoft-excel class="text-icon" />
|
||||
</template>
|
||||
导出excel
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
<div class="h-[calc(100%-50px)]">
|
||||
<ElTable v-loading="loading" height="100%" border class="sm:h-full" :data="data" row-key="id">
|
||||
<ElTableColumn v-for="col in columns" :key="col.prop" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
173
src/views/plugin/gantt/dhtmlx/data.ts
Normal file
173
src/views/plugin/gantt/dhtmlx/data.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { Task } from 'dhtmlx-gantt';
|
||||
|
||||
export const ganttTasks: Task[] = [
|
||||
{
|
||||
id: 11,
|
||||
text: 'CN-RDMS 架构设计',
|
||||
type: 'project',
|
||||
progress: 0,
|
||||
open: true,
|
||||
start_date: new Date('2024-01-10 00:00'),
|
||||
duration: 12,
|
||||
parent: 0
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
text: '测试版本',
|
||||
start_date: new Date('2024-03-20 00:00'),
|
||||
type: 'project',
|
||||
duration: 5,
|
||||
render: 'split',
|
||||
parent: '11',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 99,
|
||||
text: '测试版本1 发布',
|
||||
start_date: new Date('2024-03-20 00:00'),
|
||||
end_date: new Date('2024-03-25 00:00'),
|
||||
parent: '12',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 98,
|
||||
text: '测试版本2 发布',
|
||||
start_date: new Date('2024-03-26 00:00'),
|
||||
duration: 4,
|
||||
parent: '12',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 97,
|
||||
text: '测试版本3 发布',
|
||||
start_date: new Date('2024-03-31 00:00'),
|
||||
duration: 10,
|
||||
parent: '12',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
text: '1.0 版本',
|
||||
start_date: new Date('2024-03-31 00:00'),
|
||||
type: 'project',
|
||||
render: 'split',
|
||||
parent: '11',
|
||||
progress: 0.5,
|
||||
open: false,
|
||||
duration: 11
|
||||
},
|
||||
{
|
||||
id: 17,
|
||||
text: '1.0正式发布',
|
||||
start_date: new Date('2024-03-31 00:00'),
|
||||
end_date: new Date('2024-04-03 00:00'),
|
||||
parent: '13',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 18,
|
||||
text: '1.0.1 版本',
|
||||
start_date: new Date('2024-04-03 00:00'),
|
||||
duration: 5,
|
||||
parent: '13',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 19,
|
||||
text: '1.0.2 版本',
|
||||
start_date: new Date('2024-04-08 00:00'),
|
||||
duration: 6,
|
||||
parent: '13',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 20,
|
||||
text: '1.0.3 版本',
|
||||
start_date: new Date('2024-04-16 00:00'),
|
||||
duration: 8,
|
||||
parent: '13',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 31,
|
||||
text: '1.0.4 版本',
|
||||
start_date: new Date('2024-04-17 00:00'),
|
||||
duration: 8,
|
||||
parent: '13',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 32,
|
||||
text: '1.0.5 版本',
|
||||
start_date: new Date('2024-04-26 00:00'),
|
||||
duration: 9,
|
||||
parent: '13',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 33,
|
||||
text: '1.0.9 版本',
|
||||
start_date: new Date('2024-05-05 00:00'),
|
||||
duration: 2,
|
||||
parent: '13',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
text: '1.1 版本',
|
||||
start_date: new Date('2024-05-07 00:00'),
|
||||
duration: 30,
|
||||
parent: '11',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
text: '1.2 版本',
|
||||
start_date: new Date('2024-06-06 00:00'),
|
||||
duration: 46,
|
||||
parent: '11',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
text: '1.3版本',
|
||||
type: 'project',
|
||||
render: 'split',
|
||||
parent: '11',
|
||||
progress: 0,
|
||||
open: true,
|
||||
start_date: new Date('2024-07-22 00:00'),
|
||||
duration: 11
|
||||
},
|
||||
{
|
||||
id: 21,
|
||||
text: '1.3.1版本',
|
||||
start_date: new Date('2024-07-22 00:00'),
|
||||
duration: 7,
|
||||
parent: '16',
|
||||
progress: 0,
|
||||
open: true
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
text: '1.3.2版本',
|
||||
start_date: new Date('2024-07-29 00:00'),
|
||||
duration: 7,
|
||||
parent: '16',
|
||||
progress: 0,
|
||||
open: true
|
||||
}
|
||||
];
|
||||
169
src/views/plugin/gantt/dhtmlx/index.vue
Normal file
169
src/views/plugin/gantt/dhtmlx/index.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script setup lang="tsx">
|
||||
import { onMounted, shallowRef } from 'vue';
|
||||
import { gantt } from 'dhtmlx-gantt';
|
||||
import type { GanttConfigOptions, ZoomLevel } from 'dhtmlx-gantt';
|
||||
import 'dhtmlx-gantt/codebase/dhtmlxgantt.css';
|
||||
import { ganttTasks } from './data';
|
||||
|
||||
defineOptions({ name: 'GanttPage' });
|
||||
|
||||
const ganttRef = shallowRef<HTMLElement>();
|
||||
|
||||
type TimeType = 'day' | 'week' | 'month' | 'quarter' | 'year';
|
||||
|
||||
const timeType = shallowRef<TimeType>('quarter');
|
||||
|
||||
interface TimeData {
|
||||
label: string;
|
||||
value: TimeType;
|
||||
}
|
||||
|
||||
const data: TimeData[] = [
|
||||
{ label: '天', value: 'day' },
|
||||
{ label: '周', value: 'week' },
|
||||
{ label: '月', value: 'month' },
|
||||
{ label: '季', value: 'quarter' },
|
||||
{ label: '年', value: 'year' }
|
||||
];
|
||||
|
||||
function initGantt() {
|
||||
if (!ganttRef.value) return;
|
||||
|
||||
const config: Partial<GanttConfigOptions> = {
|
||||
grid_width: 350,
|
||||
add_column: false,
|
||||
autofit: false,
|
||||
row_height: 60,
|
||||
bar_height: 34,
|
||||
auto_types: true,
|
||||
xml_date: '%Y-%m-%d',
|
||||
columns: [
|
||||
{ name: 'text', label: '项目名称', tree: true, width: '*' },
|
||||
{ name: 'start_date', label: '开始时间', align: 'center', width: 150 }
|
||||
]
|
||||
};
|
||||
|
||||
Object.assign(gantt.config, config);
|
||||
|
||||
gantt.i18n.setLocale('cn');
|
||||
gantt.init(ganttRef.value);
|
||||
gantt.parse({ data: ganttTasks });
|
||||
|
||||
const zoomLevels: ZoomLevel[] = [
|
||||
{
|
||||
name: 'day',
|
||||
scale_height: 60,
|
||||
scales: [{ unit: 'day', step: 1, format: '%d %M' }]
|
||||
},
|
||||
{
|
||||
name: 'week',
|
||||
scale_height: 60,
|
||||
scales: [
|
||||
{
|
||||
unit: 'week',
|
||||
step: 1,
|
||||
format(date: Date) {
|
||||
const dateToStr = gantt.date.date_to_str('%m-%d');
|
||||
const endDate = gantt.date.add(date, -6, 'day'); // 第几周
|
||||
return `${dateToStr(endDate)} 至 ${dateToStr(date)}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
unit: 'day',
|
||||
step: 1,
|
||||
format: '%d',
|
||||
css(date: Date) {
|
||||
if (date.getDay() === 0 || date.getDay() === 6) {
|
||||
return 'day-item weekend weekend-border-bottom';
|
||||
}
|
||||
return 'day-item';
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'month',
|
||||
scale_height: 60,
|
||||
min_column_width: 18,
|
||||
scales: [
|
||||
{ unit: 'month', format: '%Y-%m' },
|
||||
{
|
||||
unit: 'day',
|
||||
step: 1,
|
||||
format: '%d',
|
||||
css(date: Date) {
|
||||
if (date.getDay() === 0 || date.getDay() === 6) {
|
||||
return 'day-item weekend weekend-border-bottom';
|
||||
}
|
||||
return 'day-item';
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'quarter',
|
||||
height: 60,
|
||||
min_column_width: 110,
|
||||
scales: [
|
||||
{
|
||||
unit: 'quarter',
|
||||
step: 1,
|
||||
format(date: Date) {
|
||||
const yearStr = `${new Date(date).getFullYear()}年`;
|
||||
const dateToStr = gantt.date.date_to_str('%M');
|
||||
const endDate = gantt.date.add(gantt.date.add(date, 3, 'month'), -1, 'day');
|
||||
return `${yearStr + dateToStr(date)} - ${dateToStr(endDate)}`;
|
||||
}
|
||||
},
|
||||
{
|
||||
unit: 'week',
|
||||
step: 1,
|
||||
format(date: Date) {
|
||||
const dateToStr = gantt.date.date_to_str('%m-%d');
|
||||
const endDate = gantt.date.add(date, 6, 'day');
|
||||
return `${dateToStr(date)} 至 ${dateToStr(endDate)}`;
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'year',
|
||||
scale_height: 50,
|
||||
min_column_width: 150,
|
||||
scales: [
|
||||
{ unit: 'year', step: 1, format: '%Y年' },
|
||||
{ unit: 'month', format: '%Y-%m' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
gantt.ext.zoom.init({ levels: zoomLevels });
|
||||
gantt.ext.zoom.setLevel(timeType.value);
|
||||
}
|
||||
|
||||
function changeTime(value: string | number) {
|
||||
timeType.value = value as TimeType;
|
||||
gantt.ext.zoom.setLevel(value);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initGantt();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden lt-sm:overflow-auto">
|
||||
<ElCard header="甘特图演示" content-class="overflow-y-hidden overflow-x-auto" class="h-full card-wrapper">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<p>甘特图演示</p>
|
||||
<ElSegmented v-model="timeType" :options="data" @change="changeTime" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div ref="ganttRef" class="size-full min-w-800px"></div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
721
src/views/plugin/gantt/vtable/data.ts
Normal file
721
src/views/plugin/gantt/vtable/data.ts
Normal file
@@ -0,0 +1,721 @@
|
||||
export const basicGanttRecords = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-15',
|
||||
progress: 31,
|
||||
priority: 'P0',
|
||||
children: [
|
||||
{
|
||||
id: 2,
|
||||
title: 'Project Feature Review',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-07-24',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-25',
|
||||
end: '2024-07-26',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Project Create',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-27',
|
||||
end: '2024-07-26',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Develop feature 1',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-08-01',
|
||||
end: '2024-08-15',
|
||||
progress: 0,
|
||||
priority: 'P1'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-01',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 100,
|
||||
priority: 'P1',
|
||||
children: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-08-01',
|
||||
end: '2024-08-01',
|
||||
progress: 90,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-30',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024.07.26',
|
||||
end: '2024.07.08',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '07.24.2024',
|
||||
end: '08.04.2024',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-16',
|
||||
end: '2024-07-18',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-08-09',
|
||||
end: '2024-09-11',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-26',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0',
|
||||
children: [
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1',
|
||||
children: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-26',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0',
|
||||
children: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-06',
|
||||
end: '2024-07-08',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-26',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1',
|
||||
children: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-06',
|
||||
end: '2024-07-08',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-26',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1',
|
||||
children: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-23',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-30',
|
||||
end: '2024-08-14',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0',
|
||||
children: [
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-08-04',
|
||||
end: '2024-08-04',
|
||||
progress: 90,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '07/24/2024',
|
||||
end: '08/04/2024',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-27',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1',
|
||||
children: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '07.24.2024',
|
||||
end: '08.04.2024',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-26',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-08-09',
|
||||
end: '2024-09-11',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-26',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-26',
|
||||
end: '2024-07-28',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0',
|
||||
children: [
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024.07.06',
|
||||
end: '2024.07.08',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-29',
|
||||
end: '2024-07-31',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const linkGanttRecords = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-15',
|
||||
end: '2024-07-16',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-16',
|
||||
end: '2024-07-17',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-18',
|
||||
end: '2024-07-19',
|
||||
progress: 90,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024/07/17',
|
||||
end: '2024/07/18',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '07/19/2024',
|
||||
end: '07/20/2024',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024.07.06',
|
||||
end: '2024.07.08',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024/07/09',
|
||||
end: '2024/07/11',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '07.24.2024',
|
||||
end: '08.04.2024',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
|
||||
{
|
||||
id: 11,
|
||||
title: 'Software Development',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-24',
|
||||
end: '2024-08-04',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
title: 'Scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-06',
|
||||
end: '2024-07-08',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
title: 'Determine project scope',
|
||||
developer: 'liufangfang.jane@bytedance.com',
|
||||
start: '2024-07-09',
|
||||
end: '2024-07-11',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
}
|
||||
];
|
||||
|
||||
export const customGanttRecords = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Project Task 1',
|
||||
developer: 'bear.xiong',
|
||||
avatar: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/custom-render/bear.jpg',
|
||||
start: '2024-07-24',
|
||||
end: '2024-07-26',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Project Task 2',
|
||||
developer: 'wolf.lang',
|
||||
avatar: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/custom-render/wolf.jpg',
|
||||
start: '07/25/2024',
|
||||
end: '07/28/2024',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Project Task 3',
|
||||
developer: 'rabbit.tu',
|
||||
avatar: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/custom-render/rabbit.jpg',
|
||||
start: '2024-07-28',
|
||||
end: '2024-08-01',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'Project Task 4',
|
||||
developer: 'cat.mao',
|
||||
avatar: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/custom-render/cat.jpg',
|
||||
start: '2024-07-31',
|
||||
end: '2024-08-03',
|
||||
progress: 31,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Project Task 5',
|
||||
developer: 'bird.niao',
|
||||
avatar: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/custom-render/bird.jpeg',
|
||||
start: '2024-08-02',
|
||||
end: '2024-08-04',
|
||||
progress: 60,
|
||||
priority: 'P0'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Project Task 6',
|
||||
developer: 'flower.hua',
|
||||
avatar: 'https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/custom-render/flower.jpg',
|
||||
start: '2024-08-03',
|
||||
end: '2024-08-10',
|
||||
progress: 100,
|
||||
priority: 'P1'
|
||||
}
|
||||
];
|
||||
792
src/views/plugin/gantt/vtable/index.vue
Normal file
792
src/views/plugin/gantt/vtable/index.vue
Normal file
@@ -0,0 +1,792 @@
|
||||
<script setup lang="tsx">
|
||||
import { onMounted, onUnmounted, shallowRef, watch } from 'vue';
|
||||
import * as VTableGantt from '@visactor/vtable-gantt';
|
||||
import * as VTable_editors from '@visactor/vtable-editors';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { basicGanttRecords, customGanttRecords, linkGanttRecords } from './data';
|
||||
|
||||
const theme = useThemeStore();
|
||||
|
||||
const input_editor = new VTable_editors.InputEditor();
|
||||
const date_input_editor = new VTable_editors.DateInputEditor();
|
||||
VTableGantt.VTable.register.editor('input', input_editor);
|
||||
VTableGantt.VTable.register.editor('date-input', date_input_editor);
|
||||
|
||||
const basicGanttDomRef = shallowRef<HTMLElement>();
|
||||
const linkGanttDomRef = shallowRef<HTMLElement>();
|
||||
const customGanttDomRef = shallowRef<HTMLElement>();
|
||||
|
||||
const basicGanttInstance = shallowRef<VTableGantt.Gantt>();
|
||||
const linkGanttInstance = shallowRef<VTableGantt.Gantt>();
|
||||
const customGanttInstance = shallowRef<VTableGantt.Gantt>();
|
||||
|
||||
const basicGanttColumns = [
|
||||
{
|
||||
field: 'title',
|
||||
title: 'title',
|
||||
width: 'auto',
|
||||
sort: true,
|
||||
tree: true,
|
||||
editor: 'input'
|
||||
},
|
||||
{
|
||||
field: 'start',
|
||||
title: 'start',
|
||||
width: 'auto',
|
||||
sort: true,
|
||||
editor: 'date-input'
|
||||
},
|
||||
{
|
||||
field: 'end',
|
||||
title: 'end',
|
||||
width: 'auto',
|
||||
sort: true,
|
||||
editor: 'date-input'
|
||||
},
|
||||
{
|
||||
field: 'priority',
|
||||
title: 'priority',
|
||||
width: 'auto',
|
||||
sort: true,
|
||||
editor: 'input'
|
||||
},
|
||||
{
|
||||
field: 'progress',
|
||||
title: 'progress',
|
||||
width: 'auto',
|
||||
sort: true,
|
||||
headerStyle: {
|
||||
borderColor: '#e1e4e8'
|
||||
},
|
||||
style: {
|
||||
borderColor: '#e1e4e8',
|
||||
color: 'green'
|
||||
},
|
||||
editor: 'input'
|
||||
}
|
||||
];
|
||||
const basicGanttOption: VTableGantt.GanttConstructorOptions = {
|
||||
overscrollBehavior: 'none',
|
||||
records: basicGanttRecords,
|
||||
taskListTable: {
|
||||
columns: basicGanttColumns,
|
||||
tableWidth: 250,
|
||||
minTableWidth: 100,
|
||||
maxTableWidth: 600
|
||||
// rightFrozenColCount: 1
|
||||
},
|
||||
frame: {
|
||||
outerFrameStyle: {
|
||||
borderLineWidth: 2,
|
||||
borderColor: '#e1e4e8',
|
||||
cornerRadius: 8
|
||||
},
|
||||
verticalSplitLine: {
|
||||
lineColor: '#e1e4e8',
|
||||
lineWidth: 3
|
||||
},
|
||||
horizontalSplitLine: {
|
||||
lineColor: '#e1e4e8',
|
||||
lineWidth: 3
|
||||
},
|
||||
verticalSplitLineMoveable: true,
|
||||
verticalSplitLineHighlight: {
|
||||
lineColor: 'green',
|
||||
lineWidth: 3
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
// backgroundColor: 'gray',
|
||||
verticalLine: {
|
||||
lineWidth: 1,
|
||||
lineColor: '#e1e4e8'
|
||||
},
|
||||
horizontalLine: {
|
||||
lineWidth: 1,
|
||||
lineColor: '#e1e4e8'
|
||||
}
|
||||
},
|
||||
headerRowHeight: 40,
|
||||
rowHeight: 40,
|
||||
taskBar: {
|
||||
startDateField: 'start',
|
||||
endDateField: 'end',
|
||||
progressField: 'progress',
|
||||
// resizable: false,
|
||||
moveable: true,
|
||||
hoverBarStyle: {
|
||||
barOverlayColor: 'rgba(99, 144, 0, 0.4)'
|
||||
},
|
||||
labelText: '{title} {progress}%',
|
||||
labelTextStyle: {
|
||||
// padding: 2,
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 16,
|
||||
textAlign: 'left',
|
||||
textOverflow: 'ellipsis'
|
||||
},
|
||||
barStyle: {
|
||||
width: 20,
|
||||
/** 任务条的颜色 */
|
||||
barColor: '#ee8800',
|
||||
/** 已完成部分任务条的颜色 */
|
||||
completedBarColor: '#91e8e0',
|
||||
/** 任务条的圆角 */
|
||||
cornerRadius: 8
|
||||
}
|
||||
},
|
||||
timelineHeader: {
|
||||
colWidth: 100,
|
||||
backgroundColor: '#EEF1F5',
|
||||
horizontalLine: {
|
||||
lineWidth: 1,
|
||||
lineColor: '#e1e4e8'
|
||||
},
|
||||
verticalLine: {
|
||||
lineWidth: 1,
|
||||
lineColor: '#e1e4e8'
|
||||
},
|
||||
scales: [
|
||||
{
|
||||
unit: 'week',
|
||||
step: 1,
|
||||
startOfWeek: 'sunday',
|
||||
format(date: any) {
|
||||
return `Week ${date.dateIndex}`;
|
||||
},
|
||||
style: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
strokeColor: 'black',
|
||||
textAlign: 'right',
|
||||
textBaseline: 'bottom',
|
||||
textStick: true
|
||||
// padding: [0, 30, 0, 20]
|
||||
}
|
||||
},
|
||||
{
|
||||
unit: 'day',
|
||||
step: 1,
|
||||
format(date: any) {
|
||||
return date.dateIndex.toString();
|
||||
},
|
||||
style: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
strokeColor: 'black',
|
||||
textAlign: 'right',
|
||||
textBaseline: 'bottom'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
markLine: [
|
||||
{
|
||||
date: '2024-07-28',
|
||||
style: {
|
||||
lineWidth: 1,
|
||||
lineColor: 'blue',
|
||||
lineDash: [8, 4]
|
||||
}
|
||||
},
|
||||
{
|
||||
date: '2024-08-17',
|
||||
style: {
|
||||
lineWidth: 2,
|
||||
lineColor: 'red',
|
||||
lineDash: [8, 4]
|
||||
}
|
||||
}
|
||||
],
|
||||
rowSeriesNumber: {
|
||||
title: '行号',
|
||||
dragOrder: true
|
||||
},
|
||||
scrollStyle: {
|
||||
scrollRailColor: 'RGBA(246,246,246,0.5)',
|
||||
visible: 'scrolling',
|
||||
width: 6,
|
||||
scrollSliderCornerRadius: 2,
|
||||
scrollSliderColor: '#5cb85c'
|
||||
}
|
||||
};
|
||||
|
||||
const linkGanttColumns = [
|
||||
{
|
||||
field: 'title',
|
||||
title: 'title',
|
||||
width: 'auto',
|
||||
tree: true
|
||||
},
|
||||
{
|
||||
field: 'start',
|
||||
title: 'start',
|
||||
width: 'auto',
|
||||
editor: 'date-input'
|
||||
},
|
||||
{
|
||||
field: 'end',
|
||||
title: 'end',
|
||||
width: 'auto',
|
||||
editor: 'date-input'
|
||||
},
|
||||
{
|
||||
field: 'priority',
|
||||
title: 'priority',
|
||||
width: 'auto',
|
||||
editor: 'input'
|
||||
},
|
||||
{
|
||||
field: 'progress',
|
||||
title: 'progress',
|
||||
width: 'auto',
|
||||
headerStyle: {
|
||||
borderColor: '#e1e4e8'
|
||||
},
|
||||
style: {
|
||||
borderColor: '#e1e4e8',
|
||||
color: 'green'
|
||||
},
|
||||
editor: 'input'
|
||||
}
|
||||
];
|
||||
const linkGanttOption: VTableGantt.GanttConstructorOptions = {
|
||||
records: linkGanttRecords,
|
||||
taskListTable: {
|
||||
columns: linkGanttColumns,
|
||||
tableWidth: 400,
|
||||
minTableWidth: 100,
|
||||
maxTableWidth: 600
|
||||
},
|
||||
dependency: {
|
||||
links: [
|
||||
{
|
||||
type: VTableGantt.TYPES.DependencyType.FinishToStart,
|
||||
linkedFromTaskKey: 1,
|
||||
linkedToTaskKey: 2
|
||||
},
|
||||
{
|
||||
type: VTableGantt.TYPES.DependencyType.StartToFinish,
|
||||
linkedFromTaskKey: 2,
|
||||
linkedToTaskKey: 3
|
||||
},
|
||||
{
|
||||
type: VTableGantt.TYPES.DependencyType.StartToStart,
|
||||
linkedFromTaskKey: 3,
|
||||
linkedToTaskKey: 4
|
||||
},
|
||||
{
|
||||
type: VTableGantt.TYPES.DependencyType.FinishToFinish,
|
||||
linkedFromTaskKey: 4,
|
||||
linkedToTaskKey: 5
|
||||
}
|
||||
],
|
||||
// linkSelectable: false,
|
||||
linkSelectedLineStyle: {
|
||||
shadowBlur: 5, // 阴影宽度
|
||||
shadowColor: 'red',
|
||||
lineColor: 'red',
|
||||
lineWidth: 1
|
||||
}
|
||||
},
|
||||
frame: {
|
||||
verticalSplitLineMoveable: true,
|
||||
outerFrameStyle: {
|
||||
borderLineWidth: 2,
|
||||
// borderColor: 'red',
|
||||
cornerRadius: 8
|
||||
},
|
||||
verticalSplitLine: {
|
||||
lineWidth: 3,
|
||||
lineColor: '#e1e4e8'
|
||||
},
|
||||
verticalSplitLineHighlight: {
|
||||
lineColor: 'green',
|
||||
lineWidth: 3
|
||||
}
|
||||
},
|
||||
grid: {
|
||||
// backgroundColor: 'gray',
|
||||
verticalLine: {
|
||||
lineWidth: 1,
|
||||
lineColor: '#e1e4e8'
|
||||
},
|
||||
horizontalLine: {
|
||||
lineWidth: 1,
|
||||
lineColor: '#e1e4e8'
|
||||
}
|
||||
},
|
||||
headerRowHeight: 60,
|
||||
rowHeight: 40,
|
||||
|
||||
taskBar: {
|
||||
startDateField: 'start',
|
||||
endDateField: 'end',
|
||||
progressField: 'progress',
|
||||
labelText: '{title} {progress}%',
|
||||
labelTextStyle: {
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 16,
|
||||
textAlign: 'left'
|
||||
},
|
||||
barStyle: {
|
||||
width: 20,
|
||||
/** 任务条的颜色 */
|
||||
barColor: '#ee8800',
|
||||
/** 已完成部分任务条的颜色 */
|
||||
completedBarColor: '#91e8e0',
|
||||
/** 任务条的圆角 */
|
||||
cornerRadius: 10
|
||||
},
|
||||
selectedBarStyle: {
|
||||
shadowBlur: 5, // 阴影宽度
|
||||
shadowOffsetX: 0, // x方向偏移
|
||||
shadowOffsetY: 0, // Y方向偏移
|
||||
shadowColor: 'black', // 阴影颜色
|
||||
borderColor: 'red', // 边框颜色
|
||||
borderLineWidth: 1 // 边框宽度
|
||||
}
|
||||
},
|
||||
timelineHeader: {
|
||||
verticalLine: {
|
||||
lineWidth: 1,
|
||||
lineColor: '#e1e4e8'
|
||||
},
|
||||
horizontalLine: {
|
||||
lineWidth: 1,
|
||||
lineColor: '#e1e4e8'
|
||||
},
|
||||
backgroundColor: '#EEF1F5',
|
||||
colWidth: 60,
|
||||
scales: [
|
||||
{
|
||||
unit: 'week',
|
||||
step: 1,
|
||||
startOfWeek: 'sunday',
|
||||
format(date: any) {
|
||||
return `Week ${date.dateIndex}`;
|
||||
},
|
||||
style: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: 'red'
|
||||
}
|
||||
},
|
||||
{
|
||||
unit: 'day',
|
||||
step: 1,
|
||||
format(date: any) {
|
||||
return date.dateIndex.toString();
|
||||
},
|
||||
style: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: 'red'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
minDate: '2024-07-14',
|
||||
maxDate: '2024-10-15',
|
||||
|
||||
rowSeriesNumber: {
|
||||
title: '行号',
|
||||
dragOrder: true
|
||||
},
|
||||
scrollStyle: {
|
||||
visible: 'scrolling'
|
||||
},
|
||||
overscrollBehavior: 'none'
|
||||
};
|
||||
|
||||
const barColors0 = ['#aecde6', '#c6a49a', '#ffb582', '#eec1de', '#b3d9b3', '#cccccc', '#e59a9c', '#d9d1a5', '#c9bede'];
|
||||
const barColors = ['#1f77b4', '#8c564b', '#ff7f0e', '#e377c2', '#2ca02c', '#7f7f7f', '#d62728', '#bcbd22', '#9467bd'];
|
||||
const customGanttColumns: VTableGantt.ColumnsDefine = [
|
||||
{
|
||||
field: 'title',
|
||||
title: 'TASK',
|
||||
width: '200',
|
||||
headerStyle: {
|
||||
textAlign: 'center',
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold'
|
||||
// color: 'black',
|
||||
// bgColor: '#f0f0fb'
|
||||
},
|
||||
style: {
|
||||
// bgColor: '#f0f0fb'
|
||||
},
|
||||
customLayout: (args: any) => {
|
||||
const { table, row, col, rect } = args;
|
||||
const taskRecord = table.getCellOriginRecord(col, row);
|
||||
const { height, width } = rect ?? table.getCellRect(col, row);
|
||||
const container = new VTableGantt.VRender.Group({
|
||||
y: 10,
|
||||
x: 20,
|
||||
height: height - 20,
|
||||
width: width - 40,
|
||||
fill: '#ddd',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
cornerRadius: 30
|
||||
});
|
||||
|
||||
const developer = new VTableGantt.VRender.Text({
|
||||
text: taskRecord.developer,
|
||||
fontSize: 16,
|
||||
fontFamily: 'sans-serif',
|
||||
fill: barColors[args.row],
|
||||
fontWeight: 'bold',
|
||||
maxLineWidth: width - 120,
|
||||
boundsPadding: [10, 0, 0, 0],
|
||||
alignSelf: 'center'
|
||||
});
|
||||
container.add(developer);
|
||||
|
||||
const days = new VTableGantt.VRender.Text({
|
||||
text: `${VTableGantt.tools.formatDate(new Date(taskRecord.start), 'mm/dd')}-${VTableGantt.tools.formatDate(
|
||||
new Date(taskRecord.end),
|
||||
'mm/dd'
|
||||
)}`,
|
||||
fontSize: 12,
|
||||
fontFamily: 'sans-serif',
|
||||
fontWeight: 'bold',
|
||||
fill: 'black',
|
||||
boundsPadding: [10, 0, 0, 0],
|
||||
alignSelf: 'center'
|
||||
});
|
||||
container.add(days);
|
||||
|
||||
return {
|
||||
rootContainer: container,
|
||||
expectedWidth: 160
|
||||
};
|
||||
}
|
||||
}
|
||||
];
|
||||
const customGanttOption: VTableGantt.GanttConstructorOptions = {
|
||||
records: customGanttRecords,
|
||||
taskListTable: {
|
||||
columns: customGanttColumns,
|
||||
tableWidth: 'auto'
|
||||
},
|
||||
frame: {
|
||||
outerFrameStyle: {
|
||||
borderLineWidth: 2,
|
||||
borderColor: '#E1E4E8',
|
||||
cornerRadius: 8
|
||||
}
|
||||
// verticalSplitLineHighlight: {
|
||||
// lineColor: 'green',
|
||||
// lineWidth: 3
|
||||
// }
|
||||
},
|
||||
grid: {
|
||||
// backgroundColor: '#f0f0fb',
|
||||
// vertical: {
|
||||
// lineWidth: 1,
|
||||
// lineColor: '#e1e4e8'
|
||||
// },
|
||||
horizontalLine: {
|
||||
lineWidth: 2,
|
||||
lineColor: '#d5d9ee'
|
||||
}
|
||||
},
|
||||
headerRowHeight: 60,
|
||||
rowHeight: 80,
|
||||
taskBar: {
|
||||
startDateField: 'start',
|
||||
endDateField: 'end',
|
||||
progressField: 'progress',
|
||||
barStyle: { width: 60 },
|
||||
customLayout: (args: any) => {
|
||||
const colorLength = barColors.length;
|
||||
const { width, height, index, taskDays, progress, taskRecord } = args;
|
||||
const container = new VTableGantt.VRender.Group({
|
||||
width,
|
||||
height,
|
||||
cornerRadius: 30,
|
||||
fill: {
|
||||
gradient: 'linear',
|
||||
x0: 0,
|
||||
y0: 0,
|
||||
x1: 1,
|
||||
y1: 0,
|
||||
stops: [
|
||||
{
|
||||
offset: 0,
|
||||
color: barColors0[index % colorLength]
|
||||
},
|
||||
{
|
||||
offset: 0.5,
|
||||
color: barColors[index % colorLength]
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
color: barColors0[index % colorLength]
|
||||
}
|
||||
]
|
||||
},
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'nowrap'
|
||||
});
|
||||
const containerLeft = new VTableGantt.VRender.Group({
|
||||
height,
|
||||
width: 60,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around'
|
||||
// fill: 'red'
|
||||
});
|
||||
container.add(containerLeft as any);
|
||||
|
||||
const avatar = new VTableGantt.VRender.Image({
|
||||
width: 50,
|
||||
height: 50,
|
||||
image: taskRecord.avatar,
|
||||
cornerRadius: 25
|
||||
});
|
||||
containerLeft.add(avatar);
|
||||
const containerCenter = new VTableGantt.VRender.Group({
|
||||
height,
|
||||
width: width - 120,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
// alignItems: 'left'
|
||||
});
|
||||
container.add(containerCenter as any);
|
||||
|
||||
const developer = new VTableGantt.VRender.Text({
|
||||
text: taskRecord.developer,
|
||||
fontSize: 16,
|
||||
fontFamily: 'sans-serif',
|
||||
fill: 'white',
|
||||
fontWeight: 'bold',
|
||||
maxLineWidth: width - 120,
|
||||
boundsPadding: [10, 0, 0, 0]
|
||||
});
|
||||
containerCenter.add(developer);
|
||||
|
||||
const days = new VTableGantt.VRender.Text({
|
||||
text: `${taskDays}天`,
|
||||
fontSize: 13,
|
||||
fontFamily: 'sans-serif',
|
||||
fill: 'white',
|
||||
boundsPadding: [10, 0, 0, 0]
|
||||
});
|
||||
containerCenter.add(days);
|
||||
|
||||
if (width >= 120) {
|
||||
const containerRight = new VTableGantt.VRender.Group({
|
||||
cornerRadius: 20,
|
||||
fill: 'white',
|
||||
height: 40,
|
||||
width: 40,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center', // 垂直方向居中对齐
|
||||
boundsPadding: [10, 0, 0, 0]
|
||||
});
|
||||
container.add(containerRight as any);
|
||||
|
||||
const progressText = new VTableGantt.VRender.Text({
|
||||
text: `${progress}%`,
|
||||
fontSize: 12,
|
||||
fontFamily: 'sans-serif',
|
||||
fill: 'black',
|
||||
alignSelf: 'center',
|
||||
fontWeight: 'bold',
|
||||
maxLineWidth: (width - 60) / 2,
|
||||
boundsPadding: [0, 0, 0, 0]
|
||||
});
|
||||
containerRight.add(progressText);
|
||||
}
|
||||
return {
|
||||
rootContainer: container
|
||||
// renderDefaultBar: true
|
||||
// renderDefaultText: true
|
||||
};
|
||||
},
|
||||
hoverBarStyle: {
|
||||
cornerRadius: 30
|
||||
}
|
||||
},
|
||||
timelineHeader: {
|
||||
backgroundColor: '#f0f0fb',
|
||||
colWidth: 80,
|
||||
// verticalLine: {
|
||||
// lineColor: 'red',
|
||||
// lineWidth: 1,
|
||||
// lineDash: [4, 2]
|
||||
// },
|
||||
// horizontalLine: {
|
||||
// lineColor: 'green',
|
||||
// lineWidth: 1,
|
||||
// lineDash: [4, 2]
|
||||
// },
|
||||
scales: [
|
||||
{
|
||||
unit: 'day',
|
||||
step: 1,
|
||||
format(date: any) {
|
||||
return date.dateIndex.toString();
|
||||
},
|
||||
customLayout: (args: any) => {
|
||||
const { width, height, startDate, dateIndex } = args;
|
||||
const container = new VTableGantt.VRender.Group({
|
||||
width,
|
||||
height,
|
||||
// fill: '#f0f0fb',
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'nowrap'
|
||||
});
|
||||
const containerLeft = new VTableGantt.VRender.Group({
|
||||
height,
|
||||
width: 30,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around'
|
||||
// fill: 'red'
|
||||
});
|
||||
container.add(containerLeft as any);
|
||||
|
||||
const avatar = new VTableGantt.VRender.Image({
|
||||
width: 20,
|
||||
height: 30,
|
||||
image:
|
||||
'<svg t="1724675965803" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4299" width="200" height="200"><path d="M53.085678 141.319468C23.790257 141.319468 0 165.035326 0 194.34775L0 918.084273C0 947.295126 23.796789 971.112572 53.085678 971.112572L970.914322 971.112572C1000.209743 971.112572 1024 947.396696 1024 918.084273L1024 194.34775C1024 165.136896 1000.203211 141.319468 970.914322 141.319468L776.827586 141.319468 812.137931 176.629813 812.137931 88.275862C812.137931 68.774506 796.328942 52.965517 776.827586 52.965517 757.32623 52.965517 741.517241 68.774506 741.517241 88.275862L741.517241 176.629813 741.517241 211.940158 776.827586 211.940158 970.914322 211.940158C961.186763 211.940158 953.37931 204.125926 953.37931 194.34775L953.37931 918.084273C953.37931 908.344373 961.25643 900.491882 970.914322 900.491882L53.085678 900.491882C62.813237 900.491882 70.62069 908.306097 70.62069 918.084273L70.62069 194.34775C70.62069 204.087649 62.74357 211.940158 53.085678 211.940158L247.172414 211.940158C266.67377 211.940158 282.482759 196.131169 282.482759 176.629813 282.482759 157.128439 266.67377 141.319468 247.172414 141.319468L53.085678 141.319468ZM211.862069 176.629813C211.862069 196.131169 227.671058 211.940158 247.172414 211.940158 266.67377 211.940158 282.482759 196.131169 282.482759 176.629813L282.482759 88.275862C282.482759 68.774506 266.67377 52.965517 247.172414 52.965517 227.671058 52.965517 211.862069 68.774506 211.862069 88.275862L211.862069 176.629813ZM1024 353.181537 1024 317.871192 988.689655 317.871192 35.310345 317.871192 0 317.871192 0 353.181537 0 441.457399C0 460.958755 15.808989 476.767744 35.310345 476.767744 54.811701 476.767744 70.62069 460.958755 70.62069 441.457399L70.62069 353.181537 35.310345 388.491882 988.689655 388.491882 953.37931 353.181537 953.37931 441.457399C953.37931 460.958755 969.188299 476.767744 988.689655 476.767744 1008.191011 476.767744 1024 460.958755 1024 441.457399L1024 353.181537ZM776.937913 582.62069C796.439287 582.62069 812.248258 566.811701 812.248258 547.310345 812.248258 527.808989 796.439287 512 776.937913 512L247.172414 512C227.671058 512 211.862069 527.808989 211.862069 547.310345 211.862069 566.811701 227.671058 582.62069 247.172414 582.62069L776.937913 582.62069ZM247.172414 688.551724C227.671058 688.551724 211.862069 704.360713 211.862069 723.862069 211.862069 743.363425 227.671058 759.172414 247.172414 759.172414L600.386189 759.172414C619.887563 759.172414 635.696534 743.363425 635.696534 723.862069 635.696534 704.360713 619.887563 688.551724 600.386189 688.551724L247.172414 688.551724ZM776.827586 211.940158 741.517241 176.629813 741.517241 247.328574C741.517241 266.829948 757.32623 282.638919 776.827586 282.638919 796.328942 282.638919 812.137931 266.829948 812.137931 247.328574L812.137931 176.629813 812.137931 141.319468 776.827586 141.319468 247.172414 141.319468C227.671058 141.319468 211.862069 157.128439 211.862069 176.629813 211.862069 196.131169 227.671058 211.940158 247.172414 211.940158L776.827586 211.940158ZM282.482759 176.629813C282.482759 157.128439 266.67377 141.319468 247.172414 141.319468 227.671058 141.319468 211.862069 157.128439 211.862069 176.629813L211.862069 247.328574C211.862069 266.829948 227.671058 282.638919 247.172414 282.638919 266.67377 282.638919 282.482759 266.829948 282.482759 247.328574L282.482759 176.629813Z" fill="#389BFF" p-id="4300"></path></svg>'
|
||||
});
|
||||
containerLeft.add(avatar);
|
||||
|
||||
const containerCenter = new VTableGantt.VRender.Group({
|
||||
height,
|
||||
width: width - 30,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
// alignItems: 'left'
|
||||
});
|
||||
container.add(containerCenter as any);
|
||||
const dayNumber = new VTableGantt.VRender.Text({
|
||||
text: String(dateIndex).padStart(2, '0'),
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
fontFamily: 'sans-serif',
|
||||
fill: '#777',
|
||||
textAlign: 'right',
|
||||
maxLineWidth: width - 30,
|
||||
boundsPadding: [15, 0, 0, 0]
|
||||
});
|
||||
containerCenter.add(dayNumber);
|
||||
|
||||
const weekDay = new VTableGantt.VRender.Text({
|
||||
text: VTableGantt.tools.getWeekday(startDate, 'short').toLocaleUpperCase(),
|
||||
fontSize: 12,
|
||||
fontFamily: 'sans-serif',
|
||||
fill: '#777',
|
||||
boundsPadding: [0, 0, 0, 0]
|
||||
});
|
||||
containerCenter.add(weekDay);
|
||||
return {
|
||||
rootContainer: container
|
||||
// renderDefaultText: true
|
||||
};
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
minDate: '2024-07-20',
|
||||
maxDate: '2024-08-15',
|
||||
markLine: [
|
||||
{
|
||||
date: '2024-07-29',
|
||||
style: {
|
||||
lineWidth: 1,
|
||||
lineColor: 'blue',
|
||||
lineDash: [8, 4]
|
||||
}
|
||||
},
|
||||
{
|
||||
date: '2024-08-17',
|
||||
style: {
|
||||
lineWidth: 2,
|
||||
lineColor: 'red',
|
||||
lineDash: [8, 4]
|
||||
}
|
||||
}
|
||||
],
|
||||
scrollStyle: {
|
||||
scrollRailColor: 'RGBA(246,246,246,0.5)',
|
||||
visible: 'focus',
|
||||
width: 6,
|
||||
scrollSliderCornerRadius: 2,
|
||||
scrollSliderColor: '#5cb85c'
|
||||
}
|
||||
};
|
||||
|
||||
function initVTableGantt() {
|
||||
basicGanttInstance.value = new VTableGantt.Gantt(basicGanttDomRef.value as HTMLElement, getOption(basicGanttOption));
|
||||
linkGanttInstance.value = new VTableGantt.Gantt(linkGanttDomRef.value as HTMLElement, getOption(linkGanttOption));
|
||||
customGanttInstance.value = new VTableGantt.Gantt(
|
||||
customGanttDomRef.value as HTMLElement,
|
||||
getOption(customGanttOption)
|
||||
);
|
||||
}
|
||||
|
||||
function getOption(option: VTableGantt.GanttConstructorOptions) {
|
||||
const isDark = theme.darkMode;
|
||||
if (isDark) {
|
||||
option.taskListTable!.theme = VTableGantt.VTable.themes.DARK;
|
||||
option.timelineHeader.backgroundColor = '#212121';
|
||||
option.underlayBackgroundColor = '#000';
|
||||
} else {
|
||||
option.taskListTable!.theme = VTableGantt.VTable.themes.DEFAULT;
|
||||
option.timelineHeader.backgroundColor = '#f0f0fb';
|
||||
option.underlayBackgroundColor = '#fff';
|
||||
}
|
||||
|
||||
return option;
|
||||
}
|
||||
|
||||
const stopHandle = watch(
|
||||
() => theme.darkMode,
|
||||
_newValue => {
|
||||
basicGanttInstance.value?.release();
|
||||
linkGanttInstance.value?.release();
|
||||
customGanttInstance.value?.release();
|
||||
|
||||
initVTableGantt();
|
||||
}
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
initVTableGantt();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopHandle();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSpace direction="vertical" fill :size="16">
|
||||
<ElCard header="VTableGantt" class="h-full card-wrapper">
|
||||
<WebSiteLink label="More Demos: " link="https://www.visactor.com/vtable/example" />
|
||||
</ElCard>
|
||||
<ElCard class="h-full card-wrapper">
|
||||
<div ref="basicGanttDomRef" class="relative h-400px"></div>
|
||||
</ElCard>
|
||||
<ElCard class="h-full card-wrapper">
|
||||
<div ref="linkGanttDomRef" class="relative h-400px"></div>
|
||||
</ElCard>
|
||||
<ElCard class="h-full card-wrapper">
|
||||
<div ref="customGanttDomRef" class="relative h-400px"></div>
|
||||
</ElCard>
|
||||
</ElSpace>
|
||||
</template>
|
||||
32
src/views/plugin/icon/icons.ts
Normal file
32
src/views/plugin/icon/icons.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export const icons = [
|
||||
'mdi:emoticon',
|
||||
'mdi:ab-testing',
|
||||
'ph:alarm',
|
||||
'ph:android-logo',
|
||||
'ph:align-bottom',
|
||||
'ph:archive-box-light',
|
||||
'uil:basketball',
|
||||
'uil:brightness-plus',
|
||||
'uil:capture',
|
||||
'mdi:apps-box',
|
||||
'mdi:alert',
|
||||
'mdi:airballoon',
|
||||
'mdi:airplane-edit',
|
||||
'mdi:alpha-f-box-outline',
|
||||
'mdi:arm-flex-outline',
|
||||
'ic:baseline-10mp',
|
||||
'ic:baseline-access-time',
|
||||
'ic:baseline-brightness-4',
|
||||
'ic:baseline-brightness-5',
|
||||
'ic:baseline-credit-card',
|
||||
'ic:baseline-filter-1',
|
||||
'ic:baseline-filter-2',
|
||||
'ic:baseline-filter-3',
|
||||
'ic:baseline-filter-4',
|
||||
'ic:baseline-filter-5',
|
||||
'ic:baseline-filter-6',
|
||||
'ic:baseline-filter-7',
|
||||
'ic:baseline-filter-8',
|
||||
'ic:baseline-filter-9',
|
||||
'ic:baseline-filter-9-plus'
|
||||
];
|
||||
53
src/views/plugin/icon/index.vue
Normal file
53
src/views/plugin/icon/index.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { icons } from './icons';
|
||||
|
||||
defineOptions({ name: 'IconPage' });
|
||||
|
||||
const selectValue = ref('');
|
||||
|
||||
const localIcons = ['custom-icon', 'activity', 'at-sign', 'cast', 'chrome', 'copy', 'wind'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<ElCard header="Icon组件示例" class="card-wrapper">
|
||||
<div class="grid grid-cols-10">
|
||||
<template v-for="item in icons" :key="item">
|
||||
<div class="mt-5px flex-x-center">
|
||||
<SvgIcon :icon="item" class="text-30px" />
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="mt-50px">
|
||||
<h1 class="mb-20px text-18px font-500">Icon图标选择器</h1>
|
||||
<CustomIconSelect v-model:value="selectValue" :icons="icons" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<WebSiteLink label="iconify地址:" link="https://icones.js.org/" class="mt-10px" />
|
||||
</template>
|
||||
</ElCard>
|
||||
<ElCard header="自定义图标示例" class="mt-10px card-wrapper">
|
||||
<div class="pb-12px text-16px">
|
||||
在src/assets/svg-icon文件夹下的svg文件,通过在template里面以 icon-local-{文件名} 直接渲染,
|
||||
其中icon-local为.env文件里的 VITE_ICON_LOCAL_PREFIX
|
||||
</div>
|
||||
<div class="grid grid-cols-10">
|
||||
<div class="mt-5px flex-x-center">
|
||||
<icon-local-activity class="text-40px text-success" />
|
||||
</div>
|
||||
<div class="mt-5px flex-x-center">
|
||||
<icon-local-cast class="text-20px text-error" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-12px text-16px">通过SvgIcon组件动态渲染, 菜单通过meta的localIcon属性渲染自定义图标</div>
|
||||
<div class="grid grid-cols-10">
|
||||
<div v-for="(fileName, index) in localIcons" :key="index" class="mt-5px flex-x-center">
|
||||
<SvgIcon :local-icon="fileName" class="text-30px text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
32
src/views/plugin/map/components/baidu-map.vue
Normal file
32
src/views/plugin/map/components/baidu-map.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useScriptTag } from '@vueuse/core';
|
||||
import { BAIDU_MAP_SDK_URL } from '@/constants/map-sdk';
|
||||
|
||||
defineOptions({ name: 'BaiduMap' });
|
||||
|
||||
window.HOST_TYPE = '2';
|
||||
|
||||
const { load } = useScriptTag(BAIDU_MAP_SDK_URL);
|
||||
|
||||
const domRef = ref<HTMLDivElement>();
|
||||
|
||||
async function renderMap() {
|
||||
await load(true);
|
||||
if (!domRef.value) return;
|
||||
const map = new BMap.Map(domRef.value);
|
||||
const point = new BMap.Point(114.05834626586915, 22.546789983033168);
|
||||
map.centerAndZoom(point, 15);
|
||||
map.enableScrollWheelZoom();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
renderMap();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="domRef" class="h-full w-full"></div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
32
src/views/plugin/map/components/gaode-map.vue
Normal file
32
src/views/plugin/map/components/gaode-map.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useScriptTag } from '@vueuse/core';
|
||||
import { AMAP_SDK_URL } from '@/constants/map-sdk';
|
||||
|
||||
defineOptions({ name: 'GaodeMap' });
|
||||
|
||||
const { load } = useScriptTag(AMAP_SDK_URL);
|
||||
|
||||
const domRef = ref<HTMLDivElement>();
|
||||
|
||||
async function renderMap() {
|
||||
await load(true);
|
||||
if (!domRef.value) return;
|
||||
const map = new AMap.Map(domRef.value, {
|
||||
zoom: 11,
|
||||
center: [114.05834626586915, 22.546789983033168],
|
||||
viewMode: '3D'
|
||||
});
|
||||
map.getCenter();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
renderMap();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="domRef" class="h-full w-full"></div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
5
src/views/plugin/map/components/index.ts
Normal file
5
src/views/plugin/map/components/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import BaiduMap from './baidu-map.vue';
|
||||
import GaodeMap from './gaode-map.vue';
|
||||
import TencentMap from './tencent-map.vue';
|
||||
|
||||
export { BaiduMap, GaodeMap, TencentMap };
|
||||
32
src/views/plugin/map/components/tencent-map.vue
Normal file
32
src/views/plugin/map/components/tencent-map.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useScriptTag } from '@vueuse/core';
|
||||
import { TENCENT_MAP_SDK_URL } from '@/constants/map-sdk';
|
||||
|
||||
defineOptions({ name: 'TencentMap' });
|
||||
|
||||
const { load } = useScriptTag(TENCENT_MAP_SDK_URL);
|
||||
|
||||
const domRef = ref<HTMLDivElement | null>(null);
|
||||
|
||||
async function renderMap() {
|
||||
await load(true);
|
||||
if (!domRef.value) return;
|
||||
// eslint-disable-next-line no-new
|
||||
new TMap.Map(domRef.value, {
|
||||
center: new TMap.LatLng(39.98412, 116.307484),
|
||||
zoom: 11,
|
||||
viewMode: '3D'
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
renderMap();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="domRef" class="h-full w-full"></div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
42
src/views/plugin/map/index.vue
Normal file
42
src/views/plugin/map/index.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { type Component, ref } from 'vue';
|
||||
import { BaiduMap, GaodeMap, TencentMap } from './components';
|
||||
|
||||
defineOptions({ name: 'MapComp' });
|
||||
|
||||
interface Map {
|
||||
id: string;
|
||||
label: string;
|
||||
component: Component;
|
||||
}
|
||||
|
||||
const maps: Map[] = [
|
||||
{ id: 'gaode', label: '高德地图', component: GaodeMap },
|
||||
{ id: 'tencent', label: '腾讯地图', component: TencentMap },
|
||||
{ id: 'baidu', label: '百度地图', component: BaiduMap }
|
||||
];
|
||||
|
||||
const activeMap = ref(maps[0].id);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<ElCard header="地图插件" class="h-full" content-style="overflow:hidden">
|
||||
<ElTabs class="h-full">
|
||||
<ElTabPane
|
||||
v-for="item in maps"
|
||||
:key="item.id"
|
||||
v-model="activeMap"
|
||||
class="h-full"
|
||||
:value="item.id"
|
||||
:label="item.label"
|
||||
lazy
|
||||
>
|
||||
<component :is="item.component" />
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
91
src/views/plugin/pdf/index.vue
Normal file
91
src/views/plugin/pdf/index.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, shallowRef } from 'vue';
|
||||
import VuePdfEmbed from 'vue-pdf-embed';
|
||||
import { useLoading } from '@sa/hooks';
|
||||
|
||||
defineOptions({ name: 'PdfPage' });
|
||||
|
||||
const { loading, endLoading } = useLoading(true);
|
||||
|
||||
const pdfRef = shallowRef<InstanceType<typeof VuePdfEmbed> | null>(null);
|
||||
const source = `https://xiaoxian521.github.io/hyperlink/pdf/Cookie%E5%92%8CSession%E5%8C%BA%E5%88%AB%E7%94%A8%E6%B3%95.pdf`;
|
||||
|
||||
const showAllPages = ref(false);
|
||||
const currentPage = ref<number>(1);
|
||||
const pageCount = ref(1);
|
||||
|
||||
function onPdfRendered() {
|
||||
endLoading();
|
||||
|
||||
if (pdfRef.value?.doc) {
|
||||
pageCount.value = pdfRef.value.doc.numPages;
|
||||
}
|
||||
}
|
||||
|
||||
function showAllPagesChange() {
|
||||
currentPage.value = 1;
|
||||
}
|
||||
|
||||
const rotations = [0, 90, 180, 270];
|
||||
const currentRotation = ref(0);
|
||||
|
||||
function handleRotate() {
|
||||
currentRotation.value = (currentRotation.value + 1) % 4;
|
||||
}
|
||||
|
||||
async function handlePrint() {
|
||||
await pdfRef.value?.print(undefined, 'test.pdf', true);
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
await pdfRef.value?.download('test.pdf');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-hidden">
|
||||
<ElCard header="PDF 预览" class="h-full card-wrapper" content-class="overflow-hidden">
|
||||
<div class="h-[calc(100%-30px)] flex-col-stretch">
|
||||
<GithubLink link="https://github.com/hrynko/vue-pdf-embed" />
|
||||
<WebSiteLink label="文档地址:" link="https://www.npmjs.com/package/vue-pdf-embed" />
|
||||
<div class="flex-y-center justify-end gap-12px">
|
||||
<ElCheckbox v-model="showAllPages" @change="showAllPagesChange">显示所有页面</ElCheckbox>
|
||||
<ButtonIcon tooltip-content="旋转90度" @click="handleRotate">
|
||||
<icon-material-symbols-light:rotate-90-degrees-ccw-outline-rounded />
|
||||
</ButtonIcon>
|
||||
<ButtonIcon tooltip-content="打印" @click="handlePrint">
|
||||
<icon-mdi:printer />
|
||||
</ButtonIcon>
|
||||
<ButtonIcon tooltip-content="下载" @click="handleDownload">
|
||||
<icon-charm:download />
|
||||
</ButtonIcon>
|
||||
</div>
|
||||
<ElScrollbar class="flex-1-hidden">
|
||||
<NSkeleton v-if="loading" size="small" class="mt-12px" text :repeat="12" />
|
||||
<VuePdfEmbed
|
||||
ref="pdfRef"
|
||||
class="container overflow-auto"
|
||||
:class="{ 'h-0': loading }"
|
||||
:rotation="rotations[currentRotation]"
|
||||
:page="currentPage"
|
||||
:source="source"
|
||||
@rendered="onPdfRendered"
|
||||
/>
|
||||
</ElScrollbar>
|
||||
<div class="flex-y-center justify-between">
|
||||
<div v-if="showAllPages" class="text-18px font-medium">共{{ pageCount }}页</div>
|
||||
<ElPagination
|
||||
v-else
|
||||
key="pdf-page"
|
||||
layout="prev, pager, next"
|
||||
background
|
||||
:page-count="pageCount"
|
||||
@current-change="currentPage = $event"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
59
src/views/plugin/pinyin/index.vue
Normal file
59
src/views/plugin/pinyin/index.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { html } from 'pinyin-pro';
|
||||
import domPurify from 'dompurify';
|
||||
|
||||
defineOptions({ name: 'PinyinPage' });
|
||||
|
||||
const domRef = ref<HTMLElement | null>(null);
|
||||
const domRef2 = ref<HTMLElement | null>(null);
|
||||
const domRef3 = ref<HTMLElement | null>(null);
|
||||
|
||||
function renderHtml() {
|
||||
if (!domRef.value || !domRef2.value || !domRef3.value) return;
|
||||
|
||||
const text = 'CN-RDMS 是灿能电力内部使用的研发管理系统前端项目';
|
||||
|
||||
const code = domPurify.sanitize(html(text));
|
||||
const code2 = domPurify.sanitize(html(text, { toneType: 'none' }));
|
||||
|
||||
domRef.value.innerHTML = code;
|
||||
domRef2.value.innerHTML = code2;
|
||||
domRef3.value.innerHTML = code;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
renderHtml();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ElCard header="pinyin 插件" class="h-full card-wrapper">
|
||||
<ElSpace :vertical="true">
|
||||
<GithubLink link="https://github.com/zh-lx/pinyin-pro" />
|
||||
<WebSiteLink label="文档地址:" link="https://pinyin-pro.cn/" />
|
||||
</ElSpace>
|
||||
<ElDivider content-position="left">常规使用</ElDivider>
|
||||
<p ref="domRef" class="text-18px"></p>
|
||||
<ElDivider content-position="left">不带音调</ElDivider>
|
||||
<p ref="domRef2" class="text-18px"></p>
|
||||
<ElDivider content-position="left">自定义样式</ElDivider>
|
||||
<p ref="domRef3" class="custom-style text-18px"></p>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.custom-style {
|
||||
:deep(.py-result-item) {
|
||||
.py-chinese-item {
|
||||
--uno: text-primary;
|
||||
}
|
||||
|
||||
.py-pinyin-item {
|
||||
--uno: text-error;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
41
src/views/plugin/print/index.vue
Normal file
41
src/views/plugin/print/index.vue
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts" setup>
|
||||
import printJS from 'print-js';
|
||||
|
||||
defineOptions({ name: 'PrintPage' });
|
||||
|
||||
function printTable() {
|
||||
printJS({
|
||||
printable: [
|
||||
{ name: 'CN-RDMS', wechat: 'internal', remark: '内部演示数据' },
|
||||
{ name: 'CN-RDMS', wechat: 'internal', remark: '内部演示数据' }
|
||||
],
|
||||
properties: ['name', 'wechat', 'remark'],
|
||||
type: 'json'
|
||||
});
|
||||
}
|
||||
function printImage() {
|
||||
printJS({
|
||||
printable: [
|
||||
'https://i.loli.net/2021/11/24/1J6REWXiHomU2kM.jpg',
|
||||
'https://i.loli.net/2021/11/24/1J6REWXiHomU2kM.jpg'
|
||||
],
|
||||
type: 'image',
|
||||
header: 'Multiple Images',
|
||||
imageStyle: 'width:100%;'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<ElCard header="打印" class="card-wrapper">
|
||||
<ElButton type="primary" class="mr-10px" @click="printTable">打印表格</ElButton>
|
||||
<ElButton type="primary" @click="printImage">打印图片</ElButton>
|
||||
<template #footer>
|
||||
<GithubLink label="printJS:" link="https://github.com/crabbly/Print.js" class="mt-10px" />
|
||||
</template>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
60
src/views/plugin/swiper/index.vue
Normal file
60
src/views/plugin/swiper/index.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
import SwiperCore from 'swiper';
|
||||
import { Navigation, Pagination } from 'swiper/modules';
|
||||
import { Swiper, SwiperSlide } from 'swiper/vue';
|
||||
import type { SwiperOptions } from 'swiper/types';
|
||||
|
||||
defineOptions({ name: 'SwiperComp' });
|
||||
|
||||
type SwiperExampleOptions = Pick<
|
||||
SwiperOptions,
|
||||
'navigation' | 'pagination' | 'scrollbar' | 'slidesPerView' | 'slidesPerGroup' | 'spaceBetween' | 'direction' | 'loop'
|
||||
>;
|
||||
|
||||
interface SwiperExample {
|
||||
id: number;
|
||||
label: string;
|
||||
options: Partial<SwiperExampleOptions>;
|
||||
}
|
||||
|
||||
SwiperCore.use([Navigation, Pagination]);
|
||||
|
||||
const swiperExample: SwiperExample[] = [
|
||||
{ id: 0, label: 'Default', options: {} },
|
||||
{ id: 1, label: 'Navigation', options: { navigation: true } },
|
||||
{ id: 2, label: 'Pagination', options: { pagination: true } },
|
||||
{ id: 3, label: 'Pagination dynamic', options: { pagination: { dynamicBullets: true } } },
|
||||
{ id: 4, label: 'Pagination progress', options: { navigation: true, pagination: { type: 'progressbar' } } },
|
||||
{ id: 5, label: 'Pagination fraction', options: { navigation: true, pagination: { type: 'fraction' } } },
|
||||
{ id: 6, label: 'Slides per view', options: { pagination: { clickable: true }, slidesPerView: 3, spaceBetween: 30 } },
|
||||
{ id: 7, label: 'Infinite loop', options: { navigation: true, pagination: { clickable: true }, loop: true } }
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ElCard header="Swiper插件" class="card-wrapper">
|
||||
<ElSpace :vertical="true">
|
||||
<GithubLink link="https://github.com/nolimits4web/swiper" />
|
||||
<WebSiteLink label="vue3版文档地址:" link="https://swiperjs.com/vue" />
|
||||
<WebSiteLink label="插件demo地址:" link="https://swiperjs.com/demos" />
|
||||
</ElSpace>
|
||||
<ElSpace class="w-full" direction="vertical">
|
||||
<div v-for="item in swiperExample" :key="item.id" class="w-full">
|
||||
<h3 class="py-24px text-24px font-bold">{{ item.label }}</h3>
|
||||
<Swiper v-bind="item.options">
|
||||
<SwiperSlide v-for="i in 5" :key="i">
|
||||
<div class="h-240px w-full flex-center border-1px border-#999 text-18px font-bold">Slide{{ i }}</div>
|
||||
</SwiperSlide>
|
||||
</Swiper>
|
||||
</div>
|
||||
</ElSpace>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.el-space__item) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
1044
src/views/plugin/tables/vtable/data.ts
Normal file
1044
src/views/plugin/tables/vtable/data.ts
Normal file
File diff suppressed because it is too large
Load Diff
408
src/views/plugin/tables/vtable/index.vue
Normal file
408
src/views/plugin/tables/vtable/index.vue
Normal file
@@ -0,0 +1,408 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import {
|
||||
Group,
|
||||
Image,
|
||||
ListColumn,
|
||||
ListTable,
|
||||
Menu,
|
||||
PivotChart,
|
||||
PivotColumnDimension,
|
||||
PivotCorner,
|
||||
PivotIndicator,
|
||||
PivotRowDimension,
|
||||
PivotTable,
|
||||
Tag,
|
||||
Text,
|
||||
VTable,
|
||||
registerChartModule
|
||||
} from '@visactor/vue-vtable';
|
||||
import VChart from '@visactor/vchart';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { customListRecords, listTableRecords, pivotChartColumns, pivotChartIndicators, pivotChartRows } from './data';
|
||||
|
||||
registerChartModule('vchart', VChart);
|
||||
const titleColorPool = ['#3370ff', '#34c724', '#ff9f1a', '#ff4050', '#1f2329'];
|
||||
|
||||
const themeStore = useThemeStore();
|
||||
|
||||
// list table
|
||||
const listTableRef = ref(null);
|
||||
const listOptions = computed(() => {
|
||||
const options = {
|
||||
theme: themeStore.darkMode ? VTable.themes.DARK : VTable.themes.DEFAULT
|
||||
};
|
||||
return options;
|
||||
});
|
||||
const listRecords = ref<Record<string, string | number>[]>(listTableRecords);
|
||||
|
||||
// group table
|
||||
const groupTableRef = ref(null);
|
||||
const groupOptions = computed(() => {
|
||||
const options = {
|
||||
groupBy: ['Category', 'Sub-Category'],
|
||||
theme: (themeStore.darkMode ? VTable.themes.DARK : VTable.themes.DEFAULT).extends({
|
||||
groupTitleStyle: {
|
||||
fontWeight: 'bold',
|
||||
bgColor: (args: any) => {
|
||||
const { col, row, table } = args;
|
||||
const index = table.getGroupTitleLevel(col, row);
|
||||
if (index !== undefined) {
|
||||
return titleColorPool[index % titleColorPool.length] as string;
|
||||
}
|
||||
return 'white';
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
return options;
|
||||
});
|
||||
const groupRecords = ref<Record<string, string | number>[]>(listTableRecords);
|
||||
|
||||
// pivot table
|
||||
const pivotTableRef = ref(null);
|
||||
const pivotTableOptions = computed(() => {
|
||||
return {
|
||||
tooltip: {
|
||||
isShowOverflowTextTooltip: true
|
||||
},
|
||||
dataConfig: {
|
||||
sortRules: [
|
||||
{
|
||||
sortField: 'Category',
|
||||
sortBy: ['Office Supplies', 'Technology', 'Furniture']
|
||||
}
|
||||
]
|
||||
},
|
||||
widthMode: 'standard',
|
||||
theme: themeStore.darkMode ? VTable.themes.DARK : VTable.themes.DEFAULT,
|
||||
emptyTip: {
|
||||
text: 'no data records'
|
||||
}
|
||||
};
|
||||
});
|
||||
const pivotTableIndicators = ref([
|
||||
{
|
||||
indicatorKey: 'Quantity',
|
||||
title: 'Quantity',
|
||||
width: 'auto',
|
||||
showSort: false,
|
||||
headerStyle: { fontWeight: 'normal' },
|
||||
style: {
|
||||
padding: [16, 28, 16, 28],
|
||||
color(args: any) {
|
||||
return args.dataValue >= 0 ? 'black' : 'red';
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
indicatorKey: 'Sales',
|
||||
title: 'Sales',
|
||||
width: 'auto',
|
||||
showSort: false,
|
||||
headerStyle: { fontWeight: 'normal' },
|
||||
format: (rec: string) => `$${Number(rec).toFixed(2)}`,
|
||||
style: {
|
||||
padding: [16, 28, 16, 28],
|
||||
color(args: any) {
|
||||
return args.dataValue >= 0 ? 'black' : 'red';
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
indicatorKey: 'Profit',
|
||||
title: 'Profit',
|
||||
width: 'auto',
|
||||
showSort: false,
|
||||
headerStyle: { fontWeight: 'normal' },
|
||||
format: (rec: string) => `$${Number(rec).toFixed(2)}`,
|
||||
style: {
|
||||
padding: [16, 28, 16, 28],
|
||||
color(args: any) {
|
||||
return args.dataValue >= 0 ? 'black' : 'red';
|
||||
}
|
||||
}
|
||||
}
|
||||
]);
|
||||
const pivotTableRows = ref([
|
||||
{
|
||||
dimensionKey: 'City',
|
||||
title: 'City',
|
||||
headerStyle: { textStick: true },
|
||||
width: 'auto'
|
||||
}
|
||||
]);
|
||||
const pivotTableRecords = ref([]);
|
||||
|
||||
// pivot chart
|
||||
const pivotChartRef = ref(null);
|
||||
const pivotChartOptions = computed(() => {
|
||||
return {
|
||||
rows: pivotChartRows,
|
||||
columns: pivotChartColumns,
|
||||
indicators: pivotChartIndicators,
|
||||
indicatorsAsCol: false,
|
||||
defaultRowHeight: 200,
|
||||
defaultHeaderRowHeight: 50,
|
||||
defaultColWidth: 280,
|
||||
defaultHeaderColWidth: 100,
|
||||
indicatorTitle: '指标',
|
||||
autoWrapText: true,
|
||||
corner: {
|
||||
titleOnDimension: 'row',
|
||||
headerStyle: { autoWrapText: true }
|
||||
},
|
||||
legends: {
|
||||
orient: 'bottom',
|
||||
type: 'discrete',
|
||||
data: [
|
||||
{ label: 'Consumer-Quantity', shape: { fill: '#2E62F1', symbolType: 'circle' } },
|
||||
{ label: 'Consumer-Quantity', shape: { fill: '#4DC36A', symbolType: 'square' } },
|
||||
{ label: 'Home Office-Quantity', shape: { fill: '#FF8406', symbolType: 'square' } },
|
||||
{ label: 'Consumer-Sales', shape: { fill: '#FFCC00', symbolType: 'square' } },
|
||||
{ label: 'Consumer-Sales', shape: { fill: '#4F44CF', symbolType: 'square' } },
|
||||
{ label: 'Home Office-Sales', shape: { fill: '#5AC8FA', symbolType: 'square' } },
|
||||
{ label: 'Consumer-Profit', shape: { fill: '#003A8C', symbolType: 'square' } },
|
||||
{ label: 'Consumer-Profit', shape: { fill: '#B08AE2', symbolType: 'square' } },
|
||||
{ label: 'Home Office-Profit', shape: { fill: '#FF6341', symbolType: 'square' } }
|
||||
]
|
||||
},
|
||||
theme: (themeStore.darkMode ? VTable.themes.DARK : VTable.themes.DEFAULT).extends({
|
||||
bodyStyle: { borderColor: 'gray', borderLineWidth: [1, 0, 0, 1] },
|
||||
headerStyle: { borderColor: 'gray', borderLineWidth: [0, 0, 1, 1], hover: { cellBgColor: '#CCE0FF' } },
|
||||
rowHeaderStyle: { borderColor: 'gray', borderLineWidth: [1, 1, 0, 0], hover: { cellBgColor: '#CCE0FF' } },
|
||||
cornerHeaderStyle: { borderColor: 'gray', borderLineWidth: [0, 1, 1, 0], hover: { cellBgColor: '' } },
|
||||
cornerRightTopCellStyle: { borderColor: 'gray', borderLineWidth: [0, 0, 1, 1], hover: { cellBgColor: '' } },
|
||||
cornerLeftBottomCellStyle: { borderColor: 'gray', borderLineWidth: [1, 1, 0, 0], hover: { cellBgColor: '' } },
|
||||
cornerRightBottomCellStyle: { borderColor: 'gray', borderLineWidth: [1, 0, 0, 1], hover: { cellBgColor: '' } },
|
||||
rightFrozenStyle: { borderColor: 'gray', borderLineWidth: [1, 0, 1, 1], hover: { cellBgColor: '' } },
|
||||
bottomFrozenStyle: { borderColor: 'gray', borderLineWidth: [1, 1, 0, 1], hover: { cellBgColor: '' } },
|
||||
selectionStyle: { cellBgColor: '', cellBorderColor: '' },
|
||||
frameStyle: { borderLineWidth: 0 }
|
||||
}),
|
||||
emptyTip: {
|
||||
text: 'no data records'
|
||||
}
|
||||
};
|
||||
});
|
||||
const pivotChartRecords = ref([] as any);
|
||||
const handleLegendItemClick = (args: { value: any }) => {
|
||||
(pivotChartRef?.value as any)?.vTableInstance.updateFilterRules([
|
||||
{
|
||||
filterKey: 'Segment-Indicator',
|
||||
filteredValues: args.value
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
// custom layout list table
|
||||
const customLayoutListTableRef = ref(null);
|
||||
const customLayoutListTableOptions = computed(() => {
|
||||
return {
|
||||
defaultRowHeight: 80,
|
||||
theme: themeStore.darkMode ? VTable.themes.DARK : VTable.themes.DEFAULT
|
||||
};
|
||||
});
|
||||
const customLayoutListTableRecords = ref(customListRecords);
|
||||
const customLayoutListTableColumnStyle = ref({ fontFamily: 'Arial', fontSize: 12, fontWeight: 'bold' });
|
||||
|
||||
onMounted(() => {
|
||||
// pivot tablt records
|
||||
fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/North_American_Superstore_Pivot_data.json')
|
||||
.then(res => res.json())
|
||||
.then(jsonData => {
|
||||
// update record
|
||||
pivotTableRecords.value = jsonData;
|
||||
});
|
||||
|
||||
// pivot chart records
|
||||
fetch('https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/North_American_Superstore_Pivot_Chart_data.json')
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
// update record
|
||||
pivotChartRecords.value = data;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<ElSpace fill direction="vertical" class="mb-16px w-full" :size="16">
|
||||
<ElCard header="List Table" class="h-full w-2/3 card-wrapper">
|
||||
<ListTable ref="listTableRef" :options="listOptions" :records="listRecords" height="400px">
|
||||
<ListColumn field="Order ID" title="Order ID" width="auto" />
|
||||
<ListColumn field="Customer ID" title="Customer ID" width="auto" />
|
||||
<ListColumn field="Product Name" title="Product Name" width="auto" />
|
||||
<ListColumn field="Category" title="Category" width="auto" />
|
||||
<ListColumn field="Sub-Category" title="Sub-Category" width="auto" />
|
||||
<ListColumn field="Region" title="Region" width="auto" />
|
||||
<ListColumn field="City" title="City" width="auto" />
|
||||
<ListColumn field="Order Date" title="Order Date" width="auto" />
|
||||
<ListColumn field="Quantity" title="Quantity" width="auto" />
|
||||
<ListColumn field="Sales" title="Sales" width="auto" />
|
||||
<ListColumn field="Profit" title="Profit" width="auto" />
|
||||
</ListTable>
|
||||
</ElCard>
|
||||
|
||||
<ElCard header="Group Table" class="h-full w-2/3 card-wrapper">
|
||||
<ListTable ref="groupTableRef" :options="groupOptions" :records="groupRecords" height="400px">
|
||||
<ListColumn field="Order ID" title="Order ID" width="auto" />
|
||||
<ListColumn field="Customer ID" title="Customer ID" width="auto" />
|
||||
<ListColumn field="Product Name" title="Product Name" width="auto" />
|
||||
<ListColumn field="Category" title="Category" width="auto" />
|
||||
<ListColumn field="Sub-Category" title="Sub-Category" width="auto" />
|
||||
<ListColumn field="Region" title="Region" width="auto" />
|
||||
<ListColumn field="City" title="City" width="auto" />
|
||||
<ListColumn field="Order Date" title="Order Date" width="auto" />
|
||||
<ListColumn field="Quantity" title="Quantity" width="auto" />
|
||||
<ListColumn field="Sales" title="Sales" width="auto" />
|
||||
<ListColumn field="Profit" title="Profit" width="auto" />
|
||||
</ListTable>
|
||||
</ElCard>
|
||||
|
||||
<ElCard header="Pivot Table" class="h-full w-2/3 card-wrapper">
|
||||
<PivotTable ref="pivotTableRef" :options="pivotTableOptions" :records="pivotTableRecords" height="400px">
|
||||
<PivotColumnDimension
|
||||
title="Category"
|
||||
dimension-key="Category"
|
||||
:header-style="{ textStick: true }"
|
||||
width="auto"
|
||||
/>
|
||||
<PivotRowDimension
|
||||
v-for="(row, index) in pivotTableRows"
|
||||
:key="index"
|
||||
:dimension-key="row.dimensionKey"
|
||||
:title="row.title"
|
||||
:header-style="row.headerStyle"
|
||||
:width="row.width"
|
||||
/>
|
||||
<PivotIndicator
|
||||
v-for="(indicator, index) in pivotTableIndicators"
|
||||
:key="index"
|
||||
:indicator-key="indicator.indicatorKey"
|
||||
:title="indicator.title"
|
||||
:width="indicator.width"
|
||||
:show-sort="indicator.showSort"
|
||||
:header-style="indicator.headerStyle"
|
||||
:format="indicator.format"
|
||||
:style="indicator.style"
|
||||
/>
|
||||
<PivotCorner title-on-dimension="row" />
|
||||
<Menu menu-type="html" :context-menu-items="['copy', 'paste', 'delete', '...']" />
|
||||
</PivotTable>
|
||||
</ElCard>
|
||||
|
||||
<ElCard header="Pivot Chart" class="h-full w-2/3 card-wrapper">
|
||||
<PivotChart
|
||||
ref="pivotChartRef"
|
||||
:options="pivotChartOptions"
|
||||
:records="pivotChartRecords"
|
||||
height="800px"
|
||||
@on-legend-item-click="handleLegendItemClick"
|
||||
/>
|
||||
</ElCard>
|
||||
|
||||
<ElCard header="Custom Component" class="h-full w-2/3 card-wrapper">
|
||||
<ListTable
|
||||
ref="customLayoutListTableRef"
|
||||
:options="customLayoutListTableOptions"
|
||||
:records="customLayoutListTableRecords"
|
||||
height="400px"
|
||||
>
|
||||
<!-- Order Number Column -->
|
||||
<ListColumn field="bloggerId" title="Order Number" width="100" />
|
||||
|
||||
<!-- Anchor Nickname Column with Custom Layout -->
|
||||
<ListColumn field="bloggerName" title="Anchor Nickname" :width="330">
|
||||
<template #customLayout="{ record, height, width }">
|
||||
<Group :height="height" :width="width" display="flex" flex-direction="row" flex-wrap="nowrap">
|
||||
<!-- Avatar Group -->
|
||||
<Group
|
||||
:height="height"
|
||||
:width="60"
|
||||
display="flex"
|
||||
flex-direction="column"
|
||||
align-items="center"
|
||||
justify-content="space-around"
|
||||
fill="red"
|
||||
:opacity="0.1"
|
||||
>
|
||||
<Image id="icon0" :width="50" :height="50" :image="record.bloggerAvatar" :corner-radius="25" />
|
||||
</Group>
|
||||
<!-- Blogger Info Group -->
|
||||
<Group :height="height" :width="width - 60" display="flex" flex-direction="column" flex-wrap="nowrap">
|
||||
<Group
|
||||
:height="height / 2"
|
||||
:width="width - 60"
|
||||
display="flex"
|
||||
flex-wrap="wrap"
|
||||
align-items="center"
|
||||
fill="orange"
|
||||
:opacity="0.1"
|
||||
>
|
||||
<Text
|
||||
:text="record.bloggerName"
|
||||
:font-size="13"
|
||||
font-family="sans-serif"
|
||||
fill="black"
|
||||
:bounds-padding="[0, 0, 0, 10]"
|
||||
/>
|
||||
<Image
|
||||
id="location"
|
||||
image="https://lf9-dp-fe-cms-tos.byteorg.com/obj/bit-cloud/VTable/location.svg"
|
||||
:width="15"
|
||||
:height="15"
|
||||
:bounds-padding="[0, 0, 0, 10]"
|
||||
cursor="pointer"
|
||||
/>
|
||||
<Text :text="record.city" :font-size="11" font-family="sans-serif" fill="#6f7070" />
|
||||
</Group>
|
||||
<!-- Tags Group -->
|
||||
<Group
|
||||
:height="height / 2"
|
||||
:width="width - 60"
|
||||
display="flex"
|
||||
align-items="center"
|
||||
fill="yellow"
|
||||
:opacity="0.1"
|
||||
>
|
||||
<Tag
|
||||
v-for="tag in record?.tags"
|
||||
:key="tag"
|
||||
:text="tag"
|
||||
:text-style="{ fontSize: 10, fontFamily: 'sans-serif', fill: 'rgb(51, 101, 238)' }"
|
||||
:panel="{ visible: true, fill: '#f4f4f2', cornerRadius: 5 }"
|
||||
:space="5"
|
||||
:bounds-padding="[0, 0, 0, 5]"
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</Group>
|
||||
</template>
|
||||
</ListColumn>
|
||||
|
||||
<!-- Other Columns -->
|
||||
<ListColumn
|
||||
field="fansCount"
|
||||
title="Fans Count"
|
||||
width="120"
|
||||
:field-format="rec => rec.fansCount + 'w'"
|
||||
:style="customLayoutListTableColumnStyle"
|
||||
/>
|
||||
<ListColumn field="worksCount" title="Works Count" :style="customLayoutListTableColumnStyle" width="135" />
|
||||
<ListColumn
|
||||
field="viewCount"
|
||||
title="View Count"
|
||||
width="120"
|
||||
:field-format="rec => rec.viewCount + 'w'"
|
||||
:style="customLayoutListTableColumnStyle"
|
||||
/>
|
||||
</ListTable>
|
||||
</ElCard>
|
||||
|
||||
<ElCard class="h-full w-2/3 card-wrapper">
|
||||
<WebSiteLink label="More VTable Demos: " link="https://www.visactor.com/vtable/example" />
|
||||
</ElCard>
|
||||
</ElSpace>
|
||||
</div>
|
||||
</template>
|
||||
44
src/views/plugin/typeit/index.vue
Normal file
44
src/views/plugin/typeit/index.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, shallowRef } from 'vue';
|
||||
import TypeIt from 'typeit';
|
||||
import type { Options } from 'typeit';
|
||||
import type { El } from 'typeit/dist/types';
|
||||
|
||||
defineOptions({ name: 'TypeIt' });
|
||||
|
||||
const textRef = shallowRef<El>();
|
||||
|
||||
function init() {
|
||||
if (!textRef.value) return;
|
||||
|
||||
const options: Options = {
|
||||
strings: 'CN-RDMS 是灿能电力内部使用的研发管理系统前端项目',
|
||||
lifeLike: true,
|
||||
speed: 120,
|
||||
loop: true
|
||||
};
|
||||
|
||||
const initTypeIt = new TypeIt(textRef.value, options);
|
||||
|
||||
initTypeIt.go();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ElCard header="打字机 插件" class="h-full card-wrapper">
|
||||
<ElSpace direction="vertical">
|
||||
<GithubLink link="https://github.com/alexmacarthur/typeit" />
|
||||
<WebSiteLink label="文档地址:" link="https://www.typeitjs.com/docs/vanilla/usage/" />
|
||||
</ElSpace>
|
||||
<ElDivider content-position="left">基本示例</ElDivider>
|
||||
<span ref="textRef" class="text-18px"></span>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
43
src/views/plugin/video/index.vue
Normal file
43
src/views/plugin/video/index.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue';
|
||||
import Player from 'xgplayer';
|
||||
import 'xgplayer/dist/index.min.css';
|
||||
|
||||
defineOptions({ name: 'VideoComp' });
|
||||
|
||||
const domRef = ref<HTMLElement>();
|
||||
const player = ref<Player>();
|
||||
|
||||
function renderXgPlayer() {
|
||||
if (!domRef.value) return;
|
||||
const url = 'https://lf9-cdn-tos.bytecdntp.com/cdn/expire-1-M/byted-player-videos/1.0.0/xgplayer-demo.mp4';
|
||||
player.value = new Player({
|
||||
el: domRef.value,
|
||||
url,
|
||||
playbackRate: [0.5, 0.75, 1, 1.5, 2]
|
||||
});
|
||||
}
|
||||
function destroyXgPlayer() {
|
||||
player.value?.destroy();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
renderXgPlayer();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyXgPlayer();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<ElCard header="视频播放器插件" class="h-full card-wrapper">
|
||||
<div class="flex-center">
|
||||
<div ref="domRef" class="h-auto w-full shadow-md"></div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
531
src/views/system/dict/index.vue
Normal file
531
src/views/system/dict/index.vue
Normal file
@@ -0,0 +1,531 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { ElButton, ElPopconfirm, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import type { FlatResponseData } from '@sa/axios';
|
||||
import { dictStatusRecord } from '@/constants/business';
|
||||
import {
|
||||
fetchBatchDeleteDictData,
|
||||
fetchDeleteDictData,
|
||||
fetchDeleteDictType,
|
||||
fetchGetDictDataPage,
|
||||
fetchGetDictTypePage
|
||||
} from '@/service/api';
|
||||
import { useTableOperate, useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import { $t } from '@/locales';
|
||||
import DictDataOperateModal from './modules/dict-data-operate-modal.vue';
|
||||
import DictDataSearch from './modules/dict-data-search.vue';
|
||||
import DictTypeOperateModal from './modules/dict-type-operate-modal.vue';
|
||||
import DictTypeSearch from './modules/dict-type-search.vue';
|
||||
|
||||
defineOptions({ name: 'DictManage' });
|
||||
|
||||
function getInitDictTypeSearchParams(): Api.Dict.DictTypeSearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 200,
|
||||
name: undefined,
|
||||
type: undefined,
|
||||
status: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function getInitDictDataSearchParams(): Api.Dict.DictDataSearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
label: undefined,
|
||||
dictType: undefined,
|
||||
status: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyDictPageResult<ApiData>(): Promise<FlatResponseData<any, Api.Dict.PageResult<ApiData>>> {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
list: [],
|
||||
total: 0
|
||||
},
|
||||
error: null,
|
||||
response: undefined
|
||||
} as unknown as FlatResponseData<any, Api.Dict.PageResult<ApiData>>);
|
||||
}
|
||||
|
||||
function transformDictPage<ApiData>(
|
||||
response: FlatResponseData<any, Api.Dict.PageResult<ApiData>>,
|
||||
pageNo: number,
|
||||
pageSize: number
|
||||
) {
|
||||
// 项目通用表格 hook 默认消费 data/pageNum/pageSize/total,
|
||||
// 这里把后端字典接口的 list/total 结构适配过去。
|
||||
const { data, error } = response;
|
||||
|
||||
if (!error) {
|
||||
return {
|
||||
data: data.list,
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(value?: number | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
function getDictStatusLabel(status: Api.Dict.DictStatus) {
|
||||
return $t(dictStatusRecord[String(status) as '0' | '1']);
|
||||
}
|
||||
|
||||
function getDictStatusTagType(status: Api.Dict.DictStatus): UI.ThemeColor {
|
||||
return status === 0 ? 'success' : 'info';
|
||||
}
|
||||
|
||||
const dictTypeSearchParams = reactive(getInitDictTypeSearchParams());
|
||||
const dictDataSearchParams = reactive(getInitDictDataSearchParams());
|
||||
|
||||
const typeLoading = ref(false);
|
||||
const typeList = ref<Api.Dict.DictType[]>([]);
|
||||
const typeTotal = ref(0);
|
||||
const currentTypeId = ref<number | null>(null);
|
||||
|
||||
const currentType = computed(() => typeList.value.find(item => item.id === currentTypeId.value) ?? null);
|
||||
const currentTypeCode = computed(() => currentType.value?.type ?? '');
|
||||
|
||||
async function getDictTypeList() {
|
||||
typeLoading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetDictTypePage(dictTypeSearchParams);
|
||||
|
||||
typeLoading.value = false;
|
||||
|
||||
if (error) {
|
||||
typeList.value = [];
|
||||
typeTotal.value = 0;
|
||||
currentTypeId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
typeList.value = data.list;
|
||||
typeTotal.value = data.total;
|
||||
|
||||
if (!data.list.length) {
|
||||
currentTypeId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// 搜索后尽量保留原选中项;如果原项已不在结果中,则自动落到第一项。
|
||||
const matched = data.list.find(item => item.id === currentTypeId.value);
|
||||
|
||||
if (!matched) {
|
||||
currentTypeId.value = data.list[0].id;
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
drawerVisible: dictTypeModalVisible,
|
||||
operateType: dictTypeOperateType,
|
||||
editingData: editingDictTypeData,
|
||||
handleAdd: handleAddDictType,
|
||||
handleEdit: handleEditDictType,
|
||||
onDeleted: onDeletedDictType
|
||||
} = useTableOperate<Api.Dict.DictType>(typeList, 'id', getDictTypeList);
|
||||
|
||||
const {
|
||||
columns: dictDataColumns,
|
||||
columnChecks: dictDataColumnChecks,
|
||||
data: dictData,
|
||||
loading: dictDataLoading,
|
||||
getData: getDictData,
|
||||
getDataByPage: getDictDataByPage,
|
||||
mobilePagination: dictDataPagination
|
||||
} = useUIPaginatedTable<FlatResponseData<any, Api.Dict.PageResult<Api.Dict.DictData>>, Api.Dict.DictData>({
|
||||
paginationProps: {
|
||||
currentPage: dictDataSearchParams.pageNo,
|
||||
pageSize: dictDataSearchParams.pageSize
|
||||
},
|
||||
api: () => {
|
||||
if (!currentTypeCode.value) {
|
||||
// 左侧还没选中字典类型时,右侧直接返回空结果,避免发送无效请求。
|
||||
return createEmptyDictPageResult<Api.Dict.DictData>();
|
||||
}
|
||||
|
||||
return fetchGetDictDataPage({
|
||||
...dictDataSearchParams,
|
||||
dictType: currentTypeCode.value
|
||||
});
|
||||
},
|
||||
transform: response => transformDictPage(response, dictDataSearchParams.pageNo, dictDataSearchParams.pageSize),
|
||||
onPaginationParamsChange: params => {
|
||||
dictDataSearchParams.pageNo = params.currentPage ?? 1;
|
||||
dictDataSearchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'selection', type: 'selection', width: 48 },
|
||||
{ prop: 'index', type: 'index', label: $t('common.index'), width: 64 },
|
||||
{ prop: 'label', label: $t('page.system.dict.dictLabel'), minWidth: 160 },
|
||||
{ prop: 'value', label: $t('page.system.dict.dictValue'), minWidth: 180 },
|
||||
{ prop: 'sort', label: $t('page.system.dict.sort'), width: 90, align: 'center' },
|
||||
{
|
||||
prop: 'status',
|
||||
label: $t('page.system.dict.dictStatus'),
|
||||
width: 110,
|
||||
formatter: row => <ElTag type={getDictStatusTagType(row.status)}>{getDictStatusLabel(row.status)}</ElTag>
|
||||
},
|
||||
{ prop: 'remark', label: $t('page.system.dict.remark'), minWidth: 180, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'createTime',
|
||||
label: $t('common.update'),
|
||||
minWidth: 170,
|
||||
formatter: row => formatTime(row.createTime)
|
||||
},
|
||||
{
|
||||
prop: 'operate',
|
||||
label: $t('common.operate'),
|
||||
width: 180,
|
||||
align: 'center',
|
||||
formatter: row => (
|
||||
<div class="flex-center">
|
||||
<ElButton type="primary" plain size="small" onClick={() => editDictData(row.id)}>
|
||||
{$t('common.edit')}
|
||||
</ElButton>
|
||||
<ElPopconfirm title={$t('common.confirmDelete')} onConfirm={() => handleDeleteSingleDictData(row.id)}>
|
||||
{{
|
||||
reference: () => (
|
||||
<ElButton type="danger" plain size="small">
|
||||
{$t('common.delete')}
|
||||
</ElButton>
|
||||
)
|
||||
}}
|
||||
</ElPopconfirm>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const {
|
||||
drawerVisible: dictDataModalVisible,
|
||||
operateType: dictDataOperateType,
|
||||
editingData: editingDictData,
|
||||
handleAdd: handleAddDictDataBase,
|
||||
handleEdit: handleEditDictData,
|
||||
checkedRowKeys: checkedDictDataRowKeys,
|
||||
onBatchDeleted: onBatchDeletedDictData,
|
||||
onDeleted: onDeletedDictData
|
||||
} = useTableOperate<Api.Dict.DictData>(dictData, 'id', getDictData);
|
||||
|
||||
function selectType(item: Api.Dict.DictType) {
|
||||
currentTypeId.value = item.id;
|
||||
}
|
||||
|
||||
function openEditType(id: number) {
|
||||
currentTypeId.value = id;
|
||||
handleEditDictType(id);
|
||||
}
|
||||
|
||||
async function handleDeleteType(id: number) {
|
||||
const { error } = await fetchDeleteDictType(id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTypeId.value === id) {
|
||||
currentTypeId.value = null;
|
||||
}
|
||||
|
||||
await onDeletedDictType();
|
||||
}
|
||||
|
||||
function handleDictDataSelectionChange(rows: Api.Dict.DictData[]) {
|
||||
checkedDictDataRowKeys.value = rows.map(item => String(item.id));
|
||||
}
|
||||
|
||||
function handleAddDictData() {
|
||||
if (!currentType.value) {
|
||||
window.$message?.warning($t('page.system.dict.emptyType'));
|
||||
return;
|
||||
}
|
||||
|
||||
handleAddDictDataBase();
|
||||
}
|
||||
|
||||
function editDictData(id: number) {
|
||||
handleEditDictData(id);
|
||||
}
|
||||
|
||||
async function handleDeleteSingleDictData(id: number) {
|
||||
const { error } = await fetchDeleteDictData(id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onDeletedDictData();
|
||||
}
|
||||
|
||||
async function handleBatchDeleteDictDataRows() {
|
||||
const ids = checkedDictDataRowKeys.value.map(item => Number(item));
|
||||
|
||||
if (!ids.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchBatchDeleteDictData(ids);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
await onBatchDeletedDictData();
|
||||
}
|
||||
|
||||
async function handleSearchDictType() {
|
||||
dictTypeSearchParams.pageNo = 1;
|
||||
await getDictTypeList();
|
||||
}
|
||||
|
||||
async function handleSearchDictData() {
|
||||
dictDataSearchParams.pageNo = 1;
|
||||
await getDictDataByPage(1);
|
||||
}
|
||||
|
||||
async function resetDictDataSearchParams() {
|
||||
const pageSize = dictDataSearchParams.pageSize;
|
||||
|
||||
Object.assign(dictDataSearchParams, getInitDictDataSearchParams(), { pageSize });
|
||||
|
||||
await getDictDataByPage(1);
|
||||
}
|
||||
|
||||
async function handleTypeSubmitted() {
|
||||
await getDictTypeList();
|
||||
}
|
||||
|
||||
async function handleDataSubmitted() {
|
||||
await getDictData();
|
||||
}
|
||||
|
||||
watch(
|
||||
currentTypeCode,
|
||||
async (value, oldValue) => {
|
||||
if (value === oldValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 左侧切换类型时,右侧查询条件只保留分页大小,其余条件全部重置。
|
||||
const pageSize = dictDataSearchParams.pageSize;
|
||||
|
||||
Object.assign(dictDataSearchParams, getInitDictDataSearchParams(), {
|
||||
pageSize,
|
||||
dictType: value || undefined
|
||||
});
|
||||
|
||||
await getDictDataByPage(1);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
getDictTypeList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[360px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||
>
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<DictTypeSearch v-model:model="dictTypeSearchParams" @search="handleSearchDictType" />
|
||||
<ElCard v-loading="typeLoading" class="card-wrapper xl:flex-1-hidden" body-class="dict-type-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px py-2px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p>{{ $t('page.system.dict.typeTitle') }}</p>
|
||||
<ElTag effect="plain">{{ typeTotal }}</ElTag>
|
||||
</div>
|
||||
<ElButton type="primary" plain size="small" @click="handleAddDictType">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElScrollbar class="flex-1 px-10px py-10px">
|
||||
<div v-if="typeList.length" class="flex flex-col gap-8px">
|
||||
<div
|
||||
v-for="item in typeList"
|
||||
:key="item.id"
|
||||
class="group w-full cursor-pointer border rounded-12px px-12px py-10px text-left transition-all duration-200"
|
||||
:class="
|
||||
item.id === currentTypeId
|
||||
? 'border-primary bg-primary/6 shadow-sm shadow-primary/10'
|
||||
: 'border-[#ebeef5] bg-white hover:border-primary/45 hover:bg-[#fafcff]'
|
||||
"
|
||||
@click="selectType(item)"
|
||||
>
|
||||
<div class="flex items-center gap-10px">
|
||||
<icon-ep-success-filled v-if="item.status === 0" class="shrink-0 text-16px text-[#67c23a]" />
|
||||
<icon-ep-remove-filled v-else class="shrink-0 text-16px text-[#c0c4cc]" />
|
||||
<ElTooltip :content="`${item.name} / ${item.type}`" placement="top-start">
|
||||
<div class="min-w-0 flex-1 truncate text-14px text-[#1f2329] font-600">
|
||||
<span class="truncate align-middle">{{ item.name }}</span>
|
||||
<span class="mx-6px align-middle text-[#c0c4cc]">/</span>
|
||||
<span class="align-middle text-13px text-[#7d8592] font-500">{{ item.type }}</span>
|
||||
</div>
|
||||
</ElTooltip>
|
||||
<div
|
||||
class="flex shrink-0 items-center gap-2 opacity-75 transition-opacity duration-200 group-hover:opacity-100"
|
||||
@click.stop
|
||||
>
|
||||
<ElTooltip :content="$t('common.edit')">
|
||||
<ElButton link type="primary" @click="openEditType(item.id)">
|
||||
<icon-mdi-pencil-outline class="text-16px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElPopconfirm :title="$t('common.confirmDelete')" @confirm="handleDeleteType(item.id)">
|
||||
<template #reference>
|
||||
<span class="inline-flex">
|
||||
<ElTooltip :content="$t('common.delete')">
|
||||
<ElButton link type="danger">
|
||||
<icon-mdi-delete-outline class="text-16px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</span>
|
||||
</template>
|
||||
</ElPopconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="min-h-260px flex items-center justify-center">
|
||||
<ElEmpty :description="$t('common.noData')" />
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<DictDataSearch
|
||||
v-model:model="dictDataSearchParams"
|
||||
:disabled="!currentType"
|
||||
@reset="resetDictDataSearchParams"
|
||||
@search="handleSearchDictData"
|
||||
/>
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="dict-data-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex flex-wrap items-center gap-8px">
|
||||
<p>{{ $t('page.system.dict.dataTitle') }}</p>
|
||||
<ElTag v-if="currentType" type="primary" effect="light">
|
||||
{{ currentType.name }}
|
||||
</ElTag>
|
||||
<span v-if="currentType" class="rounded-full bg-[#f4f6f8] px-10px py-4px text-12px text-[#606266]">
|
||||
{{ currentType.type }}
|
||||
</span>
|
||||
<span v-else class="text-14px text-[#909399]">{{ $t('page.system.dict.emptyType') }}</span>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="dictDataColumnChecks"
|
||||
:disabled-delete="checkedDictDataRowKeys.length === 0 || !currentType"
|
||||
:loading="dictDataLoading"
|
||||
@refresh="getDictData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton plain type="primary" :disabled="!currentType" @click="handleAddDictData">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
<ElPopconfirm :title="$t('common.confirmDelete')" @confirm="handleBatchDeleteDictDataRows">
|
||||
<template #reference>
|
||||
<ElButton type="danger" plain :disabled="checkedDictDataRowKeys.length === 0 || !currentType">
|
||||
<template #icon>
|
||||
<icon-ic-round-delete class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.batchDelete') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElPopconfirm>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="currentType">
|
||||
<div class="flex-1">
|
||||
<ElTable
|
||||
v-loading="dictDataLoading"
|
||||
height="100%"
|
||||
border
|
||||
row-key="id"
|
||||
:data="dictData"
|
||||
@selection-change="handleDictDataSelectionChange"
|
||||
>
|
||||
<ElTableColumn v-for="col in dictDataColumns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="dictDataPagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="dictDataPagination"
|
||||
@current-change="dictDataPagination['current-change']"
|
||||
@size-change="dictDataPagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="h-full flex items-center justify-center">
|
||||
<ElEmpty :description="$t('page.system.dict.emptyType')" />
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<DictTypeOperateModal
|
||||
v-model:visible="dictTypeModalVisible"
|
||||
:operate-type="dictTypeOperateType"
|
||||
:row-data="editingDictTypeData"
|
||||
@submitted="handleTypeSubmitted"
|
||||
/>
|
||||
<DictDataOperateModal
|
||||
v-model:visible="dictDataModalVisible"
|
||||
:operate-type="dictDataOperateType"
|
||||
:row-data="editingDictData"
|
||||
:current-type="currentType"
|
||||
@submitted="handleDataSubmitted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.dict-type-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.dict-data-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
203
src/views/system/dict/modules/dict-data-operate-modal.vue
Normal file
203
src/views/system/dict/modules/dict-data-operate-modal.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { dictStatusOptions } from '@/constants/business';
|
||||
import { fetchCreateDictData, fetchUpdateDictData } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'DictDataOperateModal' });
|
||||
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
rowData?: Api.Dict.DictData | null;
|
||||
currentType?: Api.Dict.DictType | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate, restoreValidation } = useForm();
|
||||
const { defaultRequiredRule } = useFormRules();
|
||||
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
const submitting = ref(false);
|
||||
|
||||
const title = computed(() => {
|
||||
const titleMap: Record<UI.TableOperateType, string> = {
|
||||
add: $t('page.system.dict.addData'),
|
||||
edit: $t('page.system.dict.editData')
|
||||
};
|
||||
|
||||
return titleMap[props.operateType];
|
||||
});
|
||||
|
||||
type Model = Api.Dict.SaveDictDataParams;
|
||||
|
||||
const model = ref(createDefaultModel());
|
||||
|
||||
const currentTypeName = computed(() => props.currentType?.name ?? '-');
|
||||
const currentTypeCode = computed(() => props.currentType?.type ?? model.value.dictType ?? '-');
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
label: '',
|
||||
value: '',
|
||||
dictType: '',
|
||||
sort: 0,
|
||||
status: 0,
|
||||
remark: ''
|
||||
};
|
||||
}
|
||||
|
||||
type RuleKey = Extract<keyof Model, 'label' | 'value' | 'sort' | 'status'>;
|
||||
|
||||
const rules: Record<RuleKey, App.Global.FormRule> = {
|
||||
label: defaultRequiredRule,
|
||||
value: defaultRequiredRule,
|
||||
sort: defaultRequiredRule,
|
||||
status: defaultRequiredRule
|
||||
};
|
||||
|
||||
function handleInitModel() {
|
||||
model.value = createDefaultModel();
|
||||
|
||||
// 新增字典数据时默认继承左侧当前选中的字典类型。
|
||||
const currentDictType = props.currentType?.type ?? '';
|
||||
model.value.dictType = currentDictType;
|
||||
|
||||
if (isEdit.value && props.rowData) {
|
||||
// 编辑时直接使用表格行数据回填,保持弹框打开速度。
|
||||
Object.assign(model.value, {
|
||||
label: props.rowData.label,
|
||||
value: props.rowData.value,
|
||||
dictType: props.rowData.dictType,
|
||||
sort: props.rowData.sort,
|
||||
status: props.rowData.status,
|
||||
remark: props.rowData.remark ?? ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
// 右侧数据必须依附左侧字典类型,没有选中类型时不允许提交。
|
||||
const dictType = model.value.dictType || props.currentType?.type;
|
||||
|
||||
if (!dictType) {
|
||||
window.$message?.warning($t('page.system.dict.emptyType'));
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const params: Api.Dict.SaveDictDataParams = {
|
||||
...model.value,
|
||||
dictType
|
||||
};
|
||||
|
||||
const request =
|
||||
isEdit.value && props.rowData
|
||||
? fetchUpdateDictData({ id: props.rowData.id, ...params })
|
||||
: fetchCreateDictData(params);
|
||||
|
||||
const { error } = await request;
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t(isEdit.value ? 'common.updateSuccess' : 'common.addSuccess'));
|
||||
|
||||
closeModal();
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(visible, value => {
|
||||
if (value) {
|
||||
handleInitModel();
|
||||
restoreValidation();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="md"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.dict.currentType')">
|
||||
<ElInput :model-value="currentTypeName" disabled />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.dict.dictCode')">
|
||||
<ElInput :model-value="currentTypeCode" disabled />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.dict.dictLabel')" prop="label">
|
||||
<ElInput v-model="model.label" :placeholder="$t('page.system.dict.form.dictLabel')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.dict.dictValue')" prop="value">
|
||||
<ElInput v-model="model.value" :placeholder="$t('page.system.dict.form.dictValue')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.dict.sort')" prop="sort">
|
||||
<ElInputNumber
|
||||
v-model="model.sort"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:placeholder="$t('page.system.dict.form.sort')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.dict.dictStatus')" prop="status">
|
||||
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
|
||||
<ElRadio v-for="{ label, value } in dictStatusOptions" :key="value" :value="value" :label="$t(label)" />
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.dict.remark')" prop="remark">
|
||||
<ElInput
|
||||
v-model="model.remark"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="$t('page.system.dict.form.remark')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
68
src/views/system/dict/modules/dict-data-search.vue
Normal file
68
src/views/system/dict/modules/dict-data-search.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { dictStatusOptions } from '@/constants/business';
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'DictDataSearch' });
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
disabled: false
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'reset'): void;
|
||||
(e: 'search'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<Api.Dict.DictDataSearchParams>('model', { required: true });
|
||||
|
||||
function handleReset() {
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
emit('search');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchPanel
|
||||
:model="model"
|
||||
:disabled="disabled"
|
||||
:action-col-lg="12"
|
||||
:action-col-md="8"
|
||||
@reset="handleReset"
|
||||
@search="handleSearch"
|
||||
>
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.dict.dictLabel')" prop="label">
|
||||
<ElInput
|
||||
v-model="model.label"
|
||||
clearable
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('page.system.dict.form.dictLabel')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.dict.dictStatus')" prop="status">
|
||||
<ElSelect
|
||||
v-model="model.status"
|
||||
clearable
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('page.system.dict.form.dictStatus')"
|
||||
>
|
||||
<ElOption v-for="{ label, value } in dictStatusOptions" :key="value" :label="$t(label)" :value="value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</TableSearchPanel>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
143
src/views/system/dict/modules/dict-type-operate-modal.vue
Normal file
143
src/views/system/dict/modules/dict-type-operate-modal.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { dictStatusOptions } from '@/constants/business';
|
||||
import { fetchCreateDictType, fetchUpdateDictType } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'DictTypeOperateModal' });
|
||||
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
rowData?: Api.Dict.DictType | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate, restoreValidation } = useForm();
|
||||
const { defaultRequiredRule } = useFormRules();
|
||||
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
const submitting = ref(false);
|
||||
|
||||
const title = computed(() => {
|
||||
const titleMap: Record<UI.TableOperateType, string> = {
|
||||
add: $t('page.system.dict.addType'),
|
||||
edit: $t('page.system.dict.editType')
|
||||
};
|
||||
|
||||
return titleMap[props.operateType];
|
||||
});
|
||||
|
||||
type Model = Api.Dict.SaveDictTypeParams;
|
||||
|
||||
const model = ref(createDefaultModel());
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
name: '',
|
||||
type: '',
|
||||
status: 0,
|
||||
remark: ''
|
||||
};
|
||||
}
|
||||
|
||||
type RuleKey = Extract<keyof Model, 'name' | 'type' | 'status'>;
|
||||
|
||||
const rules: Record<RuleKey, App.Global.FormRule> = {
|
||||
name: defaultRequiredRule,
|
||||
type: defaultRequiredRule,
|
||||
status: defaultRequiredRule
|
||||
};
|
||||
|
||||
function handleInitModel() {
|
||||
model.value = createDefaultModel();
|
||||
|
||||
if (isEdit.value && props.rowData) {
|
||||
// 当前页列表数据已经包含编辑所需字段,直接回填,避免额外详情请求。
|
||||
Object.assign(model.value, {
|
||||
name: props.rowData.name,
|
||||
type: props.rowData.type,
|
||||
status: props.rowData.status,
|
||||
remark: props.rowData.remark ?? ''
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
// 按接口文档先保守处理:编辑时仍带 type,但输入框禁用,避免误改编码引发子表联动问题。
|
||||
const request =
|
||||
isEdit.value && props.rowData
|
||||
? fetchUpdateDictType({ id: props.rowData.id, ...model.value })
|
||||
: fetchCreateDictType(model.value);
|
||||
|
||||
const { error } = await request;
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t(isEdit.value ? 'common.updateSuccess' : 'common.addSuccess'));
|
||||
|
||||
closeModal();
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(visible, value => {
|
||||
if (value) {
|
||||
handleInitModel();
|
||||
restoreValidation();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="sm"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElFormItem :label="$t('page.system.dict.dictName')" prop="name">
|
||||
<ElInput v-model="model.name" :placeholder="$t('page.system.dict.form.dictName')" />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('page.system.dict.dictCode')" prop="type">
|
||||
<ElInput v-model="model.type" :disabled="isEdit" :placeholder="$t('page.system.dict.form.dictCode')" />
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('page.system.dict.dictStatus')" prop="status">
|
||||
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
|
||||
<ElRadio v-for="{ label, value } in dictStatusOptions" :key="value" :value="value" :label="$t(label)" />
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="$t('page.system.dict.remark')" prop="remark">
|
||||
<ElInput v-model="model.remark" type="textarea" :rows="4" :placeholder="$t('page.system.dict.form.remark')" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
126
src/views/system/dict/modules/dict-type-search.vue
Normal file
126
src/views/system/dict/modules/dict-type-search.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'DictTypeSearch' });
|
||||
|
||||
interface Emits {
|
||||
(e: 'search'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<Api.Dict.DictTypeSearchParams>('model', { required: true });
|
||||
|
||||
type StatusFilter = 'all' | Api.Dict.DictStatus;
|
||||
|
||||
const filterVisible = ref(false);
|
||||
|
||||
const statusFilter = computed<StatusFilter>({
|
||||
get() {
|
||||
return model.value.status ?? 'all';
|
||||
},
|
||||
set(value) {
|
||||
model.value.status = value === 'all' ? undefined : value;
|
||||
}
|
||||
});
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ label: '全部', value: 'all' as const },
|
||||
{ label: $t('page.system.common.status.enable'), value: 0 as const },
|
||||
{ label: $t('page.system.common.status.disable'), value: 1 as const }
|
||||
]);
|
||||
|
||||
function handleSearch() {
|
||||
emit('search');
|
||||
}
|
||||
|
||||
function handleKeywordInput(value: string) {
|
||||
const keyword = value.trim() || undefined;
|
||||
|
||||
model.value.name = keyword;
|
||||
model.value.type = keyword;
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
model.value.name = undefined;
|
||||
model.value.type = undefined;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handleStatusChange(value: StatusFilter) {
|
||||
statusFilter.value = value;
|
||||
filterVisible.value = false;
|
||||
handleSearch();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="card-wrapper" body-class="px-12px py-10px">
|
||||
<ElForm :model="model" @submit.prevent>
|
||||
<div class="relative">
|
||||
<ElInput
|
||||
:model-value="model.name ?? model.type ?? ''"
|
||||
class="dict-type-search-input"
|
||||
clearable
|
||||
:placeholder="$t('page.system.dict.typeSearchPlaceholder')"
|
||||
@update:model-value="handleKeywordInput"
|
||||
@clear="handleClear"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
|
||||
<div class="absolute right-8px top-1/2 z-10 flex items-center gap-2px -translate-y-1/2">
|
||||
<ElPopover v-model:visible="filterVisible" placement="bottom-end" trigger="click" :width="132">
|
||||
<template #reference>
|
||||
<ElButton
|
||||
link
|
||||
class="relative h-24px min-w-24px w-24px"
|
||||
:type="statusFilter === 'all' ? 'default' : 'primary'"
|
||||
>
|
||||
<icon-mdi-filter-variant class="text-15px" />
|
||||
<span
|
||||
v-if="statusFilter !== 'all'"
|
||||
class="absolute right-2px top-2px h-6px w-6px rounded-full bg-primary"
|
||||
></span>
|
||||
</ElButton>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-6px py-4px">
|
||||
<button
|
||||
v-for="item in statusOptions"
|
||||
:key="String(item.value)"
|
||||
type="button"
|
||||
class="flex items-center justify-between rounded-8px px-10px py-7px text-left text-13px transition-colors duration-200"
|
||||
:class="
|
||||
statusFilter === item.value ? 'bg-primary/10 text-primary' : 'text-[#606266] hover:bg-[#f5f7fa]'
|
||||
"
|
||||
@click="handleStatusChange(item.value)"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<icon-mdi-check v-if="statusFilter === item.value" class="text-14px" />
|
||||
</button>
|
||||
</div>
|
||||
</ElPopover>
|
||||
|
||||
<ElTooltip :content="$t('common.search')">
|
||||
<ElButton link class="h-24px min-w-24px w-24px" type="primary" @click="handleSearch">
|
||||
<icon-ic-round-search class="text-15px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.dict-type-search-input {
|
||||
:deep(.el-input__wrapper) {
|
||||
padding-right: 76px;
|
||||
}
|
||||
|
||||
:deep(.el-input__clear) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
252
src/views/system/menu/index.vue
Normal file
252
src/views/system/menu/index.vue
Normal file
@@ -0,0 +1,252 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, nextTick, reactive, ref } from 'vue';
|
||||
import type { TableInstance } from 'element-plus';
|
||||
import { ElTag } from 'element-plus';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import { menuRouteKindRecord, menuTypeRecord } from '@/constants/business';
|
||||
import { fetchBatchDeleteMenu, fetchDeleteMenu, fetchGetMenuList } from '@/service/api';
|
||||
import { useUITable } from '@/hooks/common/table';
|
||||
import { $t } from '@/locales';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
import MenuIconCell from './modules/menu-icon-cell';
|
||||
import MenuOperateDialog, { type OperateType } from './modules/menu-operate-dialog.vue';
|
||||
import MenuOperateCell from './modules/menu-operate-cell';
|
||||
import MenuSearch from './modules/menu-search.vue';
|
||||
|
||||
defineOptions({ name: 'MenuManage' });
|
||||
|
||||
function getInitSearchParams(): Api.SystemManage.MenuSearchParams {
|
||||
return {
|
||||
name: undefined,
|
||||
status: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function getMenuTypeTagType(type: Api.SystemManage.MenuType): UI.ThemeColor {
|
||||
const tagMap: Record<Api.SystemManage.MenuType, UI.ThemeColor> = {
|
||||
1: 'info',
|
||||
2: 'primary',
|
||||
3: 'warning'
|
||||
};
|
||||
|
||||
return tagMap[type];
|
||||
}
|
||||
|
||||
function getRouteKindLabel(routeKind?: Api.SystemManage.MenuRouteKind | null) {
|
||||
if (!routeKind) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return $t(menuRouteKindRecord[routeKind]);
|
||||
}
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const flatMenuList = ref<Api.SystemManage.Menu[]>([]);
|
||||
|
||||
const { columns, columnChecks, data, loading, getData } = useUITable({
|
||||
api: () => fetchGetMenuList(searchParams),
|
||||
transform: response => {
|
||||
if (!response.error) {
|
||||
flatMenuList.value = response.data;
|
||||
return buildMenuTree(response.data);
|
||||
}
|
||||
|
||||
flatMenuList.value = [];
|
||||
return [];
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'selection', type: 'selection', width: 48 },
|
||||
{ prop: 'name', label: $t('page.system.menu.menuName'), minWidth: 220, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'type',
|
||||
label: $t('page.system.menu.menuType'),
|
||||
width: 96,
|
||||
align: 'center',
|
||||
formatter: row => <ElTag type={getMenuTypeTagType(row.type)}>{$t(menuTypeRecord[row.type])}</ElTag>
|
||||
},
|
||||
{
|
||||
prop: 'icon',
|
||||
label: $t('page.system.menu.icon'),
|
||||
width: 88,
|
||||
align: 'center',
|
||||
formatter: row => {
|
||||
return <MenuIconCell icon={row.icon ?? ''} />;
|
||||
}
|
||||
},
|
||||
{ prop: 'permission', label: $t('page.system.menu.permission'), minWidth: 180, showOverflowTooltip: true },
|
||||
{ prop: 'path', label: $t('page.system.menu.routePath'), minWidth: 160, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'routeKind',
|
||||
label: $t('page.system.menu.routeKind'),
|
||||
width: 116,
|
||||
formatter: row => getRouteKindLabel(row.routeKind)
|
||||
},
|
||||
{ prop: 'component', label: $t('page.system.menu.component'), minWidth: 180, showOverflowTooltip: true },
|
||||
{ prop: 'componentName', label: $t('page.system.menu.componentName'), minWidth: 160, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'operate',
|
||||
label: $t('common.operate'),
|
||||
width: 196,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => (
|
||||
<MenuOperateCell row={row} onEdit={openEdit} onAddChild={openAddChild} onDelete={handleDeleteAction} />
|
||||
)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const { bool: visible, setTrue: openModal, setFalse: closeModal } = useBoolean();
|
||||
const operateType = ref<OperateType>('add');
|
||||
const editingData = ref<Api.SystemManage.Menu | null>(null);
|
||||
const checkedRowKeys = ref<number[]>([]);
|
||||
const tableRef = ref<TableInstance>();
|
||||
const tableRenderKey = ref(0);
|
||||
|
||||
const allMenus = computed(() => flatMenuList.value);
|
||||
const expandedRowKeys = computed(() => {
|
||||
const firstRootMenu = data.value[0];
|
||||
|
||||
return firstRootMenu ? [String(firstRootMenu.id)] : [];
|
||||
});
|
||||
|
||||
function handleSelectionChange(rows: Api.SystemManage.Menu[]) {
|
||||
checkedRowKeys.value = rows.map(item => item.id);
|
||||
}
|
||||
|
||||
async function reloadTable() {
|
||||
checkedRowKeys.value = [];
|
||||
await getData();
|
||||
tableRenderKey.value += 1;
|
||||
await nextTick();
|
||||
tableRef.value?.clearSelection();
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
operateType.value = 'add';
|
||||
editingData.value = null;
|
||||
openModal();
|
||||
}
|
||||
|
||||
function openAddChild(item: Api.SystemManage.Menu) {
|
||||
operateType.value = 'addChild';
|
||||
editingData.value = item;
|
||||
openModal();
|
||||
}
|
||||
|
||||
function openEdit(item: Api.SystemManage.Menu) {
|
||||
operateType.value = 'edit';
|
||||
editingData.value = item;
|
||||
openModal();
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
const { error } = await fetchDeleteMenu(id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
async function handleDeleteAction(row: Api.SystemManage.Menu) {
|
||||
try {
|
||||
await window.$messageBox?.confirm($t('common.confirmDelete'), $t('common.warning'), {
|
||||
confirmButtonText: $t('common.confirm'),
|
||||
cancelButtonText: $t('common.cancel'),
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleDelete(row.id);
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (!checkedRowKeys.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchBatchDeleteMenu(checkedRowKeys.value);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
async function resetSearchParams() {
|
||||
Object.assign(searchParams, getInitSearchParams());
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
async function handleSubmitted() {
|
||||
closeModal();
|
||||
await reloadTable();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-560px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
|
||||
<MenuSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="card-wrapper sm:flex-1-hidden" body-class="menu-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-8px">
|
||||
<p>{{ $t('page.system.menu.title') }}</p>
|
||||
<ElTag effect="plain">{{ flatMenuList.length }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="checkedRowKeys.length === 0"
|
||||
:loading="loading"
|
||||
@add="openAdd"
|
||||
@delete="handleBatchDelete"
|
||||
@refresh="reloadTable"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ElTable
|
||||
:key="tableRenderKey"
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
height="100%"
|
||||
border
|
||||
row-key="id"
|
||||
:expand-row-keys="expandedRowKeys"
|
||||
:data="data"
|
||||
class="sm:h-full"
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</ElCard>
|
||||
|
||||
<MenuOperateDialog
|
||||
v-model:visible="visible"
|
||||
:operate-type="operateType"
|
||||
:row-data="editingData"
|
||||
:all-menus="allMenus"
|
||||
@submitted="handleSubmitted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.menu-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
203
src/views/system/menu/modules/icon-options.ts
Normal file
203
src/views/system/menu/modules/icon-options.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
export interface IconOptionGroup {
|
||||
label: string;
|
||||
value: string;
|
||||
icons: string[];
|
||||
}
|
||||
|
||||
export const menuIconGroups: IconOptionGroup[] = [
|
||||
{
|
||||
label: '导航布局',
|
||||
value: 'navigation',
|
||||
icons: [
|
||||
'mdi:home-outline',
|
||||
'mdi:view-dashboard-outline',
|
||||
'mdi:menu-open',
|
||||
'mdi:map',
|
||||
'mdi:compass-outline',
|
||||
'mdi:application-outline',
|
||||
'mdi:monitor-dashboard',
|
||||
'material-symbols:dashboard-outline',
|
||||
'material-symbols:space-dashboard-outline',
|
||||
'material-symbols:route',
|
||||
'material-symbols:account-tree-outline',
|
||||
'icon-park-outline:all-application',
|
||||
'tabler:layout-dashboard',
|
||||
'tabler:sitemap',
|
||||
'tabler:apps',
|
||||
'tabler:browser',
|
||||
'carbon:network-overlay',
|
||||
'hugeicons:flow-square'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '系统管理',
|
||||
value: 'system',
|
||||
icons: [
|
||||
'ep:setting',
|
||||
'ep:tools',
|
||||
'ep:operation',
|
||||
'ep:management',
|
||||
'ep:monitor',
|
||||
'ep:platform',
|
||||
'ep:connection',
|
||||
'ep:office-building',
|
||||
'mdi:cog-outline',
|
||||
'mdi:tune-variant',
|
||||
'mdi:server-outline',
|
||||
'mdi:cloud-outline',
|
||||
'mdi:shield-crown-outline',
|
||||
'carbon:cloud-service-management',
|
||||
'carbon:network-overlay',
|
||||
'ic:round-manage-accounts',
|
||||
'ic:round-supervisor-account',
|
||||
'carbon:user-role',
|
||||
'material-symbols:admin-panel-settings-outline',
|
||||
'material-symbols:settings-outline-rounded'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '用户权限',
|
||||
value: 'user',
|
||||
icons: [
|
||||
'ep:user',
|
||||
'ep:user-filled',
|
||||
'ep:avatar',
|
||||
'mdi:account-outline',
|
||||
'mdi:account-group-outline',
|
||||
'mdi:account-key-outline',
|
||||
'mdi:badge-account-outline',
|
||||
'mdi:card-account-details-outline',
|
||||
'mdi:key-outline',
|
||||
'mdi:shield-account-outline',
|
||||
'mdi:lock-outline',
|
||||
'ri:admin-line',
|
||||
'ri:user-settings-line',
|
||||
'carbon:user-avatar',
|
||||
'carbon:user-role',
|
||||
'carbon:user-admin',
|
||||
'carbon:user-multiple',
|
||||
'ic:round-supervisor-account'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '文档表单',
|
||||
value: 'document',
|
||||
icons: [
|
||||
'mdi:file-document-outline',
|
||||
'mdi:file-document-edit-outline',
|
||||
'mdi:file-document-multiple-outline',
|
||||
'mdi:clipboard-outline',
|
||||
'mdi:book-open-page-variant-outline',
|
||||
'mdi:form-select',
|
||||
'mdi:notebook-outline',
|
||||
'ri:file-excel-2-line',
|
||||
'ri:markdown-line',
|
||||
'uiw:file-pdf',
|
||||
'gridicons:posts',
|
||||
'icon-park-outline:editor',
|
||||
'material-symbols:article-outline',
|
||||
'material-symbols:description-outline',
|
||||
'ph:archive-box-light',
|
||||
'ph:note-pencil',
|
||||
'tabler:files',
|
||||
'tabler:report'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '数据分析',
|
||||
value: 'data',
|
||||
icons: [
|
||||
'ant-design:bar-chart-outlined',
|
||||
'mdi:chart-areaspline',
|
||||
'mdi:chart-bar',
|
||||
'mdi:chart-box-outline',
|
||||
'mdi:database-outline',
|
||||
'mdi:table-large',
|
||||
'mdi:table-search',
|
||||
'icon-park-outline:table',
|
||||
'material-symbols:analytics-outline',
|
||||
'material-symbols:table-chart-outline',
|
||||
'material-symbols:dataset-outline',
|
||||
'simple-icons:apacheecharts',
|
||||
'simple-icons:swiper',
|
||||
'ri:pie-chart-2-line',
|
||||
'ri:line-chart-line',
|
||||
'tabler:chart-bar',
|
||||
'tabler:chart-donut',
|
||||
'tabler:database'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '业务工具',
|
||||
value: 'business',
|
||||
icons: [
|
||||
'clarity:plugin-line',
|
||||
'ic:round-barcode',
|
||||
'mdi:printer',
|
||||
'mdi:typewriter',
|
||||
'mdi:video',
|
||||
'mdi:map-marker-path',
|
||||
'mdi:tools',
|
||||
'mdi:wrench-outline',
|
||||
'mdi:qrcode-scan',
|
||||
'mdi:hammer-wrench',
|
||||
'material-symbols:construction-outline',
|
||||
'material-symbols:extension-outline',
|
||||
'material-symbols:inventory-2-outline',
|
||||
'carbon:tool-kit',
|
||||
'carbon:settings-adjust',
|
||||
'ph:toolbox',
|
||||
'ph:package',
|
||||
'hugeicons:flow-square'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '状态反馈',
|
||||
value: 'status',
|
||||
icons: [
|
||||
'ant-design:exception-outlined',
|
||||
'ic:baseline-block',
|
||||
'ic:baseline-web-asset-off',
|
||||
'ic:baseline-wifi-off',
|
||||
'material-symbols:filter-list-off',
|
||||
'mdi:alert-outline',
|
||||
'mdi:check-circle-outline',
|
||||
'mdi:close-circle-outline',
|
||||
'mdi:information-outline',
|
||||
'mdi:timer-sand',
|
||||
'mdi:progress-question',
|
||||
'tabler:alert-circle',
|
||||
'tabler:circle-check',
|
||||
'tabler:circle-x',
|
||||
'tabler:info-circle'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: '通用备选',
|
||||
value: 'common',
|
||||
icons: [
|
||||
'mdi:apps-box',
|
||||
'mdi:emoticon',
|
||||
'mdi:ab-testing',
|
||||
'mdi:alert',
|
||||
'mdi:airballoon',
|
||||
'mdi:airplane-edit',
|
||||
'mdi:alpha-f-box-outline',
|
||||
'mdi:arm-flex-outline',
|
||||
'ph:alarm',
|
||||
'ph:android-logo',
|
||||
'ph:align-bottom',
|
||||
'uil:basketball',
|
||||
'uil:brightness-plus',
|
||||
'uil:capture',
|
||||
'ic:baseline-10mp',
|
||||
'ic:baseline-access-time',
|
||||
'ic:baseline-brightness-4',
|
||||
'ic:baseline-brightness-5',
|
||||
'ic:baseline-credit-card',
|
||||
'entypo-social:google-hangouts'
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export const menuIconOptions = Array.from(new Set(menuIconGroups.flatMap(group => group.icons)));
|
||||
62
src/views/system/menu/modules/menu-icon-cell.tsx
Normal file
62
src/views/system/menu/modules/menu-icon-cell.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { defineComponent, ref, watch } from 'vue';
|
||||
import { loadIcon } from '@iconify/vue';
|
||||
import SvgIcon from '@/components/custom/svg-icon.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MenuIconCell',
|
||||
props: {
|
||||
icon: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const failed = ref(false);
|
||||
|
||||
watch(
|
||||
() => props.icon,
|
||||
(value, _, onCleanup) => {
|
||||
const icon = value.trim();
|
||||
|
||||
failed.value = !icon;
|
||||
|
||||
if (!icon) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
onCleanup(() => {
|
||||
active = false;
|
||||
});
|
||||
|
||||
loadIcon(icon)
|
||||
.then(() => {
|
||||
if (active) {
|
||||
failed.value = false;
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
failed.value = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
return () => {
|
||||
const icon = props.icon.trim();
|
||||
|
||||
if (!icon || failed.value) {
|
||||
return <div class="flex-center text-[#909399]">--</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex-center">
|
||||
<SvgIcon icon={icon} class="text-18px text-[#303133]" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
});
|
||||
58
src/views/system/menu/modules/menu-operate-cell.tsx
Normal file
58
src/views/system/menu/modules/menu-operate-cell.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { computed, defineComponent } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MenuOperateCell',
|
||||
props: {
|
||||
row: {
|
||||
type: Object as PropType<Api.SystemManage.Menu>,
|
||||
required: true
|
||||
},
|
||||
onEdit: {
|
||||
type: Function as PropType<(row: Api.SystemManage.Menu) => void>,
|
||||
required: true
|
||||
},
|
||||
onAddChild: {
|
||||
type: Function as PropType<(row: Api.SystemManage.Menu) => void>,
|
||||
required: true
|
||||
},
|
||||
onDelete: {
|
||||
type: Function as PropType<(row: Api.SystemManage.Menu) => Promise<void>>,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const actions = computed<BusinessTableAction[]>(() => {
|
||||
const list: BusinessTableAction[] = [
|
||||
{
|
||||
key: 'edit',
|
||||
label: $t('common.edit'),
|
||||
buttonType: 'primary',
|
||||
onClick: () => props.onEdit(props.row)
|
||||
}
|
||||
];
|
||||
|
||||
if (props.row.type !== 3) {
|
||||
list.push({
|
||||
key: 'addChild',
|
||||
label: $t('page.system.menu.addChildMenu'),
|
||||
buttonType: 'primary',
|
||||
onClick: () => props.onAddChild(props.row)
|
||||
});
|
||||
}
|
||||
|
||||
list.push({
|
||||
key: 'delete',
|
||||
label: $t('common.delete'),
|
||||
buttonType: 'danger',
|
||||
onClick: () => props.onDelete(props.row)
|
||||
});
|
||||
|
||||
return list;
|
||||
});
|
||||
|
||||
return () => <BusinessTableActionCell actions={actions.value} />;
|
||||
}
|
||||
});
|
||||
1211
src/views/system/menu/modules/menu-operate-dialog.vue
Normal file
1211
src/views/system/menu/modules/menu-operate-dialog.vue
Normal file
File diff suppressed because it is too large
Load Diff
35
src/views/system/menu/modules/menu-search.vue
Normal file
35
src/views/system/menu/modules/menu-search.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'MenuSearch' });
|
||||
|
||||
interface Emits {
|
||||
(e: 'reset'): void;
|
||||
(e: 'search'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<Api.SystemManage.MenuSearchParams>('model', { required: true });
|
||||
|
||||
function reset() {
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
function search() {
|
||||
emit('search');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchPanel :model="model" :action-col-lg="6" :action-col-md="8" @reset="reset" @search="search">
|
||||
<ElCol :lg="6" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.menu.menuName')" prop="name">
|
||||
<ElInput v-model="model.name" clearable :placeholder="$t('page.system.menu.form.menuName')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</TableSearchPanel>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
79
src/views/system/menu/modules/shared.ts
Normal file
79
src/views/system/menu/modules/shared.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
const LAYOUT_PREFIX = 'layout.';
|
||||
const VIEW_PREFIX = 'view.';
|
||||
const FIRST_LEVEL_ROUTE_COMPONENT_SPLIT = '$';
|
||||
|
||||
export function getLayoutAndPage(component?: string | null) {
|
||||
let layout = '';
|
||||
let page = '';
|
||||
|
||||
const [layoutOrPage = '', pageItem = ''] = component?.split(FIRST_LEVEL_ROUTE_COMPONENT_SPLIT) || [];
|
||||
|
||||
layout = getLayout(layoutOrPage);
|
||||
page = getPage(pageItem || layoutOrPage);
|
||||
|
||||
return { layout, page };
|
||||
}
|
||||
|
||||
function getLayout(layout: string) {
|
||||
return layout.startsWith(LAYOUT_PREFIX) ? layout.replace(LAYOUT_PREFIX, '') : '';
|
||||
}
|
||||
|
||||
function getPage(page: string) {
|
||||
return page.startsWith(VIEW_PREFIX) ? page.replace(VIEW_PREFIX, '') : '';
|
||||
}
|
||||
|
||||
export function transformLayoutAndPageToComponent(layout: string, page: string) {
|
||||
const hasLayout = Boolean(layout);
|
||||
const hasPage = Boolean(page);
|
||||
|
||||
if (hasLayout && hasPage) {
|
||||
return `${LAYOUT_PREFIX}${layout}${FIRST_LEVEL_ROUTE_COMPONENT_SPLIT}${VIEW_PREFIX}${page}`;
|
||||
}
|
||||
|
||||
if (hasLayout) {
|
||||
return `${LAYOUT_PREFIX}${layout}`;
|
||||
}
|
||||
|
||||
if (hasPage) {
|
||||
return `${VIEW_PREFIX}${page}`;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get route name by route path
|
||||
*
|
||||
* @param routeName
|
||||
*/
|
||||
export function getRoutePathByRouteName(routeName: string) {
|
||||
return `/${routeName.replace(/_/g, '/')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path param from route path
|
||||
*
|
||||
* @param routePath route path
|
||||
*/
|
||||
export function getPathParamFromRoutePath(routePath: string) {
|
||||
const [path, param = ''] = routePath.split('/:');
|
||||
|
||||
return {
|
||||
path,
|
||||
param
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get route path with param
|
||||
*
|
||||
* @param routePath route path
|
||||
* @param param path param
|
||||
*/
|
||||
export function getRoutePathWithParam(routePath: string, param: string) {
|
||||
if (param.trim()) {
|
||||
return `${routePath}/:${param}`;
|
||||
}
|
||||
|
||||
return routePath;
|
||||
}
|
||||
3
src/views/system/post/index.vue
Normal file
3
src/views/system/post/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<h1>岗位管理</h1>
|
||||
</template>
|
||||
328
src/views/system/role/index.vue
Normal file
328
src/views/system/role/index.vue
Normal file
@@ -0,0 +1,328 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { useBoolean } from '@sa/hooks';
|
||||
import { commonStatusRecord } from '@/constants/business';
|
||||
import { fetchDeleteRole, fetchGetMenuSimpleList, fetchGetRolePage } from '@/service/api';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||
import { $t } from '@/locales';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
import RoleOperateDialog from './modules/role-operate-dialog.vue';
|
||||
import RoleResourcePanel from './modules/role-resource-panel.vue';
|
||||
import RoleSearch from './modules/role-search.vue';
|
||||
|
||||
defineOptions({ name: 'RoleManage' });
|
||||
|
||||
function getInitSearchParams(): Api.SystemManage.RoleSearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
name: undefined,
|
||||
code: undefined,
|
||||
status: undefined,
|
||||
createTime: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function transformPageResult(response: Awaited<ReturnType<typeof fetchGetRolePage>>, pageNo: number, pageSize: number) {
|
||||
if (!response.error) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: response.data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(value?: number | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
function getStatusTagType(status: Api.SystemManage.CommonStatus): UI.ThemeColor {
|
||||
return status === 0 ? 'success' : 'warning';
|
||||
}
|
||||
|
||||
function getStatusLabel(status: Api.SystemManage.CommonStatus) {
|
||||
return $t(commonStatusRecord[status]);
|
||||
}
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const selectedRoleId = ref<number | null>(null);
|
||||
const pendingSelectedRoleId = ref<number | null>(null);
|
||||
|
||||
const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagination } = useUIPaginatedTable({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchGetRolePage(searchParams),
|
||||
transform: response => transformPageResult(response, searchParams.pageNo, searchParams.pageSize),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'index', type: 'index', label: $t('common.index'), width: 64 },
|
||||
{ prop: 'name', label: $t('page.system.role.roleName'), minWidth: 160, showOverflowTooltip: true },
|
||||
{ prop: 'code', label: $t('page.system.role.roleCode'), minWidth: 180, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'status',
|
||||
label: $t('page.system.role.roleStatus'),
|
||||
width: 110,
|
||||
align: 'center',
|
||||
formatter: row => <ElTag type={getStatusTagType(row.status)}>{getStatusLabel(row.status)}</ElTag>
|
||||
},
|
||||
{ prop: 'sort', label: $t('page.system.role.sort'), width: 90, align: 'center' },
|
||||
{
|
||||
prop: 'remark',
|
||||
label: $t('page.system.role.remark'),
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => row.remark || '--'
|
||||
},
|
||||
{
|
||||
prop: 'createTime',
|
||||
label: $t('page.system.role.createTime'),
|
||||
minWidth: 170,
|
||||
formatter: row => formatTime(row.createTime)
|
||||
},
|
||||
{
|
||||
prop: 'operate',
|
||||
label: $t('common.operate'),
|
||||
width: 196,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => (
|
||||
<BusinessTableActionCell
|
||||
actions={[
|
||||
{
|
||||
key: 'edit',
|
||||
label: $t('common.edit'),
|
||||
buttonType: 'primary',
|
||||
onClick: () => openEdit(row)
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: $t('common.delete'),
|
||||
buttonType: 'danger',
|
||||
onClick: () => handleDeleteAction(row)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const menuTreeLoading = ref(false);
|
||||
const menuTree = ref<Api.SystemManage.MenuSimple[]>([]);
|
||||
|
||||
async function getMenuTreeData() {
|
||||
menuTreeLoading.value = true;
|
||||
|
||||
const { error, data: menuList } = await fetchGetMenuSimpleList();
|
||||
|
||||
menuTreeLoading.value = false;
|
||||
|
||||
if (error) {
|
||||
menuTree.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
menuTree.value = buildMenuTree(menuList);
|
||||
}
|
||||
|
||||
const currentRole = computed(() => data.value.find(item => item.id === selectedRoleId.value) ?? null);
|
||||
|
||||
const { bool: operateVisible, setTrue: openOperateModal, setFalse: closeOperateModal } = useBoolean();
|
||||
const operateType = ref<UI.TableOperateType>('add');
|
||||
const editingData = ref<Api.SystemManage.Role | null>(null);
|
||||
|
||||
function openAdd() {
|
||||
operateType.value = 'add';
|
||||
editingData.value = null;
|
||||
openOperateModal();
|
||||
}
|
||||
|
||||
function openEdit(item: Api.SystemManage.Role) {
|
||||
operateType.value = 'edit';
|
||||
editingData.value = item;
|
||||
openOperateModal();
|
||||
}
|
||||
|
||||
async function handleDelete(item: Api.SystemManage.Role) {
|
||||
const { error } = await fetchDeleteRole(item.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
|
||||
if (selectedRoleId.value === item.id) {
|
||||
selectedRoleId.value = null;
|
||||
}
|
||||
|
||||
await getData();
|
||||
}
|
||||
|
||||
async function handleDeleteAction(row: Api.SystemManage.Role) {
|
||||
try {
|
||||
await window.$messageBox?.confirm($t('common.confirmDelete'), $t('common.warning'), {
|
||||
confirmButtonText: $t('common.confirm'),
|
||||
cancelButtonText: $t('common.cancel'),
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleDelete(row);
|
||||
}
|
||||
|
||||
function resetSearchParams() {
|
||||
Object.assign(searchParams, getInitSearchParams());
|
||||
getDataByPage(1);
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
getDataByPage(1);
|
||||
}
|
||||
|
||||
function selectRole(roleId: number) {
|
||||
selectedRoleId.value = roleId;
|
||||
}
|
||||
|
||||
function handleRowClick(row: Api.SystemManage.Role) {
|
||||
selectRole(row.id);
|
||||
}
|
||||
|
||||
function handleSubmitted(roleId: number) {
|
||||
pendingSelectedRoleId.value = roleId;
|
||||
closeOperateModal();
|
||||
getData();
|
||||
}
|
||||
|
||||
watch(
|
||||
data,
|
||||
value => {
|
||||
if (!value.length) {
|
||||
selectedRoleId.value = null;
|
||||
pendingSelectedRoleId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const targetId = pendingSelectedRoleId.value ?? selectedRoleId.value;
|
||||
const matched = targetId ? value.find(item => item.id === targetId) : null;
|
||||
|
||||
if (matched) {
|
||||
selectedRoleId.value = matched.id;
|
||||
} else {
|
||||
selectedRoleId.value = value[0].id;
|
||||
}
|
||||
|
||||
pendingSelectedRoleId.value = null;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
getMenuTreeData();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[minmax(0,1fr)_360px] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||
>
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<RoleSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="role-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p>{{ $t('page.system.role.title') }}</p>
|
||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation
|
||||
v-model:columns="columnChecks"
|
||||
:disabled-delete="true"
|
||||
:loading="loading"
|
||||
@refresh="getData"
|
||||
>
|
||||
<template #default>
|
||||
<ElButton plain type="primary" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
height="100%"
|
||||
border
|
||||
row-key="id"
|
||||
highlight-current-row
|
||||
:data="data"
|
||||
:current-row-key="selectedRoleId ?? undefined"
|
||||
@row-click="handleRowClick"
|
||||
>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="mobilePagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<div class="flex-col-stretch xl:min-h-0">
|
||||
<RoleResourcePanel :role="currentRole" :menu-tree="menuTree" :loading="menuTreeLoading" />
|
||||
</div>
|
||||
|
||||
<RoleOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:operate-type="operateType"
|
||||
:row-data="editingData"
|
||||
@submitted="handleSubmitted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.role-table-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.el-table__row.current-row > td.el-table__cell) {
|
||||
background-color: rgb(64 158 255 / 8%);
|
||||
}
|
||||
</style>
|
||||
194
src/views/system/role/modules/role-operate-dialog.vue
Normal file
194
src/views/system/role/modules/role-operate-dialog.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import { fetchCreateRole, fetchGetRole, fetchUpdateRole } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'RoleOperateDialog' });
|
||||
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
rowData?: Api.SystemManage.Role | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted', roleId: number): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const detailLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
|
||||
const title = computed(() => {
|
||||
const titleMap: Record<UI.TableOperateType, string> = {
|
||||
add: $t('page.system.role.addRole'),
|
||||
edit: $t('page.system.role.editRole')
|
||||
};
|
||||
|
||||
return titleMap[props.operateType];
|
||||
});
|
||||
|
||||
type Model = Api.SystemManage.SaveRoleParams;
|
||||
|
||||
const model = ref(createDefaultModel());
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
name: '',
|
||||
code: '',
|
||||
sort: 0,
|
||||
status: 0,
|
||||
remark: ''
|
||||
};
|
||||
}
|
||||
|
||||
const rules = {
|
||||
name: createRequiredRule($t('page.system.role.form.roleName')),
|
||||
code: createRequiredRule($t('page.system.role.form.roleCode')),
|
||||
sort: createRequiredRule($t('page.system.role.form.sort')),
|
||||
status: createRequiredRule($t('page.system.role.form.roleStatus'))
|
||||
} satisfies Record<string, App.Global.FormRule>;
|
||||
|
||||
function closeModal() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function initModel() {
|
||||
model.value = createDefaultModel();
|
||||
|
||||
if (!isEdit.value || !props.rowData) {
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
return;
|
||||
}
|
||||
|
||||
detailLoading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetRole(props.rowData.id);
|
||||
|
||||
detailLoading.value = false;
|
||||
|
||||
if (!error) {
|
||||
model.value = {
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
sort: data.sort,
|
||||
status: data.status,
|
||||
remark: data.remark ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const submitData: Api.SystemManage.SaveRoleParams = {
|
||||
...model.value,
|
||||
name: model.value.name.trim(),
|
||||
code: model.value.code.trim(),
|
||||
remark: model.value.remark?.trim() || null
|
||||
};
|
||||
|
||||
const request =
|
||||
isEdit.value && props.rowData
|
||||
? fetchUpdateRole({ id: props.rowData.id, ...submitData })
|
||||
: fetchCreateRole(submitData);
|
||||
|
||||
const { error, data } = await request;
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
const roleId = isEdit.value && props.rowData ? props.rowData.id : Number(data);
|
||||
|
||||
window.$message?.success($t(isEdit.value ? 'common.updateSuccess' : 'common.addSuccess'));
|
||||
|
||||
closeModal();
|
||||
emit('submitted', roleId);
|
||||
}
|
||||
|
||||
watch(visible, value => {
|
||||
if (value) {
|
||||
initModel();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="md"
|
||||
:loading="detailLoading"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.role.roleName')" prop="name">
|
||||
<ElInput v-model="model.name" :placeholder="$t('page.system.role.form.roleName')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.role.roleCode')" prop="code">
|
||||
<ElInput v-model="model.code" :placeholder="$t('page.system.role.form.roleCode')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.role.sort')" prop="sort">
|
||||
<ElInputNumber
|
||||
v-model="model.sort"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:placeholder="$t('page.system.role.form.sort')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.role.roleStatus')" prop="status">
|
||||
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
|
||||
<ElRadio v-for="{ label, value } in commonStatusOptions" :key="value" :value="value">
|
||||
{{ $t(label) }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.role.remark')" prop="remark">
|
||||
<ElInput
|
||||
v-model="model.remark"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="$t('page.system.role.form.remark')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
238
src/views/system/role/modules/role-resource-panel.vue
Normal file
238
src/views/system/role/modules/role-resource-panel.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import type { TreeInstance } from 'element-plus';
|
||||
import { menuTypeRecord } from '@/constants/business';
|
||||
import { fetchAssignRoleMenus, fetchGetRoleMenuIds } from '@/service/api';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'RoleResourcePanel' });
|
||||
|
||||
interface Props {
|
||||
role: Api.SystemManage.Role | null;
|
||||
menuTree: Api.SystemManage.MenuSimple[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'saved'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const treeRef = ref<TreeInstance | null>(null);
|
||||
const permissionLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const filterKeyword = ref('');
|
||||
const checkedKeys = ref<number[]>([]);
|
||||
|
||||
const disabled = computed(() => !props.role || props.role.status === 1);
|
||||
const checkedCount = computed(() => checkedKeys.value.length);
|
||||
const defaultExpandedKeys = computed(() => collectExpandableNodeIds(props.menuTree));
|
||||
const treeRenderKey = computed(() => `${props.role?.id ?? 'empty'}:${defaultExpandedKeys.value.join(',')}`);
|
||||
|
||||
const treeProps = {
|
||||
children: 'children',
|
||||
label: 'name'
|
||||
} as const;
|
||||
|
||||
function getTagType(type: Api.SystemManage.MenuType): UI.ThemeColor {
|
||||
const tagMap: Record<Api.SystemManage.MenuType, UI.ThemeColor> = {
|
||||
1: 'info',
|
||||
2: 'primary',
|
||||
3: 'warning'
|
||||
};
|
||||
|
||||
return tagMap[type];
|
||||
}
|
||||
|
||||
function getMenuTypeLabel(type: number) {
|
||||
return $t(menuTypeRecord[type as Api.SystemManage.MenuType]);
|
||||
}
|
||||
|
||||
function filterNode(value: string, data: any) {
|
||||
const node = data as Api.SystemManage.MenuSimple;
|
||||
|
||||
if (!value.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return node.name.toLowerCase().includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function collectExpandableNodeIds(nodes: Api.SystemManage.MenuSimple[]) {
|
||||
const ids: number[] = [];
|
||||
|
||||
const walk = (items: Api.SystemManage.MenuSimple[]) => {
|
||||
items.forEach(item => {
|
||||
const children = item.children ?? [];
|
||||
const hasNonButtonChild = children.some(child => child.type !== 3);
|
||||
|
||||
if (hasNonButtonChild) {
|
||||
ids.push(item.id);
|
||||
walk(children);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
walk(nodes);
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function loadRoleMenus() {
|
||||
if (!props.role) {
|
||||
checkedKeys.value = [];
|
||||
treeRef.value?.setCheckedKeys([]);
|
||||
treeRef.value?.filter(filterKeyword.value);
|
||||
return;
|
||||
}
|
||||
|
||||
permissionLoading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetRoleMenuIds(props.role.id);
|
||||
|
||||
permissionLoading.value = false;
|
||||
|
||||
if (error) {
|
||||
checkedKeys.value = [];
|
||||
treeRef.value?.setCheckedKeys([]);
|
||||
treeRef.value?.filter(filterKeyword.value);
|
||||
return;
|
||||
}
|
||||
|
||||
checkedKeys.value = data;
|
||||
treeRef.value?.setCheckedKeys(data);
|
||||
treeRef.value?.filter(filterKeyword.value);
|
||||
}
|
||||
|
||||
function handleCheck() {
|
||||
checkedKeys.value = (treeRef.value?.getCheckedKeys(false) as number[]) ?? [];
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!props.role) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuIds = (treeRef.value?.getCheckedKeys(false) as number[]) ?? [];
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const { error } = await fetchAssignRoleMenus({
|
||||
roleId: props.role.id,
|
||||
menuIds
|
||||
});
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
checkedKeys.value = menuIds;
|
||||
|
||||
window.$message?.success($t('common.modifySuccess'));
|
||||
emit('saved');
|
||||
}
|
||||
|
||||
watch(filterKeyword, value => {
|
||||
treeRef.value?.filter(value);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.role?.id,
|
||||
() => {
|
||||
loadRoleMenus();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.menuTree.length,
|
||||
value => {
|
||||
if (value && props.role) {
|
||||
treeRef.value?.setCheckedKeys(checkedKeys.value);
|
||||
treeRef.value?.filter(filterKeyword.value);
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="h-full card-wrapper" body-class="role-resource-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<p>{{ $t('page.system.role.resourceAuth') }}</p>
|
||||
<ElButton type="primary" size="small" :disabled="disabled" :loading="submitting" @click="handleSave">
|
||||
{{ $t('page.system.role.saveAuth') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="role">
|
||||
<div class="mb-12px flex flex-col gap-10px">
|
||||
<ElInput v-model="filterKeyword" clearable :placeholder="$t('page.system.role.form.resourceKeyword')">
|
||||
<template #prefix>
|
||||
<icon-ic-round-search class="text-icon" />
|
||||
</template>
|
||||
</ElInput>
|
||||
<div class="flex items-center gap-8px text-13px text-[#606266]">
|
||||
<span>{{ $t('page.system.role.selectedCount') }}</span>
|
||||
<ElTag type="primary" effect="plain">{{ checkedCount }}</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElAlert
|
||||
v-if="role.status === 1"
|
||||
:title="$t('page.system.role.disabledTip')"
|
||||
type="warning"
|
||||
class="mb-12px"
|
||||
:closable="false"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-loading="permissionLoading || props.loading"
|
||||
:class="{ 'pointer-events-none opacity-70': disabled }"
|
||||
class="min-h-0 flex-1 overflow-hidden border border-[#ebeef5] rounded-12px bg-[#fcfdff]"
|
||||
>
|
||||
<ElScrollbar class="h-full px-12px py-12px">
|
||||
<ElTree
|
||||
:key="treeRenderKey"
|
||||
ref="treeRef"
|
||||
node-key="id"
|
||||
show-checkbox
|
||||
:default-expanded-keys="defaultExpandedKeys"
|
||||
:data="menuTree"
|
||||
:props="treeProps"
|
||||
:filter-node-method="filterNode"
|
||||
:check-on-click-node="true"
|
||||
@check="handleCheck"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="min-w-0 flex items-center gap-8px">
|
||||
<span class="truncate text-14px">{{ data.name }}</span>
|
||||
<ElTag size="small" effect="plain" :type="getTagType(data.type)">
|
||||
{{ getMenuTypeLabel(data.type) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
</ElTree>
|
||||
</ElScrollbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="h-full flex items-center justify-center">
|
||||
<ElEmpty :description="$t('page.system.role.emptyRole')" />
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.role-resource-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
55
src/views/system/role/modules/role-search.vue
Normal file
55
src/views/system/role/modules/role-search.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'RoleSearch' });
|
||||
|
||||
interface Emits {
|
||||
(e: 'reset'): void;
|
||||
(e: 'search'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<Api.SystemManage.RoleSearchParams>('model', { required: true });
|
||||
|
||||
const keyword = computed({
|
||||
get() {
|
||||
return model.value.name ?? model.value.code ?? '';
|
||||
},
|
||||
set(value: string) {
|
||||
const text = value.trim() || undefined;
|
||||
model.value.name = text;
|
||||
model.value.code = text;
|
||||
}
|
||||
});
|
||||
|
||||
function reset() {
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
function search() {
|
||||
emit('search');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchPanel :model="model" :action-col-lg="8" @reset="reset" @search="search">
|
||||
<ElCol :lg="8" :md="12" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.role.searchKeyword')" prop="name">
|
||||
<ElInput v-model="keyword" clearable :placeholder="$t('page.system.role.searchPlaceholder')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="8" :md="12" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.role.roleStatus')" prop="status">
|
||||
<ElSelect v-model="model.status" clearable :placeholder="$t('page.system.role.form.roleStatus')">
|
||||
<ElOption v-for="{ label, value } in commonStatusOptions" :key="value" :label="$t(label)" :value="value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</TableSearchPanel>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
92
src/views/system/shared/menu-tree.ts
Normal file
92
src/views/system/shared/menu-tree.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
type TreeNode = {
|
||||
id: number;
|
||||
parentId: number;
|
||||
sort?: number | null;
|
||||
children?: TreeNode[] | null;
|
||||
};
|
||||
|
||||
export function buildMenuTree<T extends TreeNode>(list: T[]) {
|
||||
const nodeMap = new Map<number, T>();
|
||||
const roots: T[] = [];
|
||||
|
||||
list.forEach(item => {
|
||||
nodeMap.set(item.id, {
|
||||
...item,
|
||||
children: []
|
||||
});
|
||||
});
|
||||
|
||||
nodeMap.forEach(node => {
|
||||
if (node.parentId === 0) {
|
||||
roots.push(node);
|
||||
return;
|
||||
}
|
||||
|
||||
const parent = nodeMap.get(node.parentId);
|
||||
|
||||
if (!parent) {
|
||||
roots.push(node);
|
||||
return;
|
||||
}
|
||||
|
||||
parent.children = [...(parent.children ?? []), node];
|
||||
});
|
||||
|
||||
return sortMenuTree(roots);
|
||||
}
|
||||
|
||||
export function collectDescendantIds<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], targetId: number) {
|
||||
const target = findTreeNode(nodes, targetId);
|
||||
|
||||
if (!target?.children?.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ids: number[] = [];
|
||||
|
||||
walkTree(target.children, item => {
|
||||
ids.push(item.id);
|
||||
});
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
function sortMenuTree<T extends TreeNode>(nodes: T[]) {
|
||||
const sortedNodes = [...nodes].sort((prev, next) => Number(prev.sort ?? 0) - Number(next.sort ?? 0));
|
||||
|
||||
sortedNodes.forEach(node => {
|
||||
if (node.children?.length) {
|
||||
node.children = sortMenuTree(node.children as T[]);
|
||||
}
|
||||
});
|
||||
|
||||
return sortedNodes;
|
||||
}
|
||||
|
||||
function findTreeNode<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], targetId: number): T | null {
|
||||
for (const node of nodes) {
|
||||
if (node.id === targetId) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node.children?.length) {
|
||||
const target = findTreeNode(node.children as unknown as T[], targetId);
|
||||
|
||||
if (target) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function walkTree<T extends Pick<TreeNode, 'id' | 'children'>>(nodes: T[], callback: (node: T) => void) {
|
||||
for (const node of nodes) {
|
||||
callback(node);
|
||||
|
||||
if (node.children?.length) {
|
||||
walkTree(node.children as unknown as T[], callback);
|
||||
}
|
||||
}
|
||||
}
|
||||
14
src/views/system/user-detail/[id].vue
Normal file
14
src/views/system/user-detail/[id].vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
id: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LookForward />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
737
src/views/system/user/index.vue
Normal file
737
src/views/system/user/index.vue
Normal file
@@ -0,0 +1,737 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue';
|
||||
import type { TableInstance } from 'element-plus';
|
||||
import { ElButton, ElPopconfirm, ElSwitch, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import type { FlatResponseData } from '@sa/axios';
|
||||
import { userGenderRecord } from '@/constants/business';
|
||||
import {
|
||||
fetchBatchDeleteUser,
|
||||
fetchDeleteDept,
|
||||
fetchDeleteUser,
|
||||
fetchGetDeptList,
|
||||
fetchGetPostSimpleList,
|
||||
fetchGetRoleSimpleList,
|
||||
fetchGetUser,
|
||||
fetchGetUserPage,
|
||||
fetchUpdateUser,
|
||||
fetchUpdateUserStatus
|
||||
} from '@/service/api';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||
import { $t } from '@/locales';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
import UserOperateDialog from './modules/user-operate-dialog.vue';
|
||||
import UserOrgLeaderDialog from './modules/user-org-leader-dialog.vue';
|
||||
import UserOrgOperateDialog from './modules/user-org-operate-dialog.vue';
|
||||
import UserOrgPanel from './modules/user-org-panel.vue';
|
||||
import UserResignedDialog from './modules/user-resigned-dialog.vue';
|
||||
import UserResetPasswordDialog from './modules/user-reset-password-dialog.vue';
|
||||
import UserSearch from './modules/user-search.vue';
|
||||
|
||||
defineOptions({ name: 'UserManage' });
|
||||
|
||||
function getInitSearchParams(): Api.SystemManage.UserSearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
username: undefined,
|
||||
mobile: undefined,
|
||||
status: undefined,
|
||||
deptId: undefined,
|
||||
roleId: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyUserPageResult(): Promise<FlatResponseData<any, Api.SystemManage.UserList>> {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
list: [],
|
||||
total: 0
|
||||
},
|
||||
error: null,
|
||||
response: undefined
|
||||
} as unknown as FlatResponseData<any, Api.SystemManage.UserList>);
|
||||
}
|
||||
|
||||
function transformUserPage(
|
||||
response: FlatResponseData<any, Api.SystemManage.UserList>,
|
||||
pageNo: number,
|
||||
pageSize: number
|
||||
) {
|
||||
if (!response.error) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: response.data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime(value?: number | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
function getNullableLabel(value?: string | null) {
|
||||
return value?.trim() || '--';
|
||||
}
|
||||
|
||||
type UserResignedState = 'active' | 'pending' | 'resigned';
|
||||
|
||||
function getUserResignedState(row: Api.SystemManage.User): UserResignedState {
|
||||
if (!row.resignedAt) {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
return row.resignedAt > Date.now() ? 'pending' : 'resigned';
|
||||
}
|
||||
|
||||
function getResignedActionConfig(row: Api.SystemManage.User) {
|
||||
const state = getUserResignedState(row);
|
||||
|
||||
if (state === 'active') {
|
||||
return {
|
||||
label: $t('page.system.user.resignUser'),
|
||||
buttonType: 'warning' as const
|
||||
};
|
||||
}
|
||||
|
||||
if (state === 'pending') {
|
||||
return {
|
||||
label: $t('page.system.user.adjustResignUser'),
|
||||
buttonType: 'warning' as const
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: $t('page.system.user.restoreUser'),
|
||||
buttonType: 'success' as const
|
||||
};
|
||||
}
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const deptLoading = ref(false);
|
||||
const userTableRef = ref<TableInstance>();
|
||||
const userCheckedRowKeys = ref<number[]>([]);
|
||||
const statusLoadingIds = ref<number[]>([]);
|
||||
const deptList = ref<Api.SystemManage.Dept[]>([]);
|
||||
const currentDeptId = ref<number | null>(null);
|
||||
const operateVisible = ref(false);
|
||||
const operateType = ref<UI.TableOperateType>('add');
|
||||
const editingUserId = ref<number | null>(null);
|
||||
const postOptions = ref<Api.SystemManage.PostSimple[]>([]);
|
||||
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
|
||||
const resetPasswordVisible = ref(false);
|
||||
const resetPasswordUserId = ref<number | null>(null);
|
||||
const resetPasswordUsername = ref<string | null>(null);
|
||||
const resignedVisible = ref(false);
|
||||
const resignedUserId = ref<number | null>(null);
|
||||
const resignedUsername = ref<string | null>(null);
|
||||
const resignedAt = ref<number | null>(null);
|
||||
const orgOperateVisible = ref(false);
|
||||
const orgOperateType = ref<UI.TableOperateType>('add');
|
||||
const editingDeptData = ref<Api.SystemManage.Dept | null>(null);
|
||||
const orgParentId = ref<number | null>(0);
|
||||
const orgLeaderVisible = ref(false);
|
||||
const leaderDeptData = ref<Api.SystemManage.Dept | null>(null);
|
||||
|
||||
const deptTree = computed(() => buildMenuTree(deptList.value));
|
||||
const currentDept = computed(() => deptList.value.find(item => item.id === currentDeptId.value) ?? null);
|
||||
const deptCount = computed(() => deptList.value.length);
|
||||
|
||||
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
FlatResponseData<any, Api.SystemManage.UserList>,
|
||||
Api.SystemManage.User
|
||||
>({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => {
|
||||
if (!currentDeptId.value) {
|
||||
return createEmptyUserPageResult();
|
||||
}
|
||||
|
||||
return fetchGetUserPage({
|
||||
...searchParams,
|
||||
deptId: currentDeptId.value
|
||||
});
|
||||
},
|
||||
transform: response => transformUserPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'selection', type: 'selection', width: 48 },
|
||||
{ prop: 'index', type: 'index', label: $t('common.index'), width: 64 },
|
||||
{ prop: 'username', label: $t('page.system.user.userName'), minWidth: 140, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'nickname',
|
||||
label: $t('page.system.user.nickName'),
|
||||
minWidth: 120,
|
||||
formatter: row => getNullableLabel(row.nickname)
|
||||
},
|
||||
{
|
||||
prop: 'deptName',
|
||||
label: $t('page.system.user.deptName'),
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => getNullableLabel(row.deptName)
|
||||
},
|
||||
{
|
||||
prop: 'positionName',
|
||||
label: $t('page.system.user.positionName'),
|
||||
minWidth: 140,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => getNullableLabel(row.positionName)
|
||||
},
|
||||
{
|
||||
prop: 'mobile',
|
||||
label: $t('page.system.user.userPhone'),
|
||||
width: 140,
|
||||
formatter: row => getNullableLabel(row.mobile)
|
||||
},
|
||||
{
|
||||
prop: 'email',
|
||||
label: $t('page.system.user.userEmail'),
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => getNullableLabel(row.email)
|
||||
},
|
||||
{
|
||||
prop: 'sex',
|
||||
label: $t('page.system.user.userGender'),
|
||||
width: 100,
|
||||
align: 'center',
|
||||
formatter: row => {
|
||||
const value = row.sex ?? 0;
|
||||
const tagMap: Record<Api.SystemManage.UserGender, UI.ThemeColor> = {
|
||||
0: 'info',
|
||||
1: 'primary',
|
||||
2: 'danger'
|
||||
};
|
||||
|
||||
return <ElTag type={tagMap[value]}>{$t(userGenderRecord[value])}</ElTag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'status',
|
||||
label: $t('page.system.user.userStatus'),
|
||||
width: 110,
|
||||
align: 'center',
|
||||
formatter: row => (
|
||||
<ElSwitch
|
||||
modelValue={row.status === 0}
|
||||
loading={statusLoadingIds.value.includes(row.id)}
|
||||
inlinePrompt
|
||||
activeText={$t('page.system.common.status.enable')}
|
||||
inactiveText={$t('page.system.common.status.disable')}
|
||||
onChange={value => handleToggleStatus(row, Boolean(value))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'resignedAt',
|
||||
label: $t('page.system.user.resignedAt'),
|
||||
minWidth: 170,
|
||||
formatter: row => formatTime(row.resignedAt)
|
||||
},
|
||||
{
|
||||
prop: 'resignedState',
|
||||
label: $t('page.system.user.resignedState'),
|
||||
width: 110,
|
||||
align: 'center',
|
||||
formatter: row => {
|
||||
const state = getUserResignedState(row);
|
||||
const stateMap: Record<UserResignedState, { type: UI.ThemeColor; label: App.I18n.I18nKey }> = {
|
||||
active: { type: 'success', label: 'page.system.user.resignedStateEnum.active' },
|
||||
pending: { type: 'warning', label: 'page.system.user.resignedStateEnum.pending' },
|
||||
resigned: { type: 'info', label: 'page.system.user.resignedStateEnum.resigned' }
|
||||
};
|
||||
|
||||
return <ElTag type={stateMap[state].type}>{$t(stateMap[state].label)}</ElTag>;
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'loginDate',
|
||||
label: $t('page.system.user.loginDate'),
|
||||
minWidth: 170,
|
||||
formatter: row => formatTime(row.loginDate)
|
||||
},
|
||||
{
|
||||
prop: 'createTime',
|
||||
label: $t('page.system.user.createTime'),
|
||||
minWidth: 170,
|
||||
formatter: row => formatTime(row.createTime)
|
||||
},
|
||||
{
|
||||
prop: 'operate',
|
||||
label: $t('common.operate'),
|
||||
width: 210,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => (
|
||||
<BusinessTableActionCell
|
||||
actions={[
|
||||
{
|
||||
key: 'edit',
|
||||
label: $t('common.edit'),
|
||||
buttonType: 'primary',
|
||||
onClick: () => openEdit(row.id)
|
||||
},
|
||||
{
|
||||
key: 'reset-password',
|
||||
label: $t('page.system.user.resetPassword'),
|
||||
buttonType: 'warning',
|
||||
onClick: () => openResetPassword(row)
|
||||
},
|
||||
{
|
||||
key: 'resigned',
|
||||
label: getResignedActionConfig(row).label,
|
||||
buttonType: getResignedActionConfig(row).buttonType,
|
||||
onClick: () => handleResignedAction(row)
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: $t('common.delete'),
|
||||
buttonType: 'danger',
|
||||
onClick: () => handleDeleteAction(row)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
async function loadDeptTree() {
|
||||
deptLoading.value = true;
|
||||
|
||||
const { error, data: deptItems } = await fetchGetDeptList({
|
||||
status: 0
|
||||
});
|
||||
|
||||
deptLoading.value = false;
|
||||
|
||||
if (error) {
|
||||
deptList.value = [];
|
||||
currentDeptId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
deptList.value = deptItems;
|
||||
|
||||
if (!deptItems.length) {
|
||||
currentDeptId.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const matched = deptItems.find(item => item.id === currentDeptId.value);
|
||||
currentDeptId.value = matched?.id ?? deptItems[0].id;
|
||||
}
|
||||
|
||||
async function loadFormOptions() {
|
||||
const [postResult, roleResult] = await Promise.all([fetchGetPostSimpleList(), fetchGetRoleSimpleList()]);
|
||||
|
||||
if (!postResult.error) {
|
||||
postOptions.value = postResult.data;
|
||||
}
|
||||
|
||||
if (!roleResult.error) {
|
||||
roleOptions.value = roleResult.data.filter(item => item.status === 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadUserTable(page = searchParams.pageNo) {
|
||||
userCheckedRowKeys.value = [];
|
||||
await getDataByPage(page);
|
||||
await nextTick();
|
||||
userTableRef.value?.clearSelection();
|
||||
}
|
||||
|
||||
function handleDeptSelect(nodeData: Api.SystemManage.Dept) {
|
||||
currentDeptId.value = nodeData.id;
|
||||
}
|
||||
|
||||
function handleUserSelectionChange(rows: Api.SystemManage.User[]) {
|
||||
userCheckedRowKeys.value = rows.map(item => item.id);
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
operateType.value = 'add';
|
||||
editingUserId.value = null;
|
||||
operateVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(id: number) {
|
||||
operateType.value = 'edit';
|
||||
editingUserId.value = id;
|
||||
operateVisible.value = true;
|
||||
}
|
||||
|
||||
function openResetPassword(row: Api.SystemManage.User) {
|
||||
resetPasswordUserId.value = row.id;
|
||||
resetPasswordUsername.value = row.username;
|
||||
resetPasswordVisible.value = true;
|
||||
}
|
||||
|
||||
function openResignedDialog(row: Api.SystemManage.User) {
|
||||
resignedUserId.value = row.id;
|
||||
resignedUsername.value = row.username;
|
||||
resignedAt.value = row.resignedAt ?? null;
|
||||
resignedVisible.value = true;
|
||||
}
|
||||
|
||||
function openAddRootOrg() {
|
||||
orgOperateType.value = 'add';
|
||||
editingDeptData.value = null;
|
||||
orgParentId.value = 0;
|
||||
orgOperateVisible.value = true;
|
||||
}
|
||||
|
||||
function openAddChildOrg(row: Api.SystemManage.Dept) {
|
||||
orgOperateType.value = 'add';
|
||||
editingDeptData.value = null;
|
||||
orgParentId.value = row.id;
|
||||
orgOperateVisible.value = true;
|
||||
}
|
||||
|
||||
function openEditOrg(row: Api.SystemManage.Dept) {
|
||||
orgOperateType.value = 'edit';
|
||||
editingDeptData.value = row;
|
||||
orgParentId.value = row.parentId;
|
||||
orgOperateVisible.value = true;
|
||||
}
|
||||
|
||||
function openOrgLeader(row: Api.SystemManage.Dept) {
|
||||
leaderDeptData.value = row;
|
||||
orgLeaderVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDeleteDeptAction(row: Api.SystemManage.Dept) {
|
||||
const { error } = await fetchDeleteDept(row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentDeptId.value === row.id) {
|
||||
currentDeptId.value = null;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
await loadDeptTree();
|
||||
}
|
||||
|
||||
async function handleDeleteAction(row: Api.SystemManage.User) {
|
||||
try {
|
||||
await window.$messageBox?.confirm($t('common.confirmDelete'), $t('common.warning'), {
|
||||
confirmButtonText: $t('common.confirm'),
|
||||
cancelButtonText: $t('common.cancel'),
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchDeleteUser(row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
await reloadUserTable();
|
||||
}
|
||||
|
||||
async function updateUserResignedAt(userId: number, value: number | null) {
|
||||
const detailResult = await fetchGetUser(userId);
|
||||
|
||||
if (detailResult.error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = detailResult.data;
|
||||
|
||||
const { error } = await fetchUpdateUser({
|
||||
id: userId,
|
||||
username: user.username,
|
||||
nickname: user.nickname ?? null,
|
||||
remark: user.remark ?? null,
|
||||
deptId: user.deptId,
|
||||
positionId: user.positionId ?? null,
|
||||
resignedAt: value,
|
||||
email: user.email ?? null,
|
||||
mobile: user.mobile ?? null,
|
||||
sex: user.sex ?? 0,
|
||||
avatar: user.avatar ?? null
|
||||
});
|
||||
|
||||
return !error;
|
||||
}
|
||||
|
||||
async function handleRestoreUser(row: Api.SystemManage.User) {
|
||||
try {
|
||||
await window.$messageBox?.confirm($t('common.confirmDelete'), $t('page.system.user.restoreUser'), {
|
||||
confirmButtonText: $t('common.confirm'),
|
||||
cancelButtonText: $t('common.cancel'),
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await updateUserResignedAt(row.id, null);
|
||||
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.updateSuccess'));
|
||||
await reloadUserTable();
|
||||
}
|
||||
|
||||
async function handleResignedAction(row: Api.SystemManage.User) {
|
||||
if (getUserResignedState(row) === 'resigned') {
|
||||
await handleRestoreUser(row);
|
||||
return;
|
||||
}
|
||||
|
||||
openResignedDialog(row);
|
||||
}
|
||||
|
||||
async function handleBatchDelete() {
|
||||
if (!userCheckedRowKeys.value.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchBatchDeleteUser(userCheckedRowKeys.value);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
await reloadUserTable();
|
||||
}
|
||||
|
||||
async function handleToggleStatus(row: Api.SystemManage.User, enabled: boolean) {
|
||||
statusLoadingIds.value = [...statusLoadingIds.value, row.id];
|
||||
|
||||
const { error } = await fetchUpdateUserStatus({
|
||||
id: row.id,
|
||||
status: enabled ? 0 : 1
|
||||
});
|
||||
|
||||
statusLoadingIds.value = statusLoadingIds.value.filter(item => item !== row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
row.status = enabled ? 0 : 1;
|
||||
window.$message?.success($t('common.updateSuccess'));
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
await reloadUserTable(1);
|
||||
}
|
||||
|
||||
async function handleResetSearch() {
|
||||
const pageSize = searchParams.pageSize;
|
||||
|
||||
Object.assign(searchParams, getInitSearchParams(), {
|
||||
pageSize,
|
||||
deptId: currentDeptId.value ?? undefined
|
||||
});
|
||||
|
||||
await reloadUserTable(1);
|
||||
}
|
||||
|
||||
async function handleSubmitted() {
|
||||
operateVisible.value = false;
|
||||
await reloadUserTable();
|
||||
}
|
||||
|
||||
async function handleResignedSubmitted() {
|
||||
resignedVisible.value = false;
|
||||
await reloadUserTable();
|
||||
}
|
||||
|
||||
async function handleDeptSubmitted(deptId: number) {
|
||||
orgOperateVisible.value = false;
|
||||
await loadDeptTree();
|
||||
currentDeptId.value = deptId;
|
||||
}
|
||||
|
||||
watch(currentDeptId, async (value, oldValue) => {
|
||||
if (!value || value === oldValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pageSize = searchParams.pageSize;
|
||||
|
||||
Object.assign(searchParams, getInitSearchParams(), {
|
||||
pageSize,
|
||||
deptId: value
|
||||
});
|
||||
|
||||
await reloadUserTable(1);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadDeptTree(), loadFormOptions()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="min-h-560px gap-16px overflow-hidden xl:grid xl:grid-cols-[320px_minmax(0,1fr)] lt-xl:flex lt-xl:flex-col lt-xl:overflow-auto"
|
||||
>
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<UserOrgPanel
|
||||
:loading="deptLoading"
|
||||
:tree-data="deptTree"
|
||||
:current-dept-id="currentDeptId"
|
||||
:total="deptCount"
|
||||
@add-root="openAddRootOrg"
|
||||
@add-child="openAddChildOrg"
|
||||
@leader="openOrgLeader"
|
||||
@edit="openEditOrg"
|
||||
@delete="handleDeleteDeptAction"
|
||||
@refresh="loadDeptTree"
|
||||
@select="handleDeptSelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<UserSearch
|
||||
v-model:model="searchParams"
|
||||
:role-options="roleOptions"
|
||||
:disabled="!currentDept"
|
||||
@reset="handleResetSearch"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="user-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex flex-wrap items-center gap-8px">
|
||||
<p>{{ $t('page.system.user.title') }}</p>
|
||||
<ElTag v-if="currentDept" type="primary" effect="light">
|
||||
{{ currentDept.name }}
|
||||
</ElTag>
|
||||
<ElTag effect="plain">{{ mobilePagination.total || data.length }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadUserTable">
|
||||
<template #default>
|
||||
<ElButton plain type="primary" :disabled="!currentDept" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
<ElPopconfirm :title="$t('common.confirmDelete')" @confirm="handleBatchDelete">
|
||||
<template #reference>
|
||||
<ElButton type="danger" plain :disabled="userCheckedRowKeys.length === 0">
|
||||
<template #icon>
|
||||
<icon-ic-round-delete class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.batchDelete') }}
|
||||
</ElButton>
|
||||
</template>
|
||||
</ElPopconfirm>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="currentDept">
|
||||
<div class="flex-1">
|
||||
<ElTable
|
||||
ref="userTableRef"
|
||||
v-loading="loading"
|
||||
height="100%"
|
||||
border
|
||||
row-key="id"
|
||||
:data="data"
|
||||
@selection-change="handleUserSelectionChange"
|
||||
>
|
||||
<ElTableColumn v-for="col in columns" :key="String(col.prop)" v-bind="col" />
|
||||
</ElTable>
|
||||
</div>
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="mobilePagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="h-full flex items-center justify-center">
|
||||
<ElEmpty :description="$t('page.system.user.emptyOrg')" />
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<UserOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:operate-type="operateType"
|
||||
:user-id="editingUserId"
|
||||
:current-dept-id="currentDeptId"
|
||||
:dept-tree="deptTree"
|
||||
:post-options="postOptions"
|
||||
:role-options="roleOptions"
|
||||
@submitted="handleSubmitted"
|
||||
/>
|
||||
|
||||
<UserResetPasswordDialog
|
||||
v-model:visible="resetPasswordVisible"
|
||||
:user-id="resetPasswordUserId"
|
||||
:username="resetPasswordUsername"
|
||||
/>
|
||||
|
||||
<UserResignedDialog
|
||||
v-model:visible="resignedVisible"
|
||||
:user-id="resignedUserId"
|
||||
:username="resignedUsername"
|
||||
:resigned-at="resignedAt"
|
||||
@submitted="handleResignedSubmitted"
|
||||
/>
|
||||
|
||||
<UserOrgOperateDialog
|
||||
v-model:visible="orgOperateVisible"
|
||||
:operate-type="orgOperateType"
|
||||
:row-data="editingDeptData"
|
||||
:parent-id="orgParentId"
|
||||
:dept-tree="deptTree"
|
||||
@submitted="handleDeptSubmitted"
|
||||
/>
|
||||
|
||||
<UserOrgLeaderDialog v-model:visible="orgLeaderVisible" :dept="leaderDeptData" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
:deep(.user-table-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
363
src/views/system/user/modules/user-operate-dialog.vue
Normal file
363
src/views/system/user/modules/user-operate-dialog.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { userGenderOptions } from '@/constants/business';
|
||||
import {
|
||||
fetchAssignUserRoles,
|
||||
fetchCreateUser,
|
||||
fetchGetUser,
|
||||
fetchGetUserRoleIds,
|
||||
fetchUpdateUser
|
||||
} from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { translateOptions } from '@/utils/common';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'UserOperateDialog' });
|
||||
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
userId?: number | null;
|
||||
currentDeptId?: number | null;
|
||||
deptTree: Api.SystemManage.Dept[];
|
||||
postOptions: Api.SystemManage.PostSimple[];
|
||||
roleOptions: Api.SystemManage.RoleSimple[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted', userId?: number): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule, patternRules } = useFormRules();
|
||||
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
const title = computed(() => {
|
||||
const titles: Record<UI.TableOperateType, string> = {
|
||||
add: $t('page.system.user.addUser'),
|
||||
edit: $t('page.system.user.editUser')
|
||||
};
|
||||
|
||||
return titles[props.operateType];
|
||||
});
|
||||
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
|
||||
type Model = Api.SystemManage.SaveUserParams & {
|
||||
roleIds: number[];
|
||||
};
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const genderOptions = computed(() =>
|
||||
translateOptions(userGenderOptions).map(item => ({
|
||||
...item,
|
||||
value: Number(item.value) as Api.SystemManage.UserGender
|
||||
}))
|
||||
);
|
||||
|
||||
const deptTreeProps = {
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
children: 'children'
|
||||
} as const;
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
username: '',
|
||||
nickname: '',
|
||||
remark: '',
|
||||
deptId: props.currentDeptId ?? 0,
|
||||
positionId: null,
|
||||
email: '',
|
||||
mobile: '',
|
||||
sex: 1,
|
||||
avatar: '',
|
||||
password: '',
|
||||
roleIds: []
|
||||
};
|
||||
}
|
||||
|
||||
function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
const rules = computed(() => {
|
||||
const passwordRules = isEdit.value
|
||||
? []
|
||||
: [createRequiredRule($t('page.system.user.form.password')), patternRules.pwd];
|
||||
|
||||
return {
|
||||
username: [createRequiredRule($t('page.system.user.form.userName')), patternRules.userName],
|
||||
deptId: [createRequiredRule($t('page.system.user.form.deptName'))],
|
||||
positionId: [createRequiredRule($t('page.system.user.form.positionName'))],
|
||||
mobile: getNullableText(model.value.mobile) ? [patternRules.phone] : [],
|
||||
email: getNullableText(model.value.email) ? [patternRules.email] : [],
|
||||
password: passwordRules
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
});
|
||||
|
||||
async function handleInitModel() {
|
||||
model.value = createDefaultModel();
|
||||
|
||||
if (!isEdit.value || !props.userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const [userResult, roleResult] = await Promise.all([fetchGetUser(props.userId), fetchGetUserRoleIds(props.userId)]);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
if (userResult.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = userResult.data;
|
||||
|
||||
model.value = {
|
||||
username: user.username,
|
||||
nickname: user.nickname ?? '',
|
||||
remark: user.remark ?? '',
|
||||
deptId: user.deptId,
|
||||
positionId: user.positionId ?? null,
|
||||
email: user.email ?? '',
|
||||
mobile: user.mobile ?? '',
|
||||
sex: user.sex ?? 0,
|
||||
avatar: user.avatar ?? '',
|
||||
password: '',
|
||||
roleIds: roleResult.error ? [] : roleResult.data
|
||||
};
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
const payload: Api.SystemManage.SaveUserParams = {
|
||||
username: model.value.username.trim(),
|
||||
nickname: getNullableText(model.value.nickname),
|
||||
remark: getNullableText(model.value.remark),
|
||||
deptId: model.value.deptId,
|
||||
positionId: model.value.positionId,
|
||||
email: getNullableText(model.value.email),
|
||||
mobile: getNullableText(model.value.mobile),
|
||||
sex: model.value.sex,
|
||||
avatar: getNullableText(model.value.avatar)
|
||||
};
|
||||
|
||||
if (!isEdit.value) {
|
||||
payload.password = String(model.value.password ?? '').trim();
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
let userId = props.userId ?? undefined;
|
||||
|
||||
if (isEdit.value && props.userId) {
|
||||
const result = await fetchUpdateUser({ id: props.userId, ...payload });
|
||||
|
||||
if (result.error) {
|
||||
submitting.value = false;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const result = await fetchCreateUser(payload);
|
||||
|
||||
if (result.error) {
|
||||
submitting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
userId = result.data;
|
||||
}
|
||||
|
||||
if (userId !== undefined) {
|
||||
const roleResult = await fetchAssignUserRoles({
|
||||
userId,
|
||||
roleIds: model.value.roleIds
|
||||
});
|
||||
|
||||
if (roleResult.error) {
|
||||
submitting.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
window.$message?.success(isEdit.value ? $t('common.updateSuccess') : $t('common.addSuccess'));
|
||||
closeDialog();
|
||||
emit('submitted', userId);
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleInitModel();
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="lg"
|
||||
:loading="loading"
|
||||
:confirm-loading="submitting"
|
||||
max-body-height="70vh"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" autocomplete="off">
|
||||
<input class="business-form-autofill-guard" type="text" name="fake-username" autocomplete="username" />
|
||||
<input class="business-form-autofill-guard" type="password" name="fake-password" autocomplete="new-password" />
|
||||
|
||||
<BusinessFormSection :title="$t('page.system.user.sections.basicInfo')">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.userName')" prop="username">
|
||||
<ElInput
|
||||
v-model="model.username"
|
||||
name="system-user-username"
|
||||
autocomplete="off"
|
||||
:placeholder="$t('page.system.user.form.userName')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.nickName')" prop="nickname">
|
||||
<ElInput
|
||||
v-model="model.nickname"
|
||||
name="system-user-nickname"
|
||||
autocomplete="off"
|
||||
:placeholder="$t('page.system.user.form.nickName')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol v-if="!isEdit" :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.password')" prop="password">
|
||||
<ElInput
|
||||
v-model="model.password"
|
||||
name="system-user-password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
:placeholder="$t('page.system.user.form.password')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.userGender')" prop="sex">
|
||||
<ElSelect v-model="model.sex" :placeholder="$t('page.system.user.form.userGender')">
|
||||
<ElOption v-for="{ label, value } in genderOptions" :key="value" :label="label" :value="value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.userPhone')" prop="mobile">
|
||||
<ElInput
|
||||
v-model="model.mobile"
|
||||
name="system-user-mobile"
|
||||
autocomplete="off"
|
||||
:placeholder="$t('page.system.user.form.userPhone')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.userEmail')" prop="email">
|
||||
<ElInput
|
||||
v-model="model.email"
|
||||
name="system-user-email"
|
||||
autocomplete="off"
|
||||
:placeholder="$t('page.system.user.form.userEmail')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.remark')" prop="remark">
|
||||
<ElInput
|
||||
v-model="model.remark"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
:placeholder="$t('page.system.user.form.remark')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection :title="$t('page.system.user.sections.organizationInfo')">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.deptName')" prop="deptId">
|
||||
<ElTreeSelect
|
||||
v-model="model.deptId"
|
||||
check-strictly
|
||||
clearable
|
||||
default-expand-all
|
||||
:data="deptTree"
|
||||
:props="deptTreeProps"
|
||||
:render-after-expand="false"
|
||||
:placeholder="$t('page.system.user.form.deptName')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.positionName')" prop="positionId">
|
||||
<ElSelect v-model="model.positionId" clearable :placeholder="$t('page.system.user.form.positionName')">
|
||||
<ElOption v-for="item in postOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.userRole')" prop="roleIds">
|
||||
<ElSelect
|
||||
v-model="model.roleIds"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
:placeholder="$t('page.system.user.form.userRole')"
|
||||
>
|
||||
<ElOption v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</BusinessFormSection>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.business-form-autofill-guard {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
264
src/views/system/user/modules/user-org-leader-dialog.vue
Normal file
264
src/views/system/user/modules/user-org-leader-dialog.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ElButton, ElEmpty, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchDeleteOrgLeaderRelation,
|
||||
fetchGetOrgLeaderCandidateUsers,
|
||||
fetchGetOrgLeaderListByDept,
|
||||
fetchGetUserPage
|
||||
} from '@/service/api';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||
import { $t } from '@/locales';
|
||||
import UserOrgLeaderOperateDialog from './user-org-leader-operate-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'UserOrgLeaderDialog' });
|
||||
|
||||
interface Props {
|
||||
dept?: Api.SystemManage.Dept | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const relations = ref<Api.SystemManage.OrgLeaderRelation[]>([]);
|
||||
const candidateUsers = ref<Api.SystemManage.OrgLeaderCandidateUser[]>([]);
|
||||
const operateVisible = ref(false);
|
||||
const operateType = ref<UI.TableOperateType>('add');
|
||||
const editingData = ref<Api.SystemManage.OrgLeaderRelation | null>(null);
|
||||
|
||||
const title = computed(() => {
|
||||
if (props.dept?.name) {
|
||||
return `${$t('page.system.user.orgLeaderTitle')} / ${props.dept.name}`;
|
||||
}
|
||||
|
||||
return $t('page.system.user.orgLeaderTitle');
|
||||
});
|
||||
|
||||
const total = computed(() => relations.value.length);
|
||||
|
||||
function formatTime(value?: number | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
function isCandidateUser(item: unknown): item is Api.SystemManage.OrgLeaderCandidateUser {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const record = item as Record<string, unknown>;
|
||||
|
||||
return typeof record.id === 'number' && typeof record.nickname === 'string' && typeof record.deptId === 'number';
|
||||
}
|
||||
|
||||
function mapUsersToCandidateUsers(users: Api.SystemManage.User[]): Api.SystemManage.OrgLeaderCandidateUser[] {
|
||||
const now = Date.now();
|
||||
|
||||
return users
|
||||
.filter(item => !item.resignedAt || item.resignedAt > now)
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
nickname: item.nickname?.trim() || item.username,
|
||||
deptId: item.deptId,
|
||||
deptName: item.deptName ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadCandidateUsers(deptId: number) {
|
||||
const candidateResult = await fetchGetOrgLeaderCandidateUsers(deptId);
|
||||
|
||||
if (!candidateResult.error && Array.isArray(candidateResult.data) && candidateResult.data.every(isCandidateUser)) {
|
||||
return candidateResult.data;
|
||||
}
|
||||
|
||||
const userResult = await fetchGetUserPage({
|
||||
pageNo: 1,
|
||||
pageSize: 200,
|
||||
deptId,
|
||||
status: 0
|
||||
});
|
||||
|
||||
if (userResult.error) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return mapUsersToCandidateUsers(userResult.data.list);
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (!props.dept?.id) {
|
||||
relations.value = [];
|
||||
candidateUsers.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const [relationResult, candidates] = await Promise.all([
|
||||
fetchGetOrgLeaderListByDept(props.dept.id),
|
||||
loadCandidateUsers(props.dept.id)
|
||||
]);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
relations.value = relationResult.error ? [] : relationResult.data;
|
||||
candidateUsers.value = candidates;
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
operateType.value = 'add';
|
||||
editingData.value = null;
|
||||
operateVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: Api.SystemManage.OrgLeaderRelation) {
|
||||
operateType.value = 'edit';
|
||||
editingData.value = row;
|
||||
operateVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDelete(row: Api.SystemManage.OrgLeaderRelation) {
|
||||
const { error } = await fetchDeleteOrgLeaderRelation(row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.deleteSuccess'));
|
||||
await loadData();
|
||||
}
|
||||
|
||||
async function handleDeleteAction(row: Api.SystemManage.OrgLeaderRelation) {
|
||||
try {
|
||||
await window.$messageBox?.confirm($t('common.confirmDelete'), $t('common.warning'), {
|
||||
confirmButtonText: $t('common.confirm'),
|
||||
cancelButtonText: $t('common.cancel'),
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
await handleDelete(row);
|
||||
}
|
||||
|
||||
async function handleSubmitted() {
|
||||
operateVisible.value = false;
|
||||
await loadData();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [visible.value, props.dept?.id] as const,
|
||||
async ([dialogVisible, deptId]) => {
|
||||
if (!dialogVisible || !deptId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadData();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog v-model="visible" :title="title" preset="lg" :show-footer="false">
|
||||
<div class="flex-col-stretch gap-16px">
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-8px">
|
||||
<ElTag type="primary" effect="light">{{ dept?.name || $t('common.noData') }}</ElTag>
|
||||
<ElTag effect="plain">{{ total }}</ElTag>
|
||||
</div>
|
||||
<div class="flex items-center gap-8px">
|
||||
<ElButton type="primary" plain size="small" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
<ElButton plain size="small" @click="loadData">
|
||||
<template #icon>
|
||||
<icon-mdi-refresh class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.refresh') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="min-h-260px">
|
||||
<template v-if="relations.length">
|
||||
<div class="min-h-260px">
|
||||
<ElTable border :data="relations" :max-height="420">
|
||||
<ElTableColumn type="index" :label="$t('common.index')" width="64" />
|
||||
<ElTableColumn prop="userNickname" :label="$t('page.system.user.orgLeader')" min-width="140" />
|
||||
<ElTableColumn
|
||||
prop="effectiveFrom"
|
||||
:label="$t('page.system.user.effectiveFrom')"
|
||||
min-width="170"
|
||||
:formatter="row => formatTime(row.effectiveFrom)"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="effectiveUntil"
|
||||
:label="$t('page.system.user.effectiveUntil')"
|
||||
min-width="170"
|
||||
:formatter="row => formatTime(row.effectiveUntil)"
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="remark"
|
||||
:label="$t('page.system.user.relationRemark')"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<ElTableColumn
|
||||
prop="createTime"
|
||||
:label="$t('page.system.user.createTime')"
|
||||
min-width="170"
|
||||
:formatter="row => formatTime(row.createTime)"
|
||||
/>
|
||||
<ElTableColumn :label="$t('common.operate')" width="196" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<BusinessTableActionCell
|
||||
:actions="[
|
||||
{
|
||||
key: 'edit',
|
||||
label: $t('common.edit'),
|
||||
buttonType: 'primary',
|
||||
onClick: () => openEdit(row)
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: $t('common.delete'),
|
||||
buttonType: 'danger',
|
||||
onClick: () => handleDeleteAction(row)
|
||||
}
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="min-h-260px flex items-center justify-center">
|
||||
<ElEmpty :description="$t('page.system.user.emptyLeader')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UserOrgLeaderOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:operate-type="operateType"
|
||||
:row-data="editingData"
|
||||
:dept="dept"
|
||||
:candidate-users="candidateUsers"
|
||||
@submitted="handleSubmitted"
|
||||
/>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
224
src/views/system/user/modules/user-org-leader-operate-dialog.vue
Normal file
224
src/views/system/user/modules/user-org-leader-operate-dialog.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchCreateOrgLeaderRelation, fetchUpdateOrgLeaderRelation } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'UserOrgLeaderOperateDialog' });
|
||||
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
rowData?: Api.SystemManage.OrgLeaderRelation | null;
|
||||
dept?: Api.SystemManage.Dept | null;
|
||||
candidateUsers: Api.SystemManage.OrgLeaderCandidateUser[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const submitting = ref(false);
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
|
||||
const title = computed(() => {
|
||||
const titleMap: Record<UI.TableOperateType, string> = {
|
||||
add: $t('page.system.user.addLeader'),
|
||||
edit: $t('page.system.user.editLeader')
|
||||
};
|
||||
|
||||
return titleMap[props.operateType];
|
||||
});
|
||||
|
||||
type Model = {
|
||||
userId: number | null;
|
||||
effectiveFrom: Date | null;
|
||||
effectiveUntil: Date | null;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const candidateOptions = computed(() => {
|
||||
const options = [...props.candidateUsers];
|
||||
const rowData = props.rowData;
|
||||
|
||||
if (isEdit.value && rowData && !options.some(item => item.id === rowData.userId)) {
|
||||
options.unshift({
|
||||
id: rowData.userId,
|
||||
nickname: rowData.userNickname,
|
||||
deptId: rowData.deptId,
|
||||
deptName: props.dept?.name ?? null
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
const rules = {
|
||||
userId: createRequiredRule($t('page.system.user.form.candidateUser'))
|
||||
} satisfies Partial<Record<keyof Model, App.Global.FormRule>>;
|
||||
|
||||
function createCurrentTime() {
|
||||
return dayjs().second(0).millisecond(0).toDate();
|
||||
}
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
userId: null,
|
||||
effectiveFrom: createCurrentTime(),
|
||||
effectiveUntil: null,
|
||||
remark: ''
|
||||
};
|
||||
}
|
||||
|
||||
function initModel() {
|
||||
model.value = createDefaultModel();
|
||||
|
||||
if (isEdit.value && props.rowData) {
|
||||
model.value = {
|
||||
userId: props.rowData.userId,
|
||||
effectiveFrom: props.rowData.effectiveFrom ? dayjs(props.rowData.effectiveFrom).toDate() : createCurrentTime(),
|
||||
effectiveUntil: props.rowData.effectiveUntil ? dayjs(props.rowData.effectiveUntil).toDate() : null,
|
||||
remark: props.rowData.remark ?? ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.dept?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
await validate();
|
||||
|
||||
const effectiveFrom = model.value.effectiveFrom ? dayjs(model.value.effectiveFrom).valueOf() : null;
|
||||
const effectiveUntil = model.value.effectiveUntil ? dayjs(model.value.effectiveUntil).valueOf() : null;
|
||||
|
||||
if (effectiveFrom && effectiveUntil && effectiveFrom > effectiveUntil) {
|
||||
window.$message?.warning($t('common.pleaseCheckValue'));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Api.SystemManage.SaveOrgLeaderRelationParams = {
|
||||
deptId: props.dept.id,
|
||||
userId: Number(model.value.userId),
|
||||
effectiveFrom,
|
||||
effectiveUntil,
|
||||
remark: model.value.remark.trim() || null
|
||||
};
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const request =
|
||||
isEdit.value && props.rowData
|
||||
? fetchUpdateOrgLeaderRelation({ id: props.rowData.id, ...payload })
|
||||
: fetchCreateOrgLeaderRelation(payload);
|
||||
|
||||
const { error } = await request;
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t(isEdit.value ? 'common.updateSuccess' : 'common.addSuccess'));
|
||||
closeDialog();
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
initModel();
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="md"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<BusinessFormSection :title="$t('page.system.user.sections.basicInfo')">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.deptName')">
|
||||
<ElInput :model-value="dept?.name || $t('common.noData')" disabled />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.candidateUser')" prop="userId">
|
||||
<ElSelect
|
||||
v-model="model.userId"
|
||||
class="w-full"
|
||||
filterable
|
||||
:placeholder="$t('page.system.user.form.candidateUser')"
|
||||
>
|
||||
<ElOption v-for="item in candidateOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.effectiveFrom')" prop="effectiveFrom">
|
||||
<ElDatePicker
|
||||
v-model="model.effectiveFrom"
|
||||
class="w-full"
|
||||
type="datetime"
|
||||
clearable
|
||||
:placeholder="$t('page.system.user.form.effectiveFrom')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.effectiveUntil')" prop="effectiveUntil">
|
||||
<ElDatePicker
|
||||
v-model="model.effectiveUntil"
|
||||
class="w-full"
|
||||
type="datetime"
|
||||
clearable
|
||||
:placeholder="$t('page.system.user.form.effectiveUntil')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.relationRemark')" prop="remark">
|
||||
<ElInput
|
||||
v-model="model.remark"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
:placeholder="$t('page.system.user.form.relationRemark')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</BusinessFormSection>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
250
src/views/system/user/modules/user-org-operate-dialog.vue
Normal file
250
src/views/system/user/modules/user-org-operate-dialog.vue
Normal file
@@ -0,0 +1,250 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import { fetchCreateDept, fetchUpdateDept } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'UserOrgOperateDialog' });
|
||||
|
||||
interface Props {
|
||||
operateType: UI.TableOperateType;
|
||||
rowData?: Api.SystemManage.Dept | null;
|
||||
parentId?: number | null;
|
||||
deptTree: Api.SystemManage.Dept[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted', deptId: number): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
|
||||
const title = computed(() => {
|
||||
if (isEdit.value) {
|
||||
return $t('page.system.user.editOrg');
|
||||
}
|
||||
|
||||
return props.parentId && props.parentId > 0 ? $t('page.system.user.addChildOrg') : $t('page.system.user.addOrg');
|
||||
});
|
||||
|
||||
const orgTypeOptions: CommonType.Option<Api.SystemManage.DeptOrgType, App.I18n.I18nKey>[] = [
|
||||
{ value: 'company', label: 'page.system.user.orgType.company' },
|
||||
{ value: 'dept', label: 'page.system.user.orgType.dept' },
|
||||
{ value: 'direction', label: 'page.system.user.orgType.direction' },
|
||||
{ value: 'team', label: 'page.system.user.orgType.team' }
|
||||
];
|
||||
|
||||
type Model = Api.SystemManage.SaveDeptParams;
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const treeProps = {
|
||||
value: 'id',
|
||||
label: 'name',
|
||||
children: 'children'
|
||||
} as const;
|
||||
|
||||
const parentTree = computed(() => {
|
||||
const filteredTree = filterDeptTree(props.deptTree, props.rowData?.id ?? null);
|
||||
|
||||
return [
|
||||
{
|
||||
id: 0,
|
||||
name: $t('page.system.user.topLevelOrg'),
|
||||
parentId: -1,
|
||||
orgType: 'company' as const,
|
||||
status: 0,
|
||||
children: filteredTree
|
||||
}
|
||||
];
|
||||
});
|
||||
|
||||
const expandParentTree = computed(() => !isEdit.value && (props.parentId ?? 0) === 0);
|
||||
|
||||
const rules = {
|
||||
name: createRequiredRule($t('page.system.user.form.orgName')),
|
||||
parentId: createRequiredRule($t('page.system.user.form.parentOrg')),
|
||||
orgType: createRequiredRule($t('page.system.user.form.orgTypeLabel')),
|
||||
sort: createRequiredRule($t('page.system.user.form.orgSort')),
|
||||
status: createRequiredRule($t('page.system.user.form.userStatus'))
|
||||
} satisfies Partial<Record<keyof Model, App.Global.FormRule>>;
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
name: '',
|
||||
parentId: props.parentId ?? 0,
|
||||
orgType: 'dept',
|
||||
code: '',
|
||||
sort: 0,
|
||||
status: 0
|
||||
};
|
||||
}
|
||||
|
||||
function filterDeptTree(tree: Api.SystemManage.Dept[], excludedId: number | null): Api.SystemManage.Dept[] {
|
||||
if (!excludedId) {
|
||||
return tree;
|
||||
}
|
||||
|
||||
return tree
|
||||
.filter(item => item.id !== excludedId)
|
||||
.map(item => ({
|
||||
...item,
|
||||
children: item.children ? filterDeptTree(item.children, excludedId) : item.children
|
||||
}));
|
||||
}
|
||||
|
||||
function initModel() {
|
||||
if (isEdit.value && props.rowData) {
|
||||
model.value = {
|
||||
name: props.rowData.name,
|
||||
parentId: props.rowData.parentId,
|
||||
orgType: props.rowData.orgType,
|
||||
code: props.rowData.code ?? '',
|
||||
sort: props.rowData.sort ?? 0,
|
||||
status: props.rowData.status
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = createDefaultModel();
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const payload: Api.SystemManage.SaveDeptParams = {
|
||||
name: model.value.name.trim(),
|
||||
parentId: model.value.parentId,
|
||||
orgType: model.value.orgType,
|
||||
code: String(model.value.code ?? '').trim() || null,
|
||||
sort: model.value.sort,
|
||||
status: model.value.status
|
||||
} as Api.SystemManage.SaveDeptParams;
|
||||
|
||||
if (isEdit.value && props.rowData) {
|
||||
const { error } = await fetchUpdateDept({
|
||||
id: props.rowData.id,
|
||||
...payload
|
||||
});
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.updateSuccess'));
|
||||
closeDialog();
|
||||
emit('submitted', props.rowData.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchCreateDept(payload);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.addSuccess'));
|
||||
closeDialog();
|
||||
emit('submitted', Number(data));
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
initModel();
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="md"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.orgName')" prop="name">
|
||||
<ElInput v-model="model.name" :placeholder="$t('page.system.user.form.orgName')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.deptName')" prop="parentId">
|
||||
<ElTreeSelect
|
||||
v-model="model.parentId"
|
||||
check-strictly
|
||||
:data="parentTree"
|
||||
:default-expand-all="expandParentTree"
|
||||
:props="treeProps"
|
||||
:render-after-expand="false"
|
||||
:placeholder="$t('page.system.user.form.parentOrg')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.orgTypeLabel')" prop="orgType">
|
||||
<ElSelect v-model="model.orgType" :placeholder="$t('page.system.user.form.orgTypeLabel')">
|
||||
<ElOption v-for="item in orgTypeOptions" :key="item.value" :label="$t(item.label)" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.orgCode')" prop="code">
|
||||
<ElInput v-model="model.code" :placeholder="$t('page.system.user.form.orgCode')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.orgSort')" prop="sort">
|
||||
<ElInputNumber
|
||||
v-model="model.sort"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:placeholder="$t('page.system.user.form.orgSort')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.userStatus')" prop="status">
|
||||
<ElRadioGroup v-model="model.status" class="business-form-radio-group">
|
||||
<ElRadio v-for="item in commonStatusOptions" :key="item.value" :value="item.value">
|
||||
{{ $t(item.label) }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
163
src/views/system/user/modules/user-org-panel.vue
Normal file
163
src/views/system/user/modules/user-org-panel.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { markRaw, ref, watch } from 'vue';
|
||||
import type { TreeInstance } from 'element-plus';
|
||||
import { $t } from '@/locales';
|
||||
import IconMdiAccountGroup from '~icons/mdi/account-group';
|
||||
import IconMdiDomain from '~icons/mdi/domain';
|
||||
import IconMdiOfficeBuilding from '~icons/mdi/office-building';
|
||||
import IconMdiSourceBranch from '~icons/mdi/source-branch';
|
||||
|
||||
defineOptions({ name: 'UserOrgPanel' });
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
treeData: Api.SystemManage.Dept[];
|
||||
currentDeptId?: number | null;
|
||||
total: number;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'refresh'): void;
|
||||
(e: 'select', dept: Api.SystemManage.Dept): void;
|
||||
(e: 'addRoot'): void;
|
||||
(e: 'addChild', dept: Api.SystemManage.Dept): void;
|
||||
(e: 'leader', dept: Api.SystemManage.Dept): void;
|
||||
(e: 'edit', dept: Api.SystemManage.Dept): void;
|
||||
(e: 'delete', dept: Api.SystemManage.Dept): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const keyword = ref('');
|
||||
const treeRef = ref<TreeInstance | null>(null);
|
||||
|
||||
function filterNode(value: string, nodeData: Api.SystemManage.Dept) {
|
||||
if (!value.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return nodeData.name.toLowerCase().includes(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function getOrgIcon(orgType: Api.SystemManage.DeptOrgType) {
|
||||
const iconMap: Record<Api.SystemManage.DeptOrgType, object> = {
|
||||
company: markRaw(IconMdiDomain),
|
||||
dept: markRaw(IconMdiOfficeBuilding),
|
||||
direction: markRaw(IconMdiSourceBranch),
|
||||
team: markRaw(IconMdiAccountGroup)
|
||||
};
|
||||
|
||||
return iconMap[orgType];
|
||||
}
|
||||
|
||||
watch(keyword, value => {
|
||||
treeRef.value?.filter(value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="user-org-card-body">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-8px">
|
||||
<p>{{ $t('page.system.user.orgTitle') }}</p>
|
||||
<ElTag effect="plain">{{ total }}</ElTag>
|
||||
</div>
|
||||
<div class="flex items-center gap-8px">
|
||||
<ElButton type="primary" plain size="small" @click="emit('addRoot')">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.add') }}
|
||||
</ElButton>
|
||||
<ElButton plain size="small" @click="emit('refresh')">
|
||||
<template #icon>
|
||||
<icon-mdi-refresh class="text-icon" />
|
||||
</template>
|
||||
{{ $t('common.refresh') }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="mb-12px">
|
||||
<ElInput v-model="keyword" clearable :placeholder="$t('page.system.user.orgFilterPlaceholder')">
|
||||
<template #prefix>
|
||||
<icon-ic-round-search class="text-icon" />
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
|
||||
<ElScrollbar v-loading="loading" class="flex-1">
|
||||
<ElTree
|
||||
ref="treeRef"
|
||||
node-key="id"
|
||||
highlight-current
|
||||
:current-node-key="currentDeptId ?? undefined"
|
||||
:data="treeData"
|
||||
default-expand-all
|
||||
:expand-on-click-node="false"
|
||||
:props="{ label: 'name', children: 'children' }"
|
||||
:filter-node-method="filterNode as any"
|
||||
@node-click="emit('select', $event)"
|
||||
>
|
||||
<template #default="{ data: nodeData }">
|
||||
<div class="group min-w-0 flex flex-1 items-center gap-8px pr-8px">
|
||||
<component :is="getOrgIcon(nodeData.orgType)" class="shrink-0 text-16px text-primary" />
|
||||
<span class="min-w-0 flex-1 truncate text-14px">{{ nodeData.name }}</span>
|
||||
<div class="flex items-center opacity-0 transition-opacity duration-200 group-hover:opacity-100">
|
||||
<ElTooltip :content="$t('page.system.user.orgLeader')">
|
||||
<ElButton link type="primary" class="user-org-action-btn" @click.stop="emit('leader', nodeData)">
|
||||
<icon-mdi-account-tie-outline class="text-14px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip :content="$t('common.add')">
|
||||
<ElButton link type="primary" class="user-org-action-btn" @click.stop="emit('addChild', nodeData)">
|
||||
<icon-mdi-plus class="text-14px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip :content="$t('common.edit')">
|
||||
<ElButton link type="primary" class="user-org-action-btn" @click.stop="emit('edit', nodeData)">
|
||||
<icon-mdi-pencil-outline class="text-14px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElPopconfirm :title="$t('common.confirmDelete')" @confirm="emit('delete', nodeData)">
|
||||
<template #reference>
|
||||
<span class="inline-flex" @click.stop>
|
||||
<ElTooltip :content="$t('common.delete')">
|
||||
<ElButton link type="danger" class="user-org-action-btn">
|
||||
<icon-mdi-delete-outline class="text-14px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</span>
|
||||
</template>
|
||||
</ElPopconfirm>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElTree>
|
||||
</ElScrollbar>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
:deep(.user-org-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
:deep(.user-org-action-btn) {
|
||||
padding: 2px;
|
||||
min-width: auto;
|
||||
height: auto;
|
||||
margin-left: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:deep(.user-org-action-btn:first-child) {
|
||||
margin-left: 0;
|
||||
}
|
||||
</style>
|
||||
127
src/views/system/user/modules/user-reset-password-dialog.vue
Normal file
127
src/views/system/user/modules/user-reset-password-dialog.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { REG_PWD } from '@/constants/reg';
|
||||
import { fetchUpdateUserPassword } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'UserResetPasswordDialog' });
|
||||
|
||||
interface Props {
|
||||
userId?: number | null;
|
||||
username?: string | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule, createConfirmPwdRule } = useFormRules();
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
const title = computed(() => $t('page.system.user.resetPassword'));
|
||||
const displayUsername = computed(() => props.username?.trim() || $t('common.noData'));
|
||||
|
||||
const model = ref({
|
||||
password: '',
|
||||
confirmPassword: ''
|
||||
});
|
||||
|
||||
const rules = computed<Record<'password' | 'confirmPassword', App.Global.FormRule[]>>(() => ({
|
||||
password: [
|
||||
createRequiredRule($t('page.system.user.form.newPassword')),
|
||||
{ pattern: REG_PWD, message: $t('form.pwd.invalid') }
|
||||
],
|
||||
confirmPassword: createConfirmPwdRule(model.value.password)
|
||||
}));
|
||||
|
||||
function initModel() {
|
||||
model.value.password = '';
|
||||
model.value.confirmPassword = '';
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await validate();
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const { error } = await fetchUpdateUserPassword({
|
||||
id: props.userId,
|
||||
password: model.value.password.trim()
|
||||
});
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.updateSuccess'));
|
||||
closeDialog();
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
initModel();
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="sm"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.userName')">
|
||||
<ElInput :model-value="displayUsername" disabled />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.newPassword')" prop="password">
|
||||
<ElInput v-model="model.password" show-password :placeholder="$t('page.system.user.form.newPassword')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem :label="$t('page.system.user.confirmPassword')" prop="confirmPassword">
|
||||
<ElInput
|
||||
v-model="model.confirmPassword"
|
||||
show-password
|
||||
:placeholder="$t('page.system.user.form.confirmPassword')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
140
src/views/system/user/modules/user-resigned-dialog.vue
Normal file
140
src/views/system/user/modules/user-resigned-dialog.vue
Normal file
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchGetUser, fetchUpdateUser } from '@/service/api';
|
||||
import { useForm } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'UserResignedDialog' });
|
||||
|
||||
interface Props {
|
||||
userId?: number | null;
|
||||
username?: string | null;
|
||||
resignedAt?: number | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef } = useForm();
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
const title = computed(() => {
|
||||
if (props.resignedAt && props.resignedAt > Date.now()) {
|
||||
return $t('page.system.user.adjustResignUser');
|
||||
}
|
||||
|
||||
return $t('page.system.user.resignUser');
|
||||
});
|
||||
const displayUsername = computed(() => props.username?.trim() || $t('common.noData'));
|
||||
|
||||
const model = ref({
|
||||
resignedAt: null as Date | null
|
||||
});
|
||||
|
||||
function createCurrentTime() {
|
||||
return dayjs().second(0).millisecond(0).toDate();
|
||||
}
|
||||
|
||||
function initModel() {
|
||||
model.value.resignedAt = props.resignedAt ? dayjs(props.resignedAt).toDate() : createCurrentTime();
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!props.userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const detailResult = await fetchGetUser(props.userId);
|
||||
|
||||
if (detailResult.error) {
|
||||
submitting.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const user = detailResult.data;
|
||||
|
||||
const { error } = await fetchUpdateUser({
|
||||
id: props.userId,
|
||||
username: user.username,
|
||||
nickname: user.nickname ?? null,
|
||||
remark: user.remark ?? null,
|
||||
deptId: user.deptId,
|
||||
positionId: user.positionId ?? null,
|
||||
resignedAt: model.value.resignedAt ? dayjs(model.value.resignedAt).valueOf() : null,
|
||||
email: user.email ?? null,
|
||||
mobile: user.mobile ?? null,
|
||||
sex: user.sex ?? 0,
|
||||
avatar: user.avatar ?? null
|
||||
});
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success($t('common.updateSuccess'));
|
||||
closeDialog();
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
initModel();
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="sm"
|
||||
:confirm-loading="submitting"
|
||||
:scrollbar="false"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.userName')">
|
||||
<ElInput :model-value="displayUsername" disabled />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.user.resignedAt')">
|
||||
<ElDatePicker
|
||||
v-model="model.resignedAt"
|
||||
class="w-full"
|
||||
type="datetime"
|
||||
clearable
|
||||
:placeholder="$t('page.system.user.form.resignedAt')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
91
src/views/system/user/modules/user-search.vue
Normal file
91
src/views/system/user/modules/user-search.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { commonStatusOptions } from '@/constants/business';
|
||||
import { translateOptions } from '@/utils/common';
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
defineOptions({ name: 'UserSearch' });
|
||||
|
||||
interface Props {
|
||||
roleOptions: Api.SystemManage.RoleSimple[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
disabled: false
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'reset'): void;
|
||||
(e: 'search'): void;
|
||||
}
|
||||
|
||||
defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchPanel
|
||||
:model="model"
|
||||
:disabled="disabled"
|
||||
:action-col-lg="8"
|
||||
:action-col-md="8"
|
||||
@reset="$emit('reset')"
|
||||
@search="$emit('search')"
|
||||
>
|
||||
<ElCol :lg="8" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.user.userName')" prop="username">
|
||||
<ElInput
|
||||
v-model="model.username"
|
||||
clearable
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('page.system.user.form.userName')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="8" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.user.userPhone')" prop="mobile">
|
||||
<ElInput
|
||||
v-model="model.mobile"
|
||||
clearable
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('page.system.user.form.userPhone')"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="8" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.user.userStatus')" prop="status">
|
||||
<ElSelect
|
||||
v-model="model.status"
|
||||
clearable
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('page.system.user.form.userStatus')"
|
||||
>
|
||||
<ElOption
|
||||
v-for="{ label, value } in translateOptions(commonStatusOptions)"
|
||||
:key="value"
|
||||
:label="label"
|
||||
:value="value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
|
||||
<template #extra>
|
||||
<ElCol :lg="8" :md="8" :sm="12">
|
||||
<ElFormItem :label="$t('page.system.user.userRole')" prop="roleId">
|
||||
<ElSelect
|
||||
v-model="model.roleId"
|
||||
clearable
|
||||
filterable
|
||||
:disabled="disabled"
|
||||
:placeholder="$t('page.system.user.form.userRole')"
|
||||
>
|
||||
<ElOption v-for="item in roleOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</template>
|
||||
</TableSearchPanel>
|
||||
</template>
|
||||
7
src/views/user-center/index.vue
Normal file
7
src/views/user-center/index.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts"></script>
|
||||
|
||||
<template>
|
||||
<LookForward />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
Reference in New Issue
Block a user