feat: implement gas and delivery admin systems
This commit is contained in:
38
frontend/gas_admin/src/views/dashboard/DashboardPage.vue
Normal file
38
frontend/gas_admin/src/views/dashboard/DashboardPage.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<a-spin :loading="loading" style="width: 100%">
|
||||
<a-grid :cols="{ xs: 1, sm: 2, lg: 5 }" :col-gap="16" :row-gap="16">
|
||||
<a-grid-item v-for="item in cards" :key="item.key">
|
||||
<a-card :bordered="false">
|
||||
<a-statistic :title="item.label" :value="overview[item.key]" show-group-separator />
|
||||
</a-card>
|
||||
</a-grid-item>
|
||||
</a-grid>
|
||||
</a-spin>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { request } from '@/api/http';
|
||||
|
||||
type Overview = Record<'delivery_count' | 'staff_count' | 'user_count' | 'contract_count' | 'order_count', number>;
|
||||
const loading = ref(false);
|
||||
const overview = reactive<Overview>({ delivery_count: 0, staff_count: 0, user_count: 0, contract_count: 0, order_count: 0 });
|
||||
const cards: Array<{ key: keyof Overview; label: string }> = [
|
||||
{ key: 'delivery_count', label: '配送点' },
|
||||
{ key: 'staff_count', label: '工作人员' },
|
||||
{ key: 'user_count', label: '服务用户' },
|
||||
{ key: 'contract_count', label: '配送合同' },
|
||||
{ key: 'order_count', label: '燃气配送订单' },
|
||||
];
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
Object.assign(overview, await request<Overview>('/dashboard/overview'));
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
31
frontend/gas_admin/src/views/dashboard/ReportPage.vue
Normal file
31
frontend/gas_admin/src/views/dashboard/ReportPage.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<a-card title="订单状态统计" :bordered="false">
|
||||
<a-spin :loading="loading">
|
||||
<a-table :data="rows" :pagination="false" row-key="status">
|
||||
<a-table-column title="订单状态" data-index="status" />
|
||||
<a-table-column title="订单数量" data-index="count" />
|
||||
</a-table>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { request } from '@/api/http';
|
||||
|
||||
type Row = { status: number; count: number };
|
||||
const loading = ref(false);
|
||||
const rows = ref<Row[]>([]);
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await request<{ orders_by_status: Row[] }>('/dashboard/reports');
|
||||
rows.value = result.orders_by_status;
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
47
frontend/gas_admin/src/views/invitation/QrcodePage.vue
Normal file
47
frontend/gas_admin/src/views/invitation/QrcodePage.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<a-card title="用户邀请注册" :bordered="false">
|
||||
<a-spin :loading="loading">
|
||||
<a-space direction="vertical" align="center" fill :size="20">
|
||||
<img v-if="dataUrl" :src="dataUrl" class="qrcode" alt="用户注册邀请二维码" />
|
||||
<a-typography-text copyable>{{ registerUrl }}</a-typography-text>
|
||||
<a-button type="primary" :disabled="!dataUrl" @click="download">下载二维码</a-button>
|
||||
<a-alert>二维码根据当前气站 identity 实时生成,不保存邀请记录。</a-alert>
|
||||
</a-space>
|
||||
</a-spin>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import QRCode from 'qrcode';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { request } from '@/api/http';
|
||||
|
||||
const loading = ref(false);
|
||||
const registerUrl = ref('');
|
||||
const dataUrl = ref('');
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await request<{ register_url: string }>('/invitation/qrcode');
|
||||
registerUrl.value = result.register_url;
|
||||
dataUrl.value = await QRCode.toDataURL(result.register_url, { width: 320, margin: 2 });
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function download() {
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl.value;
|
||||
link.download = '气站用户邀请二维码.png';
|
||||
link.click();
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qrcode { width: 320px; height: 320px; max-width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="banner">
|
||||
<div class="banner-inner">
|
||||
<a-carousel class="carousel" animation-name="fade">
|
||||
<a-carousel-item v-for="item in carouselItem" :key="item.slogan">
|
||||
<div :key="item.slogan" class="carousel-item">
|
||||
<div class="carousel-title">{{ item.slogan }}</div>
|
||||
<div class="carousel-sub-title">{{ item.subSlogan }}</div>
|
||||
<img class="carousel-image" :src="item.image" />
|
||||
</div>
|
||||
</a-carousel-item>
|
||||
</a-carousel>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import bannerImage from '@/assets/images/login-banner.png';
|
||||
|
||||
const carouselItem = [
|
||||
{
|
||||
slogan: '统一运营',
|
||||
subSlogan: '覆盖气站、配送站、人员、用户与产品',
|
||||
image: bannerImage,
|
||||
},
|
||||
{
|
||||
slogan: '气体配送闭环',
|
||||
subSlogan: '合同、气瓶、订单、轨迹、确认与支付',
|
||||
image: bannerImage,
|
||||
},
|
||||
{
|
||||
slogan: '资金安全',
|
||||
subSlogan: '统一钱包、流水、退款与提现审核',
|
||||
image: bannerImage,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&-inner {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.carousel {
|
||||
height: 100%;
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&-title {
|
||||
color: var(--color-fill-1);
|
||||
font-weight: 500;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
&-sub-title {
|
||||
margin-top: 8px;
|
||||
color: var(--color-text-3);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
&-image {
|
||||
width: 320px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
160
frontend/gas_admin/src/views/login/components/login-form.vue
Normal file
160
frontend/gas_admin/src/views/login/components/login-form.vue
Normal file
@@ -0,0 +1,160 @@
|
||||
<template>
|
||||
<div class="login-form-wrapper">
|
||||
<div class="login-form-title">登录和气平台</div>
|
||||
<div class="login-form-sub-title">使用平台管理员账户登录</div>
|
||||
<a-form
|
||||
ref="loginForm"
|
||||
:model="userInfo"
|
||||
class="login-form"
|
||||
layout="vertical"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<a-form-item
|
||||
field="username"
|
||||
:rules="[{ required: true, message: '请输入用户名' }]"
|
||||
:validate-trigger="['change', 'blur']"
|
||||
hide-label
|
||||
>
|
||||
<a-input
|
||||
v-model="userInfo.username"
|
||||
placeholder="用户名"
|
||||
>
|
||||
<template #prefix>
|
||||
<icon-user />
|
||||
</template>
|
||||
</a-input>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
field="password"
|
||||
:rules="[{ required: true, message: '请输入密码' }]"
|
||||
:validate-trigger="['change', 'blur']"
|
||||
hide-label
|
||||
>
|
||||
<a-input-password
|
||||
v-model="userInfo.password"
|
||||
placeholder="密码"
|
||||
allow-clear
|
||||
>
|
||||
<template #prefix>
|
||||
<icon-lock />
|
||||
</template>
|
||||
</a-input-password>
|
||||
</a-form-item>
|
||||
<a-space :size="16" direction="vertical">
|
||||
<div class="login-form-password-actions">
|
||||
<a-checkbox
|
||||
checked="rememberPassword"
|
||||
:model-value="loginConfig.rememberPassword"
|
||||
@change="setRememberPassword as any"
|
||||
>
|
||||
记住用户名
|
||||
</a-checkbox>
|
||||
</div>
|
||||
<div v-if="errorMessage" class="login-form-error-msg">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
<a-button type="primary" html-type="submit" long :loading="loading">
|
||||
登录
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import type { ValidatedError } from '@arco-design/web-vue/es/form/interface';
|
||||
import { useStorage } from '@vueuse/core';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { LoginData } from '@/api/auth';
|
||||
import useLoading from '@/hooks/loading';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
const router = useRouter();
|
||||
const errorMessage = ref('');
|
||||
const { loading, setLoading } = useLoading();
|
||||
const userStore = useUserStore();
|
||||
|
||||
const loginConfig = useStorage('login-config', {
|
||||
rememberPassword: true,
|
||||
username: 'admin',
|
||||
password: '',
|
||||
});
|
||||
const userInfo = reactive({
|
||||
username: loginConfig.value.username,
|
||||
password: loginConfig.value.password,
|
||||
});
|
||||
|
||||
const handleSubmit = async ({
|
||||
errors,
|
||||
values,
|
||||
}: {
|
||||
errors: Record<string, ValidatedError> | undefined;
|
||||
values: Record<string, any>;
|
||||
}) => {
|
||||
if (loading.value) return;
|
||||
if (!errors) {
|
||||
errorMessage.value = '';
|
||||
setLoading(true);
|
||||
try {
|
||||
await userStore.login(values as LoginData);
|
||||
const { redirect, ...othersQuery } = router.currentRoute.value.query;
|
||||
router.push({
|
||||
name: (redirect as string) || 'dashboard-overview',
|
||||
query: {
|
||||
...othersQuery,
|
||||
},
|
||||
});
|
||||
Message.success('登录成功');
|
||||
const { rememberPassword } = loginConfig.value;
|
||||
const { username } = values;
|
||||
loginConfig.value.username = rememberPassword ? username : '';
|
||||
loginConfig.value.password = '';
|
||||
} catch (err) {
|
||||
errorMessage.value = (err as Error).message;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
const setRememberPassword = (value: boolean) => {
|
||||
loginConfig.value.rememberPassword = value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.login-form {
|
||||
&-wrapper {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
&-title {
|
||||
color: var(--color-text-1);
|
||||
font-weight: 500;
|
||||
font-size: 24px;
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
&-sub-title {
|
||||
color: var(--color-text-3);
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
&-error-msg {
|
||||
height: 32px;
|
||||
color: rgb(var(--red-6));
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
&-password-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&-register-btn {
|
||||
color: var(--color-text-3) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
79
frontend/gas_admin/src/views/login/index.vue
Normal file
79
frontend/gas_admin/src/views/login/index.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="container">
|
||||
<div class="logo">
|
||||
<img alt="和气平台" :src="logoUrl" />
|
||||
<div class="logo-text">禾气气站管理系统</div>
|
||||
</div>
|
||||
<LoginBanner />
|
||||
<div class="content">
|
||||
<div class="content-inner">
|
||||
<LoginForm />
|
||||
</div>
|
||||
<div class="footer">
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import logoUrl from '@/assets/logo.svg?url';
|
||||
import Footer from '@/components/footer/index.vue';
|
||||
import LoginBanner from './components/login-banner.vue';
|
||||
import LoginForm from './components/login-form.vue';
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
|
||||
.banner {
|
||||
width: 550px;
|
||||
background: linear-gradient(163.85deg, #1d2129 0%, #00308f 100%);
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
left: 22px;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
&-text {
|
||||
margin-right: 4px;
|
||||
margin-left: 4px;
|
||||
color: var(--color-fill-1);
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="less" scoped>
|
||||
// responsive
|
||||
@media (max-width: @screen-lg) {
|
||||
.container {
|
||||
.banner {
|
||||
width: 25%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
21
frontend/gas_admin/src/views/login/locale/en-US.ts
Normal file
21
frontend/gas_admin/src/views/login/locale/en-US.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
'login.form.title': 'Login to Arco Design Pro',
|
||||
'login.form.userName.errMsg': 'Username cannot be empty',
|
||||
'login.form.password.errMsg': 'Password cannot be empty',
|
||||
'login.form.login.errMsg': 'Login error, refresh and try again',
|
||||
'login.form.login.success': 'welcome to use',
|
||||
'login.form.userName.placeholder': 'Username: admin',
|
||||
'login.form.password.placeholder': 'Password: admin',
|
||||
'login.form.rememberPassword': 'Remember password',
|
||||
'login.form.forgetPassword': 'Forgot password',
|
||||
'login.form.login': 'login',
|
||||
'login.form.register': 'register account',
|
||||
'login.banner.slogan1': 'Out-of-the-box high-quality template',
|
||||
'login.banner.subSlogan1':
|
||||
'Rich page templates, covering most typical business scenarios',
|
||||
'login.banner.slogan2': 'Built-in solutions to common problems',
|
||||
'login.banner.subSlogan2':
|
||||
'Internationalization, routing configuration, state management everything',
|
||||
'login.banner.slogan3': 'Access visualization enhancement tool AUX',
|
||||
'login.banner.subSlogan3': 'Realize flexible block development',
|
||||
};
|
||||
19
frontend/gas_admin/src/views/login/locale/zh-CN.ts
Normal file
19
frontend/gas_admin/src/views/login/locale/zh-CN.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export default {
|
||||
'login.form.title': '登录 Arco Design Pro',
|
||||
'login.form.userName.errMsg': '用户名不能为空',
|
||||
'login.form.password.errMsg': '密码不能为空',
|
||||
'login.form.login.errMsg': '登录出错,轻刷新重试',
|
||||
'login.form.login.success': '欢迎使用',
|
||||
'login.form.userName.placeholder': '用户名:admin',
|
||||
'login.form.password.placeholder': '密码:admin',
|
||||
'login.form.rememberPassword': '记住密码',
|
||||
'login.form.forgetPassword': '忘记密码',
|
||||
'login.form.login': '登录',
|
||||
'login.form.register': '注册账号',
|
||||
'login.banner.slogan1': '开箱即用的高质量模板',
|
||||
'login.banner.subSlogan1': '丰富的的页面模板,覆盖大多数典型业务场景',
|
||||
'login.banner.slogan2': '内置了常见问题的解决方案',
|
||||
'login.banner.subSlogan2': '国际化,路由配置,状态管理应有尽有',
|
||||
'login.banner.slogan3': '接入可视化增强工具AUX',
|
||||
'login.banner.subSlogan3': '实现灵活的区块式开发',
|
||||
};
|
||||
30
frontend/gas_admin/src/views/not-found/index.vue
Normal file
30
frontend/gas_admin/src/views/not-found/index.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<div class="content">
|
||||
<a-result class="result" status="404" :subtitle="'not found'"> </a-result>
|
||||
<div class="operation-row">
|
||||
<a-button key="back" type="primary" @click="back"> back </a-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const back = () => {
|
||||
// warning: Go to the node that has the permission
|
||||
router.push({ name: 'Workplace' });
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.content {
|
||||
// padding-top: 100px;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-left: -95px;
|
||||
margin-top: -121px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
16
frontend/gas_admin/src/views/redirect/index.vue
Normal file
16
frontend/gas_admin/src/views/redirect/index.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
const gotoPath = route.params.path as string;
|
||||
|
||||
router.replace({ path: gotoPath });
|
||||
</script>
|
||||
|
||||
<style scoped lang="less"></style>
|
||||
1175
frontend/gas_admin/src/views/shared/CrudListPage.vue
Normal file
1175
frontend/gas_admin/src/views/shared/CrudListPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
147
frontend/gas_admin/src/views/shared/ReadOnlyListPage.vue
Normal file
147
frontend/gas_admin/src/views/shared/ReadOnlyListPage.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<a-card :title="definition.title" :bordered="false">
|
||||
<template #extra><a-button @click="load">刷新</a-button></template>
|
||||
<a-form :model="filters" layout="inline" class="filters" @submit.prevent="load">
|
||||
<a-form-item label="关键字"><a-input v-model="filters.keyword" allow-clear placeholder="服务端筛选" /></a-form-item>
|
||||
<a-button type="primary" html-type="submit">查询</a-button>
|
||||
</a-form>
|
||||
<a-table :data="list" :loading="loading" :pagination="false" row-key="identity">
|
||||
<template #columns>
|
||||
<a-table-column title="业务标识" data-index="identity" :width="220" ellipsis tooltip />
|
||||
<a-table-column v-for="field in displayFields" :key="field.key" :title="field.label" :data-index="field.key" ellipsis tooltip />
|
||||
<a-table-column title="操作" :width="90"><template #cell="{ record }"><a-button size="mini" @click="openDetail(record)">详情</a-button></template></a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
<div class="pagination"><a-pagination :total="total" :current="page" :page-size="pageSize" show-total @change="changePage" /></div>
|
||||
</a-card>
|
||||
<a-drawer :visible="detailVisible" title="详情" :width="540" @cancel="detailVisible = false">
|
||||
<a-descriptions :column="1" bordered><a-descriptions-item v-for="[key, value] in detailEntries" :key="key" :label="key">{{ value ?? '-' }}</a-descriptions-item></a-descriptions>
|
||||
<a-space v-if="definition.detailActions?.length" class="detail-actions">
|
||||
<a-button v-for="action in definition.detailActions" :key="action.name" :status="action.payload?.status === 'rejected' ? 'danger' : 'normal'" type="primary" @click="openDetailAction(action)">{{ action.name }}</a-button>
|
||||
</a-space>
|
||||
</a-drawer>
|
||||
<a-modal :visible="actionVisible" :title="activeAction?.name" @cancel="actionVisible = false" @ok="submitDetailAction">
|
||||
<a-form :model="actionForm" layout="vertical">
|
||||
<a-form-item v-for="field in activeAction?.fields" :key="field.key" :label="field.label" :required="field.required">
|
||||
<a-textarea v-if="field.type === 'textarea'" v-model="actionForm[field.key]" />
|
||||
<a-input v-else v-model="actionForm[field.key]" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { resourceApi } from '@/api/resource';
|
||||
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
|
||||
import type { DetailAction, ResourceUiDefinition } from '@/api/resources';
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
const props = defineProps<{ definition: ResourceUiDefinition }>();
|
||||
const loading = ref(false);
|
||||
const page = ref(1);
|
||||
const pageSize = 20;
|
||||
const total = ref(0);
|
||||
const list = ref<Row[]>([]);
|
||||
const filters = reactive({ keyword: '' });
|
||||
const detail = ref<Row>({});
|
||||
const detailVisible = ref(false);
|
||||
const actionVisible = ref(false);
|
||||
const activeAction = ref<DetailAction>();
|
||||
const actionForm = reactive<Record<string, any>>({});
|
||||
const displayFields = computed(() =>
|
||||
props.definition.fields.filter((field) => field.key !== 'status'),
|
||||
);
|
||||
const detailEntries = computed(() =>
|
||||
Object.entries(detail.value).filter(
|
||||
([key]) => key !== 'id' && !key.endsWith('_id'),
|
||||
),
|
||||
);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const result = await resourceApi.list<Row>(
|
||||
props.definition.resource,
|
||||
page.value,
|
||||
pageSize,
|
||||
filters.keyword ? { keyword: filters.keyword } : {},
|
||||
);
|
||||
list.value = result.list;
|
||||
total.value = result.total;
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(row: Row) {
|
||||
try {
|
||||
detail.value = await resourceApi.detail<Row>(
|
||||
props.definition.resource,
|
||||
String(row.identity),
|
||||
);
|
||||
detailVisible.value = true;
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function openDetailAction(action: DetailAction) {
|
||||
activeAction.value = action;
|
||||
for (const field of action.fields) actionForm[field.key] = undefined;
|
||||
actionVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitDetailAction() {
|
||||
const action = activeAction.value;
|
||||
if (!action) return;
|
||||
if (
|
||||
action.fields.some(
|
||||
(field) => field.required && isMissingField(actionForm[field.key]),
|
||||
)
|
||||
) {
|
||||
Message.warning('请填写必填字段');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = {
|
||||
...action.payload,
|
||||
...buildResourcePayload(action.fields, actionForm),
|
||||
};
|
||||
await resourceApi.create(
|
||||
action.resource.replace(':identity', String(detail.value.identity)),
|
||||
payload,
|
||||
);
|
||||
Message.success('审批完成');
|
||||
actionVisible.value = false;
|
||||
await openDetail(detail.value);
|
||||
await load();
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
async function changePage(next: number) {
|
||||
page.value = next;
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.filters {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.detail-actions {
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
23
frontend/gas_admin/src/views/shared/ResourcePage.vue
Normal file
23
frontend/gas_admin/src/views/shared/ResourcePage.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<TreePage
|
||||
v-if="definition.pageKind === 'tree'"
|
||||
:key="String(route.name)"
|
||||
:definition="definition"
|
||||
/>
|
||||
<CrudListPage
|
||||
v-else
|
||||
:key="String(route.name)"
|
||||
:definition="definition"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { getResource } from '@/api/resources';
|
||||
import CrudListPage from './CrudListPage.vue';
|
||||
import TreePage from './TreePage.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const definition = computed(() => getResource(String(route.meta.resource)));
|
||||
</script>
|
||||
187
frontend/gas_admin/src/views/shared/TreePage.vue
Normal file
187
frontend/gas_admin/src/views/shared/TreePage.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<a-card :title="definition.title" :bordered="false">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button @click="load">刷新</a-button>
|
||||
<a-button v-if="canCreate" type="primary" @click="openCreate">新增</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<a-tree :data="tree" :loading="loading" :field-names="{ key: 'identity', title: 'name', children: 'children' }">
|
||||
<template #title="node">
|
||||
<a-space>
|
||||
{{ node.title }}
|
||||
<a-button v-if="canEdit" size="mini" @click.stop="openEdit(node)">编辑</a-button>
|
||||
<a-button v-if="canChangeStatus" size="mini" @click.stop="confirmStatus(node)">{{ node.status === 1 ? '停用' : '启用' }}</a-button>
|
||||
<a-button v-if="canArchive" size="mini" status="danger" @click.stop="confirmArchive(node)">归档</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-tree>
|
||||
</a-card>
|
||||
<a-drawer :visible="formVisible" :title="editingIdentity ? `编辑${definition.title}` : `新增${definition.title}`" :width="480" @cancel="formVisible = false" @ok="save">
|
||||
<a-form :model="form" layout="vertical">
|
||||
<a-form-item v-for="field in definition.fields" :key="field.key" :label="field.label" :required="field.required">
|
||||
<a-input-number v-if="field.type === 'number' || field.type === 'money'" v-model="form[field.key]" :precision="field.type === 'money' ? 2 : 0" />
|
||||
<a-switch v-else-if="field.type === 'boolean'" v-model="form[field.key]" />
|
||||
<a-select v-else-if="field.type === 'identity'" v-model="form[field.key]" allow-clear allow-search>
|
||||
<a-option v-for="option in list.filter((item) => item.identity !== editingIdentity)" :key="option.identity" :value="option.identity">
|
||||
{{ option.name ?? option.group_code ?? option.identity }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<a-input v-else v-model="form[field.key]" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { resourceApi } from '@/api/resource';
|
||||
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
|
||||
import type { ResourceUiDefinition } from '@/api/resources';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
type Node = Record<string, unknown> & {
|
||||
identity: string;
|
||||
parent_identity?: string;
|
||||
children: Node[];
|
||||
};
|
||||
const props = defineProps<{ definition: ResourceUiDefinition }>();
|
||||
const userStore = useUserStore();
|
||||
const loading = ref(false);
|
||||
const list = ref<Node[]>([]);
|
||||
const formVisible = ref(false);
|
||||
const editingIdentity = ref('');
|
||||
const form = reactive<Record<string, any>>({});
|
||||
const rootAllowed = computed(
|
||||
() => props.definition.name !== 'platform_menu' || userStore.role === 'root',
|
||||
);
|
||||
const canCreate = computed(
|
||||
() => props.definition.canCreate && rootAllowed.value,
|
||||
);
|
||||
const canEdit = computed(() => props.definition.canEdit && rootAllowed.value);
|
||||
const canChangeStatus = computed(
|
||||
() => props.definition.canChangeStatus && rootAllowed.value,
|
||||
);
|
||||
const canArchive = computed(
|
||||
() => props.definition.canArchive && rootAllowed.value,
|
||||
);
|
||||
const tree = computed(() => {
|
||||
const byIdentity = new Map<string, Node>();
|
||||
const roots: Node[] = [];
|
||||
for (const item of list.value) {
|
||||
// Arco Tree 把 data.icon 当作图标渲染函数;平台菜单接口返回的是字符串图标名,
|
||||
// 直接透传会导致 renderFunc is not a function 并破坏后续路由渲染。
|
||||
const { icon, ...data } = item;
|
||||
byIdentity.set(item.identity, {
|
||||
...data,
|
||||
...(typeof icon === 'string' ? { icon_name: icon } : {}),
|
||||
children: [],
|
||||
});
|
||||
}
|
||||
for (const item of byIdentity.values()) {
|
||||
const parent = item.parent_identity
|
||||
? byIdentity.get(item.parent_identity)
|
||||
: undefined;
|
||||
if (parent) parent.children.push(item);
|
||||
else roots.push(item);
|
||||
}
|
||||
return roots;
|
||||
});
|
||||
|
||||
function reset(data?: Node) {
|
||||
for (const field of props.definition.fields) {
|
||||
const value = data?.[field.key];
|
||||
form[field.key] = value == null ? undefined : value;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmStatus(node: Node) {
|
||||
const status = node.status === 1 ? 2 : 1;
|
||||
Modal.warning({
|
||||
title: status === 1 ? '确认启用' : '确认停用',
|
||||
content: `确定要${status === 1 ? '启用' : '停用'}“${String(node.name ?? node.identity)}”吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await resourceApi.updateStatus(
|
||||
props.definition.resource,
|
||||
node.identity,
|
||||
status,
|
||||
);
|
||||
Message.success('状态已更新');
|
||||
await load();
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingIdentity.value = '';
|
||||
reset();
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(node: Node) {
|
||||
editingIdentity.value = node.identity;
|
||||
reset(node);
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (
|
||||
props.definition.fields.some(
|
||||
(field) => field.required && isMissingField(form[field.key]),
|
||||
)
|
||||
) {
|
||||
Message.warning('请填写必填字段');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = buildResourcePayload(props.definition.fields, form);
|
||||
if (editingIdentity.value)
|
||||
await resourceApi.update(
|
||||
props.definition.resource,
|
||||
editingIdentity.value,
|
||||
payload,
|
||||
);
|
||||
else await resourceApi.create(props.definition.resource, payload);
|
||||
formVisible.value = false;
|
||||
await load();
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmArchive(node: Node) {
|
||||
Modal.warning({
|
||||
title: '确认归档',
|
||||
content: `归档“${String(node.name ?? node.identity)}”后,其历史数据仍会保留。`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await resourceApi.archive(props.definition.resource, node.identity);
|
||||
Message.success('已归档');
|
||||
await load();
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
list.value = (
|
||||
await resourceApi.list<Node>(props.definition.resource, 1, 500)
|
||||
).list;
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
Reference in New Issue
Block a user