feat(frontend): improve admin usability and light theme

This commit is contained in:
2026-08-04 21:50:20 +08:00
parent 402f050b17
commit e72750235d
15 changed files with 256 additions and 60 deletions

View File

@@ -14,6 +14,15 @@ body {
-webkit-font-smoothing: antialiased;
}
body:not([arco-theme='dark']) {
--color-text-1: #172033;
--color-text-2: #344054;
--color-text-3: #566176;
--color-text-4: #737e91;
color: var(--color-text-1);
}
.echarts-tooltip-diy {
background: linear-gradient(
304.17deg,

View File

@@ -12,6 +12,14 @@ export interface HttpResponse<T = unknown> {
}
let initialized = false;
let sessionExpiredDialogVisible = false;
function networkErrorMessage(error: unknown) {
if (!axios.isAxiosError<HttpResponse>(error)) return '网络请求失败,请稍后重试';
if (!error.response) return '无法连接服务器,请检查网络后重试';
if (error.response.status === 403) return '当前账号无权执行此操作';
return error.response.data?.msg || `请求失败(${error.response.status}`;
}
export function setupHttp() {
if (initialized) {
@@ -47,27 +55,35 @@ export function setupHttp() {
[50008, 50012, 50014].includes(res.code) &&
response.config.url !== '/api/user/info'
) {
Modal.error({
if (!sessionExpiredDialogVisible) {
sessionExpiredDialogVisible = true;
Modal.error({
title: '登录已失效',
content: '当前登录状态已失效,请重新登录。',
content: '当前登录状态已失效重新登录后将返回当前页面。',
okText: '重新登录',
async onOk() {
const userStore = useUserStore();
sessionStorage.setItem('auth-redirect', `${window.location.pathname}${window.location.search}`);
await userStore.logout();
window.location.reload();
window.location.assign('/login');
},
});
onCancel() {
sessionExpiredDialogVisible = false;
},
});
}
}
return Promise.reject(new Error(res.msg || '请求失败'));
}
return res as unknown as AxiosResponse<HttpResponse>;
},
(error) => {
const content = networkErrorMessage(error);
Message.error({
content: error.msg || '网络请求失败',
content,
duration: 5 * 1000,
});
return Promise.reject(error);
return Promise.reject(new Error(content));
},
);
}

View File

@@ -1,8 +1,16 @@
<template>
<a-spin :loading="loading" style="width: 100%">
<a-alert v-if="errorMessage" type="error" show-icon class="dashboard-alert">
{{ errorMessage }}
<template #action><a-button size="small" @click="loadOverview">重新加载</a-button></template>
</a-alert>
<div class="dashboard-meta">当前配送点业务汇总 · 更新时间{{ updatedAt || '尚未加载' }}</div>
<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"><a-statistic :title="item.label" :value="overview[item.key]" show-group-separator /></a-card>
<a-card :bordered="false" class="metric-card">
<a-statistic :title="item.label" :value="overview[item.key]" show-group-separator />
<div class="metric-hint">{{ item.hint }}</div>
</a-card>
</a-grid-item>
</a-grid>
</a-spin>
@@ -13,15 +21,29 @@ import { onMounted, reactive, ref } from 'vue';
import { request } from '@/api/http';
type Overview = Record<'staff_count' | 'user_count' | 'contract_count' | 'order_count', number>;
const loading = ref(false);
const errorMessage = ref('');
const updatedAt = ref('');
const overview = reactive<Overview>({ staff_count: 0, user_count: 0, contract_count: 0, order_count: 0 });
const cards: Array<{ key: keyof Overview; label: string }> = [
{ key: 'staff_count', label: '配送人员' }, { key: 'user_count', label: '服务用户' },
{ key: 'contract_count', label: '配送合同' }, { key: 'order_count', label: '配送订单' },
const cards: Array<{ key: keyof Overview; label: string; hint: string }> = [
{ key: 'staff_count', label: '配送人员', hint: '本站关联配送人员' }, { key: 'user_count', label: '服务用户', hint: '本站有效服务关系' },
{ key: 'contract_count', label: '配送合同', hint: '本站合同总量' }, { key: 'order_count', label: '配送订单', hint: '本站订单总量' },
];
onMounted(async () => {
async function loadOverview() {
loading.value = true;
try { Object.assign(overview, await request<Overview>('/dashboard/overview')); }
catch (error) { Message.error((error as Error).message); }
errorMessage.value = '';
try {
Object.assign(overview, await request<Overview>('/dashboard/overview'));
updatedAt.value = new Date().toLocaleString('zh-CN', { hour12: false });
}
catch (error) { errorMessage.value = (error as Error).message; Message.error(errorMessage.value); }
finally { loading.value = false; }
});
}
onMounted(loadOverview);
</script>
<style scoped lang="less">
.dashboard-alert { margin-bottom: 16px; }
.dashboard-meta { margin-bottom: 12px; color: var(--color-text-3); font-size: 12px; text-align: right; }
.metric-card { min-height: 112px; }
.metric-hint { margin-top: 8px; color: var(--color-text-3); font-size: 12px; }
</style>

View File

@@ -11,9 +11,9 @@
>
<a-form-item
field="username"
label="用户名"
:rules="[{ required: true, message: '请输入用户名' }]"
:validate-trigger="['change', 'blur']"
hide-label
>
<a-input
v-model="userInfo.username"
@@ -26,9 +26,9 @@
</a-form-item>
<a-form-item
field="password"
label="密码"
:rules="[{ required: true, message: '请输入密码' }]"
:validate-trigger="['change', 'blur']"
hide-label
>
<a-input-password
v-model="userInfo.password"
@@ -42,7 +42,6 @@
</a-form-item>
<div class="login-form-password-actions">
<a-checkbox
checked="rememberPassword"
:model-value="loginConfig.rememberPassword"
@change="setRememberPassword as any"
>
@@ -104,11 +103,12 @@ const handleSubmit = async ({
try {
await userStore.login(values as LoginData);
const { redirect, ...othersQuery } = router.currentRoute.value.query;
const storedRedirect = sessionStorage.getItem('auth-redirect');
sessionStorage.removeItem('auth-redirect');
router.push({
name: (redirect as string) || 'dashboard-overview',
query: {
...othersQuery,
},
...(storedRedirect?.startsWith('/')
? { path: storedRedirect }
: { name: (redirect as string) || 'dashboard-overview', query: othersQuery }),
});
Message.success('登录成功');
const { rememberPassword } = loginConfig.value;
@@ -197,6 +197,12 @@ const setRememberPassword = (value: boolean) => {
margin-bottom: 16px;
}
:deep(.arco-form-item-label-col) {
margin-bottom: 6px;
color: #526078;
font-weight: 500;
}
:deep(.arco-input-wrapper) {
height: 48px;
padding: 0 14px;

View File

@@ -13,6 +13,15 @@
</a-button>
</a-space>
</template>
<a-alert
v-if="definition.detailActions?.length"
class="workflow-alert"
type="warning"
show-icon
title="此资源包含受控业务状态"
>
请从详情中的专用操作推进流程操作结果以服务端状态设备回执或审核记录为准并会保留审计记录
</a-alert>
<a-form :model="filters" layout="inline" class="filters" @submit="search">
<a-form-item label="关键字">
<a-input v-model="filters.keyword" allow-clear placeholder="服务端筛选" />
@@ -223,6 +232,9 @@
</a-drawer>
<a-modal :visible="actionVisible" :title="activeAction?.name" :ok-loading="actionSubmitting" :ok-button-props="{ status: activeAction?.danger ? 'danger' : 'normal' }" @cancel="actionVisible = false" @ok="submitDetailAction">
<a-alert v-if="activeAction?.danger" class="action-alert" type="error" show-icon>
这是高风险操作可能改变业务状态且无法直接撤销请确认目标记录和填写内容准确
</a-alert>
<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-input-number v-if="field.type === 'number' || field.type === 'money'" v-model="actionForm[field.key]" :precision="field.type === 'money' ? 2 : 0" />
@@ -1122,6 +1134,10 @@ function optionLabel(option: Row) {
.filters {
margin-bottom: 16px;
}
.workflow-alert,
.action-alert {
margin-bottom: 16px;
}
.pagination {
display: flex;
justify-content: flex-end;