20260716215550

This commit is contained in:
oneao committed 2026-07-16 21:55:51 +08:00
1 parent 5cd4e1e1d2
commit 49dafe524c
21 files changed
+1218 -272

No files matched your search

@@ -43,6 +43,8 @@ const userMenu = {
onClick: handleUserMenu,
};
const displayName = computed(() => authStore.user?.name || authStore.user?.id || "当前用户");
watch(
() => appStore.locale,
(locale) => document.documentElement.setAttribute("lang", locale),
@@ -57,7 +59,7 @@ function handleUserMenu({ key }) {
function confirmLogout() {
authStore.clearSession();
logoutOpen.value = false;
router.replace("/dashboard");
router.replace("/login");
}
</script>
@@ -85,7 +87,7 @@ function confirmLogout() {
<a-dropdown :menu="userMenu" :trigger="['click']" placement="bottomRight">
<button type="button" class="toolbar-user" aria-label="用户菜单">
<a-avatar :size="24"><UserRound /></a-avatar>
<span>管理员</span>
<span>{{ displayName }}</span>
<ChevronDown class="toolbar-user__arrow" />
</button>
</a-dropdown>
@@ -94,10 +96,9 @@ function confirmLogout() {
<a-modal v-model:open="profileOpen" title="个人资料" :footer="null" width="420px">
<a-descriptions :column="1" bordered size="small">
<a-descriptions-item label="用户名">admin</a-descriptions-item>
<a-descriptions-item label="姓名">管理员</a-descriptions-item>
<a-descriptions-item label="角色">系统管理员</a-descriptions-item>
<a-descriptions-item label="机构码">{{ authStore.orgid || "--" }}</a-descriptions-item>
<a-descriptions-item label="用户ID">{{ authStore.user?.id || "--" }}</a-descriptions-item>
<a-descriptions-item label="姓名">{{ authStore.user?.name || "--" }}</a-descriptions-item>
<a-descriptions-item label="机构码">{{ authStore.orgId || "--" }}</a-descriptions-item>
</a-descriptions>
</a-modal>
+22
View File
@@ -1,13 +1,21 @@
import { createRouter, createWebHistory } from "vue-router";
import NProgress from "nprogress";
import "nprogress/nprogress.css";
import { useAuthStore } from "../stores/auth";
NProgress.configure({ showSpinner: false });
function getRedirectPath(value) {
return typeof value === "string" && value.startsWith("/") && !value.startsWith("//")
? value
: "/dashboard";
}
const routes = [
{
path: "/",
component: () => import("../layouts/DefaultLayout.vue"),
meta: { requiresAuth: true },
children: [
{
path: "",
@@ -117,6 +125,12 @@ const routes = [
},
],
},
{
path: "/login",
name: "login",
component: () => import("../views/LoginView.vue"),
meta: { title: "登录" },
},
{
path: "/:pathMatch(.*)*",
redirect: "/dashboard",
@@ -133,6 +147,14 @@ router.beforeEach((to) => {
NProgress.start();
document.title = to.meta.title ? `${to.meta.title} - FMS 管理系统` : "FMS 管理系统";
const authStore = useAuthStore();
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
return { name: "login", query: { redirect: to.fullPath } };
}
if (to.name === "login" && authStore.isAuthenticated) {
return getRedirectPath(to.query.redirect);
}
return true;
});
+39 -19
View File
@@ -1,22 +1,42 @@
import { post } from "./http";
export const authApi = {
login(credentials) {
return post("/api/auth/login", credentials);
},
};
/**
* 机构用户登录。
* @param {string} orgId 机构码。
* @param {string} userId 用户 ID。
* @param {string} password 密码。
* @returns {Promise<{token: string, orgid: string, user: {id: string, name: string}}>} 登录会话。
*/
export function loginApi(orgId, userId, password) {
return post("/auth/login", {
orgid: orgId,
userid: userId,
password,
});
}
export const dataApi = {
loadData(params) {
return post("/api/data/loaddata", params);
},
loadDataBySql(sql) {
return post("/api/data/loaddatabysql", { sql });
},
page(params) {
return post("/api/data/page", params);
},
saveObject(tables) {
return post("/api/data/saveobjt", { tables });
},
};
/**
* 按表或视图加载数据。
* @param {string} viewName 表名或视图名,必填。
* @param {string} [searchCondition] 查询条件,不包含 WHERE。
* @param {string} [orderField] 排序字段,可带 ASC 或 DESC。
* @param {string[]} [searchColumns] 需要返回的字段列表,省略时返回全部字段。
* @returns {Promise<object[]>} 查询结果行。
*/
export function loadDataApi(viewName, searchCondition, orderField, searchColumns) {
return post("/data/loaddata", {
view_name: viewName,
search_condition: searchCondition,
order_field: orderField,
search_columns: searchColumns,
});
}
/**
* 执行只读 SQL 查询。
* @param {string} sql 完整的 SELECT 或 WITH 查询语句。
* @returns {Promise<object[]>} 查询结果行。
*/
export function loadDataBySqlApi(sql) {
return post("/data/loaddatabysql", { sql });
}
+59 -27
View File
@@ -1,40 +1,72 @@
import axios from "axios";
import { useAuthStore } from "../stores/auth";
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || "/api",
timeout: 15000,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
});
export async function request(path, options = {}) {
let redirectingToLogin = false;
function redirectToLogin() {
const currentPath = window.location.pathname.replace(/\/+$/, "") || "/";
if (currentPath === "/login" || redirectingToLogin) return;
redirectingToLogin = true;
const redirect = `${window.location.pathname}${window.location.search}${window.location.hash}`;
window.location.assign(`/login?redirect=${encodeURIComponent(redirect)}`);
}
function handleUnauthorized() {
const authStore = useAuthStore();
authStore.clearSession();
redirectToLogin();
}
http.interceptors.request.use((config) => {
const authStore = useAuthStore();
const headers = new Headers(options.headers);
headers.set("Accept", "application/json");
if (options.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (authStore.token) {
headers.set("Authorization", `Bearer ${authStore.token}`);
config.headers.Authorization = `Bearer ${authStore.token}`;
}
const response = await fetch(`${API_BASE_URL}${path}`, {
...options,
headers,
});
const payload = await response.json().catch(() => null);
return config;
});
if (!response.ok) {
if (response.status === 401) authStore.clearSession();
throw new Error(payload?.message ?? `请求失败 (${response.status})`);
}
http.interceptors.response.use(
(response) => {
const payload = response.data;
if (payload?.code !== undefined && payload.code !== 0) {
throw new Error(payload.message ?? "请求失败");
}
if (payload?.code === undefined) return payload;
if (payload.code === 0) return payload.data;
return payload?.data ?? payload;
if (payload.code === 401) handleUnauthorized();
const error = new Error(payload.message || "请求失败");
error.code = payload.code;
throw error;
},
(error) => {
const status = error.response?.status;
const payload = error.response?.data;
if (status === 401 || payload?.code === 401) handleUnauthorized();
const requestError = new Error(
payload?.message ||
(error.code === "ECONNABORTED" ? "请求超时,请稍后重试" : "网络异常,请检查服务是否正常"),
);
requestError.code = payload?.code ?? status;
throw requestError;
},
);
export function post(path, data, config) {
return http.post(path, data, config);
}
export function post(path, body) {
return request(path, {
method: "POST",
body: JSON.stringify(body),
});
}
export default http;
+40 -28
View File
@@ -1,35 +1,47 @@
import { ref } from "vue";
import { defineStore } from "pinia";
const createDefaultVisitedTabs = () => [{ key: "/dashboard", label: "工作台", closable: false }];
export const useAppStore = defineStore("app", {
state: () => ({
locale: "zh-CN",
sidebarCollapsed: false,
visitedTabs: createDefaultVisitedTabs(),
}),
actions: {
toggleSidebar() {
this.sidebarCollapsed = !this.sidebarCollapsed;
},
setLocale(locale) {
this.locale = locale;
},
addVisitedTab(tab) {
if (!this.visitedTabs.some((item) => item.key === tab.key)) {
this.visitedTabs.push(tab);
export const useAppStore = defineStore(
"app",
() => {
const locale = ref("zh-CN");
const sidebarCollapsed = ref(false);
const visitedTabs = ref(createDefaultVisitedTabs());
function toggleSidebar() {
sidebarCollapsed.value = !sidebarCollapsed.value;
}
function setLocale(value) {
locale.value = value;
}
function addVisitedTab(tab) {
if (!visitedTabs.value.some((item) => item.key === tab.key)) {
visitedTabs.value.push(tab);
}
},
removeVisitedTab(key) {
this.visitedTabs = this.visitedTabs.filter((item) => item.key !== key);
}
function removeVisitedTab(key) {
visitedTabs.value = visitedTabs.value.filter((item) => item.key !== key);
}
return {
locale,
sidebarCollapsed,
visitedTabs,
toggleSidebar,
setLocale,
addVisitedTab,
removeVisitedTab,
};
},
{
persist: {
key: "fms-app",
pick: ["locale"],
},
},
persist: {
key: "fms-app",
pick: ["locale"],
afterHydrate: ({ store }) => {
store.sidebarCollapsed = false;
store.visitedTabs = createDefaultVisitedTabs();
},
},
});
);
+8 -8
View File
@@ -5,25 +5,25 @@ export const useAuthStore = defineStore(
"auth",
() => {
const token = ref("");
const orgid = ref("");
const orgId = ref("");
const user = ref(null);
const isAuthenticated = computed(() => Boolean(token.value));
function setSession(payload, organization) {
token.value = payload.token ?? payload.access_token ?? "";
orgid.value = payload.orgid ?? organization;
user.value = payload.user ?? payload.user_info ?? null;
function setSession(session, organizationId) {
token.value = session.token ?? session.access_token ?? "";
orgId.value = session.orgid ?? organizationId;
user.value = session.user ?? session.user_info ?? null;
}
function clearSession() {
token.value = "";
orgid.value = "";
orgId.value = "";
user.value = null;
}
return {
token,
orgid,
orgId,
user,
isAuthenticated,
setSession,
@@ -33,7 +33,7 @@ export const useAuthStore = defineStore(
{
persist: {
key: "fms-auth",
pick: ["token", "orgid", "user"],
pick: ["token", "orgId", "user"],
},
},
);
+207 -96
View File
@@ -2,11 +2,13 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Ellipsis, ListFilter, Plus, Search } from "@lucide/vue";
import { mockRecords } from "../data/mockRecords";
import { loadDataApi } from "../services/api";
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const loadError = ref("");
const sourceRows = ref([]);
const rows = ref([]);
const total = ref(0);
const filters = reactive({ field: "name", keyword: "" });
@@ -16,6 +18,7 @@ const tableScrollY = ref(240);
const advancedOpen = ref(false);
const appliedFilterCount = ref(0);
let resizeObserver;
let loadSequence = 0;
const columns = [
{ title: "编号", dataIndex: "id", key: "id", width: 160 },
@@ -74,20 +77,130 @@ function isFilterValueFilled(value) {
if (Array.isArray(value)) return value.some(isFilterValueFilled);
return value !== undefined && value !== null && value !== "";
}
function loadData() {
loading.value = true;
function getRowValue(row, candidates) {
const keys = Object.keys(row);
for (const candidate of candidates) {
const matchedKey = keys.find((key) => key.toLowerCase() === candidate.toLowerCase());
if (matchedKey !== undefined && row[matchedKey] != null) return row[matchedKey];
}
return undefined;
}
function normalizeRow(row, index) {
const id = getRowValue(row, ["id", "b_id", "user_id", "userid"]);
const name = getRowValue(row, ["name", "b_name", "customer_name", "title"]);
const state = getRowValue(row, ["state", "b_state", "status"]);
const updatedAt = getRowValue(row, ["updated_at", "update_time", "updated_time", "b_updated_at"]);
return {
...row,
id: id == null ? "" : String(id),
name: name == null ? "" : String(name),
state: state == null ? "" : String(state),
updated_at: updatedAt == null ? "" : String(updatedAt),
__rowKey: `${String(route.meta.source)}-${String(id ?? index)}`,
};
}
function includesValue(row, candidates, expected) {
if (!isFilterValueFilled(expected)) return true;
const actual = getRowValue(row, candidates);
return String(actual ?? "")
.toLowerCase()
.includes(String(expected).trim().toLowerCase());
}
function isInDateRange(row, candidates, range) {
if (!Array.isArray(range) || !range.some(Boolean)) return true;
const actual = String(getRowValue(row, candidates) ?? "").slice(0, 10);
if (!actual) return false;
const start = range[0]?.format?.("YYYY-MM-DD");
const end = range[1]?.format?.("YYYY-MM-DD");
return (!start || actual >= start) && (!end || actual <= end);
}
function matchesAdvancedFilters(row) {
const amount = Number(
String(getRowValue(row, ["amount", "total_amount", "b_amount"]) ?? "").replaceAll(",", ""),
);
return (
includesValue(row, ["id", "b_id", "document_no"], advancedFilters.documentNo) &&
includesValue(
row,
["customer", "customer_name", "name", "b_name"],
advancedFilters.customerName,
) &&
includesValue(row, ["business_type", "businessType", "b_type"], advancedFilters.businessType) &&
includesValue(row, ["state", "b_state", "status"], advancedFilters.status) &&
isInDateRange(row, ["business_date", "b_date"], advancedFilters.businessDate) &&
includesValue(row, ["owner", "owner_name", "sales_name"], advancedFilters.owner) &&
includesValue(row, ["department", "department_name"], advancedFilters.department) &&
includesValue(
row,
["settlement_status", "settlementStatus"],
advancedFilters.settlementStatus,
) &&
includesValue(row, ["currency", "currency_code"], advancedFilters.currency) &&
(!isFilterValueFilled(advancedFilters.amountMin) ||
(Number.isFinite(amount) && amount >= advancedFilters.amountMin)) &&
(!isFilterValueFilled(advancedFilters.amountMax) ||
(Number.isFinite(amount) && amount <= advancedFilters.amountMax)) &&
includesValue(row, ["creator", "created_by", "creator_name"], advancedFilters.creator) &&
isInDateRange(
row,
["created_at", "create_time", "created_time"],
advancedFilters.createdDate,
) &&
includesValue(row, ["remark", "remarks", "memo"], advancedFilters.remark)
);
}
function applyLocalFilters() {
const keyword = filters.keyword.trim().toLowerCase();
const filteredRows = keyword
? mockRecords.filter((row) =>
const filteredRows = sourceRows.value.filter(
(row) =>
(!keyword ||
String(row[filters.field] ?? "")
.toLowerCase()
.includes(keyword),
)
: mockRecords;
.includes(keyword)) &&
matchesAdvancedFilters(row),
);
const pageCount = Math.max(Math.ceil(filteredRows.length / pagination.pageSize), 1);
if (pagination.current > pageCount) pagination.current = pageCount;
const start = (pagination.current - 1) * pagination.pageSize;
rows.value = filteredRows.slice(start, start + pagination.pageSize);
total.value = filteredRows.length;
loading.value = false;
}
function searchData() {
pagination.current = 1;
applyLocalFilters();
}
async function loadData() {
const currentSequence = ++loadSequence;
const viewName = String(route.meta.source || "");
if (!viewName) return;
loading.value = true;
loadError.value = "";
try {
const result = await loadDataApi(viewName);
if (currentSequence !== loadSequence) return;
sourceRows.value = (Array.isArray(result) ? result : []).map(normalizeRow);
applyLocalFilters();
} catch (error) {
if (currentSequence !== loadSequence) return;
sourceRows.value = [];
rows.value = [];
total.value = 0;
loadError.value = error.message || "数据加载失败";
} finally {
if (currentSequence === loadSequence) loading.value = false;
}
}
function openDetail(record) {
@@ -97,7 +210,7 @@ function openDetail(record) {
function changePage(page, pageSize) {
pagination.current = page;
pagination.pageSize = pageSize;
loadData();
applyLocalFilters();
}
function resetAdvancedFilters() {
@@ -108,7 +221,7 @@ function applyAdvancedFilters() {
pagination.current = 1;
appliedFilterCount.value = draftFilterCount.value;
advancedOpen.value = false;
loadData();
applyLocalFilters();
}
function updateTableHeight() {
@@ -128,7 +241,17 @@ onMounted(() => {
onBeforeUnmount(() => resizeObserver?.disconnect());
watch(() => route.meta.source, loadData, { immediate: true });
watch(
() => route.meta.source,
() => {
pagination.current = 1;
filters.keyword = "";
resetAdvancedFilters();
appliedFilterCount.value = 0;
loadData();
},
{ immediate: true },
);
</script>
<template>
@@ -153,7 +276,7 @@ watch(() => route.meta.source, loadData, { immediate: true });
class="keyword-search"
allow-clear
enter-button="搜索"
@search="loadData"
@search="searchData"
/>
</a-space-compact>
@@ -179,13 +302,16 @@ watch(() => route.meta.source, loadData, { immediate: true });
</section>
<section class="table-panel">
<a-alert v-if="loadError" class="load-alert" type="error" show-icon :message="loadError">
<template #action><a-button size="small" @click="loadData">重试</a-button></template>
</a-alert>
<div
ref="tableAreaRef"
class="table-area"
:style="{ '--table-body-height': `${tableScrollY}px` }"
>
<a-table
row-key="id"
row-key="__rowKey"
:columns="columns"
:data-source="rows"
:loading="loading"
@@ -196,7 +322,7 @@ watch(() => route.meta.source, loadData, { immediate: true });
>
<template #bodyCell="{ column, text, record }">
<a-button
v-if="column.key === 'id'"
v-if="column.key === 'id' && record.id"
type="link"
class="table-id-link"
@click="openDetail(record)"
@@ -232,89 +358,69 @@ watch(() => route.meta.source, loadData, { immediate: true });
}"
>
<a-form layout="vertical" class="advanced-filter-form">
<a-form-item label="单据编号">
<a-input v-model:value="advancedFilters.documentNo" allow-clear />
</a-form-item>
<a-form-item label="客户名称">
<a-input
v-model:value="advancedFilters.customerName"
allow-clear
/>
</a-form-item>
<a-form-item label="业务类型">
<a-select
v-model:value="advancedFilters.businessType"
allow-clear
:options="businessTypeOptions"
/>
</a-form-item>
<a-form-item label="业务状态">
<a-select
v-model:value="advancedFilters.status"
allow-clear
:options="statusOptions"
/>
</a-form-item>
<a-form-item label="业务日期" class="filter-span-full">
<a-range-picker
v-model:value="advancedFilters.businessDate"
:allow-empty="[true, true]"
/>
</a-form-item>
<a-form-item label="单据编号">
<a-input v-model:value="advancedFilters.documentNo" allow-clear />
</a-form-item>
<a-form-item label="客户名称">
<a-input v-model:value="advancedFilters.customerName" allow-clear />
</a-form-item>
<a-form-item label="业务类型">
<a-select
v-model:value="advancedFilters.businessType"
allow-clear
:options="businessTypeOptions"
/>
</a-form-item>
<a-form-item label="业务状态">
<a-select v-model:value="advancedFilters.status" allow-clear :options="statusOptions" />
</a-form-item>
<a-form-item label="业务日期" class="filter-span-full">
<a-range-picker
v-model:value="advancedFilters.businessDate"
:allow-empty="[true, true]"
/>
</a-form-item>
<a-form-item label="负责人">
<a-select
v-model:value="advancedFilters.owner"
allow-clear
:options="ownerOptions"
/>
</a-form-item>
<a-form-item label="所属部门">
<a-select
v-model:value="advancedFilters.department"
allow-clear
:options="departmentOptions"
/>
</a-form-item>
<a-form-item label="结算状态">
<a-select
v-model:value="advancedFilters.settlementStatus"
allow-clear
:options="settlementOptions"
/>
</a-form-item>
<a-form-item label="币种">
<a-select
v-model:value="advancedFilters.currency"
allow-clear
:options="currencyOptions"
/>
</a-form-item>
<a-form-item label="金额下限">
<a-input-number
v-model:value="advancedFilters.amountMin"
:min="0"
/>
</a-form-item>
<a-form-item label="金额上限">
<a-input-number
v-model:value="advancedFilters.amountMax"
:min="0"
/>
</a-form-item>
<a-form-item label="负责人">
<a-select v-model:value="advancedFilters.owner" allow-clear :options="ownerOptions" />
</a-form-item>
<a-form-item label="所属部门">
<a-select
v-model:value="advancedFilters.department"
allow-clear
:options="departmentOptions"
/>
</a-form-item>
<a-form-item label="结算状态">
<a-select
v-model:value="advancedFilters.settlementStatus"
allow-clear
:options="settlementOptions"
/>
</a-form-item>
<a-form-item label="币种">
<a-select
v-model:value="advancedFilters.currency"
allow-clear
:options="currencyOptions"
/>
</a-form-item>
<a-form-item label="金额下限">
<a-input-number v-model:value="advancedFilters.amountMin" :min="0" />
</a-form-item>
<a-form-item label="金额上限">
<a-input-number v-model:value="advancedFilters.amountMax" :min="0" />
</a-form-item>
<a-form-item label="创建人">
<a-input v-model:value="advancedFilters.creator" allow-clear />
</a-form-item>
<a-form-item label="创建日期">
<a-range-picker
v-model:value="advancedFilters.createdDate"
:allow-empty="[true, true]"
/>
</a-form-item>
<a-form-item label="备注关键词" class="filter-span-full">
<a-input v-model:value="advancedFilters.remark" allow-clear />
</a-form-item>
<a-form-item label="创建人">
<a-input v-model:value="advancedFilters.creator" allow-clear />
</a-form-item>
<a-form-item label="创建日期">
<a-range-picker v-model:value="advancedFilters.createdDate" :allow-empty="[true, true]" />
</a-form-item>
<a-form-item label="备注关键词" class="filter-span-full">
<a-input v-model:value="advancedFilters.remark" allow-clear />
</a-form-item>
</a-form>
<template #footer>
@@ -396,6 +502,11 @@ watch(() => route.meta.source, loadData, { immediate: true });
overflow: hidden;
}
.load-alert {
flex: none;
margin: 0 8px;
}
.table-area {
position: relative;
flex: 1;
+451 -82
View File
@@ -1,31 +1,38 @@
<script setup>
import { reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { LockKeyhole, UserRound } from "@lucide/vue";
import { authApi } from "../services/api";
import { Building2, LockKeyhole, LogIn, MapPin, Ship, Truck, UserRound } from "@lucide/vue";
import { App } from "antdv-next";
import { loginApi } from "../services/api";
import { useAuthStore } from "../stores/auth";
const route = useRoute();
const router = useRouter();
const authStore = useAuthStore();
const { message } = App.useApp();
const loading = ref(false);
const errorMessage = ref("");
const form = reactive({
orgid: "",
userid: "",
orgId: "",
userId: "",
password: "",
});
function getRedirectPath() {
const redirect = route.query.redirect;
return typeof redirect === "string" && redirect.startsWith("/") && !redirect.startsWith("//")
? redirect
: "/dashboard";
}
async function submit() {
errorMessage.value = "";
loading.value = true;
try {
const session = await authApi.login(form);
authStore.setSession(session, form.orgid);
await router.replace(route.query.redirect || "/dashboard");
const session = await loginApi(form.orgId, form.userId, form.password);
authStore.setSession(session, form.orgId);
await router.replace(getRedirectPath());
} catch (error) {
errorMessage.value = error.message || "登录失败";
message.error(error.message || "登录失败");
} finally {
loading.value = false;
}
@@ -34,128 +41,490 @@ async function submit() {
<template>
<main class="login-page">
<section class="login-shell">
<div class="login-brand">
<div class="login-brand__mark">F</div>
<div>
<h1>FMS 管理系统</h1>
<p>机构业务管理平台</p>
<section class="login-shell" aria-label="FMS 登录">
<aside class="login-overview">
<div class="overview-brand">
<div class="overview-brand__mark">F</div>
<div class="overview-brand__copy">
<strong>FMS</strong>
<span>Freight Management System</span>
</div>
</div>
</div>
<a-form layout="vertical" :model="form" @finish="submit">
<a-alert
v-if="errorMessage"
class="login-alert"
type="error"
show-icon
:message="errorMessage"
/>
<a-form-item
label="机构码"
name="orgid"
:rules="[{ required: true, message: '请输入机构码' }]"
>
<a-input v-model:value="form.orgid" size="large" autocomplete="organization" />
</a-form-item>
<a-form-item
label="用户ID"
name="userid"
:rules="[{ required: true, message: '请输入用户ID' }]"
>
<a-input v-model:value="form.userid" size="large" autocomplete="username">
<template #prefix><UserRound /></template>
</a-input>
</a-form-item>
<a-form-item
label="密码"
name="password"
:rules="[{ required: true, message: '请输入密码' }]"
>
<a-input-password
v-model:value="form.password"
size="large"
autocomplete="current-password"
<div class="overview-copy">
<span class="overview-copy__eyebrow">一体化业务工作台</span>
<h1>FMS 管理系统</h1>
<p>让业务、费用与数据在同一工作台高效流转。</p>
</div>
<div class="route-visual" aria-hidden="true">
<div class="route-line route-line--first"></div>
<div class="route-line route-line--second"></div>
<div class="route-node route-node--origin"><Building2 /></div>
<div class="route-node route-node--truck"><Truck /></div>
<div class="route-node route-node--ship"><Ship /></div>
<div class="route-node route-node--destination"><MapPin /></div>
</div>
<div class="overview-footer">
<span>多机构协同</span>
<span>业务数据一体化</span>
</div>
</aside>
<div class="login-panel">
<div class="mobile-brand">
<div class="mobile-brand__mark">F</div>
<strong>FMS 管理系统</strong>
</div>
<header class="login-heading">
<span>ACCOUNT ACCESS</span>
<h2>账号登录</h2>
<p>欢迎回来,请登录所属机构</p>
</header>
<a-form layout="vertical" :model="form" class="login-form" @finish="submit">
<a-form-item
label="机构码"
name="orgId"
:rules="[{ required: true, message: '请输入机构码' }]"
>
<template #prefix><LockKeyhole /></template>
</a-input-password>
</a-form-item>
<a-button type="primary" html-type="submit" size="large" block :loading="loading">
登录
</a-button>
</a-form>
<a-input v-model:value="form.orgId" size="large" autocomplete="organization">
<template #prefix><Building2 /></template>
</a-input>
</a-form-item>
<a-form-item
label="账号"
name="userId"
:rules="[{ required: true, message: '请输入账号' }]"
>
<a-input v-model:value="form.userId" size="large" autocomplete="username">
<template #prefix><UserRound /></template>
</a-input>
</a-form-item>
<a-form-item label="密码" name="password">
<a-input-password
v-model:value="form.password"
size="large"
autocomplete="current-password"
>
<template #prefix><LockKeyhole /></template>
</a-input-password>
</a-form-item>
<a-button
type="primary"
html-type="submit"
size="large"
block
class="login-button"
:loading="loading"
>
<template #icon><LogIn /></template>
登录
</a-button>
</a-form>
<footer class="login-footer">FMS · 机构业务管理平台</footer>
</div>
</section>
</main>
</template>
<style lang="scss" scoped>
.login-page {
position: relative;
display: grid;
width: 100%;
height: 100%;
min-height: 0;
overflow: auto;
place-items: center;
padding: 24px;
background:
linear-gradient(90deg, var(--fms-border) 1px, transparent 1px),
linear-gradient(var(--fms-border) 1px, transparent 1px), var(--fms-bg);
background-size: 32px 32px;
padding: 32px;
background: #e8edf2;
}
.login-shell {
width: min(100%, 400px);
padding: 30px;
border: 1px solid var(--fms-border);
position: relative;
display: grid;
width: min(100%, 920px);
min-height: 560px;
grid-template-columns: minmax(0, 1.08fr) minmax(380px, 0.92fr);
overflow: hidden;
border: 1px solid #d8e0e8;
border-radius: 8px;
background: var(--fms-surface);
box-shadow: var(--fms-shadow);
background: #fff;
box-shadow: 0 22px 60px rgb(31 45 61 / 14%);
}
.login-brand {
.login-overview {
position: relative;
display: flex;
min-width: 0;
flex-direction: column;
padding: 38px 42px 34px;
overflow: hidden;
color: #f7fafc;
background: #22313f;
}
.overview-brand {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 28px;
gap: 11px;
&__mark {
display: grid;
width: 44px;
height: 44px;
width: 38px;
height: 38px;
place-items: center;
border-radius: 7px;
background: #1677ff;
border: 1px solid rgb(255 255 255 / 24%);
border-radius: 6px;
background: #168aad;
color: #fff;
font-size: 22px;
font-size: 19px;
font-weight: 700;
}
&__copy {
display: flex;
min-width: 0;
flex-direction: column;
strong {
font-size: 15px;
line-height: 1.2;
}
span {
margin-top: 3px;
color: #aebdca;
font-size: 10px;
letter-spacing: 0;
}
}
}
.overview-copy {
position: relative;
z-index: 2;
margin-top: 76px;
&__eyebrow {
display: block;
margin-bottom: 10px;
color: #55c2b6;
font-size: 12px;
font-weight: 600;
}
h1 {
margin: 0;
color: var(--fms-text);
font-size: 20px;
color: #fff;
font-size: 30px;
line-height: 1.25;
}
p {
margin: 3px 0 0;
color: var(--fms-text-secondary);
max-width: 320px;
margin: 14px 0 0;
color: #c5d0d9;
font-size: 14px;
line-height: 1.8;
}
}
.route-visual {
position: relative;
flex: 1;
min-height: 150px;
margin-top: 22px;
}
.route-line {
position: absolute;
height: 1px;
border-top: 1px dashed #6f8290;
transform-origin: left center;
&--first {
top: 75px;
left: 24px;
width: 45%;
transform: rotate(-11deg);
}
&--second {
top: 62px;
left: 45%;
width: 44%;
transform: rotate(12deg);
}
}
.route-node {
position: absolute;
z-index: 2;
display: grid;
width: 34px;
height: 34px;
place-items: center;
border: 1px solid #667987;
border-radius: 50%;
color: #d7e1e8;
background: #2c3e4c;
:deep(.lucide) {
width: 16px;
height: 16px;
}
&--origin {
top: 56px;
left: 5px;
border-color: #45ad9f;
color: #66d0c2;
}
&--truck {
top: 26px;
left: 37%;
}
&--ship {
top: 58px;
right: 25%;
border-color: #d3a34c;
color: #f0bd5f;
}
&--destination {
top: 86px;
right: 3px;
border-color: #3f9bb7;
color: #62bad5;
}
}
.overview-footer {
display: flex;
align-items: center;
gap: 18px;
color: #91a4b2;
font-size: 11px;
span {
position: relative;
}
span + span::before {
position: absolute;
top: 50%;
left: -10px;
width: 2px;
height: 2px;
border-radius: 50%;
background: #667987;
content: "";
}
}
.login-panel {
display: flex;
min-width: 0;
flex-direction: column;
padding: 54px 48px 30px;
background: #fff;
}
.mobile-brand {
display: none;
}
.login-heading {
margin-bottom: 30px;
span {
color: #168aad;
font-size: 10px;
font-weight: 700;
letter-spacing: 0;
}
h2 {
margin: 8px 0 0;
color: #1f2d3a;
font-size: 24px;
line-height: 1.3;
}
p {
margin: 8px 0 0;
color: #7a8793;
font-size: 13px;
}
}
.login-alert {
margin-bottom: 18px;
.login-form {
:deep(.ant-form-item) {
margin-bottom: 18px;
}
:deep(.ant-form-item-label) {
padding-bottom: 5px;
}
:deep(.ant-form-item-label > label) {
color: #354452;
font-size: 13px;
font-weight: 500;
}
:deep(.ant-input-affix-wrapper) {
min-height: 44px;
border-radius: 4px;
}
:deep(.ant-input-prefix) {
margin-inline-end: 10px;
color: #83909b;
}
:deep(.ant-input-prefix .lucide) {
width: 15px;
height: 15px;
}
}
.login-button {
height: 44px;
margin-top: 4px;
border-radius: 4px;
font-weight: 600;
:deep(.lucide) {
width: 15px;
height: 15px;
}
}
.login-footer {
margin-top: auto;
padding-top: 28px;
color: #9aa4ad;
font-size: 11px;
text-align: center;
}
@media (max-width: 760px) {
.login-page {
align-items: start;
padding: 20px;
}
.login-shell {
width: min(100%, 500px);
min-height: calc(100vh - 40px);
grid-template-columns: minmax(0, 1fr);
}
.login-overview {
min-height: 170px;
padding: 25px 28px;
}
.overview-brand {
display: none;
}
.overview-copy {
margin-top: 0;
h1 {
font-size: 24px;
}
p {
margin-top: 7px;
line-height: 1.6;
}
}
.route-visual,
.overview-footer {
display: none;
}
.login-panel {
padding: 30px 34px 24px;
}
.mobile-brand {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 30px;
&__mark {
display: grid;
width: 30px;
height: 30px;
place-items: center;
border-radius: 5px;
color: #fff;
background: #168aad;
font-size: 15px;
font-weight: 700;
}
strong {
color: #263746;
font-size: 14px;
}
}
}
@media (max-width: 480px) {
.login-page {
padding: 0;
background: var(--fms-surface);
}
.login-shell {
min-height: 100%;
border: 0;
border-radius: 0;
box-shadow: none;
}
.login-overview {
min-height: 140px;
padding: 22px 24px;
}
.overview-copy {
&__eyebrow {
margin-bottom: 7px;
}
h1 {
font-size: 22px;
}
p {
font-size: 12px;
}
}
.login-panel {
padding: 26px 24px 20px;
}
.mobile-brand {
margin-bottom: 25px;
}
.login-heading {
margin-bottom: 24px;
}
}
</style>