feat: implement gas and delivery admin systems

This commit is contained in:
david
2026-07-30 14:16:58 +08:00
parent 1093385f95
commit f5ecc0d973
252 changed files with 49385 additions and 363 deletions

View 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>

View 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>

View 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>

View File

@@ -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>

View 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>

View 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>

View 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',
};

View 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': '实现灵活的区块式开发',
};

View 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>

View 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>

File diff suppressed because it is too large Load Diff

View 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>

View 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>

View 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>