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; -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 { .echarts-tooltip-diy {
background: linear-gradient( background: linear-gradient(
304.17deg, 304.17deg,

View File

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

View File

@@ -1,8 +1,16 @@
<template> <template>
<a-spin :loading="loading" style="width: 100%"> <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 :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-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-item>
</a-grid> </a-grid>
</a-spin> </a-spin>
@@ -13,15 +21,29 @@ import { onMounted, reactive, ref } from 'vue';
import { request } from '@/api/http'; import { request } from '@/api/http';
type Overview = Record<'staff_count' | 'user_count' | 'contract_count' | 'order_count', number>; type Overview = Record<'staff_count' | 'user_count' | 'contract_count' | 'order_count', number>;
const loading = ref(false); 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 overview = reactive<Overview>({ staff_count: 0, user_count: 0, contract_count: 0, order_count: 0 });
const cards: Array<{ key: keyof Overview; label: string }> = [ const cards: Array<{ key: keyof Overview; label: string; hint: string }> = [
{ key: 'staff_count', label: '配送人员' }, { key: 'user_count', label: '服务用户' }, { key: 'staff_count', label: '配送人员', hint: '本站关联配送人员' }, { key: 'user_count', label: '服务用户', hint: '本站有效服务关系' },
{ key: 'contract_count', label: '配送合同' }, { key: 'order_count', label: '配送订单' }, { key: 'contract_count', label: '配送合同', hint: '本站合同总量' }, { key: 'order_count', label: '配送订单', hint: '本站订单总量' },
]; ];
onMounted(async () => { async function loadOverview() {
loading.value = true; loading.value = true;
try { Object.assign(overview, await request<Overview>('/dashboard/overview')); } errorMessage.value = '';
catch (error) { Message.error((error as Error).message); } 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; } finally { loading.value = false; }
}); }
onMounted(loadOverview);
</script> </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 <a-form-item
field="username" field="username"
label="用户名"
:rules="[{ required: true, message: '请输入用户名' }]" :rules="[{ required: true, message: '请输入用户名' }]"
:validate-trigger="['change', 'blur']" :validate-trigger="['change', 'blur']"
hide-label
> >
<a-input <a-input
v-model="userInfo.username" v-model="userInfo.username"
@@ -26,9 +26,9 @@
</a-form-item> </a-form-item>
<a-form-item <a-form-item
field="password" field="password"
label="密码"
:rules="[{ required: true, message: '请输入密码' }]" :rules="[{ required: true, message: '请输入密码' }]"
:validate-trigger="['change', 'blur']" :validate-trigger="['change', 'blur']"
hide-label
> >
<a-input-password <a-input-password
v-model="userInfo.password" v-model="userInfo.password"
@@ -42,7 +42,6 @@
</a-form-item> </a-form-item>
<div class="login-form-password-actions"> <div class="login-form-password-actions">
<a-checkbox <a-checkbox
checked="rememberPassword"
:model-value="loginConfig.rememberPassword" :model-value="loginConfig.rememberPassword"
@change="setRememberPassword as any" @change="setRememberPassword as any"
> >
@@ -104,11 +103,12 @@ const handleSubmit = async ({
try { try {
await userStore.login(values as LoginData); await userStore.login(values as LoginData);
const { redirect, ...othersQuery } = router.currentRoute.value.query; const { redirect, ...othersQuery } = router.currentRoute.value.query;
const storedRedirect = sessionStorage.getItem('auth-redirect');
sessionStorage.removeItem('auth-redirect');
router.push({ router.push({
name: (redirect as string) || 'dashboard-overview', ...(storedRedirect?.startsWith('/')
query: { ? { path: storedRedirect }
...othersQuery, : { name: (redirect as string) || 'dashboard-overview', query: othersQuery }),
},
}); });
Message.success('登录成功'); Message.success('登录成功');
const { rememberPassword } = loginConfig.value; const { rememberPassword } = loginConfig.value;
@@ -197,6 +197,12 @@ const setRememberPassword = (value: boolean) => {
margin-bottom: 16px; margin-bottom: 16px;
} }
:deep(.arco-form-item-label-col) {
margin-bottom: 6px;
color: #526078;
font-weight: 500;
}
:deep(.arco-input-wrapper) { :deep(.arco-input-wrapper) {
height: 48px; height: 48px;
padding: 0 14px; padding: 0 14px;

View File

@@ -13,6 +13,15 @@
</a-button> </a-button>
</a-space> </a-space>
</template> </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 :model="filters" layout="inline" class="filters" @submit="search">
<a-form-item label="关键字"> <a-form-item label="关键字">
<a-input v-model="filters.keyword" allow-clear placeholder="服务端筛选" /> <a-input v-model="filters.keyword" allow-clear placeholder="服务端筛选" />
@@ -223,6 +232,9 @@
</a-drawer> </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-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 :model="actionForm" layout="vertical">
<a-form-item v-for="field in activeAction?.fields ?? []" :key="field.key" :label="field.label" :required="field.required"> <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" /> <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 { .filters {
margin-bottom: 16px; margin-bottom: 16px;
} }
.workflow-alert,
.action-alert {
margin-bottom: 16px;
}
.pagination { .pagination {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View File

@@ -14,6 +14,15 @@ body {
-webkit-font-smoothing: antialiased; -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 { .echarts-tooltip-diy {
background: linear-gradient( background: linear-gradient(
304.17deg, 304.17deg,

View File

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

View File

@@ -1,9 +1,15 @@
<template> <template>
<a-spin :loading="loading" style="width: 100%"> <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: 5 }" :col-gap="16" :row-gap="16"> <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-grid-item v-for="item in cards" :key="item.key">
<a-card :bordered="false"> <a-card :bordered="false" class="metric-card">
<a-statistic :title="item.label" :value="overview[item.key]" show-group-separator /> <a-statistic :title="item.label" :value="overview[item.key]" show-group-separator />
<div class="metric-hint">{{ item.hint }}</div>
</a-card> </a-card>
</a-grid-item> </a-grid-item>
</a-grid> </a-grid>
@@ -17,22 +23,35 @@ import { request } from '@/api/http';
type Overview = Record<'delivery_count' | 'staff_count' | 'user_count' | 'contract_count' | 'order_count', number>; type Overview = Record<'delivery_count' | 'staff_count' | 'user_count' | 'contract_count' | 'order_count', number>;
const loading = ref(false); const loading = ref(false);
const errorMessage = ref('');
const updatedAt = ref('');
const overview = reactive<Overview>({ delivery_count: 0, staff_count: 0, user_count: 0, contract_count: 0, order_count: 0 }); 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 }> = [ const cards: Array<{ key: keyof Overview; label: string; hint: string }> = [
{ key: 'delivery_count', label: '配送点' }, { key: 'delivery_count', label: '配送点', hint: '当前气站服务范围' },
{ key: 'staff_count', label: '工作人员' }, { key: 'staff_count', label: '工作人员', hint: '本站关联人员' },
{ key: 'user_count', label: '服务用户' }, { key: 'user_count', label: '服务用户', hint: '本站有效服务关系' },
{ key: 'contract_count', label: '配送合同' }, { key: 'contract_count', label: '配送合同', hint: '本站合同总量' },
{ key: 'order_count', label: '燃气配送订单' }, { key: 'order_count', label: '燃气配送订单', hint: '本站订单总量' },
]; ];
onMounted(async () => { async function loadOverview() {
loading.value = true; loading.value = true;
errorMessage.value = '';
try { try {
Object.assign(overview, await request<Overview>('/dashboard/overview')); Object.assign(overview, await request<Overview>('/dashboard/overview'));
updatedAt.value = new Date().toLocaleString('zh-CN', { hour12: false });
} catch (error) { } catch (error) {
Message.error((error as Error).message); errorMessage.value = (error as Error).message;
Message.error(errorMessage.value);
} finally { } finally {
loading.value = false; loading.value = false;
} }
}); }
onMounted(loadOverview);
</script> </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 <a-form-item
field="username" field="username"
label="用户名"
:rules="[{ required: true, message: '请输入用户名' }]" :rules="[{ required: true, message: '请输入用户名' }]"
:validate-trigger="['change', 'blur']" :validate-trigger="['change', 'blur']"
hide-label
> >
<a-input <a-input
v-model="userInfo.username" v-model="userInfo.username"
@@ -26,9 +26,9 @@
</a-form-item> </a-form-item>
<a-form-item <a-form-item
field="password" field="password"
label="密码"
:rules="[{ required: true, message: '请输入密码' }]" :rules="[{ required: true, message: '请输入密码' }]"
:validate-trigger="['change', 'blur']" :validate-trigger="['change', 'blur']"
hide-label
> >
<a-input-password <a-input-password
v-model="userInfo.password" v-model="userInfo.password"
@@ -42,7 +42,6 @@
</a-form-item> </a-form-item>
<div class="login-form-password-actions"> <div class="login-form-password-actions">
<a-checkbox <a-checkbox
checked="rememberPassword"
:model-value="loginConfig.rememberPassword" :model-value="loginConfig.rememberPassword"
@change="setRememberPassword as any" @change="setRememberPassword as any"
> >
@@ -104,11 +103,12 @@ const handleSubmit = async ({
try { try {
await userStore.login(values as LoginData); await userStore.login(values as LoginData);
const { redirect, ...othersQuery } = router.currentRoute.value.query; const { redirect, ...othersQuery } = router.currentRoute.value.query;
const storedRedirect = sessionStorage.getItem('auth-redirect');
sessionStorage.removeItem('auth-redirect');
router.push({ router.push({
name: (redirect as string) || 'dashboard-overview', ...(storedRedirect?.startsWith('/')
query: { ? { path: storedRedirect }
...othersQuery, : { name: (redirect as string) || 'dashboard-overview', query: othersQuery }),
},
}); });
Message.success('登录成功'); Message.success('登录成功');
const { rememberPassword } = loginConfig.value; const { rememberPassword } = loginConfig.value;
@@ -197,6 +197,12 @@ const setRememberPassword = (value: boolean) => {
margin-bottom: 16px; margin-bottom: 16px;
} }
:deep(.arco-form-item-label-col) {
margin-bottom: 6px;
color: #526078;
font-weight: 500;
}
:deep(.arco-input-wrapper) { :deep(.arco-input-wrapper) {
height: 48px; height: 48px;
padding: 0 14px; padding: 0 14px;

View File

@@ -13,6 +13,15 @@
</a-button> </a-button>
</a-space> </a-space>
</template> </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 :model="filters" layout="inline" class="filters" @submit="search">
<a-form-item label="关键字"> <a-form-item label="关键字">
<a-input v-model="filters.keyword" allow-clear placeholder="服务端筛选" /> <a-input v-model="filters.keyword" allow-clear placeholder="服务端筛选" />
@@ -223,6 +232,9 @@
</a-drawer> </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-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 :model="actionForm" layout="vertical">
<a-form-item v-for="field in activeAction?.fields ?? []" :key="field.key" :label="field.label" :required="field.required"> <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" /> <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 { .filters {
margin-bottom: 16px; margin-bottom: 16px;
} }
.workflow-alert,
.action-alert {
margin-bottom: 16px;
}
.pagination { .pagination {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;

View File

@@ -14,6 +14,15 @@ body {
-webkit-font-smoothing: antialiased; -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 { .echarts-tooltip-diy {
background: linear-gradient( background: linear-gradient(
304.17deg, 304.17deg,

View File

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

View File

@@ -1,6 +1,11 @@
<template> <template>
<div class="dashboard"> <div class="dashboard">
<a-spin :loading="loading" class="dashboard-spin"> <a-spin :loading="loading" class="dashboard-spin">
<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 :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-grid-item v-for="item in cards" :key="item.key">
<a-card :bordered="false" class="metric-card"> <a-card :bordered="false" class="metric-card">
@@ -58,6 +63,8 @@ const emptyOverview = (): DashboardOverview => ({
order_statuses: [], product_statuses: [], payment_channels: [], recent_orders: [], order_statuses: [], product_statuses: [], payment_channels: [], recent_orders: [],
}); });
const loading = ref(false); const loading = ref(false);
const errorMessage = ref('');
const updatedAt = ref('');
const overview = ref<DashboardOverview>(emptyOverview()); const overview = ref<DashboardOverview>(emptyOverview());
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
@@ -110,8 +117,9 @@ const paymentChannelOption = computed<EChartsOption>(() => ({
series: [{ type: 'bar', data: safeArray(overview.value.payment_channels).map((item) => centsToYuan(item.value)) }], series: [{ type: 'bar', data: safeArray(overview.value.payment_channels).map((item) => centsToYuan(item.value)) }],
})); }));
onMounted(async () => { async function loadOverview() {
loading.value = true; loading.value = true;
errorMessage.value = '';
try { try {
const data = await platformApi.overview(); const data = await platformApi.overview();
overview.value = { overview.value = {
@@ -124,17 +132,23 @@ onMounted(async () => {
payment_channels: safeArray(data.payment_channels), payment_channels: safeArray(data.payment_channels),
recent_orders: safeArray(data.recent_orders), recent_orders: safeArray(data.recent_orders),
}; };
updatedAt.value = new Date().toLocaleString('zh-CN', { hour12: false });
} catch (error) { } catch (error) {
Message.error((error as Error).message); errorMessage.value = (error as Error).message;
Message.error(errorMessage.value);
} finally { } finally {
loading.value = false; loading.value = false;
} }
}); }
onMounted(loadOverview);
</script> </script>
<style scoped lang="less"> <style scoped lang="less">
.dashboard { padding: 20px; } .dashboard { padding: 20px; }
.dashboard-spin { width: 100%; } .dashboard-spin { width: 100%; }
.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: 120px; } .metric-card { min-height: 120px; }
.metric-hint { margin-top: 8px; color: var(--color-text-3); font-size: 12px; } .metric-hint { margin-top: 8px; color: var(--color-text-3); font-size: 12px; }
.section-card { margin-top: 16px; } .section-card { margin-top: 16px; }

View File

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

View File

@@ -13,6 +13,15 @@
</a-button> </a-button>
</a-space> </a-space>
</template> </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 :model="filters" layout="inline" class="filters" @submit="search">
<a-form-item label="关键字"> <a-form-item label="关键字">
<a-input v-model="filters.keyword" allow-clear placeholder="服务端筛选" /> <a-input v-model="filters.keyword" allow-clear placeholder="服务端筛选" />
@@ -223,6 +232,9 @@
</a-drawer> </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-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 :model="actionForm" layout="vertical">
<a-form-item v-for="field in activeAction?.fields ?? []" :key="field.key" :label="field.label" :required="field.required"> <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" /> <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 { .filters {
margin-bottom: 16px; margin-bottom: 16px;
} }
.workflow-alert,
.action-alert {
margin-bottom: 16px;
}
.pagination { .pagination {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;