feat platform dashboard and organization management

This commit is contained in:
david
2026-07-29 14:51:13 +08:00
parent 35a52c4b06
commit 5c5bc49e86
13 changed files with 466 additions and 94 deletions

View File

@@ -2,11 +2,36 @@ import { request } from './http';
import { resourceApi } from './resource';
/** 平台角色、菜单、账号和工作台接口。 */
export interface DashboardMetric {
name: string;
value: number;
}
export interface DashboardDailyMetric {
date: string;
order_count: number;
order_amount: number;
}
export interface DashboardOverview {
gas_basic_count: number;
delivery_basic_count: number;
staff_count: number;
user_count: number;
product_count: number;
active_contract_count: number;
today_order_count: number;
today_order_amount: number;
pending_ticket_count: number;
paid_amount: number;
order_statuses: DashboardMetric[];
product_statuses: DashboardMetric[];
payment_channels: DashboardMetric[];
recent_orders: DashboardDailyMetric[];
}
export type PlatformRole = { identity: string; role_code: string; name: string; location_scope: string; is_system: boolean; status: number };
export type PlatformMenu = { identity: string; parent_identity?: string; group_code: string; name: string; icon: string; path: string; sort_no: number };
export const platformApi = {
overview: () => request<Record<string, number>>('/dashboard/overview'),
overview: () => request<DashboardOverview>('/dashboard/overview'),
listAccount: () => request<{ total: number; list: Record<string, unknown>[] }>('/platform_account'),
listRole: () => resourceApi.list<PlatformRole>('/platform_role'),
createRole: (data: Record<string, unknown>) => resourceApi.create<PlatformRole>('/platform_role', data),

View File

@@ -48,6 +48,11 @@ export type ResourceUiDefinition = {
canEdit: boolean;
canChangeStatus: boolean;
canArchive: boolean;
accountManagement?: {
resource: `/${string}`;
relationKey: string;
title: string;
};
};
const fieldLabels: Record<string, string> = {
@@ -276,9 +281,9 @@ function define(
const reason = [f('reason', { required: true })];
export const resources: ResourceUiDefinition[] = [
define('gas_basic', '气站', 'writable', [f('code', { required: true }), f('name', { required: true }), f('credit_code'), f('principal'), f('address'), f('longitude'), f('latitude')]),
{ ...define('gas_basic', '气站管理', 'writable', [f('code', { required: true }), f('name', { required: true }), f('credit_code'), f('principal'), f('address'), f('longitude'), f('latitude')]), accountManagement: { resource: '/gas_account', relationKey: 'gas_basic_identity', title: '气站账户' } },
define('gas_account', '气站账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('role_code'), relation('gas_basic_identity', '/gas_basic', true)]),
define('delivery_basic', '配送', 'writable', [f('delivery_code', { required: true }), f('name', { required: true }), relation('gas_basic_identity', '/gas_basic'), f('principal'), f('address')]),
{ ...define('delivery_basic', '配送点管理', 'writable', [f('delivery_code', { required: true }), f('name', { required: true }), relation('gas_basic_identity', '/gas_basic'), f('principal'), f('address')]), accountManagement: { resource: '/delivery_account', relationKey: 'delivery_basic_identity', title: '配送点账户' } },
define('delivery_account', '配送站账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('role_code'), relation('delivery_basic_identity', '/delivery_basic', true)]),
define('staff_account', '工作人员', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('role_code'), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), f('work_status')]),
define('staff_credential', '工作人员资质', 'writable', [relation('staff_account_identity', '/staff_account', true), f('credential_type', { required: true }), f('credential_no'), f('expired_at')]),

View File

@@ -59,13 +59,11 @@ const routes: AppRouteRecordRaw[] = [
{ path: 'reports', name: 'dashboard-reports', component: () => import('@/views/dashboard/ReportPage.vue'), meta: { title: '统计报表', requiresAuth: true, menuCode: 'dashboard_reports' } },
],
},
group('gas', 'gas', '气站管理', 'icon-storage', 10, [
child('gas', 'gas-basic', 'basic', '气站', '/gas_basic', 'gas_basic'),
child('gas', 'gas-account', 'account', '气站账户', '/gas_account', 'gas_account'),
]),
group('delivery', 'delivery', '配送站管理', 'icon-send', 20, [
child('delivery', 'delivery-basic', 'basic', '配送站', '/delivery_basic', 'delivery_basic'),
child('delivery', 'delivery-account', 'account', '配送站账户', '/delivery_account', 'delivery_account'),
group('organization', 'organization', '机构管理', 'icon-storage', 10, [
child('organization', 'gas-basic', 'gas-basic', '气站管理', '/gas_basic', 'gas_basic'),
child('organization', 'delivery-basic', 'delivery-basic', '配送点管理', '/delivery_basic', 'delivery_basic'),
child('organization', 'gas-account', 'gas-account', '气站账户', '/gas_account', 'gas_basic', true, 'gas-basic'),
child('organization', 'delivery-account', 'delivery-account', '配送点账户', '/delivery_account', 'delivery_basic', true, 'delivery-basic'),
]),
group('staff', 'staff', '工作人员管理', 'icon-user-group', 30, [
child('staff', 'staff-account', 'account', '工作人员', '/staff_account', 'staff_account'),

View File

@@ -1,3 +1,125 @@
<template><div class="dashboard"><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] || 0" /></a-card></a-grid-item></a-grid><a-card title="运营概览" :bordered="false" class="intro"><p>跨组织查看可燃气体站配送点服务人员业主客户与待处置安全事件</p></a-card></div></template>
<script setup lang="ts">import { Message } from '@arco-design/web-vue'; import { onMounted, ref } from 'vue'; import { platformApi } from '@/api/platform'; const overview = ref<Record<string, number>>({}); const cards = [{ key: 'gas_basic_count', label: '可燃气体站' }, { key: 'delivery_basic_count', label: '配送点' }, { key: 'staff_count', label: '在岗服务人员' }, { key: 'user_count', label: '业主客户' }, { key: 'pending_safety_count', label: '待处理安全事件' }]; onMounted(async () => { try { overview.value = await platformApi.overview(); } catch (error) { Message.error((error as Error).message); } });</script>
<style scoped lang="less">.dashboard { padding: 20px; } .intro { margin-top: 16px; }</style>
<template>
<div class="dashboard">
<a-spin :loading="loading" class="dashboard-spin">
<a-grid :cols="{ xs: 1, sm: 2, lg: 4 }" :col-gap="16" :row-gap="16">
<a-grid-item v-for="item in cards" :key="item.key">
<a-card :bordered="false" class="metric-card">
<a-statistic :title="item.label" :value="overview[item.key]" :precision="item.money ? 2 : 0"
:prefix="item.money ? '¥' : undefined" show-group-separator />
<div class="metric-hint">{{ item.hint }}</div>
</a-card>
</a-grid-item>
</a-grid>
<a-card title="快捷操作" :bordered="false" class="section-card">
<a-grid :cols="{ xs: 2, sm: 3, md: 4, lg: 6 }" :col-gap="12" :row-gap="12">
<a-grid-item v-for="action in availableActions" :key="action.route">
<a-button long class="quick-action" @click="router.push({ name: action.route })">
<template #icon><component :is="action.icon" /></template>
{{ action.label }}
</a-button>
</a-grid-item>
</a-grid>
</a-card>
<a-grid :cols="{ xs: 1, lg: 2 }" :col-gap="16" :row-gap="16" class="section-card">
<a-grid-item><a-card title="近 7 日配送订单趋势" :bordered="false"><Chart :option="orderTrendOption" height="320px" /></a-card></a-grid-item>
<a-grid-item><a-card title="订单状态分布" :bordered="false"><Chart :option="orderStatusOption" height="320px" /></a-card></a-grid-item>
<a-grid-item><a-card title="气瓶状态分布" :bordered="false"><Chart :option="productStatusOption" height="320px" /></a-card></a-grid-item>
<a-grid-item><a-card title="支付渠道实收金额" :bordered="false"><Chart :option="paymentChannelOption" height="320px" /></a-card></a-grid-item>
</a-grid>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { IconApps, IconFile, IconPlus, IconSettings, IconStorage, IconUser } from '@arco-design/web-vue/es/icon';
import type { EChartsOption } from 'echarts';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { platformApi, type DashboardOverview } from '@/api/platform';
import { useUserStore } from '@/store';
type CountKey = 'gas_basic_count' | 'delivery_basic_count' | 'staff_count' | 'user_count' |
'product_count' | 'active_contract_count' | 'today_order_count' | 'today_order_amount' |
'pending_ticket_count' | 'paid_amount';
const emptyOverview = (): DashboardOverview => ({
gas_basic_count: 0, delivery_basic_count: 0, staff_count: 0, user_count: 0,
product_count: 0, active_contract_count: 0, today_order_count: 0,
today_order_amount: 0, pending_ticket_count: 0, paid_amount: 0,
order_statuses: [], product_statuses: [], payment_channels: [], recent_orders: [],
});
const loading = ref(false);
const overview = ref<DashboardOverview>(emptyOverview());
const router = useRouter();
const userStore = useUserStore();
const cards: { key: CountKey; label: string; hint: string; money?: boolean }[] = [
{ key: 'gas_basic_count', label: '启用气站', hint: '当前正常运营' },
{ key: 'delivery_basic_count', label: '启用配送点', hint: '跨组织汇总' },
{ key: 'staff_count', label: '在岗人员', hint: '当前可接单' },
{ key: 'user_count', label: '启用客户', hint: '有效业主账户' },
{ key: 'product_count', label: '在册气瓶', hint: '已启用资产' },
{ key: 'active_contract_count', label: '生效合同', hint: '当前履约中' },
{ key: 'today_order_count', label: '今日订单', hint: '自然日新增' },
{ key: 'today_order_amount', label: '今日应付金额', hint: '订单口径', money: true },
{ key: 'pending_ticket_count', label: '待受理工单', hint: '客服待办' },
{ key: 'paid_amount', label: '累计实收金额', hint: '支付成功口径', money: true },
];
const actions = [
{ label: '新建气站', route: 'gas-basic', menu: 'gas_basic', icon: IconPlus },
{ label: '配送订单', route: 'gasorder-orders', menu: 'gasorder_basic', icon: IconFile },
{ label: '气瓶档案', route: 'product-info', menu: 'product_info', icon: IconStorage },
{ label: '用户管理', route: 'user-account', menu: 'user_account', icon: IconUser },
{ label: '商城订单', route: 'ec-orders', menu: 'ec_order', icon: IconApps },
{ label: '角色权限', route: 'platform-roles', menu: 'platform_role', icon: IconSettings },
];
const availableActions = computed(() => actions.filter(
(action) => userStore.role === 'root' || userStore.menuCodes.includes(action.menu),
));
const centsToYuan = (value: number) => value / 100;
const pieOption = (data: { name: string; value: number }[]): EChartsOption => ({
tooltip: { trigger: 'item' }, legend: { bottom: 0 },
series: [{ type: 'pie', radius: ['42%', '68%'], center: ['50%', '44%'], data, label: { formatter: '{b}\\n{c}' } }],
});
const orderStatusOption = computed(() => pieOption(overview.value.order_statuses));
const productStatusOption = computed(() => pieOption(overview.value.product_statuses));
const orderTrendOption = computed<EChartsOption>(() => ({
tooltip: { trigger: 'axis' }, legend: { data: ['订单数', '订单金额'] },
grid: { left: 48, right: 58, bottom: 34 },
xAxis: { type: 'category', data: overview.value.recent_orders.map((item) => item.date.slice(5)) },
yAxis: [{ type: 'value', name: '单' }, { type: 'value', name: '元' }],
series: [
{ name: '订单数', type: 'bar', data: overview.value.recent_orders.map((item) => item.order_count) },
{ name: '订单金额', type: 'line', yAxisIndex: 1, smooth: true, data: overview.value.recent_orders.map((item) => centsToYuan(item.order_amount)) },
],
}));
const paymentChannelOption = computed<EChartsOption>(() => ({
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } }, grid: { left: 70, right: 24, bottom: 28 },
xAxis: { type: 'value', name: '元' },
yAxis: { type: 'category', data: overview.value.payment_channels.map((item) => item.name || '未知渠道') },
series: [{ type: 'bar', data: overview.value.payment_channels.map((item) => centsToYuan(item.value)) }],
}));
onMounted(async () => {
loading.value = true;
try {
const data = await platformApi.overview();
overview.value = { ...data, today_order_amount: centsToYuan(data.today_order_amount), paid_amount: centsToYuan(data.paid_amount) };
} catch (error) {
Message.error((error as Error).message);
} finally {
loading.value = false;
}
});
</script>
<style scoped lang="less">
.dashboard { padding: 20px; }
.dashboard-spin { width: 100%; }
.metric-card { min-height: 120px; }
.metric-hint { margin-top: 8px; color: var(--color-text-3); font-size: 12px; }
.section-card { margin-top: 16px; }
.quick-action { height: 52px; justify-content: flex-start; padding: 0 18px; }
</style>

View File

@@ -16,17 +16,28 @@
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { computed, onMounted, ref } from 'vue';
import { platformApi } from '@/api/platform';
import { platformApi, type DashboardOverview } from '@/api/platform';
const loading = ref(false);
const overview = ref<Record<string, number>>({});
const overview = ref<DashboardOverview | null>(null);
const labels: Record<string, string> = {
gas_basic_count: '气站数量',
delivery_basic_count: '配送站数量',
staff_account_count: '工作人员数量',
user_account_count: '用户数量',
staff_count: '在岗工作人员',
user_count: '启用用户',
product_count: '在册气瓶',
active_contract_count: '生效合同',
today_order_count: '今日订单',
today_order_amount: '今日应付金额(分)',
pending_ticket_count: '待受理工单',
paid_amount: '累计实收金额(分)',
};
const metrics = computed(() => Object.entries(overview.value));
const metrics = computed(() => {
if (!overview.value) return [];
return Object.entries(overview.value).filter(
(entry): entry is [string, number] => typeof entry[1] === 'number',
);
});
onMounted(async () => {
loading.value = true;

View File

@@ -2,6 +2,7 @@
<a-card :title="definition.title" :bordered="false">
<template #extra>
<a-space>
<a-button v-if="managedOwnerIdentity" @click="router.back()">返回机构列表</a-button>
<a-button @click="load">刷新</a-button>
<a-button v-if="canCreate" type="primary" @click="openCreate">新建</a-button>
</a-space>
@@ -34,13 +35,17 @@
{{ displayFieldValue(field, record) }}
</template>
</a-table-column>
<a-table-column title="操作" :width="260" fixed="right">
<a-table-column v-if="definition.accountManagement" title="账户数" :width="90">
<template #cell="{ record }">{{ accountCounts[String(record.identity)] ?? 0 }}</template>
</a-table-column>
<a-table-column title="操作" :width="definition.accountManagement ? 350 : 280" fixed="right">
<template #cell="{ record }">
<a-space>
<a-button v-if="definition.accountManagement" size="mini" type="primary" @click="manageAccounts(record)">账户管理</a-button>
<a-button size="mini" @click="openDetail(record)">详情</a-button>
<a-button v-if="canEdit" size="mini" :disabled="isProtectedRecord(record)" @click="openEdit(record)">编辑</a-button>
<a-button v-if="canChangeStatus" size="mini" :disabled="isProtectedRecord(record)" @click="openStatus(record)">状态</a-button>
<a-button v-if="canArchive" size="mini" status="danger" :disabled="isProtectedRecord(record)" @click="confirmArchive(record)">归档</a-button>
<a-button v-if="canChangeStatus" size="mini" :disabled="isProtectedRecord(record)" @click="openStatus(record)">审核</a-button>
<a-button v-if="canArchive" size="mini" status="danger" :disabled="isProtectedRecord(record)" @click="confirmArchive(record)">删除</a-button>
</a-space>
</template>
</a-table-column>
@@ -147,6 +152,7 @@ const page = ref(Math.max(1, Number(route.query.page) || 1));
const pageSize = 20;
const total = ref(0);
const list = ref<Row[]>([]);
const accountCounts = ref<Record<string, number>>({});
const filters = reactive({
keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '',
});
@@ -163,6 +169,12 @@ const roleOptions = ref<PlatformRole[]>([]);
const relationOptions = reactive<Record<string, Row[]>>({});
const relationLoading = reactive<Record<string, boolean>>({});
const relationSearchTimers = new Map<string, ReturnType<typeof setTimeout>>();
const managedOwnerIdentity = computed(() =>
typeof route.query.owner_identity === 'string' ? route.query.owner_identity : '',
);
const managedRelationKey = computed(() =>
typeof route.query.relation_key === 'string' ? route.query.relation_key : '',
);
const platformRootWriteResources = new Set([
'platform_account',
'platform_role',
@@ -263,14 +275,30 @@ function resetForm(data?: Row) {
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;
if (managedOwnerIdentity.value && managedRelationKey.value) {
const accounts = await loadAllRows(props.definition.resource);
const keyword = filters.keyword.trim().toLowerCase();
const filtered = accounts.filter(
(row) =>
String(row[managedRelationKey.value] ?? '') === managedOwnerIdentity.value &&
(!keyword ||
['username', 'display_name', 'role_code'].some((key) =>
String(row[key] ?? '').toLowerCase().includes(keyword),
)),
);
list.value = filtered.slice((page.value - 1) * pageSize, page.value * pageSize);
total.value = filtered.length;
} else {
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;
}
await loadAccountCounts();
} catch (error) {
Message.error((error as Error).message);
} finally {
@@ -278,8 +306,49 @@ async function load() {
}
}
async function loadAllRows(resource: string) {
const rows: Row[] = [];
for (let currentPage = 1; currentPage <= 100; currentPage += 1) {
const result = await resourceApi.list<Row>(resource, currentPage, 100);
rows.push(...result.list);
if (rows.length >= result.total || result.list.length < 100) break;
}
return rows;
}
async function loadAccountCounts() {
const management = props.definition.accountManagement;
if (!management) {
accountCounts.value = {};
return;
}
const accounts = await loadAllRows(management.resource);
accountCounts.value = accounts.reduce<Record<string, number>>((counts, account) => {
const identity = String(account[management.relationKey] ?? '');
if (identity) counts[identity] = (counts[identity] ?? 0) + 1;
return counts;
}, {});
}
function manageAccounts(row: Row) {
const management = props.definition.accountManagement;
if (!management) return;
const routeName = management.resource === '/gas_account'
? 'organization-gas-account'
: 'organization-delivery-account';
router.push({
name: routeName,
query: {
owner_identity: String(row.identity ?? ''),
relation_key: management.relationKey,
},
});
}
async function syncQuery() {
const query: Record<string, string> = {};
if (managedOwnerIdentity.value) query.owner_identity = managedOwnerIdentity.value;
if (managedRelationKey.value) query.relation_key = managedRelationKey.value;
if (page.value > 1) query.page = String(page.value);
if (filters.keyword.trim()) query.keyword = filters.keyword.trim();
await router.replace({ query });
@@ -299,6 +368,9 @@ async function resetSearch() {
function openCreate() {
editingIdentity.value = '';
resetForm();
if (managedOwnerIdentity.value && managedRelationKey.value) {
form[managedRelationKey.value] = managedOwnerIdentity.value;
}
formVisible.value = true;
}
@@ -429,7 +501,7 @@ async function save() {
function openStatus(row: Row) {
detail.value = row;
openDetailAction({
name: '修改状态',
name: '审核状态',
resource: `${props.definition.resource}/:identity/status`,
method: 'PATCH',
fields: [
@@ -441,7 +513,6 @@ function openStatus(row: Row) {
options: [
{ label: '启用', value: 1 },
{ label: '停用', value: 2 },
{ label: '归档', value: 3 },
],
},
],
@@ -450,15 +521,15 @@ function openStatus(row: Row) {
function confirmArchive(row: Row) {
Modal.warning({
title: '确认归档',
content: '归档后该记录将不再参与日常业务。',
title: '确认删除',
content: '删除后该记录将被归档,不再参与日常业务。',
onOk: async () => {
try {
await resourceApi.archive(
props.definition.resource,
String(row.identity),
);
Message.success('已归档');
Message.success('已删除');
await load();
} catch (error) {
Message.error((error as Error).message);