20260716173254

This commit is contained in:
oneao committed 2026-07-16 17:32:55 +08:00
1 parent 704dda07b5
commit 5cd4e1e1d2
46 files changed
+3569 -528

No files matched your search

+21 -22
View File
@@ -1,17 +1,11 @@
<script setup>
import {
CheckCircleOutlined,
ClockCircleOutlined,
DollarOutlined,
FileTextOutlined,
PlusOutlined,
} from "@antdv-next/icons";
import { CircleCheck, Clock3, DollarSign, FileText, Plus } from "@lucide/vue";
const metrics = [
{ label: "今日新增", value: "0", tone: "blue", icon: FileTextOutlined },
{ label: "待处理", value: "0", tone: "amber", icon: ClockCircleOutlined },
{ label: "本月应收", value: "¥ 0.00", tone: "green", icon: DollarOutlined },
{ label: "本月完成", value: "0", tone: "gray", icon: CheckCircleOutlined },
{ label: "今日新增", value: "0", tone: "blue", icon: FileText },
{ label: "待处理", value: "0", tone: "amber", icon: Clock3 },
{ label: "本月应收", value: "¥ 0.00", tone: "green", icon: DollarSign },
{ label: "本月完成", value: "0", tone: "gray", icon: CircleCheck },
];
const columns = [
@@ -44,7 +38,7 @@ const columns = [
<span>最近更新</span>
</div>
<a-button type="primary">
<template #icon><PlusOutlined /></template>
<template #icon><Plus /></template>
新建业务
</a-button>
</div>
@@ -52,7 +46,7 @@ const columns = [
:columns="columns"
:data-source="[]"
:pagination="false"
size="middle"
size="small"
:scroll="{ x: 620 }"
/>
</div>
@@ -66,15 +60,15 @@ const columns = [
</div>
<nav class="quick-links">
<router-link to="/business/orders">
<FileTextOutlined />
<FileText />
<span>业务单据</span>
</router-link>
<router-link to="/fees/receivable">
<DollarOutlined />
<DollarSign />
<span>应收费用</span>
</router-link>
<router-link to="/reports/operations">
<CheckCircleOutlined />
<CircleCheck />
<span>运营报表</span>
</router-link>
</nav>
@@ -86,13 +80,13 @@ const columns = [
<style lang="scss" scoped>
.dashboard {
display: grid;
gap: 8px;
gap: 6px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
gap: 6px;
}
.metric-card {
@@ -100,7 +94,7 @@ const columns = [
min-width: 0;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
padding: 8px 10px;
border: 1px solid var(--fms-border);
border-radius: 4px;
background: var(--fms-surface);
@@ -135,6 +129,11 @@ const columns = [
border-radius: 4px;
font-size: 17px;
:deep(.lucide) {
width: 18px;
height: 18px;
}
&--blue {
background: #e6f4ff;
color: #1677ff;
@@ -160,7 +159,7 @@ const columns = [
.dashboard-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(260px, 1fr);
gap: 8px;
gap: 6px;
}
.panel {
@@ -171,11 +170,11 @@ const columns = [
&__header {
display: flex;
min-height: 46px;
min-height: 42px;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 7px 10px;
padding: 5px 8px;
border-bottom: 1px solid var(--fms-border);
h2 {
+441 -88
View File
@@ -1,140 +1,493 @@
<script setup>
import { computed, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@antdv-next/icons";
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";
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const rows = ref([]);
const total = ref(0);
const filters = reactive({ keyword: "" });
const filters = reactive({ field: "name", keyword: "" });
const pagination = reactive({ current: 1, pageSize: 20 });
const title = computed(() => route.meta.title);
const tableAreaRef = ref(null);
const tableScrollY = ref(240);
const advancedOpen = ref(false);
const appliedFilterCount = ref(0);
let resizeObserver;
const columns = computed(() => [
{ title: "编号", dataIndex: "id", key: "id", width: 180 },
{ title: "名称", dataIndex: "name", key: "name" },
{ title: "状态", dataIndex: "state", key: "state", width: 120 },
{ title: "更新时间", dataIndex: "updated_at", key: "updated_at", width: 180 },
]);
const columns = [
{ title: "编号", dataIndex: "id", key: "id", width: 160 },
{ title: "名称", dataIndex: "name", key: "name", width: 240 },
{ title: "状态", dataIndex: "state", key: "state", width: 100 },
{ title: "更新时间", dataIndex: "updated_at", key: "updated_at", width: 160 },
];
const fieldOptions = [
{ label: "名称", value: "name" },
{ label: "编号", value: "id" },
{ label: "状态", value: "state" },
];
const businessTypeOptions = ["海运", "空运", "陆运", "铁路"].map((value) => ({
label: value,
value,
}));
const statusOptions = ["草稿", "处理中", "已完成", "已取消"].map((value) => ({
label: value,
value,
}));
const ownerOptions = ["张三", "李四", "王五"].map((value) => ({ label: value, value }));
const departmentOptions = ["销售部", "操作部", "财务部"].map((value) => ({
label: value,
value,
}));
const settlementOptions = ["未结算", "部分结算", "已结算"].map((value) => ({
label: value,
value,
}));
const currencyOptions = ["CNY", "USD", "EUR"].map((value) => ({ label: value, value }));
const advancedFilters = reactive(createEmptyAdvancedFilters());
const draftFilterCount = computed(
() => Object.values(advancedFilters).filter(isFilterValueFilled).length,
);
function createEmptyAdvancedFilters() {
return {
documentNo: "",
customerName: "",
businessType: undefined,
status: undefined,
businessDate: [],
owner: undefined,
department: undefined,
settlementStatus: undefined,
currency: undefined,
amountMin: null,
amountMax: null,
creator: "",
createdDate: [],
remark: "",
};
}
function isFilterValueFilled(value) {
if (Array.isArray(value)) return value.some(isFilterValueFilled);
return value !== undefined && value !== null && value !== "";
}
function loadData() {
loading.value = true;
rows.value = [];
total.value = 0;
const keyword = filters.keyword.trim().toLowerCase();
const filteredRows = keyword
? mockRecords.filter((row) =>
String(row[filters.field] ?? "")
.toLowerCase()
.includes(keyword),
)
: mockRecords;
const start = (pagination.current - 1) * pagination.pageSize;
rows.value = filteredRows.slice(start, start + pagination.pageSize);
total.value = filteredRows.length;
loading.value = false;
}
function reset() {
filters.keyword = "";
pagination.current = 1;
function openDetail(record) {
router.push(`${route.path}/${encodeURIComponent(record.id)}`);
}
function changePage(page, pageSize) {
pagination.current = page;
pagination.pageSize = pageSize;
loadData();
}
function resetAdvancedFilters() {
Object.assign(advancedFilters, createEmptyAdvancedFilters());
}
function applyAdvancedFilters() {
pagination.current = 1;
appliedFilterCount.value = draftFilterCount.value;
advancedOpen.value = false;
loadData();
}
function updateTableHeight() {
const tableArea = tableAreaRef.value;
if (!tableArea) return;
const headerHeight =
tableArea.querySelector(".ant-table-thead")?.getBoundingClientRect().height ?? 39;
tableScrollY.value = Math.max(Math.floor(tableArea.clientHeight - headerHeight), 80);
}
onMounted(() => {
resizeObserver = new ResizeObserver(updateTableHeight);
if (tableAreaRef.value) resizeObserver.observe(tableAreaRef.value);
nextTick(updateTableHeight);
});
onBeforeUnmount(() => resizeObserver?.disconnect());
watch(() => route.meta.source, loadData, { immediate: true });
</script>
<template>
<div class="data-page">
<section class="data-toolbar">
<div class="data-toolbar__title">
<h1>{{ title }}</h1>
<span>共 {{ total }} 条</span>
</div>
<a-button type="primary">
<template #icon><PlusOutlined /></template>
<section class="filter-bar" aria-label="查询条件">
<a-button type="primary" class="toolbar-button">
<template #icon><Plus /></template>
新增
</a-button>
</section>
<section class="filter-bar">
<a-input
v-model:value="filters.keyword"
class="keyword-input"
placeholder="输入名称查询"
allow-clear
@press-enter="loadData"
>
<template #prefix><SearchOutlined /></template>
</a-input>
<a-button type="primary" @click="loadData">查询</a-button>
<a-button @click="reset">
<template #icon><ReloadOutlined /></template>
重置
</a-button>
<div class="filter-fields">
<a-space-compact class="search-compact">
<a-select
v-model:value="filters.field"
class="keyword-select"
:options="fieldOptions"
aria-label="查询字段"
/>
<a-input-search
id="data-keyword"
v-model:value="filters.keyword"
class="keyword-search"
allow-clear
enter-button="搜索"
@search="loadData"
/>
</a-space-compact>
<a-tooltip title="高级查询" placement="bottom">
<a-badge :count="appliedFilterCount" size="small">
<a-button
shape="circle"
class="round-action"
aria-label="高级查询"
@click="advancedOpen = true"
>
<template #icon><ListFilter /></template>
</a-button>
</a-badge>
</a-tooltip>
<a-tooltip title="更多" placement="bottom">
<a-button shape="circle" class="round-action" aria-label="更多">
<template #icon><Ellipsis /></template>
</a-button>
</a-tooltip>
</div>
</section>
<section class="table-panel">
<a-table
row-key="id"
:columns="columns"
:data-source="rows"
:loading="loading"
:pagination="{
current: pagination.current,
pageSize: pagination.pageSize,
total,
showSizeChanger: true,
showTotal: (value) => `共 ${value} 条`,
}"
:scroll="{ x: 760 }"
/>
<div
ref="tableAreaRef"
class="table-area"
:style="{ '--table-body-height': `${tableScrollY}px` }"
>
<a-table
row-key="id"
:columns="columns"
:data-source="rows"
:loading="loading"
bordered
size="small"
:pagination="false"
:scroll="{ x: 'max-content', y: tableScrollY }"
>
<template #bodyCell="{ column, text, record }">
<a-button
v-if="column.key === 'id'"
type="link"
class="table-id-link"
@click="openDetail(record)"
>
{{ text }}
</a-button>
<template v-else>{{ text }}</template>
</template>
</a-table>
</div>
<footer class="table-footer">
<a-pagination
size="small"
:current="pagination.current"
:page-size="pagination.pageSize"
:total="total"
show-size-changer
@change="changePage"
/>
</footer>
</section>
<a-drawer
v-model:open="advancedOpen"
root-class="advanced-filter-drawer"
title="高级查询"
placement="left"
:size="560"
:styles="{
body: { padding: '12px 16px' },
footer: { padding: '8px 16px' },
}"
>
<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-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>
<template #footer>
<div class="drawer-footer">
<a-button @click="resetAdvancedFilters">重置</a-button>
<a-button type="primary" @click="applyAdvancedFilters">
<template #icon><Search /></template>
查询
</a-button>
</div>
</template>
</a-drawer>
</div>
</template>
<style lang="scss" scoped>
.data-page {
display: grid;
gap: 8px;
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
overflow: hidden;
background: var(--fms-surface);
}
.data-toolbar,
.filter-bar {
display: flex;
flex: none;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 6px 8px;
}
.data-toolbar {
justify-content: space-between;
gap: 8px;
.filter-fields {
display: flex;
align-items: center;
gap: 6px;
}
&__title {
display: flex;
align-items: baseline;
gap: 6px;
.search-compact {
align-items: center;
margin-right: 6px;
}
h1 {
margin: 0;
color: var(--fms-text);
font-size: 16px;
}
.keyword-select {
width: 82px;
}
span {
color: var(--fms-text-secondary);
font-size: 11px;
}
.keyword-search {
width: 260px;
}
.toolbar-button {
border-radius: 4px;
}
.round-action {
width: 32px;
min-width: 32px;
height: 32px;
padding: 0;
border-radius: 50% !important;
:deep(.lucide) {
width: 14px;
height: 14px;
}
}
.filter-bar {
flex-wrap: wrap;
gap: 6px;
padding: 8px;
border: 1px solid var(--fms-border);
border-radius: 4px;
background: var(--fms-surface);
}
.keyword-input {
width: 240px;
}
.table-panel {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
flex-direction: column;
padding: 0;
border: 1px solid var(--fms-border);
border-radius: 4px;
background: var(--fms-surface);
overflow: hidden;
}
.table-area {
position: relative;
flex: 1;
min-height: 0;
margin: 8px;
overflow: hidden;
&::after {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 2;
height: 1px;
background: var(--fms-border);
content: "";
pointer-events: none;
}
:deep(.ant-table-wrapper) {
height: 100%;
}
:deep(.ant-table-body) {
height: var(--table-body-height);
min-height: var(--table-body-height);
}
:deep(.ant-table-tbody > tr:nth-child(even) > td) {
background: var(--fms-bg);
}
.table-id-link {
height: auto;
padding: 0;
font-size: inherit;
}
}
.table-footer {
display: flex;
flex: none;
min-height: 38px;
align-items: center;
justify-content: flex-end;
padding: 4px 8px;
}
.advanced-filter-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 12px;
row-gap: 0;
:deep(.ant-form-item) {
min-width: 0;
margin-bottom: 10px;
}
:deep(.ant-form-item-label) {
padding-bottom: 3px;
}
:deep(.ant-select),
:deep(.ant-picker),
:deep(.ant-input-number) {
width: 100%;
}
}
.filter-span-full {
grid-column: 1 / -1;
}
.drawer-footer {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
:global(.advanced-filter-drawer .ant-drawer-content-wrapper) {
max-width: 100vw;
}
@media (max-width: 520px) {
.advanced-filter-form {
grid-template-columns: minmax(0, 1fr);
}
.filter-span-full {
grid-column: 1;
}
}
</style>
+125
View File
@@ -0,0 +1,125 @@
<script setup>
import { computed, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ArrowLeft } from "@lucide/vue";
import { mockRecords } from "../data/mockRecords";
const route = useRoute();
const router = useRouter();
const remark = ref("");
const recordId = computed(() => String(route.params.id ?? ""));
const detailTitle = computed(() => route.meta.listTitle || route.meta.title || "详情");
const record = computed(
() =>
mockRecords.find((item) => item.id === recordId.value) ?? {
id: recordId.value,
name: `${detailTitle.value} ${recordId.value}`,
state: "草稿",
businessType: "未设置",
owner: "未设置",
customer: "未设置",
amount: "0.00",
currency: "CNY",
updated_at: "--",
},
);
function backToList() {
router.push(String(route.meta.listPath || "/dashboard"));
}
</script>
<template>
<div class="detail-page">
<header class="detail-toolbar">
<a-button type="text" size="small" @click="backToList">
<template #icon><ArrowLeft /></template>
返回列表
</a-button>
<div class="detail-heading">
<strong>{{ detailTitle }}</strong>
<span>{{ recordId }}</span>
</div>
</header>
<main class="detail-content">
<a-descriptions bordered size="small" :column="{ xs: 1, sm: 2, md: 3 }">
<a-descriptions-item label="编号">{{ recordId }}</a-descriptions-item>
<a-descriptions-item label="名称">{{ record.name }}</a-descriptions-item>
<a-descriptions-item label="状态">{{ record.state }}</a-descriptions-item>
<a-descriptions-item label="客户">{{ record.customer }}</a-descriptions-item>
<a-descriptions-item label="业务类型">{{ record.businessType }}</a-descriptions-item>
<a-descriptions-item label="负责人">{{ record.owner }}</a-descriptions-item>
<a-descriptions-item label="金额">{{ record.amount }} {{ record.currency }}</a-descriptions-item>
<a-descriptions-item label="更新时间">{{ record.updated_at }}</a-descriptions-item>
</a-descriptions>
<section class="detail-remark">
<div class="detail-remark__label">备注</div>
<a-textarea v-model:value="remark" :rows="3" />
</section>
</main>
</div>
</template>
<style lang="scss" scoped>
.detail-page {
display: flex;
height: 100%;
min-height: 0;
flex-direction: column;
overflow: hidden;
background: var(--fms-surface);
}
.detail-toolbar {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
min-height: 38px;
padding: 3px 8px;
}
.detail-toolbar :deep(.lucide) {
width: 14px;
height: 14px;
}
.detail-heading {
display: flex;
min-width: 0;
align-items: baseline;
gap: 8px;
color: var(--fms-text);
font-size: 13px;
strong {
font-weight: 600;
}
span {
color: var(--fms-text-secondary);
font-size: 12px;
}
}
.detail-content {
flex: 1;
min-height: 0;
margin: 8px;
overflow: auto;
}
.detail-remark {
margin-top: 12px;
&__label {
margin-bottom: 5px;
color: var(--fms-text);
font-size: 13px;
font-weight: 600;
}
}
</style>
+12 -9
View File
@@ -1,7 +1,7 @@
<script setup>
import { reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { LockOutlined, UserOutlined } from "@antdv-next/icons";
import { LockKeyhole, UserRound } from "@lucide/vue";
import { authApi } from "../services/api";
import { useAuthStore } from "../stores/auth";
@@ -12,7 +12,7 @@ const loading = ref(false);
const errorMessage = ref("");
const form = reactive({
orgid: "",
username: "",
userid: "",
password: "",
});
@@ -59,12 +59,12 @@ async function submit() {
<a-input v-model:value="form.orgid" size="large" autocomplete="organization" />
</a-form-item>
<a-form-item
label="用户名"
name="username"
:rules="[{ required: true, message: '请输入用户名' }]"
label="用户ID"
name="userid"
:rules="[{ required: true, message: '请输入用户ID' }]"
>
<a-input v-model:value="form.username" size="large" autocomplete="username">
<template #prefix><UserOutlined /></template>
<a-input v-model:value="form.userid" size="large" autocomplete="username">
<template #prefix><UserRound /></template>
</a-input>
</a-form-item>
<a-form-item
@@ -77,7 +77,7 @@ async function submit() {
size="large"
autocomplete="current-password"
>
<template #prefix><LockOutlined /></template>
<template #prefix><LockKeyhole /></template>
</a-input-password>
</a-form-item>
<a-button type="primary" html-type="submit" size="large" block :loading="loading">
@@ -91,7 +91,10 @@ async function submit() {
<style lang="scss" scoped>
.login-page {
display: grid;
min-height: 100vh;
width: 100%;
height: 100%;
min-height: 0;
overflow: auto;
place-items: center;
padding: 24px;
background: