2
0

初始化项目

This commit is contained in:
caiyuchao
2024-11-14 11:06:38 +08:00
parent 988b9e6799
commit 4ffac789e1
320 changed files with 34244 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<ExceptionBase type="403" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<ExceptionBase type="404" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<ExceptionBase type="500" />
</template>
<style scoped></style>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { Component } from 'vue';
import { getColorPalette, mixColor } from '@sa/utils';
import { $t } from '@/locales';
import { useThemeStore } from '@/store/modules/theme';
import { loginModuleRecord } from '@/constants/app';
import PwdLogin from './modules/pwd-login.vue';
import CodeLogin from './modules/code-login.vue';
import Register from './modules/register.vue';
import ResetPwd from './modules/reset-pwd.vue';
import BindWechat from './modules/bind-wechat.vue';
interface Props {
/** The login module */
module?: UnionKey.LoginModule;
}
const props = defineProps<Props>();
const themeStore = useThemeStore();
interface LoginModule {
label: string;
component: Component;
}
const moduleMap: Record<UnionKey.LoginModule, LoginModule> = {
'pwd-login': { label: loginModuleRecord['pwd-login'], component: PwdLogin },
'code-login': { label: loginModuleRecord['code-login'], component: CodeLogin },
register: { label: loginModuleRecord.register, component: Register },
'reset-pwd': { label: loginModuleRecord['reset-pwd'], component: ResetPwd },
'bind-wechat': { label: loginModuleRecord['bind-wechat'], component: BindWechat }
};
const activeModule = computed(() => moduleMap[props.module || 'pwd-login']);
const bgThemeColor = computed(() =>
themeStore.darkMode ? getColorPalette(themeStore.themeColor, 7) : 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" :style="{ backgroundColor: bgColor }">
<WaveBg :theme-color="bgThemeColor" />
<ACard class="relative z-4">
<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>
</header>
<main class="pt-24px">
<h3 class="text-18px text-primary font-medium">{{ $t(activeModule.label) }}</h3>
<div class="animation-slide-in-left pt-24px">
<Transition :name="themeStore.page.animateMode" mode="out-in" appear>
<component :is="activeModule.component" />
</Transition>
</div>
</main>
</div>
</ACard>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
defineOptions({
name: 'BindWechat'
});
</script>
<template>
<div></div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { computed, reactive } from 'vue';
import { $t } from '@/locales';
import { useRouterPush } from '@/hooks/common/router';
import { useAntdForm, useFormRules } from '@/hooks/common/form';
import { useCaptcha } from '@/hooks/business/captcha';
defineOptions({
name: 'CodeLogin'
});
const { toggleLoginModule } = useRouterPush();
const { formRef, validate } = useAntdForm();
const { label, isCounting, loading, getCaptcha } = useCaptcha();
interface FormModel {
phone: string;
code: string;
}
const model: FormModel = reactive({
phone: '',
code: ''
});
const rules = computed<Record<keyof FormModel, App.Global.FormRule[]>>(() => {
const { formRules } = useFormRules();
return {
phone: formRules.phone,
code: formRules.code
};
});
async function handleSubmit() {
await validate();
// request
$message?.success($t('page.login.common.validateSuccess'));
}
</script>
<template>
<AForm ref="formRef" :model="model" :rules="rules">
<AFormItem name="phone">
<AInput v-model:value="model.phone" size="large" :placeholder="$t('page.login.common.phonePlaceholder')" />
</AFormItem>
<AFormItem name="code">
<div class="w-full flex-y-center gap-16px">
<AInput v-model:value="model.code" size="large" :placeholder="$t('page.login.common.codePlaceholder')" />
<AButton size="large" :disabled="isCounting" :loading="loading" @click="getCaptcha(model.phone)">
{{ label }}
</AButton>
</div>
</AFormItem>
<ASpace direction="vertical" size="large" class="w-full">
<AButton type="primary" block size="large" shape="round" @click="handleSubmit">
{{ $t('common.confirm') }}
</AButton>
<AButton block size="large" shape="round" @click="toggleLoginModule('pwd-login')">
{{ $t('page.login.common.back') }}
</AButton>
</ASpace>
</AForm>
</template>
<style scoped></style>

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { $t } from '@/locales';
import { useAntdForm, useFormRules } from '@/hooks/common/form';
import { useAuthStore } from '@/store/modules/auth';
defineOptions({
name: 'PwdLogin'
});
const authStore = useAuthStore();
const { formRef, validate } = useAntdForm();
const { patternRules } = useFormRules();
const codeImg = ref('');
getCheckCode();
const model = reactive({
username: 'ryadmin',
password: 'admin123',
code: '',
uuid: ''
});
const rules = {
username: patternRules.username,
password: patternRules.pwd
};
async function handleSubmit() {
await validate();
await authStore.login({
loginForm: model,
onError() {
getCheckCode();
}
});
}
async function getCheckCode() {
const { data, error } = await doGetCheckCode();
if (!error) {
codeImg.value = `data:image/png;base64,${data.img}`;
model.uuid = data.uuid;
}
}
</script>
<template>
<AForm ref="formRef" :model="model" :rules="rules">
<AFormItem name="username">
<AInput v-model:value="model.username" size="large" :placeholder="$t('page.login.common.userNamePlaceholder')" />
</AFormItem>
<AFormItem name="password">
<AInputPassword
v-model:value="model.password"
size="large"
:placeholder="$t('page.login.common.passwordPlaceholder')"
/>
</AFormItem>
<AFormItem>
<div class="flex gap-4">
<AInput v-model:value="model.code" size="large" :placeholder="$t('page.login.common.checkCode')">
<template #suffix></template>
</AInput>
<AImage :preview="false" :src="codeImg" @click="getCheckCode" />
</div>
</AFormItem>
<ASpace direction="vertical" size="large" class="w-full">
<div class="flex-y-center justify-between">
<ACheckbox>{{ $t('page.login.pwdLogin.rememberMe') }}</ACheckbox>
<AButton type="text">{{ $t('page.login.pwdLogin.forgetPassword') }}</AButton>
</div>
<AButton type="primary" block size="large" shape="round" :loading="authStore.loginLoading" @click="handleSubmit">
{{ $t('common.confirm') }}
</AButton>
</ASpace>
</AForm>
</template>
<style scoped></style>

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
import { computed, reactive } from 'vue';
import { $t } from '@/locales';
import { useRouterPush } from '@/hooks/common/router';
import { useAntdForm, useFormRules } from '@/hooks/common/form';
import { useCaptcha } from '@/hooks/business/captcha';
defineOptions({
name: 'CodeLogin'
});
const { toggleLoginModule } = useRouterPush();
const { formRef, validate } = useAntdForm();
const { label, isCounting, loading, getCaptcha } = useCaptcha();
interface FormModel {
phone: string;
code: string;
password: string;
confirmPassword: string;
}
const model: FormModel = reactive({
phone: '',
code: '',
password: '',
confirmPassword: ''
});
const rules = computed<Record<keyof FormModel, App.Global.FormRule[]>>(() => {
const { formRules, createConfirmPwdRule } = useFormRules();
return {
phone: formRules.phone,
code: formRules.code,
password: formRules.pwd,
confirmPassword: createConfirmPwdRule(model.password)
};
});
async function handleSubmit() {
await validate();
// request to register
$message?.success($t('page.login.common.validateSuccess'));
}
</script>
<template>
<AForm ref="formRef" :model="model" :rules="rules">
<AFormItem name="phone">
<AInput v-model:value="model.phone" size="large" :placeholder="$t('page.login.common.phonePlaceholder')" />
</AFormItem>
<AFormItem name="code">
<div class="w-full flex-y-center gap-16px">
<AInput v-model:value="model.code" size="large" :placeholder="$t('page.login.common.codePlaceholder')" />
<AButton size="large" :disabled="isCounting" :loading="loading" @click="getCaptcha(model.phone)">
{{ label }}
</AButton>
</div>
</AFormItem>
<AFormItem name="password">
<AInputPassword
v-model:value="model.password"
size="large"
:placeholder="$t('page.login.common.passwordPlaceholder')"
/>
</AFormItem>
<AFormItem name="confirmPassword">
<AInputPassword
v-model:value="model.confirmPassword"
size="large"
:placeholder="$t('page.login.common.confirmPasswordPlaceholder')"
/>
</AFormItem>
<ASpace direction="vertical" size="large" class="w-full">
<AButton type="primary" block size="large" shape="round" @click="handleSubmit">
{{ $t('common.confirm') }}
</AButton>
<AButton block size="large" shape="round" @click="toggleLoginModule('pwd-login')">
{{ $t('page.login.common.back') }}
</AButton>
</ASpace>
</AForm>
</template>
<style scoped></style>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed, reactive } from 'vue';
import { $t } from '@/locales';
import { useRouterPush } from '@/hooks/common/router';
import { useAntdForm, useFormRules } from '@/hooks/common/form';
defineOptions({
name: 'ResetPwd'
});
const { toggleLoginModule } = useRouterPush();
const { formRef, validate } = useAntdForm();
interface FormModel {
phone: string;
code: string;
password: string;
confirmPassword: string;
}
const model: FormModel = reactive({
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.password)
};
});
async function handleSubmit() {
await validate();
// request to reset password
$message?.success($t('page.login.common.validateSuccess'));
}
</script>
<template>
<AForm ref="formRef" :model="model" :rules="rules">
<AFormItem name="phone">
<AInput v-model:value="model.phone" size="large" :placeholder="$t('page.login.common.phonePlaceholder')" />
</AFormItem>
<AFormItem name="code">
<AInput v-model:value="model.code" size="large" :placeholder="$t('page.login.common.codePlaceholder')" />
</AFormItem>
<AFormItem name="password">
<AInputPassword
v-model:value="model.password"
size="large"
:placeholder="$t('page.login.common.passwordPlaceholder')"
/>
</AFormItem>
<AFormItem name="confirmPassword">
<AInputPassword
v-model:value="model.confirmPassword"
size="large"
:placeholder="$t('page.login.common.confirmPasswordPlaceholder')"
/>
</AFormItem>
<ASpace direction="vertical" size="large" class="w-full">
<AButton type="primary" block size="large" shape="round" @click="handleSubmit">
{{ $t('common.confirm') }}
</AButton>
<AButton block size="large" shape="round" @click="toggleLoginModule('pwd-login')">
{{ $t('page.login.common.back') }}
</AButton>
</ASpace>
</AForm>
</template>
<style scoped></style>

79
src/views/about/index.vue Normal file
View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { $t } from '@/locales';
import pkg from '~/package.json';
interface PkgJson {
name: string;
version: string;
dependencies: PkgVersionInfo[];
devDependencies: PkgVersionInfo[];
}
interface PkgVersionInfo {
name: string;
version: string;
}
const { name, version, dependencies, devDependencies } = pkg;
function transformVersionData(tuple: [string, string]): PkgVersionInfo {
const [$name, $version] = tuple;
return {
name: $name,
version: $version
};
}
const pkgJson: PkgJson = {
name,
version,
dependencies: Object.entries(dependencies).map(item => transformVersionData(item)),
devDependencies: Object.entries(devDependencies).map(item => transformVersionData(item))
};
const latestBuildTime = BUILD_TIME;
</script>
<template>
<ASpace direction="vertical" :size="16">
<ACard :title="$t('page.about.title')" :bordered="false" size="small" class="card-wrapper">
<p>{{ $t('page.about.introduction') }}</p>
</ACard>
<ACard :title="$t('page.about.projectInfo.title')" :bordered="false" size="small" class="card-wrapper">
<ADescriptions label-placement="left" bordered size="small" :column="{ xs: 1, sm: 2 }">
<ADescriptionsItem :label="$t('page.about.projectInfo.version')">
<ATag color="blue">{{ pkgJson.version }}</ATag>
</ADescriptionsItem>
<ADescriptionsItem :label="$t('page.about.projectInfo.latestBuildTime')">
<ATag color="blue">{{ latestBuildTime }}</ATag>
</ADescriptionsItem>
<ADescriptionsItem :label="$t('page.about.projectInfo.githubLink')">
<a class="text-primary" :href="pkg.homepage" target="_blank" rel="noopener noreferrer">
{{ $t('page.about.projectInfo.githubLink') }}
</a>
</ADescriptionsItem>
<ADescriptionsItem :label="$t('page.about.projectInfo.previewLink')">
<a class="text-primary" :href="pkg.website" target="_blank" rel="noopener noreferrer">
{{ $t('page.about.projectInfo.previewLink') }}
</a>
</ADescriptionsItem>
</ADescriptions>
</ACard>
<ACard :title="$t('page.about.prdDep')" :bordered="false" size="small" class="card-wrapper">
<ADescriptions label-placement="left" bordered size="small" :column="{ xs: 1, sm: 2 }">
<ADescriptionsItem v-for="item in pkgJson.dependencies" :key="item.name" :label="item.name">
{{ item.version }}
</ADescriptionsItem>
</ADescriptions>
</ACard>
<ACard :title="$t('page.about.devDep')" :bordered="false" size="small" class="card-wrapper">
<ADescriptions label-placement="left" bordered size="small" :column="{ xs: 1, sm: 2 }">
<ADescriptionsItem v-for="item in pkgJson.devDependencies" :key="item.name" :label="item.name">
{{ item.version }}
</ADescriptionsItem>
</ADescriptions>
</ACard>
</ASpace>
</template>
<style scoped></style>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<LookForward />
</template>
<style scoped></style>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<div>three</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<div>two</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { useRoute } from 'vue-router';
import { computed } from 'vue';
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>
<AButton @click="routerPushByKey('function_tab')">{{ $t('page.function.multiTab.backTab') }}</AButton>
<div class="py-24px">{{ $t('page.function.multiTab.routeParam') }}: {{ routeQuery }}</div>
</div>
</LookForward>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
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'));
}
</script>
<template>
<ASpace direction="vertical" :size="16">
<ACard :title="$t('request.logout')" :bordered="false" size="small" class="card-wrapper">
<AButton @click="logout">{{ $t('common.trigger') }}</AButton>
</ACard>
<ACard :title="$t('request.logoutWithModal')" :bordered="false" size="small" class="card-wrapper">
<AButton @click="logoutWithModal">{{ $t('common.trigger') }}</AButton>
</ACard>
<ACard :title="$t('request.refreshToken')" :bordered="false" size="small" class="card-wrapper">
<AButton @click="refreshToken">{{ $t('common.trigger') }}</AButton>
</ACard>
</ASpace>
</template>
<style scoped></style>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<LookForward />
</template>
<style scoped></style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouterPush } from '@/hooks/common/router';
import { $t } from '@/locales';
import { useTabStore } from '@/store/modules/tab';
const tabStore = useTabStore();
const { routerPushByKey } = useRouterPush();
const tabLabel = ref('');
function changeTabLabel() {
tabStore.setTabLabel(tabLabel.value);
}
function resetTabLabel() {
tabStore.resetTabLabel();
}
</script>
<template>
<ASpace direction="vertical" :size="16">
<ACard :title="$t('page.function.tab.tabOperate.title')" :bordered="false" size="small" class="card-wrapper">
<ADivider orientation="left">{{ $t('page.function.tab.tabOperate.addTab') }}</ADivider>
<AButton @click="routerPushByKey('about')">{{ $t('page.function.tab.tabOperate.addTabDesc') }}</AButton>
<ADivider orientation="left">{{ $t('page.function.tab.tabOperate.closeTab') }}</ADivider>
<ASpace :size="16">
<AButton @click="tabStore.removeActiveTab">
{{ $t('page.function.tab.tabOperate.closeCurrentTab') }}
</AButton>
<AButton @click="tabStore.removeTabByRouteName('about')">
{{ $t('page.function.tab.tabOperate.closeAboutTab') }}
</AButton>
</ASpace>
<ADivider orientation="left">{{ $t('page.function.tab.tabOperate.addMultiTab') }}</ADivider>
<ASpace :size="16" wrap class="m-0!">
<AButton @click="routerPushByKey('function_multi-tab')">
{{ $t('page.function.tab.tabOperate.addMultiTabDesc1') }}
</AButton>
<AButton @click="routerPushByKey('function_multi-tab', { query: { a: '1' } })">
{{ $t('page.function.tab.tabOperate.addMultiTabDesc2') }}
</AButton>
</ASpace>
</ACard>
<ACard :title="$t('page.function.tab.tabTitle.title')" :bordered="false" size="small" class="card-wrapper">
<ADivider orientation="left">{{ $t('page.function.tab.tabTitle.changeTitle') }}</ADivider>
<AInputSearch
v-model:value="tabLabel"
:enter-button="$t('page.function.tab.tabTitle.change')"
class="max-w-240px"
@search="changeTabLabel"
/>
<ADivider orientation="left">{{ $t('page.function.tab.tabTitle.resetTitle') }}</ADivider>
<AButton @click="resetTabLabel">{{ $t('page.function.tab.tabTitle.reset') }}</AButton>
</ACard>
</ASpace>
</template>
<style scoped></style>

View File

@@ -0,0 +1,92 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useLoading } from '@sa/hooks';
import { $t } from '@/locales';
import { useAppStore } from '@/store/modules/app';
import { useAuthStore } from '@/store/modules/auth';
import { useAuth } from '@/hooks/business/auth';
const appStore = useAppStore();
const authStore = useAuthStore();
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);
endLoading();
appStore.reloadPage();
}
</script>
<template>
<ASpace direction="vertical" :size="16">
<ACard :title="$t('route.function_toggle-auth')" :bordered="false" size="small" class="card-wrapper">
<ADescriptions layout="vertical" bordered size="small" :column="1">
<ADescriptionsItem :label="$t('page.manage.user.userRole')">
<ASpace>
<ATag v-for="role in authStore.userInfo.roles" :key="role">{{ role }}</ATag>
</ASpace>
</ADescriptionsItem>
<ADescriptionsItem ions-item :label="$t('page.function.toggleAuth.toggleAccount')">
<ASpace>
<AButton
v-for="account in accounts"
:key="account.key"
:loading="loading && loginAccount === account.key"
:disabled="loading && loginAccount !== account.key"
@click="handleToggleAccount(account)"
>
{{ account.label }}
</AButton>
</ASpace>
</ADescriptionsItem>
</ADescriptions>
</ACard>
<ACard :title="$t('page.function.toggleAuth.authHook')" :bordered="false" size="small" class="card-wrapper">
<ASpace>
<AButton v-if="hasAuth('B_CODE1')">{{ $t('page.function.toggleAuth.superAdminVisible') }}</AButton>
<AButton v-if="hasAuth('B_CODE2')">{{ $t('page.function.toggleAuth.adminVisible') }}</AButton>
<AButton v-if="hasAuth('B_CODE3')">
{{ $t('page.function.toggleAuth.adminOrUserVisible') }}
</AButton>
</ASpace>
</ACard>
</ASpace>
</template>
<style scoped></style>

33
src/views/home/index.vue Normal file
View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import HeaderBanner from './modules/header-banner.vue';
import CardData from './modules/card-data.vue';
import LineChart from './modules/line-chart.vue';
import PieChart from './modules/pie-chart.vue';
import ProjectNews from './modules/project-news.vue';
import CreativityBanner from './modules/creativity-banner.vue';
</script>
<template>
<ASpace direction="vertical" :size="16">
<HeaderBanner />
<CardData />
<ARow :gutter="[16, 16]">
<ACol :span="24" :lg="14">
<LineChart />
</ACol>
<ACol :span="24" :lg="10">
<PieChart />
</ACol>
</ARow>
<ARow :gutter="[16, 16]">
<ACol :span="24" :lg="14">
<ProjectNews />
</ACol>
<ACol :span="24" :lg="10">
<CreativityBanner />
</ACol>
</ARow>
</ASpace>
</template>
<style scoped></style>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { computed } from 'vue';
import { createReusableTemplate } from '@vueuse/core';
import { $t } from '@/locales';
defineOptions({
name: 'CardData'
});
interface CardData {
key: string;
title: string;
value: number;
unit: string;
color: {
start: string;
end: string;
};
icon: string;
}
const cardData = computed<CardData[]>(() => [
{
key: 'visitCount',
title: $t('page.home.visitCount'),
value: 9725,
unit: '',
color: {
start: '#ec4786',
end: '#b955a4'
},
icon: 'ant-design:bar-chart-outlined'
},
{
key: 'turnover',
title: $t('page.home.turnover'),
value: 1026,
unit: '$',
color: {
start: '#865ec0',
end: '#5144b4'
},
icon: 'ant-design:money-collect-outlined'
},
{
key: 'downloadCount',
title: $t('page.home.downloadCount'),
value: 970925,
unit: '',
color: {
start: '#56cdf3',
end: '#719de3'
},
icon: 'carbon:document-download'
},
{
key: 'dealCount',
title: $t('page.home.dealCount'),
value: 9527,
unit: '',
color: {
start: '#fcbc25',
end: '#f68057'
},
icon: 'ant-design:trademark-circle-outlined'
}
]);
interface GradientBgProps {
gradientColor: string;
}
const [DefineGradientBg, GradientBg] = createReusableTemplate<GradientBgProps>();
function getGradientColor(color: CardData['color']) {
return `linear-gradient(to bottom right, ${color.start}, ${color.end})`;
}
</script>
<template>
<ACard :bordered="false" size="small" class="card-wrapper">
<!-- define component start: GradientBg -->
<DefineGradientBg v-slot="{ $slots, gradientColor }">
<div class="rd-8px px-16px pb-4px pt-8px text-white" :style="{ backgroundImage: gradientColor }">
<component :is="$slots.default" />
</div>
</DefineGradientBg>
<!-- define component end: GradientBg -->
<ARow :gutter="[16, 16]">
<ACol v-for="item in cardData" :key="item.key" :span="24" :md="12" :lg="6">
<GradientBg :gradient-color="getGradientColor(item.color)" class="flex-1">
<h3 class="text-16px">{{ item.title }}</h3>
<div class="flex justify-between pt-12px">
<SvgIcon :icon="item.icon" class="text-32px" />
<CountTo
:prefix="item.unit"
:start-value="1"
:end-value="item.value"
class="text-30px text-white dark:text-dark"
/>
</div>
</GradientBg>
</ACol>
</ARow>
</ACard>
</template>
<style scoped></style>

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { $t } from '@/locales';
defineOptions({
name: 'CreativityBanner'
});
</script>
<template>
<ACard
:title="$t('page.home.creativity')"
:bordered="false"
size="small"
class="h-full flex-col-stretch card-wrapper"
:body-style="{ flex: 1, overflow: 'hidden' }"
>
<div class="h-full flex-center">
<icon-local-banner class="text-400px text-primary sm:text-320px" />
</div>
</ACard>
</template>
<style scoped></style>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@/locales';
import { useAuthStore } from '@/store/modules/auth';
defineOptions({
name: 'HeaderBanner'
});
const authStore = useAuthStore();
interface StatisticData {
id: number;
title: string;
value: string;
}
const statisticData = computed<StatisticData[]>(() => [
{
id: 0,
title: $t('page.home.projectCount'),
value: '25'
},
{
id: 1,
title: $t('page.home.todo'),
value: '4/16'
},
{
id: 2,
title: $t('page.home.message'),
value: '12'
}
]);
</script>
<template>
<ACard :bordered="false" class="card-wrapper">
<ARow :gutter="[16, 16]">
<ACol :span="24" :md="18">
<div class="flex-y-center">
<div class="size-72px shrink-0 overflow-hidden rd-1/2">
<img src="@/assets/imgs/soybean.jpg" class="size-full" />
</div>
<div class="pl-12px">
<h3 class="text-18px font-semibold">
{{ $t('page.home.greeting', { username: authStore.userInfo.username }) }}
</h3>
<p class="text-#999 leading-30px">{{ $t('page.home.weatherDesc') }}</p>
</div>
</div>
</ACol>
<ACol :span="24" :md="6">
<ASpace class="w-full justify-end" :size="24">
<AStatistic v-for="item in statisticData" :key="item.id" class="whitespace-nowrap" v-bind="item" />
</ASpace>
</ACol>
</ARow>
</ACard>
</template>
<style scoped></style>

View File

@@ -0,0 +1,151 @@
<script setup lang="ts">
import { watch } from 'vue';
import { $t } from '@/locales';
import { useAppStore } from '@/store/modules/app';
import { useEcharts } from '@/hooks/common/echarts';
defineOptions({
name: 'LineChart'
});
const appStore = useAppStore();
const { domRef, updateOptions } = useEcharts(() => ({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross',
label: {
backgroundColor: '#6a7985'
}
}
},
legend: {
data: [$t('page.home.downloadCount'), $t('page.home.registerCount')]
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: [] as string[]
},
yAxis: {
type: 'value'
},
series: [
{
color: '#8e9dff',
name: $t('page.home.downloadCount'),
type: 'line',
smooth: true,
stack: 'Total',
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0.25,
color: '#8e9dff'
},
{
offset: 1,
color: '#fff'
}
]
}
},
emphasis: {
focus: 'series'
},
data: [] as number[]
},
{
color: '#26deca',
name: $t('page.home.registerCount'),
type: 'line',
smooth: true,
stack: 'Total',
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{
offset: 0.25,
color: '#26deca'
},
{
offset: 1,
color: '#fff'
}
]
}
},
emphasis: {
focus: 'series'
},
data: []
}
]
}));
async function mockData() {
await new Promise(resolve => {
setTimeout(resolve, 1000);
});
updateOptions(opts => {
opts.xAxis.data = ['06:00', '08:00', '10:00', '12:00', '14:00', '16:00', '18:00', '20:00', '22:00', '24:00'];
opts.series[0].data = [4623, 6145, 6268, 6411, 1890, 4251, 2978, 3880, 3606, 4311];
opts.series[1].data = [2208, 2016, 2916, 4512, 8281, 2008, 1963, 2367, 2956, 678];
return opts;
});
}
function updateLocale() {
updateOptions((opts, factory) => {
const originOpts = factory();
opts.legend.data = originOpts.legend.data;
opts.series[0].name = originOpts.series[0].name;
opts.series[1].name = originOpts.series[1].name;
return opts;
});
}
async function init() {
mockData();
}
watch(
() => appStore.locale,
() => {
updateLocale();
}
);
// init
init();
</script>
<template>
<ACard :bordered="false" class="card-wrapper">
<div ref="domRef" class="h-360px overflow-hidden"></div>
</ACard>
</template>
<style scoped></style>

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { watch } from 'vue';
import { $t } from '@/locales';
import { useAppStore } from '@/store/modules/app';
import { useEcharts } from '@/hooks/common/echarts';
defineOptions({
name: 'PieChart'
});
const appStore = useAppStore();
const { domRef, updateOptions } = useEcharts(() => ({
tooltip: {
trigger: 'item'
},
legend: {
bottom: '1%',
left: 'center',
itemStyle: {
borderWidth: 0
}
},
series: [
{
color: ['#5da8ff', '#8e9dff', '#fedc69', '#26deca'],
name: $t('page.home.schedule'),
type: 'pie',
radius: ['45%', '75%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 1
},
label: {
show: false,
position: 'center'
},
emphasis: {
label: {
show: true,
fontSize: '12'
}
},
labelLine: {
show: false
},
data: [] as { name: string; value: number }[]
}
]
}));
async function mockData() {
await new Promise(resolve => {
setTimeout(resolve, 1000);
});
updateOptions(opts => {
opts.series[0].data = [
{ name: $t('page.home.study'), value: 20 },
{ name: $t('page.home.entertainment'), value: 10 },
{ name: $t('page.home.work'), value: 40 },
{ name: $t('page.home.rest'), value: 30 }
];
return opts;
});
}
function updateLocale() {
updateOptions((opts, factory) => {
const originOpts = factory();
opts.series[0].name = originOpts.series[0].name;
opts.series[0].data = [
{ name: $t('page.home.study'), value: 20 },
{ name: $t('page.home.entertainment'), value: 10 },
{ name: $t('page.home.work'), value: 40 },
{ name: $t('page.home.rest'), value: 30 }
];
return opts;
});
}
async function init() {
mockData();
}
watch(
() => appStore.locale,
() => {
updateLocale();
}
);
// init
init();
</script>
<template>
<ACard :bordered="false" class="card-wrapper">
<div ref="domRef" class="h-360px overflow-hidden"></div>
</ACard>
</template>
<style scoped></style>

View File

@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue';
import { $t } from '@/locales';
defineOptions({
name: 'ProjectNews'
});
interface NewsItem {
id: number;
content: string;
time: string;
}
const newses = computed<NewsItem[]>(() => [
{ id: 1, content: $t('page.home.projectNews.desc1'), time: '2021-05-28 22:22:22' },
{ id: 2, content: $t('page.home.projectNews.desc2'), time: '2021-10-27 10:24:54' },
{ id: 3, content: $t('page.home.projectNews.desc3'), time: '2021-10-31 22:43:12' },
{ id: 4, content: $t('page.home.projectNews.desc4'), time: '2021-11-03 20:33:31' },
{ id: 5, content: $t('page.home.projectNews.desc5'), time: '2021-11-07 22:45:32' }
]);
</script>
<template>
<ACard :title="$t('page.home.projectNews.title')" :bordered="false" size="small" class="card-wrapper">
<template #extra>
<a class="text-primary" href="javascript:;">{{ $t('page.home.projectNews.moreNews') }}</a>
</template>
<AList :data-source="newses">
<template #renderItem="{ item }">
<AListItem>
<AListItemMeta :title="item.content" :description="item.time">
<template #avatar>
<SoybeanAvatar class="size-48px!" />
</template>
</AListItemMeta>
</AListItem>
</template>
</AList>
</ACard>
</template>
<style scoped></style>

View File

@@ -0,0 +1,162 @@
<script setup lang="tsx">
import { Button, Popconfirm, Tag } from 'ant-design-vue';
import { $t } from '@/locales';
import { enableStatusRecord } from '@/constants/business';
import DeptOperateModal from './modules/dept-operate-modal.vue';
import DeptSearch from './modules/dept-search.vue';
const wrapperEl = shallowRef<HTMLElement | null>(null);
const { height: wrapperElHeight } = useElementSize(wrapperEl);
const scrollConfig = computed(() => {
return {
y: wrapperElHeight.value - 72,
x: 702
};
});
const { columns, columnChecks, data, loading, getData, searchParams, resetSearchParams } = useTable({
apiFn: doGetDeptList,
apiParams: {
status: '0'
},
rowKey: 'deptId',
columns: () => [
{
key: 'deptName',
dataIndex: 'deptName',
title: '部门名称',
align: 'center'
},
{
key: 'leader',
dataIndex: 'leader',
title: '负责人',
align: 'center'
},
{
key: 'status',
dataIndex: 'status',
title: '状态',
align: 'center',
customRender: ({ record }) => {
if (record.status === null) {
return null;
}
const tagMap: Record<Api.Common.EnableStatus, string> = {
'0': 'success',
'1': 'warning'
};
const label = $t(enableStatusRecord[record.status]);
return <Tag color={tagMap[record.status]}>{label}</Tag>;
}
},
{
key: 'remark',
dataIndex: 'remark',
align: 'center',
title: '备注'
},
{
key: 'createTime',
dataIndex: 'createTime',
align: 'center',
title: '创建时间'
},
{
key: 'operate',
title: $t('common.operate'),
align: 'center',
width: 200,
customRender: ({ record }) => (
<div class="flex justify-around gap-8px">
{isShowBtn('system:dept:edit') && (
<Button size="small" onClick={() => edit(record.deptId)}>
{$t('common.edit')}
</Button>
)}
{isShowBtn('system:dept:remove') && (
<Popconfirm onConfirm={() => handleDelete(record.deptId)} title={$t('common.confirmDelete')}>
<Button danger size="small">
{$t('common.delete')}
</Button>
</Popconfirm>
)}
</div>
)
}
]
});
const { drawerVisible, operateType, editingData, handleAdd, handleEdit, checkedRowKeys, onBatchDeleted, onDeleted } =
useTableOperate(data, { getData, idKey: 'deptId' });
const treeData = computed(() => {
const rootDept = data.value.find(item => item.deptId === 100);
return rootDept ? transformListToTree(data.value, 'deptId') : removeEmptyChildren(data.value);
});
async function handleBatchDelete() {
const { error } = await doDeleteDept(checkedRowKeys.value.join(','));
if (!error) {
onBatchDeleted();
}
}
async function handleDelete(id: number) {
const { error } = await doDeleteDept(id);
if (!error) {
onDeleted();
}
}
function edit(id: number) {
handleEdit(id);
}
</script>
<template>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<DeptSearch v-model:model="searchParams" @reset="resetSearchParams" @search="getData" />
<ACard
title="部门列表"
:bordered="false"
:body-style="{ flex: 1, overflow: 'hidden' }"
class="flex-col-stretch sm:flex-1-hidden card-wrapper"
>
<template #extra>
<TableHeaderOperation
v-model:columns="columnChecks"
table-type="dept"
:disabled-delete="checkedRowKeys.length === 0"
:loading="loading"
@add="handleAdd"
@delete="handleBatchDelete"
@refresh="getData"
/>
</template>
<ATable
ref="wrapperEl"
:columns="columns"
:data-source="treeData"
:loading="loading"
row-key="deptId"
size="small"
:scroll="scrollConfig"
class="h-full"
/>
<DeptOperateModal
v-model:visible="drawerVisible"
:operate-type="operateType"
:tree-data="treeData"
:editing-data="editingData"
@submitted="getData"
/>
</ACard>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,135 @@
<script lang="ts" setup>
import { $t } from '@/locales';
import type { DeptFormType } from '@/service/api/dept';
import { formRules, resetForm, statusOptions } from './form';
const props = defineProps<{
operateType: AntDesign.TableOperateType;
editingData?: Api.SystemManage.Dept | null;
treeData?: Api.SystemManage.Dept[];
}>();
const emits = defineEmits<{
'submit-success': [];
}>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate, resetFields } = useAntdForm();
const model = ref<DeptFormType>(resetForm());
const title = computed(() => {
const titles: Record<AntDesign.TableOperateType, string> = {
add: '新增部门',
edit: '编辑部门'
};
return titles[props.operateType];
});
watch(visible, val => {
if (val) {
if (props.operateType === 'edit' && props.editingData) {
model.value = { ...props.editingData };
}
} else {
resetFields();
model.value = resetForm();
}
});
const submitForm = async () => {
await validate();
if (!formRef.value?.validate) return;
if (props.operateType === 'edit' && !props.editingData?.deptId) return;
const { error } = await (props.operateType === 'add'
? doAddDept(model.value)
: doEditDept({ ...model.value, deptId: props.editingData!.deptId }));
if (!error) {
$message?.success($t(props.operateType === 'add' ? 'common.addSuccess' : 'common.updateSuccess'));
emits('submit-success');
}
closeModal();
};
function closeModal() {
visible.value = false;
resetFields();
}
</script>
<template>
<AModal v-model:open="visible" :title="title" :width="700">
<AForm
ref="formRef"
mt-7
:label-col="{ span: 5 }"
:wrapper-col="{ span: 17, offset: 1 }"
:model="model"
:rules="formRules"
>
<ARow :gutter="[16, 16]">
<ACol :xs="24" :sm="12">
<AFormItem label="上级部门" name="parentId">
<ATreeSelect
v-model:value="model.parentId"
:default-value="100"
:field-names="{ value: 'deptId', label: 'deptName' }"
:tree-data="treeData"
show-search
allow-clear
tree-node-filter-prop="label"
/>
</AFormItem>
</ACol>
<ACol :xs="24" :sm="12">
<AFormItem label="部门名称" name="deptName">
<AInput v-model:value="model.deptName" />
</AFormItem>
</ACol>
<ACol :xs="24" :sm="12">
<AFormItem label="负责人" name="leader">
<AInput v-model:value="model.leader" />
</AFormItem>
</ACol>
<ACol :xs="24" :sm="12">
<AFormItem label="联系电话" name="phone">
<AInput v-model:value="model.phone" />
</AFormItem>
</ACol>
<ACol :xs="24" :sm="12">
<AFormItem label="邮箱" name="email">
<AInput v-model:value="model.email" />
</AFormItem>
</ACol>
<ACol :xs="24" :sm="12">
<AFormItem label="状态" name="status">
<ASelect v-model:value="model.status" :options="statusOptions" />
</AFormItem>
</ACol>
<ACol :xs="24" :sm="12">
<AFormItem label="排序" name="orderNum">
<AInputNumber v-model:value="model.orderNum" w-full />
</AFormItem>
</ACol>
<ACol :span="24">
<AFormItem :label-col="{ span: 3 }" :wrapper-col="{ span: 20, offset: 0 }" label="备注" name="remark">
<ATextarea v-model:value="model.remark" />
</AFormItem>
</ACol>
</ARow>
</AForm>
<template #footer>
<AButton @click="submitForm">确定</AButton>
<AButton @click="closeModal">取消</AButton>
</template>
</AModal>
</template>
<style scoped>
:deep(.ant-input-number) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { $t } from '@/locales';
import { enableStatusOptions } from '@/constants/business';
defineOptions({
name: 'DeptSearch'
});
interface Emits {
(e: 'reset'): void;
(e: 'search'): void;
}
const emit = defineEmits<Emits>();
const model = defineModel<Api.SystemManage.DeptSearchParams>('model', {
required: true
});
function reset() {
emit('reset');
}
function search() {
emit('search');
}
</script>
<template>
<ACard :title="$t('common.search')" :bordered="false" class="card-wrapper">
<AForm :model="model" :label-width="80">
<ARow :gutter="[16, 16]" wrap>
<ACol :span="24" :md="12" :lg="8">
<AFormItem :label="$t('page.manage.dept.deptName')" name="deptName" class="m-0">
<AInput v-model:value="model.deptName" :placeholder="$t('page.manage.dept.form.deptName')" />
</AFormItem>
</ACol>
<ACol :span="24" :md="12" :lg="8">
<AFormItem :label="$t('page.manage.dept.status')" name="status" class="m-0">
<ASelect v-model:value="model.status" :placeholder="$t('page.manage.dept.form.status')" allow-clear>
<ASelectOption v-for="option in enableStatusOptions" :key="option.value" :value="option.value">
{{ $t(option.label) }}
</ASelectOption>
</ASelect>
</AFormItem>
</ACol>
<ACol :span="24" :md="12" :lg="8">
<AFormItem class="m-0">
<div class="w-full flex-y-center justify-end gap-12px">
<AButton @click="reset">
<div class="flex-y-center gap-8px">
<icon-ic-round-refresh class="text-icon" />
<span>{{ $t('common.reset') }}</span>
</div>
</AButton>
<AButton type="primary" ghost @click="search">
<div class="flex-y-center gap-8px">
<icon-ic-round-search class="text-icon" />
<span>{{ $t('common.search') }}</span>
</div>
</AButton>
</div>
</AFormItem>
</ACol>
</ARow>
</AForm>
</ACard>
</template>
<style scoped></style>

View File

@@ -0,0 +1,37 @@
import type { DeptFormType } from '@/service/api/dept';
const { defaultRequiredRule } = useFormRules();
export function resetForm(): DeptFormType {
return {
deptName: '',
parentId: 100,
orderNum: 0,
leader: '',
phone: '',
email: '',
status: '0',
remark: ''
};
}
export const statusOptions = [
{ label: '启用', value: '0' },
{ label: '禁用', value: '1' }
];
export const formRules = {
deptName: defaultRequiredRule,
parentId: defaultRequiredRule,
orderNum: [
defaultRequiredRule,
{
validator: (rule, value) => {
if (value < 0) {
return Promise.reject(rule.message || '排序必须大于等于0');
}
return Promise.resolve();
}
}
]
} as Record<string, App.Global.FormRule | App.Global.FormRule[]>;

View File

@@ -0,0 +1,166 @@
<script setup lang="tsx">
import type { Key } from 'ant-design-vue/es/_util/type';
import { Button, Popconfirm, Tag } from 'ant-design-vue';
import { useTable, useTableOperate } from '@/hooks/common/table';
import { $t } from '@/locales';
import { enableStatusRecord } from '@/constants/business';
import DictOperateDrawer from './modules/dict-operate-drawer.vue';
import DictSearch from './modules/dict-search.vue';
const wrapperEl = shallowRef<HTMLElement | null>(null);
const { height: wrapperElHeight } = useElementSize(wrapperEl);
const scrollConfig = computed(() => {
return {
y: wrapperElHeight.value - 72,
x: 1000
};
});
const { columns, columnChecks, data, loading, getData, mobilePagination, searchParams, resetSearchParams } = useTable({
apiFn: doGetDictList,
apiParams: {
pageNum: 1,
pageSize: 10,
status: undefined,
dictName: undefined,
dictType: undefined
},
rowKey: 'dictId',
columns: () => [
{
key: 'dictName',
dataIndex: 'dictName',
title: '字典名称',
align: 'center'
},
{
key: 'dictType',
dataIndex: 'dictType',
title: '字典类型',
align: 'center'
},
{
key: 'status',
dataIndex: 'status',
title: '状态',
align: 'center',
customRender: ({ record }) => {
if (record.status === null) {
return null;
}
const tagMap: Record<Api.Common.EnableStatus, string> = {
'0': 'success',
'1': 'warning'
};
const label = $t(enableStatusRecord[record.status]);
return <Tag color={tagMap[record.status]}>{label}</Tag>;
}
},
{
key: 'remark',
dataIndex: 'remark',
align: 'center',
title: '备注'
},
{
key: 'createTime',
dataIndex: 'createTime',
align: 'center',
title: '创建时间'
},
{
key: 'operate',
title: '操作',
align: 'center',
width: 200,
customRender: ({ record }) => (
<div class="flex justify-around gap-8px">
{isShowBtn('system:dict:edit') && (
<Button size="small" onClick={() => edit(record.dictId)}>
编辑
</Button>
)}
{isShowBtn('system:dict:remove') && (
<Popconfirm onConfirm={() => handleDelete(record.dictId)} title="确认删除吗?">
<Button danger size="small">
删除
</Button>
</Popconfirm>
)}
</div>
)
}
]
});
const { drawerVisible, operateType, editingData, handleAdd, handleEdit, checkedRowKeys, onBatchDeleted, onDeleted } =
useTableOperate(data, { getData, idKey: 'dictId' });
async function handleBatchDelete() {
const { error } = await doDeleteDict(checkedRowKeys.value.join(','));
if (!error) {
onBatchDeleted();
}
}
async function handleDelete(id: number) {
const { error } = await doDeleteDict(id);
if (!error) {
onDeleted();
}
}
function edit(id: number) {
handleEdit(id);
}
function handleDictSelectChange(selectedRowKeys: Key[]) {
checkedRowKeys.value = selectedRowKeys as number[];
}
</script>
<template>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<DictSearch v-model:model="searchParams" @reset="resetSearchParams" @search="getData" />
<ACard
title="字典列表"
:bordered="false"
:body-style="{ flex: 1, overflow: 'hidden' }"
class="flex-col-stretch sm:flex-1-hidden card-wrapper"
>
<template #extra>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="checkedRowKeys.length === 0"
:loading="loading"
:show-delete="true"
table-type="dict"
@add="handleAdd"
@delete="handleBatchDelete"
@refresh="getData"
/>
</template>
<ATable
ref="wrapperEl"
:columns="columns"
:data-source="data"
:loading="loading"
:row-selection="{ selectedRowKeys: checkedRowKeys, onChange: handleDictSelectChange }"
row-key="dictId"
size="small"
:pagination="mobilePagination"
:scroll="scrollConfig"
class="h-full"
/>
<DictOperateDrawer
v-model:visible="drawerVisible"
:operate-type="operateType"
:row-data="editingData"
@submitted="getData"
/>
</ACard>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,140 @@
<script setup lang="ts">
import { SimpleScrollbar } from '@sa/materials';
import { useAntdForm, useFormRules } from '@/hooks/common/form';
import { $t } from '@/locales';
import { enableStatusOptions } from '@/constants/business';
import type { DictSubmitModel } from '@/service/api/dict';
defineOptions({
name: 'DictOperateDrawer'
});
interface Props {
/** the type of operation */
operateType: AntDesign.TableOperateType;
/** the edit row data */
rowData?: Api.SystemManage.Dict | null;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate, resetFields } = useAntdForm();
const { defaultRequiredRule } = useFormRules();
const title = computed(() => {
const titles: Record<AntDesign.TableOperateType, string> = {
add: $t('page.manage.dict.addDict'),
edit: $t('page.manage.dict.editDict')
};
return titles[props.operateType];
});
const model = ref<DictSubmitModel>(createDefaultModel());
function createDefaultModel(): DictSubmitModel {
return {
dictName: '',
dictType: '',
status: '0',
remark: ''
};
}
type RuleKey = Exclude<keyof DictSubmitModel, 'remark'>;
const rules: Record<RuleKey, App.Global.FormRule> = {
dictId: defaultRequiredRule,
dictName: defaultRequiredRule,
dictType: defaultRequiredRule,
status: defaultRequiredRule
};
function handleUpdateModelWhenEdit() {
if (props.operateType === 'add') {
model.value = createDefaultModel();
}
if (props.operateType === 'edit' && props.rowData) {
model.value = { ...props.rowData };
}
}
function closeDrawer() {
visible.value = false;
}
async function handleSubmit() {
await validate();
const { error } = await (props.operateType === 'edit' ? doEditDict : doAddDict)(model.value);
if (!error) {
$message?.success($t(props.operateType === 'add' ? 'common.addSuccess' : 'common.updateSuccess'));
closeDrawer();
emit('submitted');
}
}
watch(
() => visible.value,
val => {
if (val) {
handleUpdateModelWhenEdit();
resetFields();
}
}
);
</script>
<template>
<ADrawer
v-model:open="visible"
:body-style="{ paddingRight: '0px', paddingTop: '0', paddingBottom: '0' }"
:title="title"
:width="460"
>
<SimpleScrollbar>
<AForm ref="formRef" py-20px pr-20px layout="vertical" :model="model" :rules="rules">
<AFormItem :label="$t('page.manage.dict.dictName')" name="dictName">
<AInput v-model:value="model.dictName" :placeholder="$t('page.manage.dict.form.dictName')" />
</AFormItem>
<AFormItem :label="$t('page.manage.dict.dictType')" name="dictType">
<AInput v-model:value="model.dictType" :placeholder="$t('page.manage.dict.form.dictType')" />
</AFormItem>
<AFormItem :label="$t('page.manage.dict.status')" name="status">
<ARadioGroup v-model:value="model.status">
<ARadio v-for="item in enableStatusOptions" :key="item.value" :value="item.value">
{{ $t(item.label) }}
</ARadio>
</ARadioGroup>
</AFormItem>
<AFormItem :label="$t('page.manage.dict.remark')" name="remark">
<ATextarea v-model:value="model.remark" :placeholder="$t('page.manage.dict.form.remark')" />
</AFormItem>
</AForm>
</SimpleScrollbar>
<template #footer>
<div class="flex-y-center justify-end gap-12px">
<AButton @click="closeDrawer">{{ $t('common.cancel') }}</AButton>
<AButton type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</AButton>
</div>
</template>
</ADrawer>
</template>
<style scoped>
:deep(.ant-input-number) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,70 @@
<script setup lang="ts">
import { $t } from '@/locales';
import { enableStatusOptions } from '@/constants/business';
defineOptions({
name: 'DictSearch'
});
interface Emits {
(e: 'reset'): void;
(e: 'search'): void;
}
const emit = defineEmits<Emits>();
const model = defineModel<Api.SystemManage.DictSearchParams>('model', {
required: true
});
function reset() {
emit('reset');
}
function search() {
emit('search');
}
</script>
<template>
<ACard :title="$t('common.search')" :bordered="false" class="card-wrapper">
<AForm :model="model" :label-width="80">
<ARow :gutter="[16, 16]" wrap>
<ACol :span="24" :md="12" :lg="8">
<AFormItem :label="$t('page.manage.dict.dictName')" name="dictName" class="m-0">
<AInput v-model:value="model.dictName" :placeholder="$t('page.manage.dict.form.dictName')" />
</AFormItem>
</ACol>
<ACol :span="24" :md="12" :lg="8">
<AFormItem :label="$t('page.manage.dict.status')" name="status" class="m-0">
<ASelect v-model:value="model.status" :placeholder="$t('page.manage.dict.form.status')" allow-clear>
<ASelectOption v-for="option in enableStatusOptions" :key="option.value" :value="option.value">
{{ $t(option.label) }}
</ASelectOption>
</ASelect>
</AFormItem>
</ACol>
<ACol :span="24" :md="12" :lg="8">
<AFormItem class="m-0">
<div class="w-full flex-y-center justify-end gap-12px">
<AButton @click="reset">
<div class="flex-y-center gap-8px">
<icon-ic-round-refresh class="text-icon" />
<span>{{ $t('common.reset') }}</span>
</div>
</AButton>
<AButton type="primary" ghost @click="search">
<div class="flex-y-center gap-8px">
<icon-ic-round-search class="text-icon" />
<span>{{ $t('common.search') }}</span>
</div>
</AButton>
</div>
</AFormItem>
</ACol>
</ARow>
</AForm>
</ACard>
</template>
<style scoped></style>

View File

@@ -0,0 +1,171 @@
<script setup lang="tsx">
import { Icon } from '@iconify/vue';
import { Button, Tag } from 'ant-design-vue';
import { SimpleScrollbar } from '~/packages/materials/src';
import MenuOperateModal from './modules/menu-operate-modal.vue';
const { data, columns, loading, getData } = useTable({
apiFn: doGetMenuList,
columns: () => [
{
key: 'menuName',
dataIndex: 'menuName',
title: '菜单名称',
align: 'left',
width: 200
},
{
key: 'path',
dataIndex: 'path',
title: '路由地址',
align: 'center'
},
{
key: 'component',
dataIndex: 'component',
title: '组件路径',
align: 'center'
},
{
key: 'status',
dataIndex: 'status',
title: '状态',
align: 'center',
customRender: ({ record }) => {
const colorTagMap: Record<string, string> = {
0: 'success',
1: 'warning'
};
const labelMap: Record<string, string> = {
0: '正常',
1: '停用'
};
return <Tag color={colorTagMap[record.status]}>{labelMap[record.status]}</Tag>;
}
},
{
key: 'menuType',
dataIndex: 'menuType',
title: '类型',
align: 'center',
customRender: ({ record }) => {
const colorTagMap: Record<string, string> = {
M: 'processing',
C: 'success',
F: 'default'
};
const labelMap: Record<string, string> = {
M: '目录',
C: '菜单',
F: '按钮'
};
return <Tag color={colorTagMap[record.menuType]}>{labelMap[record.menuType]}</Tag>;
}
},
{
key: 'orderNum',
dataIndex: 'orderNum',
title: '排序',
align: 'center'
},
{
key: 'icon',
dataIndex: 'icon',
title: '图标',
align: 'center',
customRender: ({ record }) => {
return (
<div class="flex-center text-5">
<Icon icon={`${record.icon}`} />
</div>
);
}
},
{
key: 'createTime',
dataIndex: 'createTime',
align: 'center',
title: '创建时间'
},
{
key: 'operate',
title: '操作',
align: 'center',
width: 200,
customRender: ({ record }) => (
<div class="flex justify-around gap-8px">
<Button size="small" onClick={() => edit(record.menuId)}>
编辑
</Button>
<Button size="small" danger onClick={() => handleDelete(record.menuId)}>
删除
</Button>
</div>
)
}
],
rowKey: 'menuId'
});
const { handleEdit, handleAdd, checkedRowKeys, operateType, drawerVisible, onDeleted, editingData } = useTableOperate(
data,
{
getData,
idKey: 'menuId'
}
);
function edit(id: number) {
handleEdit(id);
}
function handleDelete(id: number) {
$modal.confirm({
title: '提示',
content: '确定删除该菜单吗?',
onOk: async () => {
const { error } = await doDeleteMenu(id);
if (!error) {
$message.success('删除成功');
onDeleted();
}
}
});
}
const treeData = computed(() => {
return transformListToTree(data.value, 'menuId');
});
function handleSubmitSuccess() {
getData();
}
</script>
<template>
<SimpleScrollbar>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<ACard title="菜单列表">
<AButton mb-4 type="primary" @click="handleAdd">新增菜单</AButton>
<ATable
:checked-row-keys="checkedRowKeys"
:data-source="treeData"
row-key="menuId"
:columns="columns"
:pagination="false"
:loading="loading"
/>
<MenuOperateModal
v-model:visible="drawerVisible"
:editing-data="editingData"
:operate-type="operateType"
@submit-success="handleSubmitSuccess"
/>
</ACard>
</div>
</SimpleScrollbar>
</template>
<style scoped></style>

View File

@@ -0,0 +1,70 @@
const { defaultRequiredRule } = useFormRules();
export type MenuModelType = Pick<
Api.SystemManage.Menu,
| 'menuName'
| 'menuType'
| 'icon'
| 'path'
| 'component'
| 'orderNum'
| 'status'
| 'parentId'
| 'hideInMenu'
| 'fixedIndexInTab'
| 'iconType'
| 'isFrame'
| 'perms'
| 'isCache'
| 'name'
>;
export function resetAddForm(): MenuModelType {
return {
menuName: '',
menuType: 'M',
icon: '',
path: '',
component: '',
orderNum: 0,
status: '0',
parentId: 0,
iconType: '1',
hideInMenu: '0',
isFrame: '1',
perms: '',
name: '',
isCache: '0'
};
}
export const formRules = {
menuName: defaultRequiredRule,
menuType: defaultRequiredRule,
icon: defaultRequiredRule,
path: defaultRequiredRule,
component: defaultRequiredRule,
status: defaultRequiredRule,
orderNum: [
defaultRequiredRule,
{
validator: (rule, value) => {
if (value < 0) {
return Promise.reject(rule.message || '排序必须大于等于0');
}
return Promise.resolve();
}
}
]
} as Record<string, App.Global.FormRule | App.Global.FormRule[]>;
export const menuTypeOptions = [
{ label: '目录', value: 'M' },
{ label: '菜单', value: 'C' },
{ label: '按钮', value: 'F' }
];
export const menuStatusOptions = [
{ label: '正常', value: '0' },
{ label: '停用', value: '1' }
];

View File

@@ -0,0 +1,67 @@
<script lang="ts" setup>
import { Icon } from '@iconify/vue';
import { SimpleScrollbar } from '~/packages/materials/src';
import { icons } from './icon';
const visible = ref(false);
defineOptions({
name: 'IconSelect'
});
const value = defineModel<string>({ default: '' });
const iconSelectRef = ref<HTMLElement | null>(null);
const { isOutside } = useMouseInElement(iconSelectRef);
const blur = () => {
if (isOutside.value) {
visible.value = false;
}
};
function selectIcon(icon: string) {
value.value = icon;
setTimeout(() => {
visible.value = false;
}, 100);
}
</script>
<template>
<div relative>
<div flex items-center gap-2 @click="visible = true">
<AInput v-model:value="value" @blur="blur">
<template #suffix>
<Icon v-if="value" text-4 :icon="`${value}`" @click.prevent="() => {}"></Icon>
<Icon v-if="!value" text-4 icon="carbon:apps" @click.prevent="() => {}"></Icon>
</template>
</AInput>
</div>
<Transition>
<template v-if="visible">
<div
ref="iconSelectRef"
class="absolute left-0 top-36px z-3000 h-80 w-92 rounded-md bg-light-2 shadow-md dark:bg-dark-4"
>
<SimpleScrollbar>
<div flex="~ wrap" gap-1 p-4>
<div
v-for="icon in icons"
:key="icon"
:class="
icon === value ? 'text-dark-800 bg-gray-200 dark:text-gray-4 dark:bg-dark6' : 'text-gray-500 bg-none'
"
hover="bg-light-500 dark:bg-dark-500 "
class="flex-center cursor-pointer rounded-md p-2 transition-all"
@click="selectIcon(icon)"
>
<Icon class="text-5.5 font-bold" :icon="`${icon}`" />
</div>
</div>
</SimpleScrollbar>
</div>
</template>
</Transition>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,39 @@
export const icons = [
'carbon:3d-cursor',
'carbon:account',
'carbon:ai-results-low',
'carbon:assembly-cluster',
'carbon:battery-charging',
'carbon:battery-full',
'carbon:bicycle',
'carbon:building-insights-3',
'carbon:calendar',
'carbon:carbon',
'carbon:carbon-for-ibm-product',
'carbon:category',
'carbon:center-to-fit',
'carbon:chart-area-smooth',
'carbon:chart-median',
'carbon:chat-bot',
'carbon:cics-system-group',
'carbon:database-messaging',
'carbon:document',
'carbon:document-multiple-01',
'carbon:earth-americas-filled',
'carbon:folder',
'carbon:forum',
'carbon:group',
'carbon:ibm-cloud-bare-metal-server',
'carbon:ibm-telehealth',
'carbon:image-search-alt',
'carbon:laptop',
'carbon:machine-learning',
'carbon:report',
'carbon:rocket',
'carbon:settings',
'carbon:settings-services',
'carbon:shopping-cart',
'carbon:user-avatar',
'carbon:user-multiple',
'carbon:volume-block-storage'
];

View File

@@ -0,0 +1,185 @@
<script lang="ts" setup>
import type {} from 'ant-design-vue';
import { generatedRoutes } from '@/router/elegant/routes';
import { $t } from '@/locales';
import type { MenuModelType } from './form';
import { formRules, menuStatusOptions, menuTypeOptions, resetAddForm } from './form';
import IconSelect from './icon-select.vue';
const props = defineProps<{
operateType: AntDesign.TableOperateType;
editingData?: Api.SystemManage.Menu | null;
}>();
const emits = defineEmits<{
'submit-success': [];
}>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate, resetFields } = useAntdForm();
const model = ref<MenuModelType>(resetAddForm());
const treeData = ref<Api.SystemManage.MenuTree[]>([]);
const title = computed(() => {
const titles: Record<AntDesign.TableOperateType, string> = {
add: '新增菜单',
edit: '编辑菜单'
};
return titles[props.operateType];
});
// TODO: 根据菜单类型动态加载组件路径、目录类型只允许选择带有子元素的,菜单类型只允许选择没有子元素的
const componentOptions = computed(() => {
const excludePaths = ['/404', '/403', '/500'];
function transformRoutes(routes: any[]): any[] {
return routes.filter(route => {
if (route.children) {
route.children = transformRoutes(route.children);
return true;
}
if (!route.hideInMenu && !excludePaths.includes(route.path) && !route.path.startsWith('/login')) {
return true;
}
return false;
});
}
return transformRoutes(generatedRoutes);
});
watch(visible, val => {
if (val) {
if (props.operateType === 'edit' && props.editingData) {
model.value = {
...props.editingData,
parentId: props.editingData.parentId || 0,
hideInMenu: props.editingData.visible === '0' ? '1' : '0'
};
}
} else {
resetFields();
model.value = resetAddForm();
}
});
const submitForm = async () => {
await validate();
const { error } = await (props.operateType === 'add' ? doAddMenu(model.value) : doEditMenu(model.value));
if (!error) {
$message?.success($t(props.operateType === 'add' ? 'common.addSuccess' : 'common.updateSuccess'));
emits('submit-success');
}
closeModal();
};
function closeModal() {
visible.value = false;
resetFields();
model.value = resetAddForm();
}
async function getTreeData() {
const { data, error } = await fetchGetMenuTree();
if (!error && data) {
// 添加根节点
treeData.value = [
{
id: 0,
pId: -1,
label: '根节点',
children: data
}
];
}
}
function handleTreeSelect(node: any) {
model.value.component = node.component;
model.value.name = node.name;
}
getTreeData();
</script>
<template>
<AModal v-model:open="visible" :title="title" :width="700">
<AForm
ref="formRef"
:model="model"
:rules="formRules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16, offset: 1 }"
class="grid grid-cols-2 gap-3 px-4 py-4 pt-6"
>
<AFormItem label="上级菜单" name="parentId">
<ATreeSelect
v-model:value="model.parentId"
show-search
:field-names="{ value: 'id' }"
allow-clear
:tree-data="treeData"
tree-node-filter-prop="label"
/>
</AFormItem>
<AFormItem label="菜单名称" name="menuName">
<AInput v-model:value="model.menuName" />
</AFormItem>
<AFormItem label="菜单类型" name="menuType">
<ASelect v-model:value="model.menuType" :options="menuTypeOptions" />
</AFormItem>
<AFormItem v-if="model.menuType !== 'F'" label="菜单图标" name="icon">
<IconSelect v-model="model.icon" />
</AFormItem>
<AFormItem v-if="model.menuType === 'C'" label="是否外链">
<ARadioGroup v-model:value="model.isFrame" name="radioGroup" @change="() => (model.path = '')">
<ARadio value="0"></ARadio>
<ARadio value="1"></ARadio>
</ARadioGroup>
</AFormItem>
<AFormItem v-if="model.menuType !== 'F'" label="菜单路径" name="path">
<div>
<!-- @vue-ignore -->
<ATreeSelect
v-if="model.isFrame === '1'"
v-model:value="model.path"
show-search
:field-names="{ value: 'path', label: 'path' }"
allow-clear
:tree-data="componentOptions"
tree-node-filter-prop="label"
@select="(_val, node) => handleTreeSelect(node)"
/>
<AInput v-else v-model:value="model.path" />
</div>
</AFormItem>
<AFormItem v-if="model.menuType === 'C'" label="隐藏菜单" name="hideInMenu">
<ASwitch v-model:checked="model.hideInMenu" checked-value="0" un-checked-value="1" />
</AFormItem>
<AFormItem label="排序" name="orderNum">
<AInputNumber v-model:value="model.orderNum" w-full />
</AFormItem>
<AFormItem v-if="model.menuType !== 'F'" label="状态" name="status">
<ASelect v-model:value="model.status" :options="menuStatusOptions" />
</AFormItem>
<AFormItem v-if="model.menuType === 'C'" label="缓存" name="isCache">
<ASwitch v-model:checked="model.isCache" checked-value="0" un-checked-value="1" />
</AFormItem>
<AFormItem v-if="model.menuType === 'F'" label="权限标识" name="perms">
<AInput v-model:value="model.perms" />
</AFormItem>
</AForm>
<template #footer>
<AButton @click="submitForm">确定</AButton>
<AButton @click="closeModal">取消</AButton>
</template>
</AModal>
</template>
<style scoped>
:deep(.ant-input-number) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,173 @@
<script setup lang="tsx">
import type { Key } from 'ant-design-vue/es/_util/type';
import { Button, Popconfirm, Tag } from 'ant-design-vue';
import { useTable, useTableOperate } from '@/hooks/common/table';
import { $t } from '@/locales';
import { enableStatusRecord } from '@/constants/business';
import PostOperateDrawer from './modules/post-operate-drawer.vue';
import PostSearch from './modules/post-search.vue';
const wrapperEl = shallowRef<HTMLElement | null>(null);
const { height: wrapperElHeight } = useElementSize(wrapperEl);
const scrollConfig = computed(() => {
return {
y: wrapperElHeight.value - 72,
x: 1000
};
});
const { columns, columnChecks, data, loading, getData, mobilePagination, searchParams, resetSearchParams } = useTable({
apiFn: doGetPostList,
apiParams: {
pageNum: 1,
pageSize: 10,
status: undefined,
postName: undefined,
postCode: undefined
},
rowKey: 'postId',
columns: () => [
{
key: 'postCode',
dataIndex: 'postCode',
title: $t('page.manage.post.postCode'),
align: 'center'
},
{
key: 'postName',
dataIndex: 'postName',
title: $t('page.manage.post.postName'),
align: 'center'
},
{
key: 'postSort',
dataIndex: 'postSort',
title: $t('page.manage.post.postSort'),
align: 'center'
},
{
key: 'status',
dataIndex: 'status',
title: $t('page.manage.post.status'),
align: 'center',
customRender: ({ record }) => {
if (record.status === null) {
return null;
}
const tagMap: Record<Api.Common.EnableStatus, string> = {
'0': 'success',
'1': 'warning'
};
const label = $t(enableStatusRecord[record.status]);
return <Tag color={tagMap[record.status]}>{label}</Tag>;
}
},
{
key: 'remark',
dataIndex: 'remark',
align: 'center',
title: $t('page.manage.post.remark')
},
{
key: 'createTime',
dataIndex: 'createTime',
align: 'center',
title: '创建时间'
},
{
key: 'operate',
title: $t('common.operate'),
align: 'center',
width: 200,
customRender: ({ record }) =>
!record.flag && (
<div class="flex justify-around gap-8px">
{isShowBtn('system:post:edit') && (
<Button size="small" onClick={() => edit(record.postId)}>
{$t('common.edit')}
</Button>
)}
{isShowBtn('system:post:remove') && (
<Popconfirm onConfirm={() => handleDelete(record.postId)} title={$t('common.confirmDelete')}>
<Button danger size="small">
{$t('common.delete')}
</Button>
</Popconfirm>
)}
</div>
)
}
]
});
const { drawerVisible, operateType, editingData, handleAdd, handleEdit, checkedRowKeys, onBatchDeleted, onDeleted } =
useTableOperate(data, { getData, idKey: 'postId' });
async function handleBatchDelete() {
const { error } = await doDeletePost(checkedRowKeys.value.join(','));
if (!error) {
onBatchDeleted();
}
}
async function handleDelete(id: number) {
const { error } = await doDeletePost(id);
if (!error) {
onDeleted();
}
}
function edit(id: number) {
handleEdit(id);
}
function handlePostSelectChange(selectedRowKeys: Key[]) {
checkedRowKeys.value = selectedRowKeys as number[];
}
</script>
<template>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<PostSearch v-model:model="searchParams" @reset="resetSearchParams" @search="getData" />
<ACard
:title="$t('page.manage.post.title')"
:bordered="false"
:body-style="{ flex: 1, overflow: 'hidden' }"
class="flex-col-stretch sm:flex-1-hidden card-wrapper"
>
<template #extra>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="checkedRowKeys.length === 0"
:loading="loading"
:show-delete="true"
table-type="post"
@add="handleAdd"
@delete="handleBatchDelete"
@refresh="getData"
/>
</template>
<ATable
ref="wrapperEl"
:columns="columns"
:data-source="data"
:loading="loading"
:row-selection="{ selectedRowKeys: checkedRowKeys, onChange: handlePostSelectChange }"
row-key="postId"
size="small"
:pagination="mobilePagination"
:scroll="scrollConfig"
class="h-full"
/>
<PostOperateDrawer
v-model:visible="drawerVisible"
:operate-type="operateType"
:row-data="editingData"
@submitted="getData"
/>
</ACard>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
import { SimpleScrollbar } from '@sa/materials';
import { useAntdForm, useFormRules } from '@/hooks/common/form';
import { $t } from '@/locales';
import { enableStatusOptions } from '@/constants/business';
import type { PostSubmitModel } from '@/service/api/post';
defineOptions({
name: 'PostOperateDrawer'
});
interface Props {
/** the type of operation */
operateType: AntDesign.TableOperateType;
/** the edit row data */
rowData?: Api.SystemManage.Post | null;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate, resetFields } = useAntdForm();
const { defaultRequiredRule } = useFormRules();
const title = computed(() => {
const titles: Record<AntDesign.TableOperateType, string> = {
add: $t('page.manage.post.addPost'),
edit: $t('page.manage.post.editPost')
};
return titles[props.operateType];
});
const model = ref<PostSubmitModel>(createDefaultModel());
function createDefaultModel(): PostSubmitModel {
return {
postCode: '',
postName: '',
postSort: 0,
status: '0',
remark: ''
};
}
type RuleKey = Exclude<keyof PostSubmitModel, 'remark'>;
const rules: Record<RuleKey, App.Global.FormRule> = {
postCode: defaultRequiredRule,
postName: defaultRequiredRule,
postSort: defaultRequiredRule,
status: defaultRequiredRule
};
function handleUpdateModelWhenEdit() {
if (props.operateType === 'add') {
model.value = createDefaultModel();
}
if (props.operateType === 'edit' && props.rowData) {
model.value = { ...props.rowData };
}
}
function closeDrawer() {
visible.value = false;
}
async function handleSubmit() {
await validate();
const { error } = await (props.operateType === 'edit' ? doEditPost : doAddPost)(model.value);
if (!error) {
$message?.success($t(props.operateType === 'add' ? 'common.addSuccess' : 'common.updateSuccess'));
closeDrawer();
emit('submitted');
}
}
watch(
() => visible.value,
val => {
if (val) {
handleUpdateModelWhenEdit();
resetFields();
}
}
);
</script>
<template>
<ADrawer
v-model:open="visible"
:body-style="{ paddingRight: '0px', paddingTop: '0', paddingBottom: '0' }"
:title="title"
:width="460"
>
<SimpleScrollbar>
<AForm ref="formRef" py-20px pr-20px layout="vertical" :model="model" :rules="rules">
<AFormItem :label="$t('page.manage.post.postCode')" name="postCode">
<AInput v-model:value="model.postCode" :placeholder="$t('page.manage.post.form.postCode')" />
</AFormItem>
<AFormItem :label="$t('page.manage.post.postName')" name="postName">
<AInput v-model:value="model.postName" :placeholder="$t('page.manage.post.form.postName')" />
</AFormItem>
<AFormItem :label="$t('page.manage.post.postSort')" name="postSort">
<AInputNumber v-model:value="model.postSort" :placeholder="$t('page.manage.post.form.postSort')" />
</AFormItem>
<AFormItem :label="$t('page.manage.post.status')" name="status">
<ARadioGroup v-model:value="model.status">
<ARadio v-for="item in enableStatusOptions" :key="item.value" :value="item.value">
{{ $t(item.label) }}
</ARadio>
</ARadioGroup>
</AFormItem>
<AFormItem :label="$t('page.manage.post.remark')" name="remark">
<ATextarea v-model:value="model.remark" :placeholder="$t('page.manage.post.form.remark')" />
</AFormItem>
</AForm>
</SimpleScrollbar>
<template #footer>
<div class="flex-y-center justify-end gap-12px">
<AButton @click="closeDrawer">{{ $t('common.cancel') }}</AButton>
<AButton type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</AButton>
</div>
</template>
</ADrawer>
</template>
<style scoped>
:deep(.ant-input-number) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { $t } from '@/locales';
import { enableStatusOptions } from '@/constants/business';
defineOptions({
name: 'PostSearch'
});
interface Emits {
(e: 'reset'): void;
(e: 'search'): void;
}
const emit = defineEmits<Emits>();
const model = defineModel<Api.SystemManage.PostSearchParams>('model', {
required: true
});
function reset() {
emit('reset');
}
function search() {
emit('search');
}
</script>
<template>
<ACard :title="$t('common.search')" :bordered="false" class="card-wrapper">
<AForm :model="model" :label-width="80">
<ARow :gutter="[16, 16]" wrap>
<ACol :span="24" :md="12" :lg="6">
<AFormItem :label="$t('page.manage.post.postCode')" name="postCode" class="m-0">
<AInput v-model:value="model.postCode" :placeholder="$t('page.manage.post.form.postCode')" />
</AFormItem>
</ACol>
<ACol :span="24" :md="12" :lg="6">
<AFormItem :label="$t('page.manage.post.postName')" name="postName" class="m-0">
<AInput v-model:value="model.postName" :placeholder="$t('page.manage.post.form.postName')" />
</AFormItem>
</ACol>
<ACol :span="24" :md="12" :lg="6">
<AFormItem :label="$t('page.manage.post.status')" name="status" class="m-0">
<ASelect v-model:value="model.status" :placeholder="$t('page.manage.post.form.status')" allow-clear>
<ASelectOption v-for="option in enableStatusOptions" :key="option.value" :value="option.value">
{{ $t(option.label) }}
</ASelectOption>
</ASelect>
</AFormItem>
</ACol>
<ACol :span="24" :md="12" :lg="6">
<AFormItem class="m-0">
<div class="w-full flex-y-center justify-end gap-12px">
<AButton @click="reset">
<div class="flex-y-center gap-8px">
<icon-ic-round-refresh class="text-icon" />
<span>{{ $t('common.reset') }}</span>
</div>
</AButton>
<AButton type="primary" ghost @click="search">
<div class="flex-y-center gap-8px">
<icon-ic-round-search class="text-icon" />
<span>{{ $t('common.search') }}</span>
</div>
</AButton>
</div>
</AFormItem>
</ACol>
</ARow>
</AForm>
</ACard>
</template>
<style scoped></style>

View File

@@ -0,0 +1,181 @@
<script setup lang="tsx">
import { Button, Popconfirm, Tag } from 'ant-design-vue';
import type { Key } from 'ant-design-vue/es/_util/type';
import { useTable, useTableOperate } from '@/hooks/common/table';
import { $t } from '@/locales';
import { enableStatusRecord } from '@/constants/business';
import RoleOperateDrawer from './modules/role-operate-drawer.vue';
import RoleSearch from './modules/role-search.vue';
const wrapperEl = shallowRef<HTMLElement | null>(null);
const { height: wrapperElHeight } = useElementSize(wrapperEl);
const scrollConfig = computed(() => {
return {
y: wrapperElHeight.value - 72,
x: 702
};
});
const { columns, columnChecks, data, loading, getData, mobilePagination, searchParams, resetSearchParams } = useTable({
apiFn: doGetRoleList,
apiParams: {
pageNum: 1,
pageSize: 10,
status: undefined,
roleName: undefined,
roleKey: undefined
},
rowKey: 'roleId',
columns: () => [
{
key: 'roleKey',
dataIndex: 'roleKey',
title: $t('page.manage.role.roleCode'),
align: 'center'
},
{
key: 'roleName',
dataIndex: 'roleName',
title: $t('page.manage.role.roleName'),
align: 'center'
},
{
key: 'status',
dataIndex: 'status',
title: $t('page.manage.role.roleStatus'),
align: 'center',
customRender: ({ record }) => {
if (record.status === null) {
return null;
}
const tagMap: Record<Api.Common.EnableStatus, string> = {
'0': 'success',
'1': 'warning'
};
const label = $t(enableStatusRecord[record.status]);
return <Tag color={tagMap[record.status]}>{label}</Tag>;
}
},
{
key: 'remark',
dataIndex: 'remark',
title: $t('page.manage.role.roleDesc')
},
{
key: 'createTime',
dataIndex: 'createTime',
align: 'center',
title: '创建时间'
},
{
key: 'operate',
title: $t('common.operate'),
align: 'center',
width: 200,
customRender: ({ record }) =>
!record.admin && (
<div class="flex justify-around gap-8px">
{isShowBtn('system:role:edit') && (
<Button size="small" onClick={() => edit(record.roleId)}>
{$t('common.edit')}
</Button>
)}
{isShowBtn('system:role:remove') && (
<Popconfirm onConfirm={() => handleDelete(record.roleId)} title={$t('common.confirmDelete')}>
<Button danger size="small">
{$t('common.delete')}
</Button>
</Popconfirm>
)}
</div>
)
}
]
});
const {
drawerVisible,
operateType,
editingData,
handleAdd,
handleEdit,
checkedRowKeys,
onBatchDeleted,
onDeleted
// closeDrawer
} = useTableOperate(data, { getData, idKey: 'roleId' });
async function handleBatchDelete() {
const { error } = await doDeleteRole(checkedRowKeys.value.join(','));
if (!error) {
onBatchDeleted();
}
}
async function handleDelete(id: number) {
const { error } = await doDeleteRole(id);
if (!error) {
onDeleted();
}
}
function edit(id: number) {
handleEdit(id);
}
function handleRoleSelectChange(selectedRowKeys: Key[]) {
checkedRowKeys.value = selectedRowKeys as number[];
}
</script>
<template>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<RoleSearch v-model:model="searchParams" @reset="resetSearchParams" @search="getData" />
<ACard
:title="$t('page.manage.role.title')"
:bordered="false"
:body-style="{ flex: 1, overflow: 'hidden' }"
class="flex-col-stretch sm:flex-1-hidden card-wrapper"
>
<template #extra>
<TableHeaderOperation
v-model:columns="columnChecks"
:show-delete="true"
:disabled-delete="checkedRowKeys.length === 0"
:loading="loading"
@add="handleAdd"
@delete="handleBatchDelete"
@refresh="getData"
/>
</template>
<ATable
ref="wrapperEl"
:columns="columns"
:data-source="data"
:loading="loading"
:row-selection="{
selectedRowKeys: checkedRowKeys,
onChange: handleRoleSelectChange,
getCheckboxProps: record => ({ disabled: record.admin })
}"
row-key="id"
size="small"
:pagination="mobilePagination"
:scroll="scrollConfig"
class="h-full"
/>
<RoleOperateDrawer
v-model:visible="drawerVisible"
:operate-type="operateType"
:row-data="editingData"
@submitted="getData"
/>
</ACard>
</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,123 @@
<script setup lang="ts">
import type { DataNode } from 'ant-design-vue/es/tree';
import { SimpleScrollbar } from '@sa/materials';
defineOptions({
name: 'MenuAuthModal'
});
interface Props {
/** the roleId */
roleId: number;
type: 'add' | 'edit';
drawerVisible: boolean;
}
const props = defineProps<Props>();
const menuIds = defineModel<number[]>('menuIds', { default: [] });
const tree = shallowRef<DataNode[]>([]);
watch(
() => props.drawerVisible,
val => {
if (val) {
init();
}
},
{
immediate: true
}
);
async function getTree() {
const { error, data } = await fetchGetMenuTree();
if (!error) {
tree.value = recursiveTransform(data);
}
}
function recursiveTransform(data: Api.SystemManage.MenuTree[]): DataNode[] {
return data.map(item => {
const { id: key, label } = item;
if (item.children) {
return {
key,
title: label,
children: recursiveTransform(item.children)
};
}
return {
key,
title: label
};
});
}
async function getChecks() {
const { data, error } = await doGetRoleMenuList(props.roleId);
if (!error) {
if (props.type === 'edit') {
tree.value = recursiveTransform(data.menus);
nextTick(() => {
menuIds.value = data.checkedKeys;
});
}
}
}
async function init() {
if (props.type === 'edit') {
await getChecks();
} else {
await getTree();
}
}
function clearChecks() {
menuIds.value = [];
}
defineExpose({
clearChecks,
checkedKeys: menuIds,
tree
});
</script>
<template>
<div class="border-0.5 border-gray-300 rounded-md p-2 transition-all dark:border-dark-300" hover="border-gray-500">
<SimpleScrollbar>
<ATree
v-model:checked-keys="menuIds"
:selectable="false"
:virtual="false"
:tree-data="tree"
checkable
block-node
/>
</SimpleScrollbar>
</div>
</template>
<style scoped>
:deep(.ant-tree .ant-tree-switcher) {
line-height: normal;
display: flex;
justify-content: center;
align-items: center;
}
:deep(.ant-tree .ant-tree-checkbox) {
margin-block-start: unset;
}
:deep(.ant-tree .ant-tree-title) {
font-size: 14px;
padding: 2px;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,167 @@
<script setup lang="ts">
import { SimpleScrollbar } from '@sa/materials';
import { useAntdForm, useFormRules } from '@/hooks/common/form';
import { $t } from '@/locales';
import { enableStatusOptions } from '@/constants/business';
import { doPostRole, doPutRole } from '@/service/api/role';
import MenuAuth from './menu-auth.vue';
defineOptions({
name: 'RoleOperateDrawer'
});
interface Props {
/** the type of operation */
operateType: AntDesign.TableOperateType;
/** the edit row data */
rowData?: Api.SystemManage.Role | null;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const menuAuthRef = ref<InstanceType<typeof MenuAuth> | null>(null);
const authStore = useAuthStore();
const { formRef, validate, resetFields } = useAntdForm();
const { defaultRequiredRule } = useFormRules();
const title = computed(() => {
const titles: Record<AntDesign.TableOperateType, string> = {
add: $t('page.manage.role.addRole'),
edit: $t('page.manage.role.editRole')
};
return titles[props.operateType];
});
type Model = Pick<Api.SystemManage.Role, 'roleName' | 'roleKey' | 'remark' | 'status'> & { menuIds: number[] };
const model = ref<Model>(createDefaultModel());
function createDefaultModel(): Model {
return {
roleName: '',
roleKey: '',
remark: '',
status: '0',
menuIds: []
};
}
type RuleKey = Exclude<keyof Model, 'remark' | 'menuIds'>;
const rules: Record<RuleKey, App.Global.FormRule> = {
roleName: defaultRequiredRule,
roleKey: defaultRequiredRule,
status: defaultRequiredRule
};
const roleId = computed(() => props.rowData?.roleId || -1);
function handleUpdateModelWhenEdit() {
if (props.operateType === 'add') {
model.value = createDefaultModel();
}
if (props.operateType === 'edit' && props.rowData) {
model.value = { ...props.rowData, menuIds: [] };
}
}
function closeDrawer() {
visible.value = false;
}
async function handleSubmit() {
await validate();
const menuIds = transformMenuChildWithRootIds(menuAuthRef.value?.tree || [], model.value.menuIds);
const { error } = await (props.operateType === 'edit' ? doPutRole : doPostRole)({
...model.value,
menuIds,
menuCheckStrictly: true
} as Api.SystemManage.Role);
if (!error) {
authStore.refreshUserInfo();
$message?.success($t(props.operateType === 'add' ? 'common.addSuccess' : 'common.updateSuccess'));
closeDrawer();
emit('submitted');
}
}
watch(
() => visible.value,
val => {
if (val) {
handleUpdateModelWhenEdit();
resetFields();
} else {
menuAuthRef.value?.clearChecks();
}
}
);
</script>
<template>
<ADrawer
v-model:open="visible"
:body-style="{ paddingRight: '0px', paddingTop: '0', paddingBottom: '0' }"
:title="title"
:width="460"
>
<SimpleScrollbar>
<AForm ref="formRef" py-20px pr-20px layout="vertical" :model="model" :rules="rules">
<AFormItem :label="$t('page.manage.role.roleName')" name="roleName">
<AInput v-model:value="model.roleName" :placeholder="$t('page.manage.role.form.roleName')" />
</AFormItem>
<AFormItem :label="$t('page.manage.role.roleCode')" name="roleKey">
<AInput v-model:value="model.roleKey" :placeholder="$t('page.manage.role.form.roleCode')" />
</AFormItem>
<AFormItem :label="$t('page.manage.role.roleStatus')" name="status">
<ARadioGroup v-model:value="model.status">
<ARadio v-for="item in enableStatusOptions" :key="item.value" :value="item.value">
{{ $t(item.label) }}
</ARadio>
</ARadioGroup>
</AFormItem>
<AFormItem :label="$t('page.manage.role.roleDesc')" name="roleDesc">
<AInput v-model:value="model.remark" :placeholder="$t('page.manage.role.form.roleDesc')" />
</AFormItem>
<AFormItem>
<MenuAuth
ref="menuAuthRef"
v-model:menu-ids="model.menuIds"
:drawer-visible="visible"
:type="operateType"
:role-id="roleId"
/>
<template #label>
<div class="w-full flex-between">
<span>{{ $t('page.manage.role.menuAuth') }}</span>
</div>
</template>
</AFormItem>
</AForm>
</SimpleScrollbar>
<template #footer>
<div class="flex-y-center justify-end gap-12px">
<AButton @click="closeDrawer">{{ $t('common.cancel') }}</AButton>
<AButton type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</AButton>
</div>
</template>
</ADrawer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { $t } from '@/locales';
import { enableStatusOptions } from '@/constants/business';
defineOptions({
name: 'RoleSearch'
});
interface Emits {
(e: 'reset'): void;
(e: 'search'): void;
}
const emit = defineEmits<Emits>();
const model = defineModel<Api.SystemManage.RoleSearchParams>('model', { required: true });
function reset() {
emit('reset');
}
function search() {
emit('search');
}
</script>
<template>
<ACard :title="$t('common.search')" :bordered="false" class="card-wrapper">
<AForm :model="model" :label-width="80">
<ARow :gutter="[16, 16]" wrap>
<ACol :span="24" :md="12" :lg="6">
<AFormItem :label="$t('page.manage.role.roleName')" name="roleName" class="m-0">
<AInput v-model:value="model.roleName" :placeholder="$t('page.manage.role.form.roleName')" />
</AFormItem>
</ACol>
<ACol :span="24" :md="12" :lg="6">
<AFormItem :label="$t('page.manage.role.roleCode')" name="roleCode" class="m-0">
<AInput v-model:value="model.roleKey" :placeholder="$t('page.manage.role.form.roleCode')" />
</AFormItem>
</ACol>
<ACol :span="24" :md="12" :lg="6">
<AFormItem :label="$t('page.manage.role.roleStatus')" name="status" class="m-0">
<ASelect v-model:value="model.status" :placeholder="$t('page.manage.role.form.roleStatus')" allow-clear>
<ASelectOption v-for="option in enableStatusOptions" :key="option.value" :value="option.value">
{{ $t(option.label) }}
</ASelectOption>
</ASelect>
</AFormItem>
</ACol>
<ACol :span="24" :md="12" :lg="6">
<AFormItem class="m-0">
<div class="w-full flex-y-center justify-end gap-12px">
<AButton @click="reset">
<div class="flex-y-center gap-8px">
<icon-ic-round-refresh class="text-icon" />
<span>{{ $t('common.reset') }}</span>
</div>
</AButton>
<AButton type="primary" ghost @click="search">
<div class="flex-y-center gap-8px">
<icon-ic-round-search class="text-icon" />
<span>{{ $t('common.search') }}</span>
</div>
</AButton>
</div>
</AFormItem>
</ACol>
</ARow>
</AForm>
</ACard>
</template>
<style scoped></style>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts"></script>
<template>
<div>路由</div>
</template>
<style scoped></style>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
interface Props {
id: string;
}
defineProps<Props>();
</script>
<template>
<LookForward>
<p>{{ id }}</p>
</LookForward>
</template>
<style scoped></style>

View File

@@ -0,0 +1,216 @@
<script setup lang="tsx">
import { Button, Popconfirm, Tag } from 'ant-design-vue';
import type { Key } from 'ant-design-vue/es/_util/type';
import { useTable, useTableOperate } from '@/hooks/common/table';
import { $t } from '@/locales';
import { enableStatusRecord } from '@/constants/business';
import { SimpleScrollbar } from '~/packages/materials/src';
import UserOperateDrawer from './modules/user-operate-drawer.vue';
import UserSearch from './modules/user-search.vue';
const wrapperEl = shallowRef<HTMLElement | null>(null);
const { height: wrapperElHeight } = useElementSize(wrapperEl);
const scrollConfig = computed(() => {
return {
y: wrapperElHeight.value - 72,
x: 1000
};
});
const { columns, columnChecks, data, loading, getData, mobilePagination, searchParams, resetSearchParams } = useTable({
apiFn: doGetUserList,
apiParams: {
pageNum: 1,
pageSize: 10,
status: undefined,
userName: undefined,
email: undefined
},
rowKey: 'userId',
columns: () => [
{
key: 'userName',
dataIndex: 'userName',
title: $t('page.manage.user.userName'),
align: 'center'
},
{
key: 'nickName',
dataIndex: 'nickName',
title: $t('page.manage.user.nickName'),
align: 'center'
},
{
key: 'email',
dataIndex: 'email',
title: $t('page.manage.user.email'),
align: 'center'
},
{
key: 'phonenumber',
dataIndex: 'phonenumber',
title: $t('page.manage.user.phonenumber'),
align: 'center'
},
{
key: 'status',
dataIndex: 'status',
title: $t('page.manage.user.status'),
align: 'center',
customRender: ({ record }) => {
if (record.status === null) {
return null;
}
const tagMap: Record<Api.Common.EnableStatus, string> = {
'0': 'success',
'1': 'warning'
};
const label = $t(enableStatusRecord[record.status]);
return <Tag color={tagMap[record.status]}>{label}</Tag>;
}
},
{
key: 'dept',
dataIndex: 'dept',
title: $t('page.manage.user.dept'),
align: 'center',
customRender: ({ record }) => record.dept?.deptName || ''
},
{
key: 'createTime',
dataIndex: 'createTime',
align: 'center',
title: '创建时间'
},
{
key: 'operate',
title: $t('common.operate'),
align: 'center',
width: 200,
customRender: ({ record }) =>
!record.admin && (
<div class="flex justify-around gap-8px">
{isShowBtn('system:user:edit') && (
<Button size="small" onClick={() => edit(record.userId)}>
{$t('common.edit')}
</Button>
)}
{isShowBtn('system:user:remove') && (
<Popconfirm onConfirm={() => handleDelete(record.userId)} title={$t('common.confirmDelete')}>
<Button danger size="small">
{$t('common.delete')}
</Button>
</Popconfirm>
)}
</div>
)
}
]
});
const {
drawerVisible,
operateType,
editingData,
handleAdd,
handleEdit,
checkedRowKeys,
onBatchDeleted,
onDeleted
// closeDrawer
} = useTableOperate(data, { getData, idKey: 'userId' });
const deptTreeData = ref<Api.Common.CommonTree>([]);
onMounted(() => {
getUserDeptTree();
});
async function handleBatchDelete() {
const { error } = await doDeleteUser(checkedRowKeys.value.join(','));
if (!error) {
onBatchDeleted();
}
}
async function handleDelete(id: number) {
const { error } = await doDeleteUser(id);
if (!error) {
onDeleted();
}
}
function edit(id: number) {
handleEdit(id);
}
function handleUserSelectChange(selectedRowKeys: Key[]) {
checkedRowKeys.value = selectedRowKeys as number[];
}
async function getUserDeptTree() {
const { error, data: tree } = await doGetUserDeptTree();
if (!error) {
deptTreeData.value = tree;
}
}
</script>
<template>
<SimpleScrollbar>
<div class="min-h-500px flex-col-stretch gap-16px overflow-hidden lt-sm:overflow-auto">
<UserSearch
v-model:model="searchParams"
:dept-tree-data="deptTreeData"
@reset="resetSearchParams"
@search="getData"
/>
<ACard
:title="$t('page.manage.user.title')"
:bordered="false"
:body-style="{ flex: 1, overflow: 'hidden' }"
class="flex-col-stretch sm:flex-1-hidden card-wrapper"
>
<template #extra>
<TableHeaderOperation
v-model:columns="columnChecks"
:disabled-delete="checkedRowKeys.length === 0"
:loading="loading"
:show-delete="true"
@add="handleAdd"
@delete="handleBatchDelete"
@refresh="getData"
/>
</template>
<ATable
ref="wrapperEl"
:columns="columns"
:data-source="data"
:loading="loading"
:row-selection="{
selectedRowKeys: checkedRowKeys,
onChange: handleUserSelectChange,
getCheckboxProps: record => ({
disabled: record.admin
})
}"
row-key="userId"
size="small"
:pagination="mobilePagination"
:scroll="scrollConfig"
class="h-full"
/>
<UserOperateDrawer
v-model:visible="drawerVisible"
:dept-tree-data="deptTreeData"
:operate-type="operateType"
:row-data="editingData"
@submitted="getData"
/>
</ACard>
</div>
</SimpleScrollbar>
</template>
<style scoped></style>

View File

@@ -0,0 +1,216 @@
<script setup lang="ts">
import { SimpleScrollbar } from '@sa/materials';
import { useAntdForm, useFormRules } from '@/hooks/common/form';
import { $t } from '@/locales';
import { enableStatusOptions } from '@/constants/business';
defineOptions({
name: 'UserOperateDrawer'
});
interface Props {
/** the type of operation */
operateType: AntDesign.TableOperateType;
/** the edit row data */
rowData?: Api.Auth.User | null;
deptTreeData: Api.Common.CommonTree;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted'): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const { formRef, validate, resetFields } = useAntdForm();
const { defaultRequiredRule, formRules } = useFormRules();
const [spinning, spin] = useToggle();
const title = computed(() => {
const titles: Record<AntDesign.TableOperateType, string> = {
add: $t('page.manage.user.addUser'),
edit: $t('page.manage.user.editUser')
};
return titles[props.operateType];
});
type Model = Partial<
Pick<Api.Auth.User, 'userName' | 'nickName' | 'email' | 'phonenumber' | 'status' | 'deptId' | 'remark'> & {
postIds: number[];
roleIds: number[];
password?: string;
}
>;
const model = ref<Model>(createDefaultModel());
const userPosts = ref<Api.SystemManage.Post[]>([]);
const userRoles = ref<Api.SystemManage.Role[]>([]);
function createDefaultModel(): Model {
return {
userName: '',
nickName: '',
email: '',
phonenumber: '',
status: '0',
deptId: undefined,
remark: '',
postIds: [],
roleIds: [],
password: ''
};
}
const rules = {
userName: defaultRequiredRule,
nickName: defaultRequiredRule,
status: defaultRequiredRule,
deptId: defaultRequiredRule,
email: formRules.email,
phonenumber: formRules.phone,
password: formRules.pwd,
postIds: defaultRequiredRule,
roleIds: defaultRequiredRule
};
async function init(userId: number | undefined = undefined) {
spin(true);
try {
await Promise.all([getUserPostAndRole(userId)]);
spin(false);
} catch (error) {
spin(false);
}
}
async function getUserPostAndRole(userId: number | undefined) {
const { error, data } = await doGetUserPostsAndRoles(userId);
if (!error) {
const { postIds, posts, roleIds, roles } = data;
userPosts.value = posts;
userRoles.value = roles;
model.value.postIds = postIds;
model.value.roleIds = roleIds;
}
}
async function handleUpdateModelWhenEdit() {
if (props.operateType === 'add') {
model.value = createDefaultModel();
}
if (props.operateType === 'edit' && props.rowData) {
await init(props.rowData.userId);
model.value = Object.assign(model.value, omit(props.rowData, ['postIds', 'roleIds']));
} else {
await init();
model.value = createDefaultModel();
}
}
function closeDrawer() {
visible.value = false;
}
async function handleSubmit() {
await validate();
const { error } = await (props.operateType === 'edit' ? doPutUser : doPostUser)(model.value as Api.Auth.User);
if (!error) {
$message?.success($t(props.operateType === 'add' ? 'common.addSuccess' : 'common.updateSuccess'));
closeDrawer();
emit('submitted');
}
}
watch(
() => visible.value,
val => {
if (val) {
handleUpdateModelWhenEdit();
resetFields();
}
}
);
</script>
<template>
<ADrawer
v-model:open="visible"
:body-style="{ paddingRight: '0px', paddingTop: '0', paddingBottom: '0' }"
:title="title"
:width="460"
>
<SimpleScrollbar>
<ASpin :spinning="spinning" size="small">
<AForm ref="formRef" py-20px pr-20px layout="vertical" :model="model" :rules="rules">
<AFormItem :label="$t('page.manage.user.userName')" name="userName">
<AInput v-model:value="model.userName" :placeholder="$t('page.manage.user.form.userName')" />
</AFormItem>
<AFormItem :label="$t('page.manage.user.nickName')" name="nickName">
<AInput v-model:value="model.nickName" :placeholder="$t('page.manage.user.form.nickName')" />
</AFormItem>
<AFormItem :label="$t('page.manage.user.email')" name="email">
<AInput v-model:value="model.email" :placeholder="$t('page.manage.user.form.email')" />
</AFormItem>
<AFormItem :label="$t('page.manage.user.phonenumber')" name="phonenumber">
<AInput v-model:value="model.phonenumber" :placeholder="$t('page.manage.user.form.phonenumber')" />
</AFormItem>
<AFormItem v-if="props.operateType === 'add'" :label="$t('page.manage.user.password')" name="password">
<AInput
v-model:value="model.password"
type="password"
:placeholder="$t('page.manage.user.form.password')"
/>
</AFormItem>
<AFormItem :label="$t('page.manage.user.post')" name="postIds">
<ASelect
v-model:value="model.postIds"
:field-names="{ label: 'postName', value: 'postId' }"
mode="multiple"
:options="userPosts"
/>
</AFormItem>
<AFormItem :label="$t('page.manage.user.role')" name="roleIds">
<ASelect
v-model:value="model.roleIds"
:field-names="{ label: 'roleName', value: 'roleId' }"
mode="multiple"
:options="userRoles"
/>
</AFormItem>
<AFormItem :label="$t('page.manage.user.status')" name="status">
<ARadioGroup v-model:value="model.status">
<ARadio v-for="item in enableStatusOptions" :key="item.value" :value="item.value">
{{ $t(item.label) }}
</ARadio>
</ARadioGroup>
</AFormItem>
<AFormItem :label="$t('page.manage.user.dept')" name="deptId">
<ATreeSelect v-model:value="model.deptId" :field-names="{ value: 'id' }" :tree-data="deptTreeData" />
</AFormItem>
<AFormItem :label="$t('page.manage.user.remark')" name="remark">
<ATextarea v-model:value="model.remark" :placeholder="$t('page.manage.user.form.remark')" />
</AFormItem>
</AForm>
</ASpin>
</SimpleScrollbar>
<template #footer>
<div class="flex-y-center justify-end gap-12px">
<AButton @click="closeDrawer">{{ $t('common.cancel') }}</AButton>
<AButton type="primary" @click="handleSubmit">{{ $t('common.confirm') }}</AButton>
</div>
</template>
</ADrawer>
</template>
<style scoped></style>

View File

@@ -0,0 +1,90 @@
<script setup lang="ts">
import { $t } from '@/locales';
import { enableStatusOptions } from '@/constants/business';
defineOptions({
name: 'UserSearch'
});
interface Emits {
(e: 'reset'): void;
(e: 'search'): void;
}
defineProps<{
deptTreeData: Api.Common.CommonTree;
}>();
const emit = defineEmits<Emits>();
const model = defineModel<Api.SystemManage.UserSearchParams>('model', { required: true });
function reset() {
emit('reset');
}
function search() {
emit('search');
}
</script>
<template>
<ACard :title="$t('common.search')" :bordered="false" class="card-wrapper">
<AForm :model="model" :label-width="80">
<ARow :gutter="[16, 16]" wrap>
<ACol :span="24" :md="12" :lg="4">
<AFormItem :label="$t('page.manage.user.userName')" name="userName" class="m-0">
<AInput v-model:value="model.userName" :placeholder="$t('page.manage.user.form.userName')" />
</AFormItem>
</ACol>
<ACol :span="24" :md="12" :lg="4">
<AFormItem :label="$t('page.manage.user.phonenumber')" name="phonenumber" class="m-0">
<AInput v-model:value="model.phonenumber" :placeholder="$t('page.manage.user.form.phonenumber')" />
</AFormItem>
</ACol>
<ACol :span="24" :md="12" :lg="4">
<AFormItem :label="$t('page.manage.user.status')" name="status" class="m-0">
<ASelect v-model:value="model.status" :placeholder="$t('page.manage.user.form.status')" allow-clear>
<ASelectOption v-for="option in enableStatusOptions" :key="option.value" :value="option.value">
{{ $t(option.label) }}
</ASelectOption>
</ASelect>
</AFormItem>
</ACol>
<ACol :span="24" :md="12" :lg="4">
<!-- 部门 -->
<AFormItem :label="$t('page.manage.user.dept')" name="deptId" class="m-0">
<ATreeSelect
v-model:value="model.deptId"
:field-names="{ value: 'id' }"
:placeholder="$t('page.manage.user.form.dept')"
:tree-data="deptTreeData"
:tree-default-expand-all="true"
:allow-clear="true"
/>
</AFormItem>
</ACol>
<ACol :span="24" :md="12" :lg="8">
<AFormItem class="m-0">
<div class="w-full flex-y-center justify-end gap-12px">
<AButton @click="reset">
<div class="flex-y-center gap-8px">
<icon-ic-round-refresh class="text-icon" />
<span>{{ $t('common.reset') }}</span>
</div>
</AButton>
<AButton type="primary" ghost @click="search">
<div class="flex-y-center gap-8px">
<icon-ic-round-search class="text-icon" />
<span>{{ $t('common.search') }}</span>
</div>
</AButton>
</div>
</AFormItem>
</ACol>
</ARow>
</AForm>
</ACard>
</template>
<style scoped></style>

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
import { useAuthStore } from '@/store/modules/auth';
const authStore = useAuthStore();
</script>
<template>
<div>{{ authStore.userInfo.user }}</div>
</template>
<style scoped></style>