Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)

含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-02 10:04:03 +00:00
commit 76f266645d
507 changed files with 99891 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+50
View File
@@ -0,0 +1,50 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default tseslint.config({
languageOptions: {
// other options...
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
},
})
```
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
- Optionally add `...tseslint.configs.stylisticTypeChecked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
```js
// eslint.config.js
import react from 'eslint-plugin-react'
export default tseslint.config({
// Set the react version
settings: { react: { version: '18.3' } },
plugins: {
// Add the react plugin
react,
},
rules: {
// other rules...
// Enable its recommended rules
...react.configs.recommended.rules,
...react.configs['jsx-runtime'].rules,
},
})
```
+28
View File
@@ -0,0 +1,28 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>风影智慧办公综合平台</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+47
View File
@@ -0,0 +1,47 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^5.5.2",
"@tanstack/react-query": "^5.102.8",
"@wangeditor/editor": "^5.1.23",
"@wangeditor/editor-for-react": "^1.0.6",
"antd": "^5.22.6",
"axios": "^1.20.0",
"dayjs": "^1.11.23",
"livekit-client": "^2.22.1",
"mammoth": "^1.12.2",
"pdfjs-dist": "4.10.38",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"xlsx": "^0.18.5",
"zustand": "^5.0.15"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"eslint": "^9.13.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.14",
"globals": "^15.11.0",
"typescript": "~5.6.2",
"typescript-eslint": "^8.11.0",
"vite": "^5.4.10"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild"
]
}
}
+3217
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+15
View File
@@ -0,0 +1,15 @@
<svg width="1024" height="1024" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="fy-bg" x1="10" y1="0" x2="70" y2="80" gradientUnits="userSpaceOnUse">
<stop stop-color="#5b4fe3"/>
<stop offset="1" stop-color="#7c3aed"/>
</linearGradient>
</defs>
<rect x="1" y="1" width="78" height="78" rx="20" fill="url(#fy-bg)"/>
<path d="M22 20h28c5 0 9 4 9 9v6" stroke="#fff" stroke-width="6.5" stroke-linecap="round" fill="none"/>
<path d="M22 20v40" stroke="#fff" stroke-width="6.5" stroke-linecap="round"/>
<path d="M28 34h22" stroke="#fff" stroke-width="5.5" stroke-linecap="round"/>
<path d="M34 26l16 28" stroke="#fff" stroke-width="5.5" stroke-linecap="round"/>
<path d="M50 26L34 54" stroke="#fff" stroke-width="5.5" stroke-linecap="round"/>
<path d="M20 58c14 10 32 10 44-6" stroke="#fff" stroke-width="5" stroke-linecap="round" fill="none"/>
</svg>

After

Width:  |  Height:  |  Size: 931 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+405
View File
@@ -0,0 +1,405 @@
import type { ReactNode } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { useEffect } from 'react';
import { Spin } from 'antd';
import { useAuth } from './store/auth';
import AppLayout from './layouts/AppLayout';
import LoginPage from './pages/LoginPage';
import DesktopLoginPage from './pages/DesktopLoginPage';
import LegalPage from './pages/LegalPage';
import OverviewPage from './pages/OverviewPage';
import BidCreatePage from './pages/BidCreatePage';
import BidListPage from './pages/BidListPage';
import BidDetailPage from './pages/BidDetailPage';
import ContractListPage from './pages/ContractListPage';
import ContractDetailPage from './pages/ContractDetailPage';
import LaborContractPage from './pages/LaborContractPage';
import OnboardPage from './pages/OnboardPage';
import WorkAssignPage from './pages/WorkAssignPage';
import { AssetRegistryPage } from './pages/AssetRegistryPage';
import OfficeApprovalsPage from './pages/OfficeApprovalsPage';
import OfficeTodosPage from './pages/OfficeTodosPage';
import OfficeApplyPage from './pages/OfficeApplyPage';
import MyFlowPage from './pages/MyFlowPage';
import NoticePage from './pages/NoticePage';
import NoticeDetailPage from './pages/NoticeDetailPage';
import HrNoticePage from './pages/HrNoticePage';
import DirectoryPage from './pages/DirectoryPage';
import ImPage from './pages/im/ImPage';
import MyExpensesPage from './pages/MyExpensesPage';
import ExpenseDetailPage from './pages/ExpenseDetailPage';
import ExpenseListPage, { LoanListPage } from './pages/ExpenseListPage';
import LoanDetailPage from './pages/LoanDetailPage';
import AssetPurchaseLedgerPage from './pages/AssetPurchaseLedgerPage';
import BondListPage from './pages/BondListPage';
import InvoiceListPage from './pages/InvoiceListPage';
import ProjectListPage from './pages/ProjectListPage';
import ProjectDetailPage from './pages/ProjectDetailPage';
import TaskListPage, { TimesheetPage } from './pages/TaskListPage';
import QualificationListPage, { BorrowListPage } from './pages/QualificationListPage';
import SealRequestListPage from './pages/SealRequestListPage';
import CalendarPage from './pages/CalendarPage';
import OrgPage from './pages/OrganizationPage';
import EmployeeListPage from './pages/EmployeePage';
import HrApplyPage from './pages/HrApplyPage';
import HrLeaveDetailPage from './pages/HrLeaveDetailPage';
import {
AttendancePage,
LifecyclePage,
PayrollPage,
PerformancePage,
} from './pages/HrPages';
import { AttendanceRulesPage, HrParamsPage } from './pages/HrSettingsPage';
import {
AclPage,
AclPermsPage,
AssetPage,
CashLockPage,
ConversionPage,
EfficiencyPage,
LogsPage,
LostBidsPage,
NumberRulesPage,
PaymentPage,
ProfitPage,
PurchasePage,
ResourceBoardPage,
SealRegistryPage,
WorkReportPage,
} from './pages/OpsPages';
import { ForbiddenPage, NotFoundPage } from './pages/StatusPages';
import SmsPage from './pages/SmsPage';
import AppReleasePage from './pages/AppReleasePage';
import MessagesChannelPage from './pages/MessagesChannelPage';
import OfficeSmsPage from './pages/OfficeSmsPage';
import {
ContactDirectoryPage,
DictsPage,
LegalEntityPage,
TenderPartyPage,
} from './pages/PartyPages';
const NOTICE_PUBLISH_ROLES = [
'hr',
'biz_director',
'tech_director',
'rd_director',
'3d_director',
'video_director',
'material_director',
];
function canSee(permissions: string[], roles: string[], code: string) {
if (roles.includes('admin') || roles.includes('owner')) return true;
if ((code === 'hr:notices' || code === 'office:sms') && NOTICE_PUBLISH_ROLES.some((r) => roles.includes(r))) return true;
return permissions.includes(code) || permissions.some((p) => p.startsWith(`${code}:`));
}
function Require({ code, children }: { code: string; children: ReactNode }) {
const roles = useAuth((s) => s.roles);
const permissions = useAuth((s) => s.permissions);
if (!canSee(permissions, roles, code)) return <Navigate to="/403" replace />;
return <>{children}</>;
}
function Guard({ children, allowOnboard }: { children: ReactNode; allowOnboard?: boolean }) {
const { loaded, username, loadSession, needsOnboard } = useAuth();
useEffect(() => {
void loadSession();
}, [loadSession]);
if (!loaded) {
return (
<div style={{ display: 'grid', placeItems: 'center', minHeight: '100vh' }}>
<Spin />
</div>
);
}
if (!username) return <Navigate to="/login" replace />;
if (needsOnboard && !allowOnboard) return <Navigate to="/office/onboard" replace />;
return <>{children}</>;
}
export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/login/desktop" element={<DesktopLoginPage />} />
<Route path="/legal/:kind" element={<LegalPage />} />
<Route
path="/office/onboard"
element={
<Guard allowOnboard>
<OnboardPage />
</Guard>
}
/>
<Route
path="/"
element={
<Guard>
<AppLayout />
</Guard>
}
>
<Route index element={<Navigate to="/workbench" replace />} />
<Route path="workbench" element={<OverviewPage />} />
<Route path="office/overview" element={<Navigate to="/workbench" replace />} />
<Route path="office/todos" element={<OfficeTodosPage />} />
<Route path="office/bid-open/:id" element={<BidDetailPage officeOpen />} />
<Route
path="office/approvals"
element={
<Require code="office:approvals">
<OfficeApprovalsPage />
</Require>
}
/>
<Route path="office/flow" element={<MyFlowPage />} />
<Route path="office/calendar" element={<CalendarPage />} />
<Route path="office/reports" element={<WorkReportPage />} />
<Route path="office/hr-apply" element={<HrApplyPage />} />
<Route path="office/leaves/:id" element={<HrLeaveDetailPage />} />
<Route path="office/seal-apply" element={<SealRequestListPage mode="apply" />} />
<Route path="office/apply" element={<OfficeApplyPage />} />
<Route path="office/notices" element={<NoticePage />} />
<Route path="office/notices/:id" element={<NoticeDetailPage />} />
<Route
path="office/sms"
element={
<Require code="office:sms">
<OfficeSmsPage />
</Require>
}
/>
<Route path="office/policies" element={<Navigate to="/office/notices" replace />} />
<Route path="office/directory" element={<DirectoryPage />} />
<Route path="office/im" element={<ImPage />} />
<Route
path="office/assign"
element={
<Require code="office:assign">
<WorkAssignPage />
</Require>
}
/>
<Route path="office/attendance" element={<Navigate to="/office/hr-apply" replace />} />
<Route path="office/expenses" element={<MyExpensesPage />}>
<Route path="new" element={<ExpenseDetailPage />} />
<Route path=":id" element={<ExpenseDetailPage />} />
</Route>
<Route path="office/loans" element={<MyExpensesPage />}>
<Route path="new" element={<LoanDetailPage />} />
<Route path=":id" element={<LoanDetailPage />} />
</Route>
<Route path="party/tenders" element={<Require code="party"><TenderPartyPage /></Require>} />
<Route path="party/entities" element={<Require code="party"><LegalEntityPage /></Require>} />
<Route path="party/contacts" element={<Require code="party"><ContactDirectoryPage /></Require>} />
<Route path="seal/qualifications" element={<Require code="seal"><QualificationListPage /></Require>} />
<Route path="seal/borrows" element={<Require code="seal"><BorrowListPage /></Require>} />
<Route path="seal/requests" element={<Require code="seal"><SealRequestListPage mode="register" /></Require>} />
<Route path="seal/registry" element={<Require code="seal"><SealRegistryPage /></Require>} />
<Route path="bid/screening" element={<Require code="bid"><BidListPage title="项目筛选" bucket="screening" extra /></Require>} />
<Route path="bid/screening/new" element={<Require code="bid"><BidCreatePage /></Require>} />
<Route
path="bid/special-screening"
element={<Require code="bid"><BidListPage title="特殊筛选" extra special /></Require>}
/>
<Route path="bid/special-screening/new" element={<Require code="bid"><BidCreatePage special /></Require>} />
<Route path="bid/cases/:id" element={<Require code="bid"><BidDetailPage /></Require>} />
<Route path="bid/making" element={<Navigate to="/bid/drafting" replace />} />
<Route path="bid/drafting" element={<Require code="bid"><BidListPage title="标书制作" bucket="drafting" /></Require>} />
<Route
path="bid/special-drafting"
element={<Require code="bid"><BidListPage title="特殊制标" bucket="drafting" special /></Require>}
/>
<Route path="bid/execution" element={<Require code="bid"><BidListPage title="投标执行" bucket="execution" /></Require>} />
<Route path="bid/result" element={<Require code="bid"><BidListPage title="开标结果" bucket="result" /></Require>} />
<Route
path="bid/terminated"
element={<Require code="bid"><BidListPage title="终止投标" bucket="terminated" /></Require>}
/>
<Route path="contract/list" element={<Navigate to="/contract/incremental" replace />} />
<Route path="contract/approval" element={<Navigate to="/contract/tender" replace />} />
<Route
path="contract/tender"
element={
<Require code="contract">
<ContractListPage
title="投标合同"
description="中标后自动生成草稿,商务完善编号和条款后提交审批。也可在本页查看已有合同。不与直签、其他合同重复。"
bucket="tender"
/>
</Require>
}
/>
<Route
path="contract/direct"
element={
<Require code="contract">
<ContractListPage
title="直签合同"
description="不经过投标、直接签订的合同。在本页新建。"
bucket="direct"
/>
</Require>
}
/>
<Route
path="contract/incremental"
element={
<Require code="contract">
<ContractListPage
title="其他合同"
description="除投标合同、直签合同以外的合同。在本页新建。"
bucket="incremental"
/>
</Require>
}
/>
<Route
path="contract/hr"
element={
<Require code="contract:hr">
<LaborContractPage />
</Require>
}
/>
<Route
path="contract/milestones"
element={<Navigate to="/contract/incremental" replace />}
/>
<Route path="contract/:id" element={<Require code="contract"><ContractDetailPage /></Require>} />
<Route
path="asset/digital"
element={
<Require code="asset">
<AssetRegistryPage kind="DIGITAL" title="数字资产" />
</Require>
}
/>
<Route
path="asset/fixed"
element={
<Require code="asset">
<AssetRegistryPage kind="FIXED" title="固定资产" />
</Require>
}
/>
<Route
path="project/list"
element={
<Require code="project:list">
<ProjectListPage
title="项目台账"
description="中标后自动写入。未开始和执行中的项目也可在此补登直签。预算、报销和花费都在办理里看:商务差旅进商务,技术部门差旅、工时和采购进技术。"
bucket="ledger"
/>
</Require>
}
/>
<Route path="project/tasks" element={<TaskListPage />} />
<Route path="project/timesheets" element={<TimesheetPage />} />
<Route path="project/resources" element={<ResourceBoardPage />} />
<Route path="project/budgets" element={<Navigate to="/project/list" replace />} />
<Route
path="project/acceptance"
element={
<ProjectListPage
title="项目验收"
description="台账里加入验收材料后,项目会出现在这里。点操作只看出验收材料,不必在本页新增项目。"
bucket="acceptance"
mode="acceptance"
/>
}
/>
<Route path="project/changes" element={<Navigate to="/project/list" replace />} />
<Route path="project/purchases" element={<PurchasePage />} />
<Route path="project/assets" element={<AssetPage />} />
<Route path="project/:id" element={<ProjectDetailPage />} />
<Route path="finance/expenses" element={<ExpenseListPage />}>
<Route path=":id" element={<ExpenseDetailPage />} />
</Route>
<Route path="finance/loans" element={<LoanListPage />}>
<Route path=":id" element={<LoanDetailPage />} />
</Route>
<Route
path="finance/asset-purchases"
element={
<Require code="finance:asset-purchases">
<AssetPurchaseLedgerPage />
</Require>
}
/>
<Route path="finance/bonds" element={<Navigate to="/finance/bonds/tender" replace />} />
<Route path="finance/bonds/tender" element={<BondListPage bondType="TENDER" />} />
<Route path="finance/bonds/performance" element={<BondListPage bondType="PERFORMANCE" />} />
<Route path="finance/bonds/warranty" element={<BondListPage bondType="WARRANTY" />} />
<Route path="finance/invoices" element={<InvoiceListPage />} />
<Route path="finance/payments" element={<PaymentPage />} />
<Route path="hr/org" element={<OrgPage />} />
<Route path="hr/employees" element={<EmployeeListPage />} />
<Route path="hr/lifecycle" element={<LifecyclePage />} />
<Route path="hr/attendance" element={<AttendancePage />} />
<Route path="hr/payroll" element={<PayrollPage />} />
<Route path="hr/performance" element={<PerformancePage />} />
<Route
path="hr/notices"
element={
<Require code="hr:notices">
<HrNoticePage />
</Require>
}
/>
<Route
path="hr/notices/:id"
element={
<Require code="hr:notices">
<HrNoticePage />
</Require>
}
/>
<Route
path="hr/policies"
element={
<Require code="hr:policies">
<HrNoticePage />
</Require>
}
/>
<Route
path="hr/policies/:id"
element={
<Require code="hr:policies">
<HrNoticePage />
</Require>
}
/>
<Route path="report/conversion" element={<Require code="report"><ConversionPage /></Require>} />
<Route path="report/lost" element={<Require code="report"><LostBidsPage /></Require>} />
<Route path="report/profit" element={<Require code="report"><ProfitPage /></Require>} />
<Route path="report/efficiency" element={<Require code="report"><EfficiencyPage /></Require>} />
<Route path="report/cash" element={<Require code="report"><CashLockPage /></Require>} />
<Route path="system/acl" element={<AclPage />} />
<Route
path="system/perms"
element={
<Require code="system:perms">
<AclPermsPage />
</Require>
}
/>
<Route path="system/hr-params" element={<HrParamsPage />} />
<Route path="system/org" element={<Navigate to="/hr/org" replace />} />
<Route path="system/attendance-rules" element={<AttendanceRulesPage />} />
<Route path="system/dicts" element={<DictsPage />} />
<Route path="system/numbers" element={<NumberRulesPage />} />
<Route path="system/sms" element={<SmsPage />} />
<Route path="system/app-release" element={<AppReleasePage />} />
<Route path="system/messages" element={<MessagesChannelPage />} />
<Route path="system/logs" element={<LogsPage />} />
<Route path="403" element={<ForbiddenPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
);
}
+8
View File
@@ -0,0 +1,8 @@
import { Tag } from 'antd';
import { ZH_COLOR, zhCtx } from './labels';
export function StatusTag({ value, context }: { value?: string | number | null; context?: string }) {
if (value === undefined || value === null || value === '') return <span></span>;
const key = String(value);
return <Tag color={ZH_COLOR[key] || 'default'}>{zhCtx(key, context)}</Tag>;
}
+32
View File
@@ -0,0 +1,32 @@
const ADMIN = ['admin', 'owner'];
export function canHr(roles: string[]) {
return [...ADMIN, 'hr', 'biz_director'].some((r) => roles.includes(r));
}
export function canConfirmPay(roles: string[]) {
return [...ADMIN, 'hr', 'biz_director'].some((r) => roles.includes(r));
}
export function canApproveFin(roles: string[]) {
return [...ADMIN, 'finance', 'biz_director'].some((r) => roles.includes(r));
}
const ACTIONS = new Set(['view', 'create', 'edit', 'delete', 'approve']);
/** 菜单下的具体操作。还没配操作项时,有该菜单就视为能做。 */
export function canDo(
permissions: string[],
roles: string[],
menuCode: string,
action: 'view' | 'create' | 'edit' | 'delete' | 'approve',
) {
if (ADMIN.some((r) => roles.includes(r))) return true;
if (permissions.includes(`${menuCode}:${action}`)) return true;
const hasActions = permissions.some((p) => {
const tail = p.slice(menuCode.length + 1);
return p.startsWith(`${menuCode}:`) && ACTIONS.has(tail);
});
if (hasActions) return false;
return permissions.includes(menuCode) || permissions.some((p) => p.startsWith(`${menuCode}:`));
}
+165
View File
@@ -0,0 +1,165 @@
import axios from 'axios';
function requestId() {
const c = globalThis.crypto as Crypto | undefined;
if (c && typeof c.randomUUID === 'function') {
return c.randomUUID();
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
const n = (Math.random() * 16) | 0;
const v = ch === 'x' ? n : (n & 0x3) | 0x8;
return v.toString(16);
});
}
const mem = { access: '', refresh: '', kind: 'web' };
const tabCh = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('fy-web-session') : null;
function persistKind(kind?: string | null) {
const v = kind === 'desktop' || kind === 'mobile' ? kind : 'web';
mem.kind = v;
sessionStorage.setItem('clientKind', v);
}
function applyTokens(access?: string | null, refresh?: string | null, kind?: string | null, broadcast = true) {
if (access) {
mem.access = access;
sessionStorage.setItem('accessToken', access);
}
if (refresh) {
mem.refresh = refresh;
sessionStorage.setItem('refreshToken', refresh);
}
if (kind) persistKind(kind);
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
if (broadcast) {
tabCh?.postMessage({ type: 'set', access: mem.access, refresh: mem.refresh, kind: mem.kind });
}
}
tabCh?.addEventListener('message', (e: MessageEvent) => {
const data = e.data as { type?: string; access?: string; refresh?: string; kind?: string } | undefined;
if (data?.type === 'ask' && mem.access) {
tabCh.postMessage({ type: 'set', access: mem.access, refresh: mem.refresh, kind: mem.kind });
return;
}
if (data?.type === 'set' && data.access) {
applyTokens(data.access, data.refresh, data.kind, false);
return;
}
if (data?.type === 'clear') {
mem.access = '';
mem.refresh = '';
sessionStorage.removeItem('accessToken');
sessionStorage.removeItem('refreshToken');
sessionStorage.removeItem('clientKind');
}
});
export const tokenStore = {
getAccess() {
return mem.access || sessionStorage.getItem('accessToken') || '';
},
getRefresh() {
return mem.refresh || sessionStorage.getItem('refreshToken') || '';
},
getKind() {
return mem.kind || sessionStorage.getItem('clientKind') || 'web';
},
set(access?: string | null, refresh?: string | null, kind?: string | null) {
applyTokens(access, refresh, kind);
},
clear() {
mem.access = '';
mem.refresh = '';
mem.kind = 'web';
sessionStorage.removeItem('accessToken');
sessionStorage.removeItem('refreshToken');
sessionStorage.removeItem('clientKind');
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
tabCh?.postMessage({ type: 'clear' });
},
/** 同浏览器多标签共用会话;关浏览器后 sessionStorage 清空,需重新登录。 */
waitShare() {
if (this.getAccess() || !tabCh) return Promise.resolve();
return new Promise<void>((resolve) => {
const timer = window.setTimeout(() => {
tabCh.removeEventListener('message', onMsg);
resolve();
}, 160);
const onMsg = (e: MessageEvent) => {
if (e.data?.type === 'set' && e.data.access) {
window.clearTimeout(timer);
tabCh.removeEventListener('message', onMsg);
resolve();
}
};
tabCh.addEventListener('message', onMsg);
tabCh.postMessage({ type: 'ask' });
});
},
};
let refreshing: Promise<boolean> | null = null;
export const api = axios.create({
baseURL: '/api/v1',
timeout: 30000,
});
api.interceptors.request.use((config) => {
const token = tokenStore.getAccess();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
config.headers['X-Request-Id'] = requestId();
return config;
});
api.interceptors.response.use(
(res) => res.data,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
original._retry = true;
const refreshToken = tokenStore.getRefresh();
if (refreshToken && !original.url?.includes('/auth/refresh')) {
try {
if (!refreshing) {
refreshing = api
.post('/auth/refresh', { refreshToken, clientKind: tokenStore.getKind() || 'web' })
.then((data: { data?: { accessToken?: string; refreshToken?: string } }) => {
const body = (data?.data || data) as { accessToken?: string; refreshToken?: string };
tokenStore.set(body.accessToken, body.refreshToken, tokenStore.getKind());
return Boolean(body.accessToken);
})
.catch(() => false)
.finally(() => {
refreshing = null;
});
}
const ok = await refreshing;
if (ok) {
original.headers.Authorization = `Bearer ${tokenStore.getAccess()}`;
return api(original);
}
} catch {
/* fallthrough */
}
tokenStore.clear();
window.location.href = '/login';
} else {
tokenStore.clear();
if (window.location.pathname !== '/login' && !window.location.pathname.startsWith('/login/')) {
window.location.href = '/login';
}
}
}
const message = error.response?.data?.message || error.message || '请求失败';
return Promise.reject(new Error(message));
},
);
export type ApiOk<T> = { code: number; message: string; data: T; requestId: string };
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,67 @@
import { useEffect, useRef } from 'react';
export default function AmbientBackground() {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let frame = 0;
let width = (canvas.width = window.innerWidth);
let height = (canvas.height = window.innerHeight);
const onResize = () => {
width = canvas.width = window.innerWidth;
height = canvas.height = window.innerHeight;
};
window.addEventListener('resize', onResize);
const particles = Array.from({ length: 32 }, () => ({
x: Math.random() * width,
y: Math.random() * height,
size: Math.random() * 3 + 1.2,
speedY: -(Math.random() * 0.4 + 0.15),
speedX: (Math.random() - 0.5) * 0.2,
opacity: Math.random() * 0.5 + 0.2,
pulse: Math.random() * Math.PI,
pulseSpeed: Math.random() * 0.03 + 0.01,
}));
const render = () => {
ctx.clearRect(0, 0, width, height);
for (const p of particles) {
p.y += p.speedY;
p.x += p.speedX;
p.pulse += p.pulseSpeed;
if (p.y < -10) {
p.y = height + 10;
p.x = Math.random() * width;
}
if (p.x < -10) p.x = width + 10;
if (p.x > width + 10) p.x = -10;
const a = p.opacity * (0.7 + 0.3 * Math.sin(p.pulse));
ctx.beginPath();
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
ctx.fillStyle = `rgba(107, 56, 212, ${a * 0.45})`;
ctx.fill();
}
frame = requestAnimationFrame(render);
};
render();
return () => {
window.removeEventListener('resize', onResize);
cancelAnimationFrame(frame);
};
}, []);
return (
<div className="yzl-ambient" aria-hidden>
<div className="yzl-orb yzl-orb-a" />
<div className="yzl-orb yzl-orb-b" />
<div className="yzl-orb yzl-orb-c" />
<canvas ref={canvasRef} className="yzl-particles" />
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
export function BrandLogo({ size = 36, className }: { size?: number; className?: string }) {
const id = `fy-${size}`;
return (
<svg
className={className}
width={size}
height={size}
viewBox="0 0 80 80"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden
>
<defs>
<linearGradient id={`${id}-bg`} x1="10" y1="0" x2="70" y2="80" gradientUnits="userSpaceOnUse">
<stop stopColor="#5b4fe3" />
<stop offset="1" stopColor="#7c3aed" />
</linearGradient>
</defs>
<rect x="1" y="1" width="78" height="78" rx="20" fill={`url(#${id}-bg)`} />
<path
d="M22 20h28c5 0 9 4 9 9v6"
stroke="#fff"
strokeWidth="6.5"
strokeLinecap="round"
fill="none"
/>
<path d="M22 20v40" stroke="#fff" strokeWidth="6.5" strokeLinecap="round" />
<path d="M28 34h22" stroke="#fff" strokeWidth="5.5" strokeLinecap="round" />
<path d="M34 26l16 28" stroke="#fff" strokeWidth="5.5" strokeLinecap="round" />
<path d="M50 26L34 54" stroke="#fff" strokeWidth="5.5" strokeLinecap="round" />
<path
d="M20 58c14 10 32 10 44-6"
stroke="#fff"
strokeWidth="5"
strokeLinecap="round"
fill="none"
/>
</svg>
);
}
@@ -0,0 +1,42 @@
import { Button, Popover, Typography } from 'antd';
import { DownloadOutlined, LaptopOutlined } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
export function DesktopDownload() {
const q = useQuery({
queryKey: ['desktop-release-public'],
queryFn: async () => {
const res = await api.get('/system/app-release');
return ((res as { data?: { windowsUrl?: string; version?: string } }).data ?? res) as {
windowsUrl?: string;
version?: string;
};
},
staleTime: 60_000,
});
const url = q.data?.windowsUrl || '';
const version = q.data?.version || '';
const content = url ? (
<div className="client-dl-card">
<Typography.Text type="secondary">Windows {version ? ` v${version}` : ''}</Typography.Text>
<Button type="primary" icon={<DownloadOutlined />} href={url} download className="client-dl-btn">
</Button>
<Typography.Text type="secondary" className="client-dl-hint">
.exe
</Typography.Text>
</div>
) : (
<div className="client-dl-card">
<Typography.Text type="secondary">Windows </Typography.Text>
</div>
);
return (
<Popover placement="bottomRight" trigger={['hover', 'click']} content={content} overlayClassName="mobile-dl-pop">
<Button type="text" icon={<LaptopOutlined />}>
Windows端
</Button>
</Popover>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { Button, Popover, Typography } from 'antd';
import { CodeOutlined, DownloadOutlined } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
export function LinuxDownload() {
const q = useQuery({
queryKey: ['linux-release-public'],
queryFn: async () => {
const res = await api.get('/system/app-release');
return ((res as { data?: { linuxDebUrl?: string; linuxUrl?: string; version?: string } }).data ?? res) as {
linuxDebUrl?: string;
linuxUrl?: string;
version?: string;
};
},
staleTime: 60_000,
});
const debUrl = q.data?.linuxDebUrl || '';
const tarUrl = q.data?.linuxUrl || '';
const version = q.data?.version || '';
const content = debUrl || tarUrl ? (
<div className="client-dl-card">
<Typography.Text type="secondary">Linux {version ? ` v${version}` : ''}</Typography.Text>
{debUrl ? (
<Button type="primary" icon={<DownloadOutlined />} href={debUrl} download className="client-dl-btn">
deb
</Button>
) : null}
{tarUrl ? (
<Button type={debUrl ? 'default' : 'primary'} icon={<DownloadOutlined />} href={tarUrl} download className="client-dl-btn">
tar.gz
</Button>
) : null}
<Typography.Text type="secondary" className="client-dl-hint">
{debUrl
? 'deb 适用于 Ubuntu / Debian / Deepin 25sudo dpkg -i fengying-oa_amd64.deb'
: '解压 tar.gz 后运行 fengying_oa'}
</Typography.Text>
</div>
) : (
<div className="client-dl-card">
<Typography.Text type="secondary">Linux </Typography.Text>
</div>
);
return (
<Popover placement="bottomRight" trigger={['hover', 'click']} content={content} overlayClassName="mobile-dl-pop">
<Button type="text" icon={<CodeOutlined />}>
Linux端
</Button>
</Popover>
);
}
@@ -0,0 +1,48 @@
import { Button, Popover, QRCode, Typography } from 'antd';
import type { TooltipPlacement } from 'antd/es/tooltip';
import { DownloadOutlined, MobileOutlined } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
function fallbackApk() {
if (typeof window === 'undefined') return 'https://oa.fysxkj.com/fengying-oa.apk';
return `${window.location.origin}/fengying-oa.apk`;
}
export function MobileDownload({
compact = false,
placement = 'bottomRight',
}: {
compact?: boolean;
placement?: TooltipPlacement;
}) {
const q = useQuery({
queryKey: ['app-release-public'],
queryFn: async () => {
const res = await api.get('/system/app-release');
return ((res as { data?: { url?: string; version?: string } }).data ?? res) as {
url?: string;
version?: string;
};
},
staleTime: 60_000,
});
const url = q.data?.url || fallbackApk();
const version = q.data?.version || '';
const card = (
<div className="mobile-dl-card">
<QRCode value={url} size={148} bordered={false} />
<Typography.Text type="secondary"> Android {version ? ` v${version}` : ''}</Typography.Text>
<Button type="link" icon={<DownloadOutlined />} href={url} download="fengying-oa.apk" className="mobile-dl-link-btn">
APK
</Button>
</div>
);
return (
<Popover content={card} trigger={['hover', 'click']} placement={placement} overlayClassName="mobile-dl-pop">
<Button type="text" icon={<MobileOutlined />} className={compact ? 'mobile-dl-btn' : undefined}>
</Button>
</Popover>
);
}
+17
View File
@@ -0,0 +1,17 @@
const ICP = '苏ICP备2026017371号-2';
const COMPANY = '江苏风影随行科技有限公司';
const PHONE = '13142801138';
export function SiteFooter({ compact = false }: { compact?: boolean }) {
return (
<footer className={compact ? 'site-beian site-beian-compact' : 'site-beian'}>
<span>{COMPANY}</span>
<span className="site-beian-sep">|</span>
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noreferrer">
{ICP}
</a>
<span className="site-beian-sep">|</span>
<a href={`tel:${PHONE}`}> {PHONE}</a>
</footer>
);
}
+76
View File
@@ -0,0 +1,76 @@
export type DeptNode = {
id: string;
name: string;
code?: string | null;
parentId?: string | null;
};
function companyRootId(depts: DeptNode[]) {
const roots = depts.filter((d) => !d.parentId);
const named = roots.find((d) => d.name?.includes('公司') || d.code === 'ROOT' || d.code === 'OA_1');
return named?.id || roots[0]?.id;
}
export function isTopLevelDept(depts: DeptNode[], dept: DeptNode) {
const rootId = companyRootId(depts);
if (!rootId) return !dept.parentId;
if (dept.id === rootId) return false;
return dept.parentId === rootId || !dept.parentId;
}
export function topLevelDepartments(depts: DeptNode[]) {
return depts.filter((d) => isTopLevelDept(depts, d)).sort((a, b) => a.name.localeCompare(b.name, 'zh'));
}
export function topLevelOf(depts: DeptNode[], deptId?: string | null): DeptNode | undefined {
if (!deptId) return undefined;
const byId = new Map(depts.map((d) => [d.id, d]));
const rootId = companyRootId(depts);
let cur = byId.get(deptId);
const seen = new Set<string>();
while (cur && !seen.has(cur.id)) {
seen.add(cur.id);
if (isTopLevelDept(depts, cur)) return cur;
if (!cur.parentId || cur.parentId === rootId) return cur.id === rootId ? undefined : cur;
cur = byId.get(cur.parentId);
}
return undefined;
}
export function topLevelNameOf(depts: DeptNode[], deptId?: string | null, fallback?: string | null) {
return topLevelOf(depts, deptId)?.name || fallback || '';
}
export function topLevelNameByLeafName(depts: DeptNode[], name?: string | null) {
if (!name) return '';
const leaf = depts.find((d) => d.name === name);
if (!leaf) return name;
return topLevelOf(depts, leaf.id)?.name || name;
}
export function descendantIds(depts: DeptNode[], root?: string) {
if (!root) return null;
const kids = new Map<string, string[]>();
for (const d of depts) {
if (!d.parentId) continue;
const arr = kids.get(d.parentId) || [];
arr.push(d.id);
kids.set(d.parentId, arr);
}
const out = new Set<string>([root]);
const stack = [root];
while (stack.length) {
const id = stack.pop()!;
for (const c of kids.get(id) || []) {
if (!out.has(c)) {
out.add(c);
stack.push(c);
}
}
}
return out;
}
export function topLevelSelectOptions(depts: DeptNode[]) {
return topLevelDepartments(depts).map((d) => ({ value: d.id, label: d.name }));
}
+36
View File
@@ -0,0 +1,36 @@
/** 浏览器/卫星 GPS 是 WGS-84,腾讯地图是 GCJ-02。国内不转换会偏约 300600 米。 */
const A = 6378245.0;
const EE = 0.00669342162296594323;
const PI = Math.PI;
function outOfChina(lat: number, lng: number) {
return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271;
}
function transformLat(x: number, y: number) {
let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
ret += ((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) / 3.0;
ret += ((20.0 * Math.sin(y * PI) + 40.0 * Math.sin((y / 3.0) * PI)) * 2.0) / 3.0;
ret += ((160.0 * Math.sin((y / 12.0) * PI) + 320 * Math.sin((y * PI) / 30.0)) * 2.0) / 3.0;
return ret;
}
function transformLng(x: number, y: number) {
let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
ret += ((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) / 3.0;
ret += ((20.0 * Math.sin(x * PI) + 40.0 * Math.sin((x / 3.0) * PI)) * 2.0) / 3.0;
ret += ((150.0 * Math.sin((x / 12.0) * PI) + 300.0 * Math.sin((x / 30.0) * PI)) * 2.0) / 3.0;
return ret;
}
export function wgs84ToGcj02(lat: number, lng: number) {
if (outOfChina(lat, lng)) return { lat, lng };
let dLat = transformLat(lng - 105.0, lat - 35.0);
let dLng = transformLng(lng - 105.0, lat - 35.0);
const radLat = (lat / 180.0) * PI;
let magic = 1 - EE * Math.sin(radLat) * Math.sin(radLat);
const sqrtMagic = Math.sqrt(magic);
dLat = (dLat * 180.0) / (((A * (1 - EE)) / (magic * sqrtMagic)) * PI);
dLng = (dLng * 180.0) / ((A / sqrtMagic) * Math.cos(radLat) * PI);
return { lat: lat + dLat, lng: lng + dLng };
}
File diff suppressed because it is too large Load Diff
+224
View File
@@ -0,0 +1,224 @@
/** 后端枚举 → 中文。未知码才回落到原文,避免表格直接露出英文。 */
export const ZH: Record<string, string> = {
TECH_INITIAL: '技术初审',
FETCH: '取标',
TECH_FINAL: '技术终审',
DRAFTING: '制标',
REVIEW1: '商务一审',
REVIEW2: '商务二审',
PRINT: '印封',
LIST: '投标执行',
PENDING: '待办理',
WON: '中标',
LOST: '未中标',
FAILED: '流标废标',
TERMINATED: '已终止',
DRAFT: '草稿',
APPROVED: '已通过',
REJECTED: '已驳回',
CANCELLED: '已取消',
ACTIVE: '启用',
DISABLED: '停用',
OPEN: '未还 / 进行中',
OFFSET: '已冲账',
DONE: '已完成',
RETURNED: '已交回',
PAID: '已付',
UNPAID: '待付',
TO_ISSUE: '待开',
ISSUED: '已开',
RECEIVED: '已回',
OVERDUE: '逾期',
INTERNAL_ACCEPTED: '内部已验收',
ACCEPTED: '已验收',
ALL: '全部',
DEPT: '本部门',
SELF: '本人',
BID: '投标',
PROJECT: '正式项目',
ADMIN: '行政',
MARKETING: '营销',
LABOR: '人工',
TRAVEL: '差旅',
PURCHASE: '采购',
TENDER: '投标保证金',
PERFORMANCE: '履约保证金',
WARRANTY: '项目质保金',
EXPENSE: '报销打款',
BOND: '保证金打出',
INTERN: '实习',
PROBATION: '试用',
REGULAR: '正式',
LEFT: '离职',
SINGLE: '单休',
DOUBLE: '双休',
MALE: '男',
FEMALE: '女',
HIGH_SCHOOL: '高中及以下',
COLLEGE: '专科',
BACHELOR: '本科',
MASTER: '硕士',
DOCTOR: '博士',
HIRE: '入职',
REGULARIZE: '转正',
TRANSFER: '调岗',
LEAVE: '离职',
CREATE: '发起',
SUBMIT: '提交',
APPROVE: '同意',
REJECT: '驳回',
DRAFTER: '制标人',
FETCHER: '取标人',
COORDINATOR: '特殊协调人',
BIDDER: '投标人',
SPECIAL: '特殊筛选',
FLOW: '普通筛选',
BID_TECH_INITIAL: '技术初审',
BID_TECH_FINAL: '技术终审',
BID_FETCH: '取标',
BID_ASSIGN_FETCH: '派取标人',
BID_ASSIGN_DRAFT: '派制标人',
BID_FINAL_PASSED: '终审通过通知',
BID_DRAFT_DONE: '制标完成通知',
BID_TERMINATED: '投标终止通知',
BID_DRAFT: '制标',
BID_REVIEW1: '商务一审',
BID_REVIEW2: '商务二审',
BID_PRINT: '印封',
BID_COMPLETE: '补全投标',
BID_EXECUTION: '投标执行',
BID_ASSIGN_FETCHER: '派取标人',
CONFIRMED: '已确认',
INTERNAL: '内部验收',
CUSTOMER: '客户验收',
CONTRACT_APPROVAL: '合同审批',
CONTRACT_DRAFT: '完善合同草稿',
EXPENSE_APPROVAL: '报销确认',
LOAN_APPROVAL: '借款确认',
LOAN_OFFSET: '借款冲账',
PROJECT_TIMESHEET: '工时填报',
PROJECT_ACCEPT: '项目验收',
SEAL_APPROVAL: '用章审批',
SEAL_RETURN: '印章归还',
CREDENTIAL_RETURN: '证照归还',
BID_OPEN: '开标反馈',
WORK_ASSIGN: '工作安排',
OFFICE_APPLY: '办公申请',
HR_LEAVE: '人事申请',
SEAL_REQUEST: '用章申请',
WORK_REPORT: '工作汇报',
NOTICE: '通知公告',
ASSET_BUY: '资产购置',
ASSET_USE: '资产领用',
VEHICLE: '用车',
DIGITAL: '数字资产',
FIXED: '固定资产',
IN_USE: '在用',
IN_STOCK: '在库',
OUT_STOCK: '已出库',
TAKEN_OUT: '外带',
ID_CARD: '身份证',
PASSPORT: '护照',
GENERAL: '通用',
TODO: '待付款',
TASK: '任务截止',
BORROW: '证照归还',
SEAL: '印章归还',
LOGIN: '登录',
LOGIN_FAIL: '登录失败',
APPROVAL: '审批',
FORBIDDEN: '越权',
ACL: '权限',
SENT: '已提交',
UPDATE: '修改',
DELETE: '删除',
OK: '成功',
FAIL: '失败',
BUSINESS: '商务',
TECH: '技术',
HR: '人事',
FINANCE: '财务',
meal: '餐饮',
meals: '餐饮',
food: '餐饮',
traffic: '交通',
transport: '交通',
transportation: '交通',
hotel: '住宿',
office: '办公',
entertain: '招待',
entertainment: '招待',
OVERTIME: '加班',
MAKEUP: '补卡',
PROMOTE: '晋升',
RESIGN: '离职',
PENDING_IN: '待入库',
OCCUPIED: '占用中',
LENT: '外借',
BORROWED: '已外借',
IDLE: '闲置',
SCRAPPED: '已报废',
AVAILABLE: '可用',
MAINTAIN: '维修中',
PUBLISHED: '已发布',
ANNOUNCE: '公告',
POLICY: '制度',
};
/** 同一枚举码在不同业务里中文不同,按字段上下文取。 */
const ZH_CTX: Record<string, Record<string, string>> = {
OUT: { seal: '外带', hr: '外出', punch: '下班' },
};
export function zhCtx(code?: string | number | null, context?: string) {
if (code === undefined || code === null || code === '') return '—';
const key = String(code);
if (context && ZH_CTX[key]?.[context]) return ZH_CTX[key][context];
return ZH[key] || key;
}
export const ZH_COLOR: Record<string, string> = {
TECH_INITIAL: 'processing',
FETCH: 'processing',
TECH_FINAL: 'processing',
DRAFTING: 'gold',
REVIEW1: 'gold',
REVIEW2: 'gold',
PRINT: 'gold',
LIST: 'blue',
PENDING: 'processing',
WON: 'success',
LOST: 'default',
FAILED: 'default',
TERMINATED: 'error',
DRAFT: 'default',
APPROVED: 'success',
REJECTED: 'error',
CANCELLED: 'default',
ACTIVE: 'success',
DISABLED: 'default',
OPEN: 'warning',
OFFSET: 'success',
DONE: 'success',
RETURNED: 'success',
PAID: 'success',
UNPAID: 'default',
TO_ISSUE: 'default',
ISSUED: 'processing',
RECEIVED: 'success',
OVERDUE: 'error',
INTERNAL_ACCEPTED: 'gold',
ACCEPTED: 'success',
REGULAR: 'success',
PROBATION: 'gold',
INTERN: 'blue',
LEFT: 'default',
OK: 'success',
FAIL: 'error',
};
export function zh(code?: string | number | null) {
if (code === undefined || code === null || code === '') return '—';
const key = String(code);
return ZH[key] || key;
}
+213
View File
@@ -0,0 +1,213 @@
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Layout, Menu, Dropdown, Button, Badge } from 'antd';
import {
HomeOutlined,
AppstoreOutlined,
ClusterOutlined,
SafetyCertificateOutlined,
AuditOutlined,
FileProtectOutlined,
ProjectOutlined,
AccountBookOutlined,
TeamOutlined,
BarChartOutlined,
SettingOutlined,
DatabaseOutlined,
LogoutOutlined,
EditOutlined,
LockOutlined,
} from '@ant-design/icons';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import type { MenuProps } from 'antd';
import { useAuth, type MenuItem } from '../store/auth';
import AmbientBackground from '../components/AmbientBackground';
import { BrandLogo } from '../components/BrandLogo';
import { MobileDownload } from '../components/MobileDownload';
import { DesktopDownload } from '../components/DesktopDownload';
import { LinuxDownload } from '../components/LinuxDownload';
import { SiteFooter } from '../components/SiteFooter';
import { ProfileDrawer, UserAvatar } from '../pages/ProfileDrawer';
const { Sider, Content } = Layout;
const ICONS: Record<string, ReactNode> = {
HomeOutlined: <HomeOutlined />,
AppstoreOutlined: <AppstoreOutlined />,
ClusterOutlined: <ClusterOutlined />,
SafetyCertificateOutlined: <SafetyCertificateOutlined />,
AuditOutlined: <AuditOutlined />,
FileProtectOutlined: <FileProtectOutlined />,
ProjectOutlined: <ProjectOutlined />,
AccountBookOutlined: <AccountBookOutlined />,
TeamOutlined: <TeamOutlined />,
BarChartOutlined: <BarChartOutlined />,
DatabaseOutlined: <DatabaseOutlined />,
SettingOutlined: <SettingOutlined />,
};
function bare(path?: string | null) {
return (path || '').split('?')[0];
}
function toItems(menus: MenuItem[]): MenuProps['items'] {
const walk = (m: MenuItem): NonNullable<MenuProps['items']>[number] => ({
key: m.path || m.code,
icon: m.icon ? ICONS[m.icon] : undefined,
label: m.children?.length ? m.name : <Link to={m.path || '/'}>{m.name}</Link>,
children: m.children?.length ? m.children.map(walk) : undefined,
});
return menus.map(walk);
}
function collectOpenKeys(menus: MenuItem[], pathname: string): string[] {
const keys: string[] = [];
const walk = (items: MenuItem[], ancestors: string[]) => {
for (const m of items) {
const key = m.path || m.code;
const next = [...ancestors, key];
const p = bare(m.path);
const hit = Boolean(p && (pathname === p || pathname.startsWith(`${p}/`)));
const childHit = Boolean(m.children?.length && pathInTree(m.children, pathname));
if (hit || childHit) keys.push(...next);
if (m.children?.length) walk(m.children, next);
}
};
walk(menus, []);
return [...new Set(keys)];
}
function rootSubmenuKeys(menus: MenuItem[]): string[] {
return menus.filter((m) => m.children?.length).map((m) => m.path || m.code);
}
function accordionKeys(menus: MenuItem[], pathname: string, openKeys: string[]): string[] {
const roots = new Set(rootSubmenuKeys(menus));
const fromPath = collectOpenKeys(menus, pathname);
const openRoot = openKeys.find((k) => roots.has(k)) || fromPath.find((k) => roots.has(k));
const nested = [...openKeys, ...fromPath].filter((k) => !roots.has(k));
return openRoot ? [...new Set([openRoot, ...nested])] : [...new Set(nested)];
}
function pathInTree(items: MenuItem[], pathname: string): boolean {
return items.some(
(m) =>
(m.path && (pathname === bare(m.path) || pathname.startsWith(`${bare(m.path)}/`))) ||
(m.children?.length ? pathInTree(m.children, pathname) : false),
);
}
function selectedMenuKeys(menus: MenuItem[], pathname: string, search: string): string[] {
const keys: string[] = [];
const collect = (items: MenuItem[]) => {
for (const m of items) {
if (m.path) keys.push(m.path);
if (m.children?.length) collect(m.children);
}
};
collect(menus);
const full = `${pathname}${search}`;
if (keys.includes(full)) return [full];
if (keys.includes(pathname)) return [pathname];
let best = '';
for (const k of keys) {
if (k.includes('?')) continue;
const p = bare(k);
if (p && (pathname === p || pathname.startsWith(`${p}/`)) && p.length >= bare(best).length) best = k;
}
return best ? [best] : [full];
}
export default function AppLayout() {
const location = useLocation();
const navigate = useNavigate();
const { displayName, menus, logout, idPhotoFileId } = useAuth();
const [profileMode, setProfileMode] = useState<'profile' | 'password' | null>(null);
const [openKeys, setOpenKeys] = useState<string[]>([]);
const isWorkbench = location.pathname === '/workbench' || location.pathname === '/office/overview';
const rootKeys = useMemo(() => rootSubmenuKeys(menus), [menus]);
useEffect(() => {
setOpenKeys(accordionKeys(menus, location.pathname, []));
}, [location.pathname, menus]);
const onOpenChange: MenuProps['onOpenChange'] = (keys) => {
const latest = keys.find((k) => !openKeys.includes(k));
if (latest && rootKeys.includes(latest)) {
setOpenKeys([latest]);
return;
}
const openRoot = keys.find((k) => rootKeys.includes(k));
setOpenKeys(openRoot ? keys.filter((k) => k === openRoot || !rootKeys.includes(k)) : []);
};
return (
<div className="yzl-shell">
<AmbientBackground />
<header className="yzl-topbar">
<Link to="/workbench" className="yzl-brand">
<span className="yzl-logo">
<BrandLogo size={36} />
</span>
<span className="yzl-brand-name"></span>
</Link>
<div className="yzl-topbar-right">
<MobileDownload compact />
<DesktopDownload />
<LinuxDownload />
<Dropdown
menu={{
items: [
{ key: 'profile', icon: <EditOutlined />, label: '修改个人资料', onClick: () => setProfileMode('profile') },
{ key: 'password', icon: <LockOutlined />, label: '修改密码', onClick: () => setProfileMode('password') },
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: async () => {
await logout();
navigate('/login');
},
},
],
}}
>
<Button type="text" style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<Badge dot={!idPhotoFileId} color="red">
<UserAvatar size={28} />
</Badge>
{displayName}
</Button>
</Dropdown>
</div>
<ProfileDrawer open={Boolean(profileMode)} mode={profileMode || 'profile'} onClose={() => setProfileMode(null)} />
</header>
<div className="yzl-body">
<Sider className="ops-sider" breakpoint="lg" collapsedWidth={64} width={220} theme="light">
<div className="ops-brand">
<strong></strong>
<span></span>
</div>
<Menu
theme="light"
mode="inline"
selectedKeys={selectedMenuKeys(menus, location.pathname, location.search)}
openKeys={openKeys}
onOpenChange={onOpenChange}
items={toItems(menus)}
style={{ borderInlineEnd: 0, flex: 1, overflow: 'auto' }}
/>
</Sider>
<Layout style={{ background: 'transparent', flex: 1, minWidth: 0 }}>
<Content className="ops-content">
<div className={isWorkbench ? undefined : 'ops-sheet'}>
<Outlet />
</div>
</Content>
</Layout>
</div>
<SiteFooter compact />
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ConfigProvider, App as AntApp } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import 'antd/dist/reset.css';
import './index.css';
import App from './App';
dayjs.locale('zh-cn');
const client = new QueryClient();
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#6b38d4',
colorInfo: '#6063ee',
colorLink: '#6b38d4',
colorBgLayout: 'transparent',
colorBorder: '#ddd6fe',
colorText: '#191c1e',
borderRadius: 12,
borderRadiusLG: 16,
controlHeight: 36,
fontFamily:
"'Noto Sans SC', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
},
components: {
Layout: { bodyBg: 'transparent', headerBg: 'transparent', siderBg: 'transparent' },
Menu: { itemBg: 'transparent', subMenuItemBg: 'transparent', itemBorderRadius: 10, itemMarginInline: 8 },
Table: { headerBg: 'rgba(248,250,252,0.8)', headerSplitColor: 'transparent' },
Button: { primaryShadow: '0 4px 16px -4px rgba(132, 85, 239, 0.45)' },
},
}}
>
<AntApp>
<QueryClientProvider client={client}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</AntApp>
</ConfigProvider>
</StrictMode>,
);
+3
View File
@@ -0,0 +1,3 @@
declare module 'mammoth/mammoth.browser' {
export function convertToHtml(input: { arrayBuffer: ArrayBuffer }): Promise<{ value: string; messages: unknown[] }>;
}
+87
View File
@@ -0,0 +1,87 @@
import { Alert, Button, Form, Input, InputNumber, Switch, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../api/client';
import { PageHeader } from './ResourcePage';
type Release = {
version?: string;
build?: number;
url?: string;
windowsUrl?: string;
linuxUrl?: string;
linuxDebUrl?: string;
changelog?: string;
force?: boolean;
publishedAt?: string;
};
export default function AppReleasePage() {
const [form] = Form.useForm<Release>();
const qc = useQueryClient();
const q = useQuery({
queryKey: ['app-release'],
queryFn: async () => {
const res = await api.get('/system/app-release');
const row = (res as { data?: Release }).data ?? res;
return row as Release;
},
});
const save = useMutation({
mutationFn: (v: Release) => api.patch('/system/app-release', v),
onSuccess: () => {
message.success('已登记新版本,手机端下次打开会提示升级');
qc.invalidateQueries({ queryKey: ['app-release'] });
},
});
return (
<>
<PageHeader title="手机端升级" description="登记 APK 版本后,员工打开「风影办公」会提示下载安装。build 必须大于手机里当前数字才会弹窗。" />
{q.data ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message={`当前登记 ${q.data.version || '-'}build ${q.data.build ?? '-'}${q.data.publishedAt ? ` · ${q.data.publishedAt}` : ''}`}
/>
) : null}
<Form
form={form}
layout="vertical"
style={{ maxWidth: 520 }}
initialValues={q.data}
key={q.data?.publishedAt || 'empty'}
onFinish={(v) => save.mutate(v)}
>
<Form.Item name="version" label="版本号" rules={[{ required: true }]}>
<Input placeholder="1.1.0" />
</Form.Item>
<Form.Item name="build" label="Build" rules={[{ required: true }]} extra="整数,比上一版大即可">
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="url" label="Android 下载地址" rules={[{ required: true }]}>
<Input placeholder="https://oa.fysxkj.com/fengying-oa.apk" />
</Form.Item>
<Form.Item name="windowsUrl" label="Windows 下载地址">
<Input placeholder="https://oa.fysxkj.com/fengying-oa-setup.exe" />
</Form.Item>
<Form.Item name="linuxUrl" label="Linux tar.gz(备用)">
<Input placeholder="https://oa.fysxkj.com/fengying-oa-linux.tar.gz" />
</Form.Item>
<Form.Item name="linuxDebUrl" label="Linux debUbuntu/Debian/Deepin">
<Input placeholder="https://oa.fysxkj.com/fengying-oa_amd64.deb" />
</Form.Item>
<Form.Item name="changelog" label="更新说明">
<Input.TextArea rows={3} placeholder="这次改了什么" />
</Form.Item>
<Form.Item name="force" label="强制升级" valuePropName="checked">
<Switch />
</Form.Item>
<Button type="primary" htmlType="submit" loading={save.isPending}>
</Button>
</Form>
</>
);
}
+44
View File
@@ -0,0 +1,44 @@
import type { ReactNode } from 'react';
import { Segmented, Space } from 'antd';
import { useNavigate, useSearchParams } from 'react-router-dom';
export type ApplyBucket = { value: string; label: string };
/** 申请类列表统一分段筛选。空字符串表示全部。会保留当前 URL 上的其它参数。 */
export function ApplyBucketBar({
basePath,
buckets,
extra,
}: {
basePath: string;
buckets: ApplyBucket[];
extra?: ReactNode;
}) {
const [params] = useSearchParams();
const navigate = useNavigate();
const current = params.get('bucket') || '';
const path = basePath.split('?')[0];
return (
<Space wrap>
<Segmented
value={current}
onChange={(v) => {
const next = new URLSearchParams(params);
const val = String(v);
if (val) next.set('bucket', val);
else next.delete('bucket');
const s = next.toString();
navigate(s ? `${path}?${s}` : path);
}}
options={[{ label: '全部', value: '' }, ...buckets]}
/>
{extra}
</Space>
);
}
export const PROCESS_BUCKETS: ApplyBucket[] = [
{ value: 'pending', label: '待审批' },
{ value: 'approved', label: '已通过' },
{ value: 'rejected', label: '已驳回' },
];
@@ -0,0 +1,55 @@
import { Table, Tag } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import { PageHeader } from './ResourcePage';
import { money } from './financeStatus';
type Row = {
id: string;
title: string;
amount?: string | number | null;
reason?: string | null;
status: string;
createdAt: string;
applicant?: { displayName?: string } | null;
project?: { projectNo?: string; name?: string } | null;
purchase?: { id: string; status: string } | null;
inboundAsset?: { code?: string | null; name?: string; status?: string } | null;
};
const STATUS: Record<string, { color: string; text: string }> = {
PENDING: { color: 'processing', text: '待审批' },
APPROVED: { color: 'success', text: '已通过' },
REJECTED: { color: 'error', text: '已驳回' },
};
export default function AssetPurchaseLedgerPage() {
const q = useQuery({ queryKey: ['/asset-purchases'], queryFn: () => api.get('/asset-purchases') });
const rows = (q.data?.data ?? []) as Row[];
return (
<>
<PageHeader
title="资产购置台账"
description="记录个人办公「资产购置」申请。审批通过后自动形成采购记录、付款待办和待入库资产。"
/>
<Table
className="ops-table"
rowKey="id"
loading={q.isLoading}
dataSource={rows}
scroll={{ x: 'max-content' }}
columns={[
{ title: '购置内容', dataIndex: 'title', width: 230 },
{ title: '金额', dataIndex: 'amount', width: 120, render: (v: string | number) => money(v) },
{ title: '申请人', width: 120, render: (_: unknown, r: Row) => r.applicant?.displayName || '—' },
{ title: '关联项目', width: 220, render: (_: unknown, r: Row) => r.project ? `${r.project.projectNo || ''} ${r.project.name || ''}`.trim() : '—' },
{ title: '采购记录', width: 120, render: (_: unknown, r: Row) => r.purchase ? <Link to="/project/purchases"></Link> : '待审批' },
{ title: '待入库资产', width: 180, render: (_: unknown, r: Row) => r.inboundAsset ? `${r.inboundAsset.code || '待编码'} ${r.inboundAsset.name || ''}`.trim() : '—' },
{ title: '状态', dataIndex: 'status', width: 110, render: (v: string) => <Tag color={(STATUS[v] || {}).color}>{(STATUS[v] || {}).text || v}</Tag> },
{ title: '申请时间', dataIndex: 'createdAt', width: 180, render: (v: string) => v ? String(v).replace('T', ' ').slice(0, 19) : '—' },
]}
/>
</>
);
}
+209
View File
@@ -0,0 +1,209 @@
import { Button, Checkbox, Form, Input, InputNumber, Popconfirm, Select, Space, Table, Tag, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
import { FormDrawer } from './FormDrawer';
import { PageHeader, unwrapList } from './ResourcePage';
import { money } from './financeStatus';
import { ApplyBucketBar } from './ApplyBucketBar';
export function AssetRegistryPage({ kind, title }: { kind: 'DIGITAL' | 'FIXED'; title: string }) {
const qc = useQueryClient();
const [params] = useSearchParams();
const bucket = params.get('bucket') || '';
const basePath = kind === 'DIGITAL' ? '/asset/digital' : '/asset/fixed';
const endpoint = `/registered-assets?kind=${kind}`;
const q = useQuery({ queryKey: [endpoint], queryFn: () => api.get(endpoint) });
const entities = useQuery({ queryKey: ['/legal-entities'], queryFn: () => api.get('/legal-entities') });
const staff = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') });
const { items } = unwrapList(q.data);
const [form] = Form.useForm();
const [drawer, setDrawer] = useState<null | 'create' | { id: string }>(null);
const entityOpts = ((entities.data as { data?: { id: string; name: string; shortName?: string }[] })?.data || []).map(
(e) => ({ value: e.id, label: e.shortName || e.name }),
);
const staffOpts = ((staff.data as { data?: { name: string; department?: string }[] })?.data || []).map((s) => ({
value: s.name,
label: `${s.name}${s.department ? `${s.department}` : ''}`,
}));
const filtered = useMemo(() => {
return items.filter((r) => {
if (bucket === 'pending_in') return r.status === 'PENDING_IN';
if (bucket === 'occupying') return Boolean((r.occupancies as { holder: string }[] | undefined)?.length);
if (bucket === 'vehicle') return r.usage === 'VEHICLE';
return true;
});
}, [items, bucket]);
const mut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/registered-assets', { ...body, kind }),
onSuccess: () => {
message.success('已登记');
qc.invalidateQueries({ queryKey: [endpoint] });
},
onError: (e: Error) => message.error(e.message),
});
return (
<>
<PageHeader
title={title}
description={
kind === 'DIGITAL'
? '软件账号、域名、数字证书等数字资产登记。占用和领用必须从本台账选择。保管人是责任人,占用另记。'
: '设备、车辆、家具等固定资产登记。占用、领用、用车必须从本台账选择。购置通过后会出现待入库,到货后点确认入库。'
}
extra={
<ApplyBucketBar
basePath={basePath}
buckets={[
{ value: 'pending_in', label: '待入库' },
{ value: 'occupying', label: '占用中' },
...(kind === 'FIXED' ? [{ value: 'vehicle', label: '车辆' }] : []),
]}
extra={
<Button
type="primary"
onClick={() => {
form.resetFields();
setDrawer('create');
}}
>
</Button>
}
/>
}
/>
<FormDrawer
title={drawer === 'create' ? `登记${title}` : `编辑${title}`}
open={Boolean(drawer)}
onClose={() => setDrawer(null)}
okText="提交保存"
okLoading={mut.isPending}
onOk={async () => {
const v = await form.validateFields();
const body = { ...v, usage: kind === 'FIXED' && v.vehicle ? 'VEHICLE' : 'GENERAL' };
delete body.vehicle;
if (drawer === 'create') await mut.mutateAsync(body);
else if (drawer && typeof drawer === 'object') {
await api.patch(`/registered-assets/${drawer.id}`, body);
message.success('已保存');
qc.invalidateQueries({ queryKey: [endpoint] });
}
setDrawer(null);
}}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="编号">
<Input />
</Form.Item>
<Form.Item name="spec" label="规格 / 说明">
<Input />
</Form.Item>
{kind === 'FIXED' ? (
<Form.Item name="vehicle" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
) : null}
<Form.Item name="legalEntityId" label="所属公司">
<Select allowClear showSearch optionFilterProp="label" options={entityOpts} />
</Form.Item>
<Form.Item name="keeper" label="保管人">
<Select allowClear showSearch optionFilterProp="label" options={staffOpts} />
</Form.Item>
<Form.Item name="location" label="存放 / 使用位置">
<Input />
</Form.Item>
<Form.Item name="amount" label="金额">
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</FormDrawer>
<Table
className="ops-table"
rowKey="id"
loading={q.isLoading}
dataSource={filtered}
pagination={false}
columns={[
{ title: '名称', dataIndex: 'name', width: 200 },
{ title: '编号', dataIndex: 'code', width: 140, render: (v: string) => v || '—' },
{ title: '规格', dataIndex: 'spec', ellipsis: true, render: (v: string) => v || '—' },
{
title: '状态',
width: 90,
render: (_: unknown, r: Record<string, unknown>) =>
r.status === 'PENDING_IN' ? <Tag color="gold"></Tag> : r.usage === 'VEHICLE' ? <Tag></Tag> : <Tag color="success"></Tag>,
},
{
title: '所属公司',
width: 140,
render: (_: unknown, r: Record<string, unknown>) => {
const e = r.legalEntity as { shortName?: string; name?: string } | null;
return e?.shortName || e?.name || '—';
},
},
{ title: '保管', dataIndex: 'keeper', width: 100, render: (v: string) => v || '—' },
{
title: '占用',
width: 110,
render: (_: unknown, r: Record<string, unknown>) => {
const occ = r.occupancies as { holder: string }[] | undefined;
return occ?.length ? `${occ[0].holder}在用` : '空闲';
},
},
{ title: '位置', dataIndex: 'location', width: 140, render: (v: string) => v || '—' },
{ title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) },
{
title: '操作',
width: 200,
render: (_: unknown, r: Record<string, unknown>) => (
<Space>
{r.status === 'PENDING_IN' ? (
<Button
type="link"
onClick={async () => {
await api.post(`/registered-assets/${r.id}/inbound`);
message.success('已入库');
qc.invalidateQueries({ queryKey: [endpoint] });
}}
>
</Button>
) : null}
<Button
type="link"
onClick={() => {
form.setFieldsValue({ ...r, vehicle: r.usage === 'VEHICLE' });
setDrawer({ id: String(r.id) });
}}
>
</Button>
<Popconfirm
title="确认删除?"
onConfirm={async () => {
await api.delete(`/registered-assets/${r.id}`);
message.success('已删除');
qc.invalidateQueries({ queryKey: [endpoint] });
}}
>
<Button type="link" danger>
</Button>
</Popconfirm>
</Space>
),
},
]}
/>
</>
);
}
+286
View File
@@ -0,0 +1,286 @@
import { useEffect, useState } from 'react';
import { AutoComplete, Button, Checkbox, Col, DatePicker, Form, Input, Row, Select, Upload, message } from 'antd';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import { FormDrawer } from './FormDrawer';
import { OPEN_METHOD_OPTIONS, TENDER_METHOD_OPTIONS, TYPE_REVIEW_ROLES } from './bidStatus';
function asList<T>(raw: unknown): T[] {
const body = (raw as { data?: unknown })?.data ?? raw;
if (Array.isArray(body)) return body as T[];
if (body && typeof body === 'object' && 'items' in body) return ((body as { items: T[] }).items ?? []) as T[];
return [];
}
type Director = { id: string; name: string; department?: string; roles?: string[] };
function pickReviewers(type: string, directors: Director[]) {
const wanted = TYPE_REVIEW_ROLES[type] || ['tech_director'];
const ids = directors.filter((d) => (d.roles || []).some((r) => wanted.includes(r))).map((d) => d.id);
return type === '集成' ? [...new Set(ids)] : ids.slice(0, 1);
}
export default function BidCreatePage({
open = true,
onClose,
special = false,
}: {
open?: boolean;
onClose?: () => void;
special?: boolean;
}) {
const [form] = Form.useForm();
const navigate = useNavigate();
const listPath = special ? '/bid/special-screening' : '/bid/screening';
const close = () => {
if (onClose) onClose();
else navigate(listPath);
};
const [files, setFiles] = useState<File[]>([]);
const types = useQuery({ queryKey: ['dict', 'projectType'], queryFn: () => api.get('/system/dicts?type=projectType') });
const quals = useQuery({
queryKey: ['dict', 'qualificationNeed'],
queryFn: () => api.get('/system/dicts?type=qualificationNeed'),
});
const entities = useQuery({ queryKey: ['legal-entities'], queryFn: () => api.get('/legal-entities') });
const directors = useQuery({ queryKey: ['directors'], queryFn: () => api.get('/directors') });
const coordinators = useQuery({
queryKey: ['special-coordinators'],
queryFn: () => api.get('/bid-cases/special-coordinators'),
enabled: special && open,
});
const directorList = asList<Director>(directors.data);
const coordinatorList = asList<{ id: string; displayName?: string; name?: string; username?: string; employee?: { department?: { name?: string } } }>(
coordinators.data,
);
useEffect(() => {
if (!open) return;
form.resetFields();
setFiles([]);
}, [open, form]);
const mut = useMutation({
mutationFn: async (values: Record<string, unknown>) => {
const res = await api.post('/bid-cases', values);
const id = res.data?.id as string | undefined;
if (id) {
for (const f of files) {
const fd = new FormData();
fd.append('file', f);
fd.append('bizType', 'BID_ANNOUNCE');
fd.append('bizId', id);
await api.post('/files', fd);
}
}
return res;
},
onSuccess: (res: { data?: { id?: string } }) => {
message.success(special ? '已提交特殊筛选' : '已提交项目筛选');
if (onClose) onClose();
navigate(res.data?.id ? `/bid/cases/${res.data.id}` : listPath);
},
onError: (e: Error) => message.error(e.message),
});
const dictOpts = (q: { data?: unknown }) =>
asList<{ label: string; value: string }>(q.data).map((i) => ({ label: i.label, value: i.value }));
return (
<FormDrawer
title={special ? '发起特殊筛选' : '发起项目筛选'}
extra={
special
? '跳过初审。指定全公司任一办理人派取标,取标后再由总监终审,通过后进入特殊制标。'
: '项目编号完全手填,填写招标公告上的官方编号;系统不生成编号,重复公告可再次发起筛选。'
}
open={open}
onClose={close}
onOk={() => form.submit()}
okText={special ? '提交特殊筛选' : '提交筛选'}
okLoading={mut.isPending}
>
<Form
form={form}
layout="vertical"
initialValues={{ source: '军采网' }}
onFinish={(values) => {
mut.mutate({
...values,
sourceType: special ? 'SPECIAL' : 'FLOW',
applyStartAt: values.applyRange?.[0]?.toISOString(),
applyEndAt: values.applyRange?.[1]?.toISOString(),
openAt: values.openAt?.toISOString(),
applyRange: undefined,
});
}}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="name" label="项目名称" rules={[{ required: true, message: '请填写项目名称' }]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="externalNo" label="项目编号" rules={[{ required: true, message: '请填写招标公告里的官方编号' }]} extra="手填公告编号,不由系统生成;同一公告再次筛选时可重复填写。">
<Input placeholder="手填招标公告中的项目编号" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="projectType" label="项目类型" rules={[{ required: true, message: '请选择类型' }]}>
<Select
options={dictOpts(types)}
placeholder="软件 / 三维 / 摄制 / 物资 / 集成"
onChange={(v: string) => form.setFieldsValue({ reviewerIds: pickReviewers(v, directorList) })}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name="source"
label="项目来源"
extra="默认军采网,其他来源直接改或手输"
rules={[{ required: true, message: '请填写来源' }]}
>
<AutoComplete
options={[{ value: '军采网' }, { value: '公开招标公告' }, { value: '其他' }]}
placeholder="默认军采网,也可手输"
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="qualificationNeed" label="所需资质" rules={[{ required: true, message: '请选择资质' }]}>
<Select options={dictOpts(quals)} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="legalEntityId" label="参与主体" rules={[{ required: true, message: '请选择用哪家公司名义投' }]}>
<Select
options={asList<{ id: string; name: string }>(entities.data).map((e) => ({
label: e.name,
value: e.id,
}))}
placeholder="我方用哪家公司名义投"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="tenderMethod" label="招标方式">
<Select allowClear options={TENDER_METHOD_OPTIONS} placeholder="可暂不填写" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="needSurvey" valuePropName="checked" extra="项目筛选阶段标记是否需要现场踏勘">
<Checkbox></Checkbox>
</Form.Item>
</Col>
<Col span={16}>
<Form.Item
name="applyRange"
label="申领时间"
extra="招标文件申领的开始时间和截止时间"
rules={[{ required: true, type: 'array', min: 2, message: '请选择申领起止时间' }]}
>
<DatePicker.RangePicker format="YYYY-MM-DD HH:mm" showTime={{ format: 'HH:mm' }} style={{ width: '100%' }} placeholder={['开始时间', '结束时间']} />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="applyMethod" label="申领方式" rules={[{ required: true, message: '请选择申领方式' }]}>
<Select
options={[
{ label: '现场申领', value: '现场申领' },
{ label: '网上申领', value: '网上申领' },
{ label: '其他', value: '其他' },
]}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="openMethod" label="开标方式" rules={[{ required: true, message: '请选择开标方式' }]}>
<Select options={OPEN_METHOD_OPTIONS} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="openAt" label="开标时间" rules={[{ required: true, message: '请选择开标时间' }]}>
<DatePicker format="YYYY-MM-DD HH:mm" showTime={{ format: 'HH:mm' }} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="openPlace"
label="开标地点"
extra="线上写平台名称,线下写现场地址,没有默认值"
rules={[{ required: true, message: '请填写开标地点' }]}
>
<AutoComplete
options={[{ value: '军采网' }, { value: '线上开标大厅' }]}
placeholder="请填写开标地点"
/>
</Form.Item>
</Col>
{special ? (
<Col span={24}>
<Form.Item
name="coordinatorIds"
label="发给"
extra="可从全公司在职人员中选择;只有这里选中的人收到该步骤短信并可派取标人。"
rules={[{ required: true, type: 'array', min: 1, message: '请选择特殊筛选办理人' }]}
>
<Select
mode="multiple"
options={coordinatorList.map((c) => ({
label: `${c.displayName || c.name || c.username}${c.employee?.department?.name ? `${c.employee.department.name}` : ''}`,
value: c.id,
}))}
placeholder={coordinatorList.length ? '选择办理人' : '暂无在职人员'}
/>
</Form.Item>
</Col>
) : null}
<Col span={24}>
<Form.Item
name="reviewerIds"
label={special ? '终审总监' : '项目初审人'}
extra={
special
? '取标完成后转给这些总监终审。按项目类型自动勾选,集成必须多选。'
: '按项目类型自动勾选对应部门总监;集成必须多选。'
}
rules={[{ required: true, type: 'array', min: 1, message: special ? '请选择终审总监' : '请选择项目初审人' }]}
>
<Select
mode="multiple"
options={directorList.map((d) => ({
label: `${d.name}${d.department ? `${d.department}` : ''}`,
value: d.id,
}))}
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item label="附件">
<Upload
multiple
beforeUpload={(file) => {
setFiles((prev) => [...prev, file]);
return false;
}}
onRemove={(file) => {
setFiles((prev) => prev.filter((f) => f.name !== file.name));
}}
>
<Button></Button>
</Upload>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Col>
</Row>
</Form>
</FormDrawer>
);
}
File diff suppressed because it is too large Load Diff
+375
View File
@@ -0,0 +1,375 @@
import { Button, Form, Input, InputNumber, Modal, Select, Space, Tag, Typography, message } from 'antd';
import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAuth } from '../store/auth';
import BidCreatePage from './BidCreatePage';
import BidDetailPage from './BidDetailPage';
import { FormDrawer } from './FormDrawer';
import { inferTenderMethod, OPEN_METHOD_OPTIONS, statusMeta, tenderNo, BID_STATUS_META } from './bidStatus';
import { ResourcePage } from './ResourcePage';
import { api } from '../api/client';
function text(v: unknown) {
return v ? String(v) : '—';
}
function day(v: unknown) {
if (!v) return '—';
return String(v).slice(0, 10);
}
export default function BidListPage({
title,
bucket,
extra,
special,
}: {
title: string;
bucket?: string;
extra?: boolean;
special?: boolean;
}) {
const [creating, setCreating] = useState(false);
const [openRow, setOpenRow] = useState<Record<string, unknown>>();
const [editRow, setEditRow] = useState<Record<string, unknown>>();
const [competitorForm] = Form.useForm();
const [projectType, setProjectType] = useState<string>();
const [status, setStatus] = useState<string>();
const [legalEntityId, setLegalEntityId] = useState<string>();
const roles = useAuth((s) => s.roles);
const permissions = useAuth((s) => s.permissions);
const qc = useQueryClient();
const entities = useQuery({ queryKey: ['/legal-entities'], queryFn: () => api.get('/legal-entities') });
const canCreateSpecial = roles.some((r) => r === 'admin' || r === 'owner');
const canCreate =
['admin', 'owner', 'biz_director', 'biz_staff'].some((r) => roles.includes(r)) || permissions.includes('bid:screening');
const params = new URLSearchParams();
if (bucket) params.set('bucket', bucket);
if (special) {
params.set('sourceType', 'SPECIAL');
if (!bucket) params.set('bucket', 'screening');
}
if (projectType) params.set('projectType', projectType);
if (status) params.set('status', status);
if (legalEntityId) params.set('legalEntityId', legalEntityId);
const qs = params.toString();
const listBucket = bucket || (special ? 'screening' : undefined);
const statusOpts = (
listBucket === 'screening'
? ['TECH_INITIAL', 'FETCH', 'TECH_FINAL']
: listBucket === 'drafting'
? ['DRAFTING', 'REVIEW1', 'REVIEW2', 'PRINT']
: listBucket === 'execution'
? ['LIST', 'PENDING']
: listBucket === 'result'
? ['WON', 'LOST', 'FAILED']
: listBucket === 'terminated'
? ['TERMINATED']
: Object.keys(BID_STATUS_META)
).map((v) => ({ value: v, label: BID_STATUS_META[v]?.text || v }));
return (
<>
<ResourcePage
title={title}
description={
special
? extra
? '企业负责人发起,指定全公司办理人派取标人,再转总监终审。通过后进入特殊制标。'
: '特殊筛选终审通过后,由已指定办理人派制标人。表单和后续与普通制标相同。'
: bucket === 'screening'
? '软件、三维、摄制、物资合成一张表,用「类型」区分。只看待初审、取标、终审。'
: bucket === 'drafting'
? '终审通过后、印封完成前的制标、一审、二审、印封。已开标的不在这里。'
: bucket === 'execution'
? '印封完成后在这里派投标人。开标结果和费用由投标人在个人办公 · 待办事项里反馈,不在本页登记。'
: bucket === 'result'
? '投标人已反馈的开标结果:中标、未中标、流标废标。'
: bucket === 'terminated'
? '生产里已终止,以及各筛选回收站「已删除」的,都在这里。点「编辑」登记对方中标单位和金额,经营报表「落标对照」能查到。'
: '中标前费用挂招标编号。点办理进入详情。'
}
searchPlaceholder="搜索招标编号、项目名称"
exportName={title}
filters={[
{
key: 'applyMethod',
label: '申领方式',
options: [
{ label: '现场申领', value: '现场申领' },
{ label: '网上申领', value: '网上申领' },
{ label: '其他', value: '其他' },
],
},
{ key: 'openMethod', label: '开标方式', options: OPEN_METHOD_OPTIONS },
]}
onRowClick={(row) => setOpenRow(row)}
emptyText={
extra
? '还没有在途筛选。右上角可以发起。'
: bucket === 'drafting'
? '没有正在制标的事项。终审通过后才会进来。'
: bucket === 'execution'
? '没有待派投标人或待开标的事项。'
: bucket === 'result'
? '还没有开标结果。'
: bucket === 'terminated'
? '没有终止的投标。'
: '还没有记录。'
}
endpoint={qs ? `/bid-cases?${qs}` : '/bid-cases'}
toolbarExtra={
<Space wrap>
<Select
allowClear
placeholder="状态"
style={{ width: 140 }}
value={status}
onChange={(v) => setStatus(v)}
options={statusOpts}
/>
<Select
allowClear
placeholder="项目类型"
style={{ width: 140 }}
value={projectType}
onChange={(v) => setProjectType(v)}
options={[
{ label: '软件', value: '软件' },
{ label: '三维', value: '三维' },
{ label: '摄制', value: '摄制' },
{ label: '物资', value: '物资' },
{ label: '集成', value: '集成' },
]}
/>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="参与主体"
style={{ width: 200 }}
value={legalEntityId}
onChange={(v) => setLegalEntityId(v)}
options={(((entities.data as { data?: { id: string; name: string; shortName?: string }[] })?.data) || []).map((e) => ({
value: e.id,
label: e.shortName && e.shortName !== e.name ? `${e.shortName} · ${e.name}` : e.name,
}))}
/>
</Space>
}
extra={
extra ? (
<Button
type="primary"
onClick={() => setCreating(true)}
disabled={special ? !canCreateSpecial : !canCreate}
>
{special ? '发起特殊筛选' : '发起筛选'}
</Button>
) : null
}
columns={[
{
title: '招标编号',
width: 170,
render: (_: unknown, row: Record<string, unknown>) =>
tenderNo({ externalNo: row.externalNo as string, bidNo: row.bidNo as string }),
},
{
title: '项目名称',
dataIndex: 'name',
width: 240,
ellipsis: true,
render: (v: string, row: Record<string, unknown>) => (
<Button type="link" onClick={() => setOpenRow(row)}>
{v}
</Button>
),
},
{
title: '参与主体',
width: 140,
ellipsis: true,
render: (_: unknown, row: Record<string, unknown>) =>
text(
(row.legalEntity as { name?: string; shortName?: string } | null)?.name ||
(row.legalEntity as { shortName?: string } | null)?.shortName,
),
},
{ title: '类型', dataIndex: 'projectType', width: 80, render: (v: string) => text(v) },
{ title: '项目来源', dataIndex: 'source', width: 120, ellipsis: true, render: (v: string) => text(v) },
{
title: '招标方式',
width: 140,
render: (_: unknown, row: Record<string, unknown>) =>
text(inferTenderMethod(row.name as string, row.tenderMethod as string)),
},
{ title: '所需资质', dataIndex: 'qualificationNeed', width: 100, render: (v: string) => text(v) },
{ title: '申领方式', dataIndex: 'applyMethod', width: 100, render: (v: string) => text(v) },
{ title: '开标方式', dataIndex: 'openMethod', width: 100, render: (v: string) => text(v) },
{ title: '开标地点', dataIndex: 'openPlace', width: 140, ellipsis: true, render: (v: string) => text(v) },
...(extra
? [
{
title: '是否踏勘',
width: 120,
render: (_: unknown, row: Record<string, unknown>) => (
<Button
size="small"
type={row.needSurvey ? 'primary' : 'default'}
onClick={async (e) => {
e.stopPropagation();
try {
await api.patch(`/bid-cases/${row.id}/survey`, { needSurvey: !row.needSurvey });
message.success(row.needSurvey ? '已改为不需要踏勘' : '已标记需要踏勘');
qc.invalidateQueries();
} catch (err) {
message.error(err instanceof Error ? err.message : '操作失败');
}
}}
>
{row.needSurvey ? '需要踏勘' : '不需要'}
</Button>
),
},
]
: []),
{
title: '状态',
dataIndex: 'status',
width: 110,
render: (v: string, row: Record<string, unknown>) => {
const s = statusMeta(v);
return (
<>
{row.sourceType === 'SPECIAL' ? <Tag color="purple"></Tag> : null}
<Tag color={s.color}>{s.text}</Tag>
</>
);
},
},
{
title: '经办',
width: 160,
ellipsis: true,
render: (_: unknown, row: Record<string, unknown>) =>
text(
(row.handlerName as string) ||
(row.createdBy as { displayName?: string } | null)?.displayName,
),
},
{
title: '申领时间',
width: 200,
render: (_: unknown, row: Record<string, unknown>) => {
const start = day(row.applyStartAt);
const end = day(row.applyEndAt);
if (start === '—' && end === '—') return '—';
return `${start} ~ ${end}`;
},
},
{ title: '开标时间', dataIndex: 'openAt', width: 110, render: (v: string) => day(v) },
...(bucket === 'terminated' || bucket === 'result'
? [
{
title: '对方中标单位',
width: 160,
ellipsis: true,
render: (_: unknown, row: Record<string, unknown>) => text(row.winnerName),
},
{
title: '对方中标金额',
width: 130,
render: (_: unknown, row: Record<string, unknown>) => text(row.winnerAmount),
},
]
: []),
{
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: (v: string) => (v ? String(v).replace('T', ' ').slice(0, 19) : '—'),
},
{
title: '操作',
width: bucket === 'terminated' || bucket === 'result' ? 150 : 80,
render: (_: unknown, row: Record<string, unknown>) => (
<Space>
<Button type="link" onClick={() => setOpenRow(row)}>
</Button>
{bucket === 'terminated' || (bucket === 'result' && row.status !== 'WON') ? (
<Button
type="link"
onClick={(e) => {
e.stopPropagation();
competitorForm.setFieldsValue({
winnerName: row.winnerName || undefined,
winnerAmount: row.winnerAmount != null && row.winnerAmount !== '' ? Number(row.winnerAmount) : undefined,
winnerNote: row.winnerNote || undefined,
});
setEditRow(row);
}}
>
</Button>
) : null}
</Space>
),
},
]}
/>
<BidCreatePage open={creating} onClose={() => setCreating(false)} special={special} />
<FormDrawer
title="投标办理"
open={Boolean(openRow)}
onClose={() => setOpenRow(undefined)}
hideOk
width="min(1120px, 86vw)"
createdAt={openRow?.createdAt as string | undefined}
>
{openRow ? <BidDetailPage id={String(openRow.id)} embedded /> : null}
</FormDrawer>
<Modal
title="登记对方中标信息"
open={Boolean(editRow)}
onCancel={() => setEditRow(undefined)}
onOk={() => competitorForm.submit()}
okText="保存"
destroyOnClose
>
<Typography.Paragraph type="secondary">
</Typography.Paragraph>
<Form
form={competitorForm}
layout="vertical"
onFinish={async (v) => {
if (!editRow?.id) return;
try {
await api.patch(`/bid-cases/${editRow.id}/competitor`, {
winnerName: v.winnerName,
winnerAmount: v.winnerAmount,
winnerNote: v.winnerNote,
});
message.success('已登记对方中标信息');
setEditRow(undefined);
qc.invalidateQueries();
} catch (err) {
message.error(err instanceof Error ? err.message : '保存失败');
}
}}
>
<Form.Item name="winnerName" label="中标单位" rules={[{ required: true, message: '请填写中标单位' }]}>
<Input placeholder="实际中标单位全称" />
</Form.Item>
<Form.Item name="winnerAmount" label="中标金额">
<InputNumber min={0} style={{ width: '100%' }} placeholder="对方中标金额" />
</Form.Item>
<Form.Item name="winnerNote" label="备注">
<Input.TextArea rows={2} placeholder="可选,例如我们报价、放弃原因" />
</Form.Item>
</Form>
</Modal>
</>
);
}
+237
View File
@@ -0,0 +1,237 @@
import { Button, DatePicker, Form, Input, InputNumber, Popconfirm, Select, Space, Tag, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { FormDrawer } from './FormDrawer';
import { ResourcePage } from './ResourcePage';
import { CostTargetLink, targetName } from './FinanceCells';
import { BOND_BUCKET_META, BOND_TYPE_LABEL, money } from './financeStatus';
import { ApplyBucketBar } from './ApplyBucketBar';
const META: Record<
string,
{ title: string; description: string; path: string; needBid?: boolean; needProject?: boolean }
> = {
TENDER: {
title: '投标保证金',
description: '挂投标。开标后该退未退会标成逾期。后续中标转项目时仍可回看这笔。',
path: '/finance/bonds/tender',
needBid: true,
},
PERFORMANCE: {
title: '履约保证金',
description: '挂中标后的投标或已立项项目。缴出未退会进资金占用。',
path: '/finance/bonds/performance',
},
WARRANTY: {
title: '项目质保金',
description: '挂正式项目。验收后按约定日期退回,逾期单独列出。',
path: '/finance/bonds/warranty',
needProject: true,
},
};
export default function BondListPage({ bondType }: { bondType: 'TENDER' | 'PERFORMANCE' | 'WARRANTY' }) {
const [params] = useSearchParams();
const bucket = params.get('bucket') || '';
const meta = META[bondType];
const endpoint = `/bonds?bondType=${bondType}${bucket ? `&bucket=${bucket}` : ''}`;
const qc = useQueryClient();
const [form] = Form.useForm();
const [creating, setCreating] = useState(false);
const targets = useQuery({
queryKey: ['cost-targets'],
queryFn: () => api.get('/cost-targets'),
});
const bids = ((targets.data?.data?.bids ?? []) as { id: string; bidNo: string; name: string }[]).map((b) => ({
value: b.id,
label: `${b.bidNo} ${b.name}`,
}));
const projects = ((targets.data?.data?.projects ?? []) as { id: string; projectNo: string; name: string }[]).map((p) => ({
value: p.id,
label: `${p.projectNo} ${p.name}`,
}));
const createMut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/bonds', body),
onSuccess: () => {
message.success('已登记');
qc.invalidateQueries({ queryKey: [endpoint] });
qc.invalidateQueries({ queryKey: ['/bonds'] });
},
onError: (e: Error) => message.error(e.message),
});
const actMut = useMutation({
mutationFn: ({ id, action, dueBackAt }: { id: string; action: 'pay' | 'return'; dueBackAt?: string }) =>
api.post(`/bonds/${id}/${action}`, action === 'pay' ? { dueBackAt } : {}),
onSuccess: () => {
message.success('已更新');
qc.invalidateQueries();
},
onError: (e: Error) => message.error(e.message),
});
const openCreate = () => {
form.resetFields();
form.setFieldsValue({ bondType });
setCreating(true);
};
return (
<>
<ResourcePage
title={meta.title}
description={meta.description}
endpoint={endpoint}
extra={
<ApplyBucketBar
basePath={meta.path}
buckets={[
{ value: 'unpaid', label: '未缴纳' },
{ value: 'due', label: '应退' },
{ value: 'overdue', label: '逾期' },
]}
extra={
<Button type="primary" onClick={openCreate}>
</Button>
}
/>
}
columns={[
{ title: '编号', dataIndex: 'bondNo', width: 150 },
{ title: '类型', dataIndex: 'bondType', width: 120, render: (v: string) => BOND_TYPE_LABEL[v] || v },
{ title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) },
{
title: '对象',
width: 160,
render: (_: unknown, row: Record<string, unknown>) => (
<CostTargetLink
bid={row.bidCase as { id?: string; bidNo?: string } | null}
project={row.project as { id?: string; projectNo?: string } | null}
contract={row.contract as { id?: string; contractNo?: string } | null}
/>
),
},
{
title: bondType === 'TENDER' ? '投标' : '项目',
ellipsis: true,
render: (_: unknown, row: Record<string, unknown>) =>
targetName(
row.bidCase as { name?: string } | null,
row.project as { name?: string } | null,
row.contract as { name?: string } | null,
),
},
{
title: '状态',
dataIndex: 'bucket',
width: 100,
render: (v: string) => {
const s = BOND_BUCKET_META[v] || { color: 'default', text: v || '—' };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '应退日',
dataIndex: 'dueBackAt',
width: 120,
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—'),
},
{
title: '操作',
width: 180,
render: (_: unknown, row: Record<string, unknown>) => (
<Space>
{row.status === 'UNPAID' ? (
<Button
type="link"
size="small"
onClick={() => {
const due = window.prompt('预计退回日期 YYYY-MM-DD(可空)') || undefined;
actMut.mutate({ id: String(row.id), action: 'pay', dueBackAt: due });
}}
>
</Button>
) : null}
{row.status === 'PAID' ? (
<Button type="link" size="small" onClick={() => actMut.mutate({ id: String(row.id), action: 'return' })}>
退
</Button>
) : null}
<Popconfirm
title="确认删除这条保证金?"
onConfirm={async () => {
await api.delete(`/bonds/${row.id}`);
message.success('已删除');
qc.invalidateQueries();
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
]}
/>
<FormDrawer
title={`登记${meta.title}`}
open={creating}
onClose={() => setCreating(false)}
okText="提交保存"
okLoading={createMut.isPending}
onOk={async () => {
const v = await form.validateFields();
await createMut.mutateAsync({
...v,
bondType,
dueBackAt: v.dueBackAt ? v.dueBackAt.toISOString() : undefined,
});
setCreating(false);
}}
>
<Form form={form} layout="vertical">
<Form.Item label="类型">
<Input disabled value={BOND_TYPE_LABEL[bondType]} />
</Form.Item>
<Form.Item name="amount" label="金额" rules={[{ required: true }]}>
<InputNumber min={0.01} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="bidCaseId"
label="投标"
rules={meta.needBid ? [{ required: true, message: '投标保证金必须挂投标' }] : undefined}
extra={bondType === 'TENDER' ? '后续中标转项目后仍可从这笔回看。' : '可挂来源投标,现在可空。'}
>
<Select allowClear showSearch optionFilterProp="label" options={bids} placeholder="选择招标编号" />
</Form.Item>
{bondType !== 'TENDER' ? (
<Form.Item
name="projectId"
label="项目"
rules={meta.needProject ? [{ required: true, message: '项目质保金必须挂项目' }] : undefined}
extra={bondType === 'PERFORMANCE' ? '履约金请挂投标或项目,至少填一项。' : undefined}
>
<Select allowClear showSearch optionFilterProp="label" options={projects} placeholder="选择项目" />
</Form.Item>
) : (
<Form.Item name="projectId" label="项目" extra="中标立项后可补挂,现在可空。">
<Select allowClear showSearch optionFilterProp="label" options={projects} placeholder="可选" />
</Form.Item>
)}
<Form.Item name="dueBackAt" label="预计退回">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="remark" label="说明">
<Input />
</Form.Item>
</Form>
</FormDrawer>
</>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { Table, Tag } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { PageHeader } from './ResourcePage';
const KIND_LABEL: Record<string, string> = {
BID_OPEN: '开标',
TASK: '任务截止',
TODO: '待办',
BORROW: '证照归还',
SEAL: '印章归还',
VEHICLE: '用车',
};
type CalItem = {
id: string;
kind: string;
title: string;
start?: string | null;
place?: string | null;
ref?: string | null;
overdue?: boolean;
path?: string | null;
};
export default function CalendarPage() {
const q = useQuery({
queryKey: ['/office/calendar'],
queryFn: () => api.get('/office/calendar'),
});
const items = (q.data?.data ?? []) as CalItem[];
return (
<>
<PageHeader
title="我的日程"
description="只显示派给你的事:自己的待办、任务截止、被派去参加的开标。投标全貌在投标管理里看,不会因为有权限就把所有开标塞进来。"
/>
<Table
rowKey="id"
loading={q.isLoading}
dataSource={items}
columns={[
{
title: '类型',
dataIndex: 'kind',
width: 110,
render: (v: string) => KIND_LABEL[v] || v,
},
{
title: '事项',
dataIndex: 'title',
render: (v: string, r: CalItem) => (r.path ? <Link to={r.path}>{v}</Link> : v),
},
{ title: '编号', dataIndex: 'ref', width: 150, render: (v: string) => v || '—' },
{
title: '时间',
dataIndex: 'start',
width: 170,
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '—'),
},
{ title: '地点 / 说明', dataIndex: 'place', render: (v: string) => v || '—' },
{
title: '状态',
width: 90,
render: (_: unknown, r: CalItem) =>
r.overdue ? <Tag color="error"></Tag> : <Tag></Tag>,
},
]}
/>
</>
);
}
+591
View File
@@ -0,0 +1,591 @@
import { useEffect, type ReactNode } from 'react';
import {
Alert,
Button,
Checkbox,
DatePicker,
Descriptions,
Form,
Input,
InputNumber,
Modal,
Space,
Table,
Tag,
Timeline,
Typography,
message,
} from 'antd';
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useParams } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { PageHeader } from './ResourcePage';
import { FormDrawer } from './FormDrawer';
import { BizFileList } from './FileAttachments';
import { REVIEW_RESULT } from './bidStatus';
import { CONTRACT_ACTION_LABEL, contractNoText, contractStatusMeta } from './contractStatus';
type UserRef = { id: string; displayName: string; username?: string };
type ContractDetail = {
id: string;
contractNo: string;
name: string;
amount: string | number;
status: string;
partyA?: string | null;
partyB?: string | null;
nasPath?: string | null;
remark?: string | null;
signedAt?: string | null;
submittedAt?: string | null;
approvedAt?: string | null;
createdAt: string;
bidCase?: { id: string; bidNo: string; name: string; status: string; quoteAmount?: string | null } | null;
legalEntity?: { name: string } | null;
createdBy?: UserRef | null;
milestones: {
id: string;
name: string;
ratio: string | number;
dueAt?: string | null;
trigger?: string | null;
invoice?: { id: string; status: string; invoiceNo?: string | null } | null;
}[];
reviewers: {
id: string;
userId: string;
result: string;
comment?: string | null;
decidedAt?: string | null;
user?: UserRef;
}[];
actionLogs: {
id: string;
action: string;
fromStatus?: string | null;
toStatus?: string | null;
comment?: string | null;
createdAt: string;
actor?: UserRef;
}[];
sealRequests: {
id: string;
sealType: string;
reason: string;
takeOut: boolean;
status: string;
createdAt: string;
}[];
projects: { id: string; projectNo: string; status: string; presalesCost?: string | number }[];
files?: { id: string; fileName: string; size: number; mimeType: string; bizType?: string }[];
myActions: {
canEdit: boolean;
canSubmit: boolean;
canRequestSeal: boolean;
canReview: boolean;
};
};
function fmt(v?: string | null) {
return v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '—';
}
export default function ContractDetailPage({
id: idProp,
embedded,
}: {
id?: string;
embedded?: boolean;
} = {}) {
const { id: routeId } = useParams();
const id = idProp || routeId;
const navigate = useNavigate();
const qc = useQueryClient();
const [infoForm] = Form.useForm();
const [mileForm] = Form.useForm();
const [sealForm] = Form.useForm();
const [reviewForm] = Form.useForm();
const q = useQuery({
queryKey: ['contract', id],
enabled: Boolean(id),
queryFn: () => api.get(`/contracts/${id}`),
});
const row = q.data?.data as ContractDetail | undefined;
useEffect(() => {
if (!row) return;
infoForm.setFieldsValue({
contractNo: row.contractNo || undefined,
name: row.name,
amount: Number(row.amount) || undefined,
partyA: row.partyA || undefined,
partyB: row.partyB || undefined,
nasPath: row.nasPath || undefined,
remark: row.remark || undefined,
});
mileForm.setFieldsValue({
items: row.milestones.length
? row.milestones.map((m) => ({
name: m.name,
ratio: Number(m.ratio),
dueAt: m.dueAt ? dayjs(m.dueAt) : undefined,
trigger: m.trigger || undefined,
}))
: [
{ name: '预付款', ratio: 30 },
{ name: '进度款', ratio: 40 },
{ name: '验收款', ratio: 30 },
],
});
}, [row, infoForm, mileForm]);
const invalidate = async () => {
await qc.invalidateQueries({ queryKey: ['contract', id] });
await qc.invalidateQueries({ queryKey: ['/contracts'] });
await qc.invalidateQueries({ queryKey: ['/projects'] });
await qc.invalidateQueries({ queryKey: ['/office/approvals'] });
await qc.invalidateQueries({ queryKey: ['/office/todos'] });
};
const updateMut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.patch(`/contracts/${id}`, body),
onSuccess: async () => {
message.success('已保存');
await invalidate();
},
onError: (e: Error) => message.error(e.message),
});
const mileMut = useMutation({
mutationFn: (items: Array<Record<string, unknown>>) => api.put(`/contracts/${id}/milestones`, { items }),
onSuccess: async () => {
message.success('收款条款已保存');
await invalidate();
},
onError: (e: Error) => message.error(e.message),
});
const sealMut = useMutation({
mutationFn: (body: Record<string, unknown>) =>
api.post(`/contracts/${id}/seal`, {
...body,
returnAt: body.returnAt ? dayjs(body.returnAt as string).format('YYYY-MM-DD') : undefined,
}),
onSuccess: async () => {
message.success('已申请用章');
sealForm.resetFields();
await invalidate();
},
onError: (e: Error) => message.error(e.message),
});
const submitMut = useMutation({
mutationFn: () => api.post(`/contracts/${id}/submit`),
onSuccess: async () => {
message.success('已提交审批');
await invalidate();
},
onError: (e: Error) => message.error(e.message),
});
const reviewMut = useMutation({
mutationFn: (body: { result: 'APPROVED' | 'REJECTED'; comment?: string }) =>
api.post(`/contracts/${id}/reviews`, body),
onSuccess: async () => {
message.success('已办理');
reviewForm.resetFields();
await invalidate();
},
onError: (e: Error) => message.error(e.message),
});
const invoiceMut = useMutation({
mutationFn: (milestoneId: string) => api.post('/invoices', { contractId: id, milestoneId }),
onSuccess: async () => {
message.success('已生成待开票,请到开票收款登记发票号');
await invalidate();
},
onError: (e: Error) => message.error(e.message),
});
if (q.isError) {
return (
<Alert
type="error"
message={(q.error as Error).message || '无法打开合同'}
action={
<Button onClick={() => navigate('/contract/tender')} size="small">
</Button>
}
/>
);
}
if (!row) {
return <Typography.Paragraph></Typography.Paragraph>;
}
const meta = contractStatusMeta(row.status);
const project = row.projects[0];
const decide = (result: 'APPROVED' | 'REJECTED') => {
if (result === 'REJECTED') {
Modal.confirm({
title: '驳回合同',
content: (
<Form form={reviewForm} layout="vertical" style={{ marginTop: 12 }}>
<Form.Item name="comment" label="意见" rules={[{ required: true, message: '驳回请填写意见' }]}>
<Input.TextArea rows={3} />
</Form.Item>
</Form>
),
okButtonProps: { danger: true },
okText: '确认驳回',
onOk: async () => {
const v = await reviewForm.validateFields();
await reviewMut.mutateAsync({ result: 'REJECTED', comment: v.comment });
},
});
return;
}
reviewMut.mutate({ result: 'APPROVED' });
};
const page = (
<>
<PageHeader
title={row.name}
description={contractNoText(row.contractNo)}
extra={
<Space>
{embedded ? null : (
<Button>
<Link to={row.bidCase ? '/contract/tender' : '/contract/incremental'}></Link>
</Button>
)}
{row.bidCase ? (
<Button>
<Link to={`/bid/cases/${row.bidCase.id}`}></Link>
</Button>
) : null}
</Space>
}
/>
<Space style={{ marginBottom: 16 }} wrap>
<Tag color={meta.color}>{meta.text}</Tag>
{row.legalEntity?.name ? <Tag>{row.legalEntity.name}</Tag> : null}
{row.bidCase?.bidNo ? <Tag>{row.bidCase.bidNo}</Tag> : null}
{project ? (
<Tag color="success">
<Link to={`/project/${project.id}`}> {project.projectNo}</Link>
</Tag>
) : null}
</Space>
{row.status === 'APPROVED' && project ? (
<Alert
type="success"
showIcon
style={{ marginBottom: 16 }}
message={`合同已通过,项目 ${project.projectNo} 可继续执行。售前成本 ${project.presalesCost ?? 0} 已继承。`}
/>
) : null}
{(row.status === 'DRAFT' || row.status === 'REJECTED') && project && row.myActions.canEdit ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="中标后已自动写入项目台账并生成本草稿。请完善正式编号、金额、收款条款并申请用章后提交审批。报销可先挂项目。"
/>
) : null}
{row.myActions.canReview ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message={
row.projects?.length
? '项目台账已在中标时创建,报销可挂项目。通过后合同生效;驳回只退回草稿,不删除项目。'
: '请办理合同审批。全部通过才立项;一人驳回则退回草稿,不创建项目。'
}
action={
<Space>
<Button type="primary" onClick={() => decide('APPROVED')}>
</Button>
<Button danger onClick={() => decide('REJECTED')}>
</Button>
</Space>
}
/>
) : null}
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
<Descriptions.Item label="合同编号">{contractNoText(row.contractNo)}</Descriptions.Item>
<Descriptions.Item label="甲方">{row.partyA || '—'}</Descriptions.Item>
<Descriptions.Item label="乙方">{row.partyB || row.legalEntity?.name || '—'}</Descriptions.Item>
<Descriptions.Item label="合同金额">{row.amount}</Descriptions.Item>
<Descriptions.Item label="经办">{row.createdBy?.displayName === '系统管理员' ? '—' : row.createdBy?.displayName || '—'}</Descriptions.Item>
<Descriptions.Item label="提交时间">{fmt(row.submittedAt)}</Descriptions.Item>
<Descriptions.Item label="通过时间">{fmt(row.approvedAt)}</Descriptions.Item>
<Descriptions.Item label="资料路径" span={2}>
{row.nasPath || '—'}
</Descriptions.Item>
<Descriptions.Item label="备注" span={2}>
{row.remark || '—'}
</Descriptions.Item>
</Descriptions>
<div style={{ marginBottom: 24 }}>
<Typography.Text strong></Typography.Text>
<BizFileList files={row.files} empty="该合同没有从生产带过来的扫描件。" />
</div>
{row.myActions.canEdit ? (
<Panel title="合同信息" hint="正式编号填纸质合同上的号,系统不自动生成。草稿可改甲乙方、金额和扫描件路径。">
<Form
form={infoForm}
layout="vertical"
onFinish={(v) => updateMut.mutate(v)}
>
<Form.Item name="contractNo" label="正式合同编号" extra="由业务手填,提交审批前必填。">
<Input placeholder="纸质合同上的编号" />
</Form.Item>
<Form.Item name="name" label="合同名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="partyA" label="甲方" rules={[{ required: true, message: '请填写甲方' }]}>
<Input placeholder="对方单位" />
</Form.Item>
<Form.Item name="partyB" label="乙方" rules={[{ required: true, message: '请填写乙方' }]}>
<Input placeholder="我方签约主体" />
</Form.Item>
<Form.Item name="amount" label="合同金额" rules={[{ required: true }]}>
<InputNumber min={0.01} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="nasPath" label="合同扫描件 NAS 路径">
<Input placeholder="\\\\nas\\contracts\\..." />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
<Button type="primary" htmlType="submit" loading={updateMut.isPending}>
</Button>
</Form>
</Panel>
) : null}
<Panel
title="收款条款"
hint="比例合计必须为 100%。提交审批前请先保存。"
>
{row.myActions.canEdit ? (
<Form
form={mileForm}
layout="vertical"
onFinish={(v) =>
mileMut.mutate(
(v.items || []).map((i: { name: string; ratio: number; dueAt?: dayjs.Dayjs; trigger?: string }) => ({
name: i.name,
ratio: i.ratio,
dueAt: i.dueAt ? i.dueAt.toISOString() : undefined,
trigger: i.trigger,
})),
)
}
>
<Form.List name="items">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="baseline" wrap style={{ display: 'flex', marginBottom: 8 }}>
<Form.Item
{...field}
name={[field.name, 'name']}
rules={[{ required: true, message: '名称' }]}
>
<Input placeholder="条款名称" style={{ width: 140 }} />
</Form.Item>
<Form.Item
{...field}
name={[field.name, 'ratio']}
rules={[{ required: true, message: '比例' }]}
>
<InputNumber min={0.01} max={100} addonAfter="%" style={{ width: 120 }} />
</Form.Item>
<Form.Item {...field} name={[field.name, 'dueAt']}>
<DatePicker placeholder="预计日期" />
</Form.Item>
<Form.Item {...field} name={[field.name, 'trigger']}>
<Input placeholder="触发条件" style={{ width: 160 }} />
</Form.Item>
<MinusCircleOutlined onClick={() => remove(field.name)} />
</Space>
))}
<Form.Item>
<Button type="dashed" onClick={() => add()} icon={<PlusOutlined />}>
</Button>
</Form.Item>
</>
)}
</Form.List>
<Button type="primary" htmlType="submit" loading={mileMut.isPending}>
</Button>
</Form>
) : (
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={row.milestones}
columns={[
{ title: '条款', dataIndex: 'name' },
{ title: '比例 %', dataIndex: 'ratio', width: 100 },
{ title: '预计日期', dataIndex: 'dueAt', render: (v: string) => fmt(v) },
{ title: '触发条件', dataIndex: 'trigger', render: (v: string) => v || '—' },
{
title: '开票',
width: 140,
render: (_: unknown, m: ContractDetail['milestones'][0]) => {
if (row.status !== 'APPROVED') return '—';
if (m.invoice) {
return <Link to="/finance/invoices">{m.invoice.invoiceNo || m.invoice.status}</Link>;
}
return (
<Button type="link" size="small" onClick={() => invoiceMut.mutate(m.id)}>
</Button>
);
},
},
]}
/>
)}
</Panel>
<Panel title="用章" hint="提交审批前必须有一条关联本合同的用章申请。">
{row.sealRequests.length ? (
<Table
rowKey="id"
size="small"
pagination={false}
style={{ marginBottom: 12 }}
dataSource={row.sealRequests}
columns={[
{ title: '印章', dataIndex: 'sealType', width: 120 },
{ title: '事由', dataIndex: 'reason' },
{ title: '外带', dataIndex: 'takeOut', width: 80, render: (v: boolean) => (v ? '是' : '否') },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: string) => {
const s = REVIEW_RESULT[v] || { color: 'default', text: v };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
]}
/>
) : (
<Typography.Paragraph type="secondary"></Typography.Paragraph>
)}
{row.myActions.canRequestSeal ? (
<Form form={sealForm} layout="vertical" onFinish={(v) => sealMut.mutate(v)}>
<Form.Item name="sealType" label="印章类型" rules={[{ required: true }]}>
<Input placeholder="公章 / 合同章 / 法人章" />
</Form.Item>
<Form.Item name="reason" label="事由" rules={[{ required: true }]}>
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="takeOut" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{() =>
sealForm.getFieldValue('takeOut') ? (
<Form.Item name="returnAt" label="预计归还" rules={[{ required: true, message: '外带请填写预计归还' }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
) : null
}
</Form.Item>
<Button type="primary" htmlType="submit" loading={sealMut.isPending}>
</Button>
</Form>
) : null}
</Panel>
{row.myActions.canSubmit ? (
<Panel title="提交审批" hint="正式编号、金额、收款条款合计 100%、至少一条用章齐备后提交。审批人在个人办公 · 待我审批办理。">
<Button type="primary" onClick={() => submitMut.mutate()} loading={submitMut.isPending}>
</Button>
</Panel>
) : null}
{row.reviewers.length ? (
<>
<Typography.Title level={5}></Typography.Title>
<Table
rowKey="id"
size="small"
pagination={false}
style={{ marginBottom: 24 }}
dataSource={row.reviewers}
columns={[
{ title: '审批人', render: (_: unknown, r: ContractDetail['reviewers'][0]) => r.user?.displayName || r.userId },
{
title: '结果',
dataIndex: 'result',
width: 110,
render: (v: string) => {
const s = REVIEW_RESULT[v] || { color: 'default', text: v };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{ title: '意见', dataIndex: 'comment', render: (v: string) => v || '—' },
{ title: '时间', dataIndex: 'decidedAt', width: 170, render: (v: string) => fmt(v) },
]}
/>
</>
) : null}
{row.actionLogs.length ? (
<>
<Typography.Title level={5}></Typography.Title>
<Timeline
items={row.actionLogs.map((l) => ({
children: (
<span>
{fmt(l.createdAt)} · {l.actor?.displayName || ''} · {CONTRACT_ACTION_LABEL[l.action] || l.action}
{l.comment ? `${l.comment}` : ''}
</span>
),
}))}
/>
</>
) : null}
</>
);
if (embedded) return page;
return (
<FormDrawer title={row.name} extra={row.contractNo} open hideOk width="min(1120px, 86vw)" createdAt={row.createdAt} onClose={() => navigate(row.bidCase ? '/contract/tender' : '/contract/incremental')}>
{page}
</FormDrawer>
);
}
function Panel({ title, hint, children }: { title: string; hint: string; children: ReactNode }) {
return (
<div style={{ marginBottom: 24, maxWidth: 720 }}>
<Typography.Title level={5}>{title}</Typography.Title>
<Typography.Paragraph type="secondary">{hint}</Typography.Paragraph>
{children}
</div>
);
}
+243
View File
@@ -0,0 +1,243 @@
import { Button, Form, Input, InputNumber, Select, Tag, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import ContractDetailPage from './ContractDetailPage';
import { FormDrawer } from './FormDrawer';
import { ResourcePage } from './ResourcePage';
import { contractHandler, contractNoText, contractStatusMeta } from './contractStatus';
import { money } from './financeStatus';
import { tenderNo } from './bidStatus';
import { api } from '../api/client';
export default function ContractListPage({
title,
description,
bucket,
}: {
title: string;
description?: string;
bucket?: string;
}) {
const kind = bucket === 'tender' ? 'TENDER' : bucket === 'direct' ? 'DIRECT' : 'OTHER';
const createLabel = bucket === 'tender' ? '新建投标合同' : bucket === 'direct' ? '新建直签合同' : '新建其他合同';
const qc = useQueryClient();
const [openRow, setOpenRow] = useState<Record<string, unknown>>();
const [creating, setCreating] = useState(false);
const [form] = Form.useForm();
const endpoint = bucket ? `/contracts?bucket=${bucket}` : '/contracts';
const entities = useQuery({
queryKey: ['/legal-entities'],
queryFn: () => api.get('/legal-entities'),
});
const bids = useQuery({
queryKey: ['/bid-cases', 'won'],
queryFn: () => api.get('/bid-cases?bucket=result'),
enabled: bucket === 'tender',
});
const entityOpts = ((entities.data as { data?: { id: string; name: string; shortName?: string }[] })?.data || []).map(
(e) => ({ value: e.id, label: e.shortName || e.name }),
);
const bidItems = ((bids.data as { data?: { items?: { id: string; bidNo: string; name: string; status: string }[] } })?.data?.items ||
[]) as { id: string; bidNo: string; name: string; status: string }[];
const bidOpts = bidItems
.filter((b) => b.status === 'WON')
.map((b) => ({ value: b.id, label: `${b.bidNo} ${b.name}` }));
const createMut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/contracts', { ...body, kind }),
onSuccess: () => {
message.success(`${createLabel}`);
qc.invalidateQueries({ queryKey: [endpoint] });
},
onError: (e: Error) => message.error(e.message),
});
const columns = [
{
title: '合同号',
dataIndex: 'contractNo',
width: 180,
render: (v: string) => contractNoText(v),
},
{
title: '名称',
dataIndex: 'name',
width: 220,
ellipsis: true,
render: (v: string, row: Record<string, unknown>) => (
<Button type="link" onClick={() => setOpenRow(row)}>
{v}
</Button>
),
},
...(bucket === 'tender'
? [
{
title: '招标编号',
width: 150,
render: (_: unknown, row: Record<string, unknown>) => {
const bid = row.bidCase as { bidNo?: string; externalNo?: string } | null;
return tenderNo(bid);
},
},
]
: []),
{
title: '甲方',
dataIndex: 'partyA',
width: 180,
ellipsis: true,
render: (v: string, row: Record<string, unknown>) => {
const bid = row.bidCase as { party?: { name?: string } } | null;
return v || bid?.party?.name || '—';
},
},
{
title: '乙方',
dataIndex: 'partyB',
width: 180,
ellipsis: true,
render: (v: string, row: Record<string, unknown>) => {
const e = row.legalEntity as { shortName?: string; name?: string } | null;
return v || e?.name || '—';
},
},
{
title: '我方主体',
width: 120,
ellipsis: true,
render: (_: unknown, row: Record<string, unknown>) => {
const e = row.legalEntity as { shortName?: string; name?: string } | null;
return e?.shortName || e?.name || '—';
},
},
{ title: '金额', dataIndex: 'amount', width: 120, render: (v: string) => money(v) },
{
title: '经办',
width: 90,
render: (_: unknown, row: Record<string, unknown>) =>
contractHandler({
createdBy: row.createdBy as { displayName?: string; username?: string } | null,
bidCase: row.bidCase as { createdBy?: { displayName?: string } | null } | null,
}),
},
{
title: '签订',
dataIndex: 'signedAt',
width: 110,
render: (v: string) => (v ? String(v).slice(0, 10) : '—'),
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: (v: string) => {
const s = contractStatusMeta(v);
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '操作',
width: 90,
render: (_: unknown, row: Record<string, unknown>) => (
<Button type="link" onClick={() => setOpenRow(row)}>
</Button>
),
},
];
return (
<>
<ResourcePage
title={title}
description={
description ||
(bucket === 'tender'
? '投标管理转来或本页新建的投标合同。'
: bucket === 'direct'
? '不经过投标、直接签订的合同。'
: '除投标合同、直签合同以外的合同。')
}
searchPlaceholder="搜索合同号、名称、甲乙方"
exportName={title}
extra={
<Button
type="primary"
onClick={() => {
form.resetFields();
setCreating(true);
}}
>
{createLabel}
</Button>
}
filters={[
{
key: 'status',
label: '状态',
options: [
{ label: '草稿', value: 'DRAFT' },
{ label: '审批中', value: 'PENDING' },
{ label: '已通过', value: 'APPROVED' },
{ label: '已驳回', value: 'REJECTED' },
],
},
]}
onRowClick={(row) => setOpenRow(row)}
endpoint={endpoint}
columns={columns}
/>
<FormDrawer
title={createLabel}
open={creating}
onClose={() => setCreating(false)}
okText="提交保存"
okLoading={createMut.isPending}
onOk={async () => {
const v = await form.validateFields();
await createMut.mutateAsync(v);
setCreating(false);
}}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="合同名称" rules={[{ required: true }]}>
<Input placeholder="例如:某某项目合同" />
</Form.Item>
{bucket === 'tender' ? (
<Form.Item name="bidCaseId" label="关联中标事项" extra="中标后一般已自动生成草稿,无需再新建。仅补登历史合同时再选。">
<Select allowClear showSearch optionFilterProp="label" options={bidOpts} />
</Form.Item>
) : null}
<Form.Item name="contractNo" label="正式合同编号" extra="合同编号由业务手填;没有正式合同可先空着,提交审批前必须填写。">
<Input placeholder="纸质合同上的编号" />
</Form.Item>
<Form.Item name="amount" label="金额" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="partyA" label="甲方">
<Input />
</Form.Item>
<Form.Item name="partyB" label="乙方">
<Input />
</Form.Item>
<Form.Item name="legalEntityId" label="我方主体">
<Select allowClear showSearch optionFilterProp="label" options={entityOpts} />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</FormDrawer>
<FormDrawer
title="合同办理"
open={Boolean(openRow)}
onClose={() => setOpenRow(undefined)}
hideOk
width="min(1120px, 86vw)"
createdAt={openRow?.createdAt as string | undefined}
>
{openRow ? <ContractDetailPage id={String(openRow.id)} embedded /> : null}
</FormDrawer>
</>
);
}
+138
View File
@@ -0,0 +1,138 @@
import { Form, Input, InputNumber, Select } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
import { COST_TYPE_LABEL } from './financeStatus';
import { tenderNo } from './bidStatus';
type Target = { id: string; bidNo?: string; externalNo?: string; projectNo?: string; name: string };
const FALLBACK_CATEGORY = ['交通', '餐饮', '住宿', '办公', '招待', '其他'].map((v) => ({ value: v, label: v }));
const COST_TYPE_HINT: Record<string, string> = {
BID: '中标前的差旅、踏勘,选出下面那条投标。',
PROJECT: '中标后已立项的项目。技术部门的车票、机票、住宿进技术预算;商务差旅进商务预算。',
DEPT: '本部门日常开支,不用挂投标或项目。',
ADMIN: '公司公共办公,不算某个部门或项目。',
MARKETING: '市场、接待这类,不算投标售前。',
};
export function CostObjectFields({
disabled,
showCategory,
showPurpose,
hideAmount,
}: {
disabled?: boolean;
showCategory?: boolean;
showPurpose?: boolean;
hideAmount?: boolean;
}) {
const costType = Form.useWatch('costType');
const budgetKind = Form.useWatch('budgetCategory');
const q = useQuery({
queryKey: ['cost-targets'],
queryFn: () => api.get('/cost-targets'),
});
const cats = useQuery({
queryKey: ['dict', 'expenseCategory'],
queryFn: () => api.get('/system/dicts?type=expenseCategory'),
enabled: Boolean(showCategory),
});
const bids = ((q.data?.data?.bids ?? []) as Target[]).map((b) => ({
value: b.id,
label: `${tenderNo(b)} ${b.name}`,
}));
const projects = ((q.data?.data?.projects ?? []) as Target[]).map((p) => ({
value: p.id,
label: `${p.projectNo} ${p.name}`,
}));
const categoryOpts =
((cats.data?.data ?? []) as { label: string; value: string }[]).map((i) => ({
value: i.value,
label: i.label,
}));
return (
<>
<Form.Item
name="costType"
label="费用归属"
extra={COST_TYPE_HINT[costType] || '先选这笔费用算哪边。'}
rules={[{ required: true, message: '请选择这笔费用算哪边' }]}
>
<Select
disabled={disabled}
placeholder="这笔费用算哪边"
options={[
{ value: 'BID', label: COST_TYPE_LABEL.BID },
{ value: 'PROJECT', label: COST_TYPE_LABEL.PROJECT },
{ value: 'DEPT', label: COST_TYPE_LABEL.DEPT },
{ value: 'ADMIN', label: COST_TYPE_LABEL.ADMIN },
{ value: 'MARKETING', label: COST_TYPE_LABEL.MARKETING },
]}
/>
</Form.Item>
{costType === 'BID' ? (
<Form.Item name="bidCaseId" label="投标事项" rules={[{ required: true, message: '请选择投标' }]}>
<Select showSearch optionFilterProp="label" options={bids} disabled={disabled} placeholder="中标前费用挂招标编号" />
</Form.Item>
) : null}
{costType === 'PROJECT' ? (
<>
<Form.Item name="projectId" label="正式项目" rules={[{ required: true, message: '请选择项目' }]}>
<Select showSearch optionFilterProp="label" options={projects} disabled={disabled} />
</Form.Item>
<Form.Item
name="budgetCategory"
label="花费类型"
extra="车票、机票、住宿、打车选差旅;买设备材料选采购。"
rules={[{ required: true, message: '请选择差旅或采购' }]}
>
<Select
disabled={disabled}
placeholder="差旅或采购"
options={[
{ value: 'TRAVEL', label: '差旅(车票 / 机票 / 住宿)' },
{ value: 'PURCHASE', label: '采购(设备 / 材料)' },
]}
/>
</Form.Item>
{budgetKind !== 'PURCHASE' ? (
<Form.Item
name="budgetEnvelope"
label="预算归属"
extra="技术部门报销、代买技术人员的差旅一律进技术预算。商务自己出差进商务预算。"
rules={[{ required: true, message: '请选择商务或技术预算' }]}
>
<Select
disabled={disabled}
placeholder="这笔差旅算商务还是技术"
options={[
{ value: 'TECH', label: '技术预算' },
{ value: 'BUSINESS', label: '商务预算' },
]}
/>
</Form.Item>
) : null}
</>
) : null}
{showCategory ? (
<Form.Item name="expenseCategory" label="费用类别" rules={[{ required: true, message: '请选择交通、餐饮等类别' }]}>
<Select options={categoryOpts.length ? categoryOpts : FALLBACK_CATEGORY} disabled={disabled} placeholder="交通 / 餐饮 / 住宿 / 办公 / 招待" />
</Form.Item>
) : null}
{showPurpose ? (
<Form.Item name="purpose" label="用途" rules={[{ required: true, message: '请填写用途' }]}>
<Input placeholder="如:出差投标打车、现场踏勘" disabled={disabled} />
</Form.Item>
) : null}
{hideAmount ? null : (
<Form.Item name="amount" label="金额" rules={[{ required: true, message: '请填写金额' }]}>
<InputNumber min={0.01} style={{ width: '100%' }} disabled={disabled} />
</Form.Item>
)}
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} disabled={disabled} placeholder="选填" />
</Form.Item>
</>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { Alert, Button, Input, QRCode, Typography } from 'antd';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import { useAuth } from '../store/auth';
import AmbientBackground from '../components/AmbientBackground';
import { BrandLogo } from '../components/BrandLogo';
import { SiteFooter } from '../components/SiteFooter';
type QrStart = { ticket: string; expiresAt: string; payload: string };
export default function DesktopLoginPage() {
const navigate = useNavigate();
const acceptSession = useAuth((s) => s.acceptSession);
const [ticket, setTicket] = useState('');
const [payload, setPayload] = useState('');
const [error, setError] = useState('');
const [hint, setHint] = useState('打开手机端「消息」右上角 + → 扫一扫,扫描此二维码');
const [mobileCode, setMobileCode] = useState('');
const [submitting, setSubmitting] = useState(false);
const start = useCallback(async () => {
setError('');
const res = await api.post('/auth/qr/start', {});
const data = (res.data || res) as QrStart;
setTicket(data.ticket);
setPayload(data.payload || `fengyingoa://qr?ticket=${data.ticket}`);
setMobileCode('');
setHint('打开手机端「消息」右上角 + → 扫一扫,扫描此二维码');
}, []);
useEffect(() => {
void start().catch((e) => setError(e instanceof Error ? e.message : '无法生成登录码'));
}, [start]);
useEffect(() => {
if (!ticket) return;
const timer = window.setInterval(async () => {
try {
const res = await api.get(`/auth/qr/status?ticket=${encodeURIComponent(ticket)}`);
const data = (res.data || res) as {
status?: string;
accessToken?: string;
refreshToken?: string;
clientKind?: string;
};
if (data.status === 'code_required') {
setHint('手机已确认,请输入手机屏幕显示的六位登录码');
return;
}
if (data.status === 'expired' || data.status === 'consumed') {
window.clearInterval(timer);
setHint('登录码已过期,正在刷新…');
await start();
}
} catch (e) {
setError(e instanceof Error ? e.message : '查询登录状态失败');
}
}, 1600);
return () => window.clearInterval(timer);
}, [ticket, acceptSession, navigate, start]);
const complete = async () => {
if (!/^\d{6}$/.test(mobileCode)) {
setError('请输入手机端显示的六位登录码');
return;
}
setSubmitting(true);
setError('');
try {
const res = await api.post('/auth/qr/complete', { ticket, code: mobileCode });
const data = (res.data || res) as { accessToken?: string; refreshToken?: string; clientKind?: string };
if (!data.accessToken) throw new Error('电脑登录未完成');
await acceptSession(data.accessToken, data.refreshToken, data.clientKind || 'desktop');
navigate('/workbench');
} catch (e) {
setError(e instanceof Error ? e.message : '登录码校验失败');
} finally {
setSubmitting(false);
}
};
return (
<div className="ops-login">
<AmbientBackground />
<div className="ops-login-wrap">
<div className="ops-login-card" style={{ textAlign: 'center' }}>
<div className="login-mark">
<BrandLogo size={72} />
</div>
<h1></h1>
<p className="login-sub"> App </p>
{error ? <Alert type="error" message={error} style={{ marginBottom: 16 }} /> : null}
{payload ? (
<div style={{ display: 'flex', justifyContent: 'center', margin: '16px 0' }}>
<QRCode value={payload} size={220} />
</div>
) : null}
<Typography.Paragraph type="secondary" copyable>{ticket || '……'}</Typography.Paragraph>
<Typography.Paragraph type="secondary">{hint}</Typography.Paragraph>
<Input.OTP
length={6}
value={mobileCode}
onChange={setMobileCode}
formatter={(value) => value.replace(/\D/g, '')}
style={{ marginBottom: 16 }}
/>
<br />
<Button type="primary" onClick={() => void complete()} loading={submitting} disabled={!ticket} style={{ marginRight: 8 }}>
</Button>
<Button onClick={() => void start()}></Button>
</div>
</div>
<SiteFooter />
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
import { Button, Table } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import { PageHeader } from './ResourcePage';
import { useAuth } from '../store/auth';
type StaffRow = {
id: string;
name: string;
username?: string;
department?: string | null;
title?: string | null;
mobile?: string | null;
email?: string | null;
};
export default function DirectoryPage() {
const q = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') });
const items = ((q.data as { data?: StaffRow[] })?.data || []) as StaffRow[];
const navigate = useNavigate();
const me = useAuth((s) => s.id);
return (
<>
<PageHeader title="员工通讯" description="在职同事的部门和手机。点「发消息」进入私聊;群聊、通话在「消息」里。" />
<Table
className="ops-table"
rowKey="id"
loading={q.isLoading}
dataSource={items}
pagination={{ defaultPageSize: 20, showSizeChanger: true, showTotal: (n) => `${n}` }}
columns={[
{ title: '姓名', dataIndex: 'name', width: 120 },
{ title: '部门', dataIndex: 'department', width: 160, render: (v: string) => v || '—' },
{ title: '职务', dataIndex: 'title', width: 140, render: (v: string) => v || '—' },
{ title: '手机', dataIndex: 'mobile', width: 140, render: (v: string) => v || '—' },
{ title: '邮箱', dataIndex: 'email', width: 220, render: (v: string) => v || '—' },
{
title: '沟通',
width: 100,
render: (_: unknown, r: StaffRow) =>
r.id === me ? (
'—'
) : (
<Button type="link" onClick={() => navigate(`/office/im?peerId=${r.id}&name=${encodeURIComponent(r.name)}`)}>
</Button>
),
},
]}
/>
</>
);
}
File diff suppressed because it is too large Load Diff
+482
View File
@@ -0,0 +1,482 @@
import { useEffect, useRef, useState } from 'react';
import { Alert, Button, Descriptions, Form, Select, Space, Spin, Table, Tag, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { FormDrawer } from './FormDrawer';
import { CostObjectFields } from './CostObjectFields';
import { ExpenseInvoiceFields, type LineFile } from './ExpenseInvoiceFields';
import { BizFileActions, FileAttachments } from './FileAttachments';
import { belongLabel, CostTargetLink } from './FinanceCells';
import {
COST_TYPE_LABEL,
EXPENSE_LINE_KIND_LABEL,
EXPENSE_STATUS_META,
BUDGET_CATEGORY_LABEL,
money,
} from './financeStatus';
type ExpenseLine = {
id?: string;
kind: string;
amount: string | number;
invoiceNo?: string | null;
occurAt?: string | null;
remark?: string | null;
files?: LineFile[];
};
type Expense = {
id: string;
claimNo: string;
costType: string;
amount: string | number;
status: string;
remark?: string | null;
rejectComment?: string | null;
budgetCategory?: string | null;
bidCaseId?: string | null;
projectId?: string | null;
createdAt?: string;
loanId?: string | null;
expenseCategory?: string | null;
purpose?: string | null;
prepaid?: boolean;
payerId?: string | null;
payer?: { displayName: string } | null;
lines?: ExpenseLine[];
bidCase?: { id: string; bidNo: string; name: string } | null;
project?: { id: string; projectNo: string; name: string } | null;
loan?: { id: string; loanNo: string; status: string } | null;
applicant?: { displayName: string; employee?: { department?: { name?: string } } };
files?: { id: string; fileName: string }[];
payout?: { kind: string; name?: string | null; bankName?: string | null; bankAccount?: string | null };
payment?: { id: string; payNo: string; status: string } | null;
myActions?: {
canEdit: boolean;
canSubmit: boolean;
canDelete?: boolean;
canReview: boolean;
canAttachFiles?: boolean;
canLinkLoan?: boolean;
};
approvalChain?: { role: string; name: string; status: string }[];
};
function packExpense(v: Record<string, unknown>) {
const lines = ((v.lines as ExpenseLine[]) || []).map((l) => ({
id: l.id,
kind: l.kind,
amount: Number(l.amount) > 0 ? Number(l.amount) : 0,
invoiceNo: l.invoiceNo || undefined,
occurAt: l.occurAt ? dayjs(l.occurAt).format('YYYY-MM-DD') : undefined,
remark: l.remark || undefined,
}));
const kind = v.budgetCategory === 'PURCHASE' ? 'PURCHASE' : 'TRAVEL';
const envelope = v.budgetEnvelope === 'TECH' || v.budgetCategory === 'TECH' ? 'TECH' : 'BUSINESS';
return {
...v,
prepaid: Boolean(v.prepaid),
payerId: v.prepaid ? v.payerId : null,
lines,
amount: lines.reduce((s, l) => s + Number(l.amount || 0), 0),
budgetCategory: v.costType === 'PROJECT' ? (kind === 'PURCHASE' ? 'PURCHASE' : envelope === 'TECH' ? 'TECH' : 'TRAVEL') : undefined,
};
}
export default function ExpenseDetailPage() {
const { id } = useParams();
const [params] = useSearchParams();
const location = useLocation();
const onDesk = location.pathname.startsWith('/finance');
const listPath = onDesk ? '/finance/expenses' : '/office/expenses';
const isNew = id === 'new' || !id;
const navigate = useNavigate();
const qc = useQueryClient();
const [form] = Form.useForm();
const bootstrapped = useRef(false);
const [createTick, setCreateTick] = useState(0);
const [creating, setCreating] = useState(isNew);
const [createError, setCreateError] = useState<string | null>(null);
const [linkLoanId, setLinkLoanId] = useState<string>();
const prepaid = Form.useWatch('prepaid', form);
const q = useQuery({
queryKey: ['expense', id],
enabled: !isNew && Boolean(id),
queryFn: () => api.get(`/expenses/${id}`),
});
const loans = useQuery({
queryKey: ['loans', 'mine-for-offset'],
queryFn: () => api.get('/loans?bucket=mine&pageSize=100'),
});
const row = q.data?.data as Expense | undefined;
useEffect(() => {
if (!isNew || bootstrapped.current) return;
bootstrapped.current = true;
setCreating(true);
setCreateError(null);
(async () => {
try {
const res = (await api.post('/expenses', {
costType: params.get('costType') || 'BID',
bidCaseId: params.get('bidCaseId') || undefined,
projectId: params.get('projectId') || undefined,
loanId: params.get('loanId') || undefined,
prepaid: false,
lines: [{ kind: 'AIR', amount: 0 }],
})) as { data?: { id?: string } };
if (res.data?.id) navigate(`${listPath}/${res.data.id}`, { replace: true });
} catch (e) {
bootstrapped.current = false;
setCreateError((e as Error).message || '新建报销单失败');
} finally {
setCreating(false);
}
})();
}, [isNew, listPath, navigate, params, createTick]);
const lineIds = (row?.lines || []).map((l) => l.id).join(',');
useEffect(() => {
if (isNew || !row) return;
const currentIds = ((form.getFieldValue('lines') as { id?: string }[]) || []).map((l) => l.id).join(',');
if (currentIds && currentIds === lineIds) return;
form.setFieldsValue({
costType: row.costType,
bidCaseId: row.bidCaseId || undefined,
projectId: row.projectId || undefined,
loanId: row.loanId || undefined,
amount: Number(row.amount) || undefined,
remark: row.remark || undefined,
budgetCategory: row.budgetCategory === 'TECH' ? 'TRAVEL' : row.budgetCategory || undefined,
budgetEnvelope: row.budgetCategory === 'TECH' || row.budgetCategory === 'PURCHASE' ? 'TECH' : 'BUSINESS',
prepaid: Boolean(row.prepaid),
payerId: row.payerId || undefined,
lines: row.lines?.length
? row.lines.map((l) => ({
id: l.id,
kind: l.kind,
amount: Number(l.amount) || undefined,
invoiceNo: l.invoiceNo || undefined,
occurAt: l.occurAt ? dayjs(l.occurAt) : undefined,
remark: l.remark || undefined,
}))
: [{ kind: 'AIR' }],
});
}, [isNew, row, form, lineIds]);
const invalidate = () => qc.invalidateQueries();
const persist = async (lines?: Record<string, unknown>[]) => {
const body = packExpense({ ...form.getFieldsValue(), ...(lines ? { lines } : {}) });
const res = (isNew
? await api.post('/expenses', body)
: await api.patch(`/expenses/${id}`, body)) as { data?: Expense };
if (res.data) qc.setQueryData(['expense', res.data.id], { data: res.data });
if (isNew && res.data?.id) navigate(`${listPath}/${res.data.id}`, { replace: true });
return res.data;
};
const saveMut = useMutation({
mutationFn: async () => persist(),
onSuccess: () => message.success('已保存草稿'),
onError: (e: Error) => message.error(e.message),
});
const submitMut = useMutation({
mutationFn: async (savedId?: string) => {
const target = savedId || id;
if (!target || target === 'new') throw new Error('请先保存');
return api.post(`/expenses/${target}/submit`);
},
onSuccess: async () => {
message.success('已提交审批');
await invalidate();
},
onError: (e: Error) => message.error(e.message),
});
const reviewMut = useMutation({
mutationFn: (body: { result: 'APPROVED' | 'REJECTED'; comment?: string }) =>
api.post(`/expenses/${id}/reviews`, body),
onSuccess: async () => {
message.success('已办理');
await invalidate();
},
onError: (e: Error) => message.error(e.message),
});
const linkLoanMut = useMutation({
mutationFn: (loanId: string) => api.post(`/expenses/${id}/link-loan`, { loanId }),
onSuccess: async () => {
message.success('已关联冲账借款,并同步报销归属');
setLinkLoanId(undefined);
await invalidate();
},
onError: (e: Error) => message.error(e.message),
});
const allLoans = (loans.data?.data?.items ?? []) as {
id: string;
loanNo: string;
remaining?: number;
costType: string;
status: string;
bidCaseId?: string | null;
projectId?: string | null;
bidCase?: { bidNo?: string | null; name?: string | null } | null;
project?: { projectNo?: string | null; name?: string | null } | null;
}[];
const offsetLoans = allLoans.filter((l) => Number(l.remaining || 0) > 0 && ['OPEN', 'PENDING'].includes(l.status));
const openLoanCount = offsetLoans.filter((l) => l.status === 'OPEN').length;
const loanTarget = (l: (typeof offsetLoans)[number]) => {
if (l.costType === 'BID') return l.bidCase?.name || l.bidCase?.bidNo || '投标费用';
if (l.costType === 'PROJECT') return l.project?.name || l.project?.projectNo || '项目费用';
return l.costType === 'ADMIN' ? '行政费用' : '部门费用';
};
const meta = EXPENSE_STATUS_META[row?.status || 'DRAFT'] || EXPENSE_STATUS_META.DRAFT;
const editable = isNew || row?.myActions?.canEdit;
return (
<FormDrawer
title={isNew ? '填报销' : row?.claimNo || '报销单'}
extra="每张票都可以挂图片或文件。机票高铁住宿由别人垫付时打开代付。"
open
onClose={() => navigate(listPath)}
hideOk
createdAt={isNew ? undefined : row?.createdAt}
>
{row ? (
<Space style={{ marginBottom: 16 }}>
<Tag color={meta.color}>{meta.text}</Tag>
<Tag>{COST_TYPE_LABEL[row.costType] || row.costType}</Tag>
{row.bidCase ? <Link to={`/bid/cases/${row.bidCase.id}`}>{row.bidCase.bidNo}</Link> : null}
{row.project ? <Link to={`/project/${row.project.id}`}>{row.project.projectNo}</Link> : null}
{row.budgetCategory ? <Tag>{BUDGET_CATEGORY_LABEL[row.budgetCategory] || row.budgetCategory}</Tag> : null}
</Space>
) : null}
{row?.status === 'REJECTED' ? (
<Alert type="error" showIcon style={{ marginBottom: 16 }} message={`被打回:${row.rejectComment || ''}`} />
) : null}
{row?.approvalChain?.length ? (
<p style={{ color: 'var(--mute)', marginBottom: 16 }}>
{row.approvalChain
.map((s) => {
const st = s.status === 'APPROVED' ? '已过' : s.status === 'PENDING' ? '待审' : s.status === 'REJECTED' ? '驳回' : '等待';
return `${s.role} ${s.name}${st}`;
})
.join(' → ')}
</p>
) : null}
{row?.myActions?.canReview ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="请确认这笔费用。通过后进入下一岗;最后一岗通过后,若挂了借款将冲减未还金额。"
action={
<Space>
<Button type="primary" onClick={() => reviewMut.mutate({ result: 'APPROVED' })}>
</Button>
<Button
danger
onClick={() => {
const comment = window.prompt('打回意见');
if (comment) reviewMut.mutate({ result: 'REJECTED', comment });
}}
>
</Button>
</Space>
}
/>
) : null}
{!editable && row ? (
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
<Descriptions.Item label="金额">{money(row.amount)}</Descriptions.Item>
<Descriptions.Item label="申请人">{row.applicant?.displayName || '—'}</Descriptions.Item>
<Descriptions.Item label="代付">{row.prepaid ? row.payer?.displayName || '已代付' : '自付'}</Descriptions.Item>
<Descriptions.Item label="打款到">
{row.payout?.kind === 'OFFSET'
? '冲借款,不打款'
: row.payout?.name
? `${row.payout.kind === 'PAYER' ? '代付人' : '申请人'} ${row.payout.name}`
: '—'}
</Descriptions.Item>
<Descriptions.Item label="收款账户">
{row.payout?.kind === 'OFFSET'
? '—'
: [row.payout?.bankName, row.payout?.bankAccount].filter(Boolean).join(' ') || '档案缺银行卡'}
</Descriptions.Item>
<Descriptions.Item label="付款单">{row.payment?.payNo || (row.status === 'APPROVED' ? '待生成' : '通过后进入付款执行')}</Descriptions.Item>
<Descriptions.Item label="归属">{belongLabel(row)}</Descriptions.Item>
<Descriptions.Item label="对象">
<CostTargetLink costType={row.costType} bid={row.bidCase} project={row.project} />
</Descriptions.Item>
<Descriptions.Item label="借款冲账">{row.prepaid ? '代付不冲账' : row.loan?.loanNo || '无'}</Descriptions.Item>
<Descriptions.Item label="备注" span={2}>
{row.remark || '—'}
</Descriptions.Item>
</Descriptions>
) : null}
{!editable && row?.myActions?.canLinkLoan ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="关联冲账"
description="可在审批开始前关联一笔本人已放款借款;选择后会同步报销的项目或投标归属,审批通过后自动冲减该借款余额。"
action={
<Space direction="vertical" size={8} style={{ minWidth: 300 }}>
<Select
allowClear
value={linkLoanId}
onChange={setLinkLoanId}
placeholder="选择本人未结清借款"
options={offsetLoans
.filter((loan) => loan.status === 'OPEN')
.map((loan) => ({
value: loan.id,
label: `${loan.loanNo} · ${loanTarget(loan)} · 未还 ${loan.remaining ?? 0}`,
}))}
/>
<Button
type="primary"
disabled={!linkLoanId}
loading={linkLoanMut.isPending}
onClick={() => linkLoanId && linkLoanMut.mutate(linkLoanId)}
>
</Button>
</Space>
}
/>
) : null}
{!editable && row?.lines?.length ? (
<Table
size="small"
pagination={false}
style={{ marginBottom: 24 }}
rowKey={(_, i) => String(i)}
dataSource={row.lines}
columns={[
{ title: '票种', dataIndex: 'kind', width: 90, render: (v: string) => EXPENSE_LINE_KIND_LABEL[v] || v },
{ title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) },
{ title: '发票号', dataIndex: 'invoiceNo', render: (v: string) => v || '—' },
{ title: '日期', dataIndex: 'occurAt', width: 120, render: (v: string) => (v ? String(v).slice(0, 10) : '—') },
{ title: '说明', dataIndex: 'remark', render: (v: string) => v || '—' },
{
title: '附件',
render: (_: unknown, r: ExpenseLine) =>
r.files?.length
? r.files.map((f) => (
<BizFileActions
key={f.id}
file={{ id: f.id, fileName: f.fileName, size: 0, mimeType: '' }}
/>
))
: '—',
},
]}
/>
) : null}
{!editable && row ? (
<FileAttachments
bizType="EXPENSE"
bizId={row.id}
disabled={!row.myActions?.canAttachFiles}
label="报销附件"
/>
) : null}
{isNew && creating ? (
<div style={{ padding: '48px 0', textAlign: 'center' }}>
<Spin />
<p style={{ color: 'var(--mute)', marginTop: 12 }}></p>
</div>
) : null}
{isNew && createError ? (
<Alert
type="error"
showIcon
style={{ marginBottom: 16 }}
message="报销单没有建出来"
description={createError}
action={
<Button
size="small"
onClick={() => {
bootstrapped.current = false;
setCreateTick((n) => n + 1);
}}
>
</Button>
}
/>
) : null}
{!editable && row ? null : isNew ? null : (
<Form form={form} layout="vertical" style={{ maxWidth: 720 }} onFinish={() => saveMut.mutate()}>
<CostObjectFields disabled={!editable} hideAmount />
<ExpenseInvoiceFields
disabled={!editable}
lineFiles={Object.fromEntries((row?.lines || []).filter((l) => l.id).map((l) => [l.id as string, l.files || []]))}
onPersist={persist}
/>
{prepaid ? null : (
<Form.Item
name="loanId"
label="冲账借款"
extra={
openLoanCount
? '选择已放款借款后,会自动带入对应的投标/项目;报销审批通过后自动冲减该借款余额。'
: '暂无已放款、可冲销的借款。待财务确认的借款会在确认放款后自动变为可选。'
}
>
<Select
allowClear
placeholder="选择本人未结清借款"
onChange={(loanId) => {
const loan = offsetLoans.find((l) => l.id === loanId);
if (!loan || loan.status !== 'OPEN') return;
form.setFieldsValue({
costType: loan.costType,
bidCaseId: loan.bidCaseId || undefined,
projectId: loan.projectId || undefined,
});
}}
options={offsetLoans.map((l) => ({
value: l.id,
disabled: l.status !== 'OPEN',
label: `${l.loanNo} · ${loanTarget(l)} · 未还 ${l.remaining ?? 0}${l.status === 'PENDING' ? '(待财务确认)' : ''}`,
}))}
/>
</Form.Item>
)}
<Space>
<Button type="primary" htmlType="submit" loading={saveMut.isPending}>
稿
</Button>
<Button
loading={submitMut.isPending}
onClick={async () => {
await form.validateFields();
const saved = await persist();
const target = saved?.id || id;
if (target && target !== 'new') await submitMut.mutateAsync(target);
}}
>
</Button>
</Space>
</Form>
)}
{!editable && row?.myActions?.canSubmit ? (
<Button type="primary" onClick={() => submitMut.mutate()} loading={submitMut.isPending}>
</Button>
) : null}
{null}
</FormDrawer>
);
}
+287
View File
@@ -0,0 +1,287 @@
import { Alert, Button, Card, Col, DatePicker, Form, Input, InputNumber, Row, Select, Space, Switch, Typography, Upload, message } from 'antd';
import type { UploadFile } from 'antd/es/upload/interface';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../api/client';
import { useState } from 'react';
import { AttachmentPreviewModal, displayFileName, loadBizPreview, type FilePreview } from './FileAttachments';
import { EXPENSE_LINE_KIND_OPTIONS, money } from './financeStatus';
type Staff = {
id: string;
name: string;
department?: string;
bankReady?: boolean;
bankName?: string | null;
bankAccountMasked?: string | null;
};
export type LineFile = { id: string; fileName: string };
function LineAttach({
lineId,
files,
disabled,
onNeedLine,
}: {
lineId?: string;
files?: LineFile[];
disabled?: boolean;
onNeedLine: () => Promise<string | undefined>;
}) {
const qc = useQueryClient();
const [preview, setPreview] = useState<FilePreview | null>(null);
const fileList: UploadFile[] = (files || []).map((f) => ({
uid: f.id,
name: displayFileName(f.fileName),
status: 'done',
}));
return (
<Form.Item label="附件" extra="图片或文件都可以,一次能选多张,立刻挂到这一行。">
<Upload
multiple
listType="picture-card"
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx"
fileList={fileList}
disabled={disabled}
beforeUpload={() => true}
onPreview={async (file) => {
if (!file.uid) return;
try {
const next = await loadBizPreview(file.uid, undefined, file.name);
if (next) setPreview(next);
} catch (e) {
message.error(e instanceof Error ? e.message : '无法打开附件');
}
}}
onRemove={async (file) => {
if (disabled || !file.uid) return false;
try {
await api.delete(`/files/${file.uid}`);
message.success('已删除');
qc.invalidateQueries({ queryKey: ['expense'] });
return true;
} catch (e) {
message.error((e as Error).message);
return false;
}
}}
customRequest={async (opt) => {
const file = opt.file as File;
try {
const target = lineId || (await onNeedLine());
if (!target) throw new Error('发票还没建起来,请稍后再传');
const fd = new FormData();
fd.append('file', file);
fd.append('bizType', 'EXPENSE_LINE');
fd.append('bizId', target);
await api.post('/files', fd);
qc.invalidateQueries({ queryKey: ['expense'] });
opt.onSuccess?.({}, new XMLHttpRequest());
} catch (e) {
const err = e as Error;
message.error(err.message);
opt.onError?.(err);
}
}}
>
{disabled ? null : <div></div>}
</Upload>
<AttachmentPreviewModal
preview={preview}
onClose={() => {
if (preview?.kind === 'image') URL.revokeObjectURL(preview.url);
setPreview(null);
}}
/>
</Form.Item>
);
}
export function ExpenseInvoiceFields({
disabled,
lineFiles,
onPersist,
}: {
disabled?: boolean;
lineFiles?: Record<string, LineFile[]>;
onPersist?: (lines: Record<string, unknown>[]) => Promise<{ id?: string; lines?: { id?: string }[] } | undefined>;
}) {
const form = Form.useFormInstance();
const prepaid = Form.useWatch('prepaid');
const payerId = Form.useWatch('payerId');
const lines = Form.useWatch('lines') as { id?: string; amount?: number }[] | undefined;
const staff = useQuery({
queryKey: ['/staff'],
queryFn: () => api.get('/staff'),
});
const sum = (lines || []).reduce((s, l) => s + Number(l?.amount || 0), 0);
const people = ((staff.data?.data ?? []) as Staff[]).map((s) => ({
value: s.id,
label: s.department ? `${s.name} · ${s.department}` : s.name,
}));
const payer = ((staff.data?.data ?? []) as Staff[]).find((s) => s.id === payerId);
const persistLines = async (next: Record<string, unknown>[]) => {
if (!onPersist) return undefined;
const saved = await onPersist(next);
if (saved?.lines?.length) {
form.setFieldsValue({
lines: next.map((l, i) => ({ ...l, id: saved.lines?.[i]?.id || l.id })),
});
}
return saved;
};
return (
<>
<Form.Item
name="prepaid"
label="是否代付"
valuePropName="checked"
extra="机票、高铁、火车票、住宿等由公司里别人垫付或对公支付时打开。通过后打款到代付人银行卡,不冲申请人的借款。"
>
<Switch disabled={disabled} checkedChildren="代付" unCheckedChildren="自付" />
</Form.Item>
{prepaid ? (
<>
<Form.Item name="payerId" label="代付人" rules={[{ required: true, message: '请选择代付人' }]}>
<Select
showSearch
optionFilterProp="label"
options={people}
disabled={disabled}
placeholder="谁垫付或对公支付的"
/>
</Form.Item>
{payer ? (
payer.bankReady ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message={`通过后打款到 ${payer.name} · ${payer.bankName || '开户行未填'} ${payer.bankAccountMasked}`}
/>
) : (
<Alert
type="error"
showIcon
style={{ marginBottom: 16 }}
message={`${payer.name} 的人事档案没有银行账号,先补卡号才能代付。`}
/>
)
) : null}
</>
) : null}
<Form.List
name="lines"
rules={[
{
validator: async (_, value) => {
if (!value?.length) throw new Error('至少填一张发票');
},
},
]}
>
{(fields, { add, remove }, { errors }) => (
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8, fontWeight: 600 }}></div>
{fields.map((field, index) => {
const lineId = lines?.[index]?.id;
return (
<Card
key={field.key}
size="small"
title={`发票 ${index + 1}`}
extra={
disabled || fields.length <= 1 ? null : (
<Button
type="link"
danger
onClick={async () => {
remove(field.name);
const next = ((form.getFieldValue('lines') as Record<string, unknown>[]) || []).filter((_, i) => i !== index);
await persistLines(next);
}}
>
</Button>
)
}
style={{ marginBottom: 12 }}
>
<Form.Item name={[field.name, 'id']} hidden>
<Input />
</Form.Item>
<Row gutter={12}>
<Col span={8}>
<Form.Item
name={[field.name, 'kind']}
label="票种"
rules={[{ required: true, message: '请选票种' }]}
>
<Select options={EXPENSE_LINE_KIND_OPTIONS} disabled={disabled} placeholder="机票 / 高铁 / 住宿" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item
name={[field.name, 'amount']}
label="金额"
rules={[{ required: true, message: '请填写金额' }]}
>
<InputNumber min={0.01} style={{ width: '100%' }} disabled={disabled} placeholder="发票金额" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name={[field.name, 'invoiceNo']} label="发票号">
<Input disabled={disabled} placeholder="选填" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name={[field.name, 'occurAt']} label="发生日期">
<DatePicker style={{ width: '100%' }} disabled={disabled} />
</Form.Item>
</Col>
<Col span={16}>
<Form.Item name={[field.name, 'remark']} label="说明">
<Input disabled={disabled} placeholder="如:上海—杭州高铁、希尔顿一晚" />
</Form.Item>
</Col>
<Col span={24}>
<LineAttach
lineId={lineId}
files={lineId ? lineFiles?.[lineId] : undefined}
disabled={disabled}
onNeedLine={async () => {
const current = (form.getFieldValue('lines') as Record<string, unknown>[]) || [];
const saved = await persistLines(current);
return saved?.lines?.[index]?.id;
}}
/>
</Col>
</Row>
</Card>
);
})}
{disabled ? null : (
<Space>
<Button
onClick={async () => {
add({ kind: 'AIR' });
const next = [...((form.getFieldValue('lines') as Record<string, unknown>[]) || [])];
await persistLines(next);
}}
>
</Button>
<Typography.Text type="secondary"> {money(sum)}</Typography.Text>
</Space>
)}
<Form.ErrorList errors={errors} />
</div>
)}
</Form.List>
</>
);
}
+211
View File
@@ -0,0 +1,211 @@
import { Tag } from 'antd';
import { Link, Outlet, useNavigate, useSearchParams } from 'react-router-dom';
import { ResourcePage } from './ResourcePage';
import { belongLabel, CostTargetLink } from './FinanceCells';
import { applicantDept, applicantName, EXPENSE_STATUS_META, expenseCategoryLabel, money } from './financeStatus';
import { ApplyBucketBar } from './ApplyBucketBar';
export default function ExpenseListPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const bucket = params.get('bucket') || '';
const endpoint = bucket ? `/expenses?bucket=${bucket}` : '/expenses';
return (
<>
<ResourcePage
onRowClick={(row) => navigate(`/finance/expenses/${row.id}`)}
title="费用报销"
description="只看已提交的报销。草稿只在填写人「我的费用」里。没确认通过的不会进付款和报表。"
endpoint={endpoint}
searchPlaceholder="搜索单号、申请人、用途"
exportName="费用报销"
filters={[
{
key: 'status',
label: '状态',
options: Object.entries(EXPENSE_STATUS_META).map(([value, m]) => ({ label: m.text, value })),
},
]}
extra={
<ApplyBucketBar
basePath="/finance/expenses"
buckets={[
{ value: 'pending', label: '待确认' },
{ value: 'approved', label: '已通过' },
{ value: 'rejected', label: '已驳回' },
]}
/>
}
columns={[
{
title: '单号',
dataIndex: 'claimNo',
width: 160,
render: (v: string, row: Record<string, unknown>) => <Link to={`/finance/expenses/${row.id}`}>{v}</Link>,
},
{
title: '申请人',
width: 90,
render: (_: unknown, row: Record<string, unknown>) => applicantName(row),
},
{
title: '部门',
width: 110,
render: (_: unknown, row: Record<string, unknown>) => applicantDept(row),
},
{ title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) },
{
title: '代付',
width: 90,
render: (_: unknown, row: Record<string, unknown>) =>
row.prepaid ? (row.payer as { displayName?: string } | null)?.displayName || '代付' : '自付',
},
{ title: '类别', width: 80, render: (_: unknown, row: Record<string, unknown>) => expenseCategoryLabel(row.expenseCategory as string) },
{ title: '用途', ellipsis: true, render: (_: unknown, row: Record<string, unknown>) => String(row.purpose || '—') },
{
title: '归属',
ellipsis: true,
render: (_: unknown, row: Record<string, unknown>) =>
belongLabel({
costType: row.costType as string,
project: row.project as { name?: string } | null,
bidCase: row.bidCase as { name?: string } | null,
applicant: row.applicant as { employee?: { department?: { name?: string } } } | null,
}),
},
{
title: '对象',
width: 150,
render: (_: unknown, row: Record<string, unknown>) => (
<CostTargetLink
costType={row.costType as string}
bid={row.bidCase as { id?: string; bidNo?: string } | null}
project={row.project as { id?: string; projectNo?: string } | null}
/>
),
},
{
title: '日期',
width: 110,
render: (_: unknown, row: Record<string, unknown>) =>
String(row.submittedAt || row.createdAt || '').slice(0, 10) || '—',
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: string) => {
const s = EXPENSE_STATUS_META[v] || { color: 'default', text: v };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '操作',
width: 80,
render: (_: unknown, row: Record<string, unknown>) => <Link to={`/finance/expenses/${row.id}`}></Link>,
},
]}
/>
<Outlet />
</>
);
}
export function LoanListPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const bucket = params.get('bucket') || '';
const endpoint = bucket ? `/loans?bucket=${bucket}` : '/loans';
return (
<>
<ResourcePage
onRowClick={(row) => navigate(`/finance/loans/${row.id}`)}
title="借支记录"
description="全公司借支台账。员工在「我的费用」申请,财务在「待我审批」确认。未还借款会拦住同一成本对象上的新报销。"
endpoint={endpoint}
searchPlaceholder="搜索借款单号、用途、申请人"
exportName="借支记录"
extra={
<ApplyBucketBar
basePath="/finance/loans"
buckets={[
{ value: 'pending', label: '待确认' },
{ value: 'open', label: '未还' },
]}
/>
}
columns={[
{
title: '单号',
dataIndex: 'loanNo',
width: 160,
render: (v: string, row: Record<string, unknown>) => <Link to={`/finance/loans/${row.id}`}>{v}</Link>,
},
{
title: '申请人',
width: 90,
render: (_: unknown, row: Record<string, unknown>) => applicantName(row),
},
{
title: '部门',
width: 110,
render: (_: unknown, row: Record<string, unknown>) => applicantDept(row),
},
{ title: '用途', dataIndex: 'purpose', ellipsis: true },
{ title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) },
{ title: '未还', dataIndex: 'remaining', width: 110, render: (v: string) => money(v) },
{
title: '归属',
ellipsis: true,
render: (_: unknown, row: Record<string, unknown>) =>
belongLabel({
costType: row.costType as string,
project: row.project as { name?: string } | null,
bidCase: row.bidCase as { name?: string } | null,
applicant: row.applicant as { employee?: { department?: { name?: string } } } | null,
}),
},
{
title: '对象',
width: 150,
render: (_: unknown, row: Record<string, unknown>) => (
<CostTargetLink
costType={row.costType as string}
bid={row.bidCase as { id?: string; bidNo?: string } | null}
project={row.project as { id?: string; projectNo?: string } | null}
/>
),
},
{
title: '日期',
width: 110,
render: (_: unknown, row: Record<string, unknown>) =>
String(row.submittedAt || row.createdAt || '').slice(0, 10) || '—',
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: string) => {
const map: Record<string, { color: string; text: string }> = {
DRAFT: { color: 'default', text: '草稿' },
PENDING: { color: 'processing', text: '待确认' },
OPEN: { color: 'warning', text: '未还' },
OFFSET: { color: 'success', text: '已冲账' },
REJECTED: { color: 'error', text: '被打回' },
};
const s = map[v] || { color: 'default', text: v };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '操作',
width: 90,
render: (_: unknown, row: Record<string, unknown>) => <Link to={`/finance/loans/${row.id}`}></Link>,
},
]}
/>
<Outlet />
</>
);
}
+463
View File
@@ -0,0 +1,463 @@
import { Suspense, lazy, useState } from 'react';
import { Button, Modal, Space, Spin, Upload, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../api/client';
const PdfPreview = lazy(() => import('./PdfPreview').then((m) => ({ default: m.PdfPreview })));
type FileRow = { id: string; fileName: string; size: number; mimeType: string; bizType?: string };
export const FILE_KIND_LABEL: Record<string, string> = {
BID_ANNOUNCE: '招标 / 询价文件',
BID_FILE: '投标附件',
BID_FORMAL: '正式标书',
BID_PROOF: '递交 / 保证金证明',
CONTRACT: '合同扫描件',
CONTRACT_ACCEPT: '验收单',
CONTRACT_AWARD: '中标通知书',
PROJECT: '项目附件',
PROJECT_ACCEPT: '验收材料',
PURCHASE: '采购合同',
PURCHASE_ACCEPT: '采购验收',
EXPENSE: '发票 / 凭证',
LOAN: '借据 / 凭证',
USER_AVATAR: '头像',
USER_ID_PHOTO: '证件照',
EMPLOYEE_ID_PHOTO: '证件照',
EMPLOYEE_ID_FRONT: '身份证正面',
EMPLOYEE_ID_BACK: '身份证反面',
EMPLOYEE_CERT: '个人证书',
EMPLOYEE_LABOR: '劳动合同',
NOTICE: '通知附件',
};
/** 纠正 multer latin1 解出来的中文文件名。 */
export function displayFileName(name: string) {
if (!name) return '附件';
if (/[\u4e00-\u9fff]/.test(name)) return name;
try {
const bytes = Uint8Array.from(name, (c) => c.charCodeAt(0) & 0xff);
const decoded = new TextDecoder('utf-8').decode(bytes);
if (/[\u4e00-\u9fff]/.test(decoded) && !decoded.includes('\uFFFD')) return decoded;
} catch {
/* keep original */
}
return name;
}
function sniffMime(bytes: Uint8Array, name?: string, declared?: string) {
const declaredType = (declared || '').toLowerCase();
if (bytes.length >= 4 && bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) {
return 'application/pdf';
}
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
return 'image/png';
}
if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif';
if (declaredType.startsWith('image/') || declaredType === 'application/pdf') return declaredType;
if (/\.pdf$/i.test(name || '')) return 'application/pdf';
if (/\.(png|jpe?g|gif|webp|bmp)$/i.test(name || '')) {
const ext = (name || '').split('.').pop()?.toLowerCase();
if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
if (ext === 'png') return 'image/png';
if (ext === 'gif') return 'image/gif';
if (ext === 'webp') return 'image/webp';
if (ext === 'bmp') return 'image/bmp';
}
return declaredType;
}
function isPreviewable(name: string, mime?: string) {
const type = (mime || '').toLowerCase();
if (type.startsWith('image/') || type === 'application/pdf') return true;
return /\.(png|jpe?g|gif|webp|bmp|pdf)$/i.test(name);
}
async function fetchBizFileBlob(id: string, mime?: string, name?: string) {
const raw = (await api.get(`/files/${id}`, { responseType: 'blob' })) as unknown;
let blob: Blob | null = null;
if (raw instanceof Blob) blob = raw;
else if (raw && typeof raw === 'object' && (raw as { data?: unknown }).data instanceof Blob) {
blob = (raw as { data: Blob }).data;
}
if (!blob) throw new Error('无法读取附件');
const head = new Uint8Array(await blob.slice(0, 24).arrayBuffer());
const headText = new TextDecoder().decode(head).trimStart();
if (headText.startsWith('{') || headText.startsWith('<')) {
const full = await blob.text();
try {
const parsed = JSON.parse(full) as { message?: string };
throw new Error(parsed.message || '无法打开附件');
} catch (e) {
if (e instanceof Error && e.message !== '无法打开附件' && !e.message.includes('JSON')) throw e;
throw new Error('无法打开附件');
}
}
const type = sniffMime(head, name, mime || blob.type);
if (type && blob.type !== type) return new Blob([await blob.arrayBuffer()], { type });
return blob;
}
export async function downloadBizFile(id: string, name: string) {
const blob = await fetchBizFileBlob(id, undefined, name);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = displayFileName(name);
a.click();
URL.revokeObjectURL(url);
}
export function nonImageFiles(files?: FileRow[]) {
return (files || []).filter((f) => !(f.mimeType || '').startsWith('image/'));
}
export async function uploadBizFile(bizType: string, bizId: string, file: File) {
const fd = new FormData();
fd.append('file', file);
fd.append('bizType', bizType);
fd.append('bizId', bizId);
const res = (await api.post('/files', fd)) as { data: FileRow };
return res.data;
}
export function IdScanUpload({
label,
bizType,
bizId,
fileId,
onDone,
}: {
label: string;
bizType: string;
bizId: string;
fileId?: string | null;
onDone?: () => void | Promise<void>;
}) {
return (
<Space wrap>
<Upload
showUploadList={false}
accept="image/*,.pdf"
customRequest={async (opt) => {
try {
const fd = new FormData();
fd.append('file', opt.file as File);
fd.append('bizType', bizType);
fd.append('bizId', bizId);
await api.post('/files', fd);
message.success(`${label}已上传`);
await onDone?.();
opt.onSuccess?.({}, new XMLHttpRequest());
} catch (e) {
message.error((e as Error).message);
opt.onError?.(e as Error);
}
}}
>
<Button>{fileId ? '重新上传' : `上传${label}`}</Button>
</Upload>
{fileId ? (
<BizFileActions
file={{
id: fileId,
fileName: `${label}.jpg`,
size: 0,
mimeType: 'image/jpeg',
bizType,
}}
/>
) : (
<span style={{ color: '#cf1322' }}></span>
)}
</Space>
);
}
export type FilePreview =
| { kind: 'image'; url: string; fileName: string }
| { kind: 'pdf'; data: ArrayBuffer; fileName: string }
| { kind: 'html'; html: string; fileName: string };
export async function loadBizPreview(id: string, mime?: string, name?: string): Promise<FilePreview | null> {
const display = displayFileName(name || '');
const blob = await fetchBizFileBlob(id, mime, display);
if (isPreviewable(display, blob.type)) {
const isImage = (blob.type || '').startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp)$/i.test(display);
if (isImage) {
return { kind: 'image', url: URL.createObjectURL(blob), fileName: display || '图片' };
}
const buf = await blob.arrayBuffer();
return { kind: 'pdf', data: buf.slice(0), fileName: display || '附件.pdf' };
}
const buffer = await blob.arrayBuffer();
if (/\.docx$/i.test(display) || blob.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
const mammoth = await import('mammoth/mammoth.browser');
const result = await mammoth.convertToHtml({ arrayBuffer: buffer });
return {
kind: 'html',
fileName: display || 'Word 文档',
html: `<style>body{font:15px/1.7 system-ui;padding:24px;color:#222}img{max-width:100%}table{border-collapse:collapse}td,th{border:1px solid #ddd;padding:6px}</style>${result.value}`,
};
}
if (/\.(xlsx?|csv)$/i.test(display) || /spreadsheet|ms-excel|csv/i.test(blob.type)) {
const XLSX = await import('xlsx');
const workbook = XLSX.read(buffer, { type: 'array' });
const sheets = workbook.SheetNames.map((sheetName) => {
const heading = `<h2>${sheetName.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!)}</h2>`;
return heading + XLSX.utils.sheet_to_html(workbook.Sheets[sheetName]);
}).join('');
return {
kind: 'html',
fileName: display || 'Excel 表格',
html: `<style>body{font:14px/1.5 system-ui;padding:20px;color:#222}table{border-collapse:collapse;margin-bottom:28px}td,th{border:1px solid #bbb;padding:5px 8px;white-space:nowrap}tr:first-child{background:#f5f5f5}</style>${sheets}`,
};
}
await downloadBizFile(id, display);
message.info('旧版 Word .doc 等格式已下载,请用本地软件打开');
return null;
}
export function AttachmentPreviewModal({
preview,
onClose,
}: {
preview: FilePreview | null;
onClose: () => void;
}) {
return (
<Modal
title={preview?.fileName || '预览'}
open={Boolean(preview)}
onCancel={onClose}
footer={null}
width="80vw"
destroyOnClose
>
{preview?.kind === 'image' ? (
<img src={preview.url} alt={preview.fileName} style={{ width: '100%' }} />
) : preview?.kind === 'pdf' ? (
<Suspense fallback={<Spin />}>
<PdfPreview data={preview.data} fileName={preview.fileName} />
</Suspense>
) : preview?.kind === 'html' ? (
<iframe
title={preview.fileName}
sandbox=""
srcDoc={preview.html}
style={{ width: '100%', height: '72vh', border: 0 }}
/>
) : null}
</Modal>
);
}
export function BizFileActions({
file,
onDelete,
}: {
file: FileRow;
onDelete?: () => void;
}) {
const [preview, setPreview] = useState<FilePreview | null>(null);
const name = displayFileName(file.fileName);
const kindLabel = file.bizType && FILE_KIND_LABEL[file.bizType] ? `${FILE_KIND_LABEL[file.bizType]}` : '';
const closePreview = () => {
if (preview?.kind === 'image') URL.revokeObjectURL(preview.url);
setPreview(null);
};
const openPreview = async () => {
try {
const next = await loadBizPreview(file.id, file.mimeType, name);
if (next) setPreview(next);
} catch (e) {
message.error(e instanceof Error ? e.message : '无法打开附件');
}
};
return (
<>
<Button type="link" onClick={() => void openPreview()}>
{kindLabel}
{name}
</Button>
<Button type="link" onClick={() => void downloadBizFile(file.id, name)}>
</Button>
{onDelete ? (
<Button type="link" danger onClick={onDelete}>
</Button>
) : null}
<AttachmentPreviewModal preview={preview} onClose={closePreview} />
</>
);
}
export function BizFileList({ files, empty = '没有附件' }: { files?: FileRow[]; empty?: string }) {
const rows = files || [];
if (!rows.length) return <p style={{ color: 'var(--mute)' }}>{empty}</p>;
return (
<ul style={{ paddingLeft: 18, marginBottom: 16 }}>
{rows.map((f) => (
<li key={f.id}>
<BizFileActions file={f} />
</li>
))}
</ul>
);
}
export function BizFileUpload({
bizType,
bizId,
files,
disabled,
label,
extra,
queryKey,
onUploaded,
}: {
bizType: string;
bizId?: string;
files?: FileRow[];
disabled?: boolean;
label?: string;
extra?: string;
queryKey: unknown[];
onUploaded?: () => void;
}) {
const qc = useQueryClient();
const rows = files || [];
const del = useMutation({
mutationFn: (id: string) => api.delete(`/files/${id}`),
onSuccess: () => {
message.success('已删除附件');
qc.invalidateQueries({ queryKey });
onUploaded?.();
},
onError: (e: Error) => message.error(e.message),
});
return (
<div style={{ marginBottom: 16 }}>
{label ? <div style={{ marginBottom: 8 }}>{label}</div> : null}
{extra ? <p style={{ color: 'var(--mute)', marginBottom: 8 }}>{extra}</p> : null}
{!bizId ? (
<p style={{ color: 'var(--mute)' }}></p>
) : (
<>
<ul style={{ paddingLeft: 18 }}>
{rows.map((f) => (
<li key={f.id}>
<BizFileActions file={f} onDelete={disabled ? undefined : () => del.mutate(f.id)} />
</li>
))}
{!rows.length ? <li style={{ color: 'var(--mute)' }}></li> : null}
</ul>
{!disabled ? (
<Upload
multiple
showUploadList={false}
beforeUpload={() => true}
customRequest={async (opt) => {
try {
const fd = new FormData();
fd.append('file', opt.file as File);
fd.append('bizType', bizType);
fd.append('bizId', bizId);
await api.post('/files', fd);
message.success('已上传');
qc.invalidateQueries({ queryKey });
onUploaded?.();
opt.onSuccess?.({}, new XMLHttpRequest());
} catch (e) {
const err = e as Error;
message.error(err.message);
opt.onError?.(err);
}
}}
>
<Button></Button>
</Upload>
) : null}
</>
)}
</div>
);
}
export function FileAttachments({
bizType,
bizId,
disabled,
label,
}: {
bizType: 'EXPENSE' | 'LOAN';
bizId?: string;
disabled?: boolean;
label: string;
}) {
const qc = useQueryClient();
const q = useQuery({
queryKey: [bizType === 'EXPENSE' ? 'expense' : 'loan', bizId],
enabled: Boolean(bizId),
queryFn: () => api.get(bizType === 'EXPENSE' ? `/expenses/${bizId}` : `/loans/${bizId}`),
});
const files = ((q.data?.data?.files ?? []) as FileRow[]);
const del = useMutation({
mutationFn: (id: string) => api.delete(`/files/${id}`),
onSuccess: () => {
message.success('已删除附件');
qc.invalidateQueries({ queryKey: [bizType === 'EXPENSE' ? 'expense' : 'loan', bizId] });
},
onError: (e: Error) => message.error(e.message),
});
return (
<div style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}>{label}</div>
{!bizId ? (
<p style={{ color: 'var(--mute)' }}>稿</p>
) : (
<>
<ul style={{ paddingLeft: 18 }}>
{files.map((f) => (
<li key={f.id}>
<BizFileActions file={f} onDelete={disabled ? undefined : () => del.mutate(f.id)} />
</li>
))}
{!files.length ? <li style={{ color: 'var(--mute)' }}></li> : null}
</ul>
{!disabled ? (
<Upload
showUploadList={false}
beforeUpload={() => true}
customRequest={async (opt) => {
try {
const fd = new FormData();
fd.append('file', opt.file as File);
fd.append('bizType', bizType);
fd.append('bizId', bizId);
await api.post('/files', fd);
message.success('已上传');
qc.invalidateQueries({ queryKey: [bizType === 'EXPENSE' ? 'expense' : 'loan', bizId] });
opt.onSuccess?.({}, new XMLHttpRequest());
} catch (e) {
const err = e as Error;
message.error(err.message);
opt.onError?.(err);
}
}}
>
<Button> / </Button>
</Upload>
) : null}
</>
)}
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { Link } from 'react-router-dom';
import { COST_TYPE_LABEL, applicantDept } from './financeStatus';
export type CostTarget = {
id?: string;
bidNo?: string;
projectNo?: string;
contractNo?: string;
name?: string;
} | null;
export function systemTargetNo(bid?: CostTarget, project?: CostTarget, contract?: CostTarget) {
return project?.projectNo || bid?.bidNo || contract?.contractNo || '—';
}
export function CostTargetLink({
costType,
bid,
project,
contract,
}: {
costType?: string;
bid?: CostTarget;
project?: CostTarget;
contract?: CostTarget;
}) {
if (costType === 'BID' && bid?.id && bid.bidNo) {
return <Link to={`/bid/cases/${bid.id}`}>{bid.bidNo}</Link>;
}
if (costType === 'PROJECT' && project?.id && project.projectNo) {
return <Link to={`/project/${project.id}`}>{project.projectNo}</Link>;
}
if (project?.id && project.projectNo) {
return <Link to={`/project/${project.id}`}>{project.projectNo}</Link>;
}
if (bid?.id && bid.bidNo) {
return <Link to={`/bid/cases/${bid.id}`}>{bid.bidNo}</Link>;
}
if (contract?.id && contract.contractNo) {
return <Link to={`/contract/${contract.id}`}>{contract.contractNo}</Link>;
}
return <span></span>;
}
export function belongLabel(row: {
costType?: string;
project?: { name?: string } | null;
bidCase?: { name?: string } | null;
contract?: { name?: string } | null;
applicant?: { employee?: { department?: { name?: string } } } | null;
}) {
if (row.costType === 'BID') {
return row.bidCase?.name ? `投标 · ${row.bidCase.name}` : '投标';
}
if (row.costType === 'PROJECT') {
return row.project?.name ? `项目 · ${row.project.name}` : '正式项目';
}
if (row.costType === 'DEPT') {
const dept = applicantDept(row as Record<string, unknown>);
return dept !== '—' ? `部门 · ${dept}` : '部门';
}
if (row.project?.name) return `项目 · ${row.project.name}`;
if (row.bidCase?.name) return `投标 · ${row.bidCase.name}`;
if (row.contract?.name) return `合同 · ${row.contract.name}`;
return COST_TYPE_LABEL[row.costType || ''] || '—';
}
export function targetName(bid?: CostTarget, project?: CostTarget, contract?: CostTarget) {
return project?.name || bid?.name || contract?.name || '—';
}
+141
View File
@@ -0,0 +1,141 @@
import type { ReactNode } from 'react';
import { Button, Drawer, Space } from 'antd';
import { fmtCreated } from '../time';
export type FormStep = { key: string; title: string };
export function FormDrawer({
title,
open,
onClose,
width = 'min(1120px, 78vw)',
steps,
step = 0,
maxStep,
onStep,
onPrev,
onNext,
onOk,
okText = '提交保存',
okLoading,
hideOk,
showOkAlways,
children,
extra,
createdAt,
}: {
title: ReactNode;
open: boolean;
onClose: () => void;
width?: number | string;
steps?: FormStep[];
step?: number;
maxStep?: number;
onStep?: (index: number) => void;
onPrev?: () => void;
onNext?: () => void;
onOk?: () => void;
okText?: string;
okLoading?: boolean;
hideOk?: boolean;
showOkAlways?: boolean;
children: ReactNode;
extra?: ReactNode;
createdAt?: string | null;
}) {
const total = steps?.length || 0;
const last = total ? step >= total - 1 : true;
const first = !total || step <= 0;
const note = total
? last
? '填写完成后点保存。'
: showOkAlways
? `${step + 1} / ${total} 步。改完可直接保存,不必走到最后一步。`
: `${step + 1} / ${total} 步,填完本页后点下一步。`
: typeof extra === 'string'
? extra
: null;
return (
<Drawer
title={title}
open={open}
onClose={onClose}
width={width}
destroyOnClose
className="form-drawer"
styles={{ body: { padding: steps ? '0' : '16px 24px', display: 'flex', flexDirection: 'column' } }}
footer={
<div className="emp-wizard-foot">
<span className="emp-wizard-note">{note}</span>
<Space>
<Button className="form-drawer-btn" onClick={onClose}>
</Button>
{!first && onPrev ? (
<Button className="form-drawer-btn" onClick={onPrev}>
</Button>
) : null}
{!last && onNext ? (
<Button className="form-drawer-btn" type={showOkAlways ? 'default' : 'primary'} onClick={onNext}>
</Button>
) : null}
{!hideOk && onOk && (last || showOkAlways) ? (
<Button className="form-drawer-btn" type="primary" loading={okLoading} onClick={onOk}>
{okText}
</Button>
) : extra && !onOk && typeof extra !== 'string' ? (
extra
) : null}
</Space>
</div>
}
>
{steps?.length ? (
<div className="emp-wizard">
<aside className="emp-wizard-nav">
<div className="emp-wizard-nav-head"></div>
<div className="emp-wizard-track">
{steps.map((s, i) => {
const reached = maxStep ?? step;
const locked = i > reached;
const state = i === step ? 'on' : i < step ? 'done' : locked ? 'locked' : '';
return (
<button
key={s.key}
type="button"
className={state}
disabled={locked && i !== step + 1}
onClick={() => {
if (locked && i !== step + 1) return;
onStep?.(i);
}}
>
<span className="emp-wizard-n">{state === 'done' ? '✓' : i + 1}</span>
<span className="emp-wizard-t">{s.title}</span>
</button>
);
})}
</div>
</aside>
<div className="emp-wizard-body">
<div className="emp-wizard-doc">
<span></span>
<strong>{title}</strong>
<em>{steps[step]?.title}</em>
</div>
{createdAt ? <div className="form-created"> {fmtCreated(createdAt)}</div> : null}
{children}
</div>
</div>
) : (
<>
{createdAt ? <div className="form-created"> {fmtCreated(createdAt)}</div> : null}
{children}
</>
)}
</Drawer>
);
}
+304
View File
@@ -0,0 +1,304 @@
import { Alert, Button, DatePicker, Form, Input, InputNumber, Popconfirm, Select, Table, Tag, Typography, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
import { FormDrawer } from './FormDrawer';
import { PageHeader } from './ResourcePage';
import { HR_APPLY_LABEL, HR_APPLY_OPTIONS, LEAVE_KIND_HINT, LEAVE_STATUS_META, isLeaveApplyKind } from './hrStatus';
import { fmtCreated, fmtDay } from '../time';
import { ApplyBucketBar, PROCESS_BUCKETS } from './ApplyBucketBar';
type ApplyRow = {
id: string;
kind: string;
startAt: string;
endAt: string;
days: string | number;
reason: string;
status: string;
comment?: string | null;
targetTitle?: string | null;
createdAt: string;
};
export default function HrApplyPage() {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [params] = useSearchParams();
const bucket = params.get('bucket') || '';
const [form] = Form.useForm();
const kind = Form.useWatch('kind', form);
const depts = useQuery({ queryKey: ['/departments'], queryFn: () => api.get('/departments') });
const leaves = useQuery({
queryKey: ['leave-mine'],
queryFn: () => api.get('/leave-requests?bucket=mine'),
});
const balanceQ = useQuery({
queryKey: ['comp-balance'],
queryFn: () => api.get('/attendance/comp-balance'),
});
const items = useMemo(() => {
const all = ((leaves.data?.data as { items?: ApplyRow[] })?.items || []) as ApplyRow[];
if (!bucket) return all;
const map: Record<string, string> = { pending: 'PENDING', approved: 'APPROVED', rejected: 'REJECTED' };
return all.filter((r) => r.status === map[bucket]);
}, [leaves.data, bucket]);
const bal = (balanceQ.data?.data || leaves.data?.data?.balance) as
| {
hours?: number;
days?: number;
usableHours?: number;
usableDays?: number;
pendingCompDays?: number;
pendingOvertimeHours?: number;
canRegularize?: boolean;
probationEndAt?: string | null;
}
| undefined;
const canRegularize = Boolean(bal?.canRegularize);
const deptRaw = depts.data?.data as { id: string; name: string }[] | { items?: { id: string; name: string }[] } | undefined;
const deptOpts = Array.isArray(deptRaw) ? deptRaw : deptRaw?.items || [];
const mut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/leave-requests', body),
onSuccess: async () => {
message.success('已提交,由汇报对象和部门总监审批');
setOpen(false);
form.resetFields();
await qc.invalidateQueries({ queryKey: ['leave-mine'] });
await qc.invalidateQueries({ queryKey: ['comp-balance'] });
},
onError: (e: Error) => message.error(e.message),
});
const isLeave = isLeaveApplyKind(kind);
const isOt = kind === 'OVERTIME';
const leaveHint = kind ? LEAVE_KIND_HINT[kind] : undefined;
const applyOptions = [
...HR_APPLY_OPTIONS,
...(canRegularize ? [{ value: 'REGULARIZE', label: '转正' }] : []),
];
return (
<>
<PageHeader
title="人事申请"
description="提交请假、出差、加班、补卡、晋升、转岗、离职。试用期默认可申请转正,通过后档案转正日期自动同步,入口随之隐藏。"
extra={
<ApplyBucketBar
basePath="/office/hr-apply"
buckets={PROCESS_BUCKETS}
extra={
<Button
type="primary"
onClick={() => {
form.setFieldsValue({ kind: canRegularize ? 'REGULARIZE' : 'PERSONAL', effectiveAt: dayjs() });
setOpen(true);
}}
>
</Button>
}
/>
}
/>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message={`剩余调休 ${bal?.usableHours ?? 0} 小时(约 ${bal?.usableDays ?? 0} 天)`}
description={
(bal?.pendingCompDays || bal?.pendingOvertimeHours)
? `待批调休已预扣 ${bal?.pendingCompDays || 0} 天;待批加班 ${bal?.pendingOvertimeHours || 0} 小时,通过后到账。档案结余 ${bal?.hours ?? 0} 小时。`
: `档案结余 ${bal?.hours ?? 0} 小时。加班申请通过后按小时转入调休。`
}
/>
<Table
className="ops-table"
rowKey="id"
size="small"
dataSource={items}
pagination={false}
scroll={{ x: 'max-content' }}
tableLayout="fixed"
columns={[
{ title: '类型', width: 140, render: (_: unknown, r: ApplyRow) => HR_APPLY_LABEL[r.kind] || r.kind },
{
title: '日期',
width: 200,
render: (_: unknown, r: ApplyRow) =>
r.kind === 'MAKEUP' ||
r.kind === 'PROMOTE' ||
r.kind === 'TRANSFER' ||
r.kind === 'RESIGN' ||
r.kind === 'OVERTIME' ||
r.kind === 'REGULARIZE'
? fmtDay(r.startAt)
: `${fmtDay(r.startAt)} ~ ${fmtDay(r.endAt)}`,
},
{
title: '时长',
dataIndex: 'days',
width: 90,
render: (v: number, r: ApplyRow) => (r.kind === 'OVERTIME' ? `${v} 小时` : Number(v) ? `${v}` : '—'),
},
{ title: '事由', dataIndex: 'reason', ellipsis: true },
{
title: '状态',
width: 90,
render: (_: unknown, r: ApplyRow) => {
const s = LEAVE_STATUS_META[r.status] || { color: 'default', text: r.status };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{ title: '创建时间', width: 170, render: (_: unknown, r: ApplyRow) => fmtCreated(r.createdAt) },
{
title: '操作',
width: 80,
render: (_: unknown, r: ApplyRow) =>
r.status === 'PENDING' ? (
<Popconfirm
title="确认删除这条申请?"
onConfirm={async () => {
await api.delete(`/leave-requests/${r.id}`);
message.success('已删除');
await qc.invalidateQueries({ queryKey: ['leave-mine'] });
await qc.invalidateQueries({ queryKey: ['comp-balance'] });
}}
>
<Button type="link" danger>
</Button>
</Popconfirm>
) : null,
},
]}
locale={{ emptyText: '还没有人事申请' }}
/>
<FormDrawer
title="人事申请"
open={open}
onClose={() => setOpen(false)}
okText="提交"
okLoading={mut.isPending}
onOk={() => form.submit()}
>
<Typography.Paragraph type="secondary"></Typography.Paragraph>
<Form
form={form}
layout="vertical"
initialValues={{ kind: 'PERSONAL', range: [dayjs().startOf('day'), dayjs().endOf('day')], punchAt: dayjs(), overtimeAt: dayjs() }}
onFinish={(v) => {
const start = v.range?.[0] || v.effectiveAt || v.punchAt || v.overtimeAt;
const end = v.range?.[1] || v.effectiveAt || v.punchAt || v.overtimeAt;
mut.mutate({
kind: v.kind,
startAt: start ? dayjs(start).toISOString() : undefined,
endAt: end ? dayjs(end).toISOString() : undefined,
days: v.hours || v.days,
reason: v.reason,
toDeptId: v.toDeptId,
targetTitle: v.targetTitle,
punchKind: v.punchKind,
});
}}
>
<Form.Item name="kind" label="申请类型" rules={[{ required: true }]}>
<Select optionFilterProp="label" showSearch options={applyOptions} />
</Form.Item>
{leaveHint ? (
<Alert type="info" showIcon style={{ marginBottom: 16 }} message={leaveHint} />
) : null}
{kind === 'BUSINESS' ? (
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="出差不计入事假、病假扣薪,视同公务外出。审批通过后会自动生成差旅报销草稿,到「我的费用」补金额和票据。" />
) : null}
{kind === 'OUT' ? (
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="外出用于当天离岗办事。审批通过后方可外勤打卡;与印章「外带」不是同一概念。" />
) : null}
{kind === 'COMP' || isOt ? (
<Alert
type={kind === 'COMP' ? 'warning' : 'info'}
showIcon
style={{ marginBottom: 16 }}
message={`剩余调休 ${bal?.usableHours ?? 0} 小时(约 ${bal?.usableDays ?? 0} 天)`}
description={kind === 'COMP' ? '请调休不能超过剩余时长。待批调休已预扣。' : '加班通过后按小时转入调休,不发加班费。'}
/>
) : null}
{isLeave ? (
<>
<Form.Item name="range" label="起止日期" rules={[{ required: true }]}>
<DatePicker.RangePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="days" label="天数(可不填,按起止自动算)">
<InputNumber min={0.5} step={0.5} max={kind === 'COMP' ? bal?.usableDays || undefined : undefined} style={{ width: '100%' }} />
</Form.Item>
</>
) : null}
{isOt ? (
<>
<Form.Item name="overtimeAt" label="加班日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="hours" label="加班小时" rules={[{ required: true, message: '请填写加班小时' }]}>
<InputNumber min={0.5} step={0.5} style={{ width: '100%' }} />
</Form.Item>
</>
) : null}
{kind === 'MAKEUP' ? (
<>
<Form.Item name="punchKind" label="补卡类型" rules={[{ required: true }]}>
<Select options={[{ value: 'IN', label: '上班卡' }, { value: 'OUT', label: '下班卡' }]} />
</Form.Item>
<Form.Item name="punchAt" label="补卡日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</>
) : null}
{kind === 'REGULARIZE' ? (
<>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="转正通过后,档案里的转正日期按本申请生效日回写,本入口不再显示。"
/>
<Form.Item name="effectiveAt" label="拟转正日" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</>
) : null}
{kind === 'PROMOTE' ? (
<>
<Form.Item name="targetTitle" label="拟任职务" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="effectiveAt" label="生效日" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</>
) : null}
{kind === 'TRANSFER' ? (
<>
<Form.Item name="toDeptId" label="转入部门" rules={[{ required: true }]}>
<Select options={deptOpts.map((d) => ({ value: d.id, label: d.name }))} />
</Form.Item>
<Form.Item name="effectiveAt" label="生效日" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</>
) : null}
{kind === 'RESIGN' ? (
<Form.Item name="effectiveAt" label="拟离职日" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
) : null}
<Form.Item name="reason" label="事由" rules={[{ required: true, message: '请填写事由' }]}>
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</FormDrawer>
</>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { Button, Descriptions, Space, Spin, Tag, Typography, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate, useParams } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { PageHeader } from './ResourcePage';
import { HR_APPLY_LABEL, LEAVE_STATUS_META } from './hrStatus';
type LeaveRow = {
id: string;
kind: string;
status: string;
days?: number | string;
reason?: string | null;
comment?: string | null;
startAt?: string | null;
endAt?: string | null;
createdAt?: string;
employee?: { name?: string; employeeNo?: string; department?: { name?: string } | null };
decidedBy?: { displayName?: string } | null;
};
export default function HrLeaveDetailPage() {
const { id } = useParams();
const nav = useNavigate();
const qc = useQueryClient();
const q = useQuery({
queryKey: ['/leave-requests', id],
queryFn: () => api.get(`/leave-requests/${id}`),
enabled: Boolean(id),
});
const row = ((q.data as { data?: LeaveRow })?.data || q.data) as LeaveRow | undefined;
const mut = useMutation({
mutationFn: (result: 'APPROVED' | 'REJECTED') => api.post(`/leave-requests/${id}/review`, { result }),
onSuccess: async () => {
message.success('已办理');
await qc.invalidateQueries();
nav('/office/approvals');
},
onError: (e: Error) => message.error(e.message),
});
const st = row ? LEAVE_STATUS_META[row.status] || { color: 'default', text: row.status } : null;
const hours = row?.kind === 'OVERTIME';
return (
<>
<PageHeader
title="人事申请"
extra={
<Space>
<Button onClick={() => nav('/office/approvals')}></Button>
{row?.status === 'PENDING' ? (
<>
<Button type="primary" loading={mut.isPending} onClick={() => mut.mutate('APPROVED')}>
</Button>
<Button danger loading={mut.isPending} onClick={() => mut.mutate('REJECTED')}>
</Button>
</>
) : null}
</Space>
}
/>
{q.isError ? (
<Typography.Paragraph type="danger">{(q.error as Error).message || '申请不存在'}</Typography.Paragraph>
) : (
<Spin spinning={q.isLoading}>
<Descriptions bordered column={2}>
<Descriptions.Item label="申请人">{row?.employee?.name || '—'}</Descriptions.Item>
<Descriptions.Item label="部门">{row?.employee?.department?.name || '—'}</Descriptions.Item>
<Descriptions.Item label="类型">{row ? HR_APPLY_LABEL[row.kind] || row.kind : '—'}</Descriptions.Item>
<Descriptions.Item label="状态">{st ? <Tag color={st.color}>{st.text}</Tag> : '—'}</Descriptions.Item>
<Descriptions.Item label="开始">{row?.startAt ? dayjs(row.startAt).format('YYYY-MM-DD HH:mm') : '—'}</Descriptions.Item>
<Descriptions.Item label="结束">{row?.endAt ? dayjs(row.endAt).format('YYYY-MM-DD HH:mm') : '—'}</Descriptions.Item>
<Descriptions.Item label="时长">
{row?.days != null && row.days !== '' ? `${row.days}${hours ? ' 小时' : ' 天'}` : '—'}
</Descriptions.Item>
<Descriptions.Item label="审批人">{row?.decidedBy?.displayName || '—'}</Descriptions.Item>
<Descriptions.Item label="事由" span={2}>
{row?.reason || '—'}
</Descriptions.Item>
<Descriptions.Item label="审批意见" span={2}>
{row?.comment || '—'}
</Descriptions.Item>
</Descriptions>
</Spin>
)}
</>
);
}
+271
View File
@@ -0,0 +1,271 @@
import { Button, Form, Input, Popconfirm, Radio, Select, Space, Table, Tag, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { api } from '../api/client';
import { BizFileUpload, nonImageFiles } from './FileAttachments';
import { PageHeader, unwrapList } from './ResourcePage';
import RichEditor from './RichEditor';
import { fmtCreated } from '../time';
import { topLevelSelectOptions } from '../deptTree';
type NoticeRow = {
id: string;
title: string;
excerpt?: string;
content?: string;
kind?: string;
status?: string;
audience?: string;
departmentIds?: string[];
userIds?: string[];
createdAt: string;
createdBy?: { displayName?: string };
files?: { id: string; fileName: string; size: number; mimeType: string; bizType?: string }[];
};
function kindOf(pathname: string) {
return pathname.includes('policies') ? 'POLICY' : 'ANNOUNCEMENT';
}
function labels(kind: string) {
return kind === 'POLICY'
? { title: '规章制度', one: '制度', create: '新建制度', publish: '发送制度' }
: { title: '发布公告', one: '通知', create: '新建通知', publish: '发表通知' };
}
export default function HrNoticePage() {
const loc = useLocation();
const { id } = useParams();
const kind = kindOf(loc.pathname);
if (id) return <HrNoticeEditor kind={kind} id={id} />;
return <HrNoticeList kind={kind} />;
}
function HrNoticeList({ kind }: { kind: string }) {
const nav = useNavigate();
const qc = useQueryClient();
const t = labels(kind);
const base = kind === 'POLICY' ? '/hr/policies' : '/hr/notices';
const qKey = ['/office/notices', 'manage', kind];
const q = useQuery({
queryKey: qKey,
queryFn: () => api.get('/office/notices', { params: { kind, scope: 'manage' } }),
});
const items = unwrapList(q.data).items as unknown as NoticeRow[];
const create = useMutation({
mutationFn: () => api.post('/office/notices', { kind, status: 'DRAFT', title: '未命名' }),
onSuccess: async (res) => {
const row = (res as { data: NoticeRow }).data;
await qc.invalidateQueries({ queryKey: qKey });
nav(`${base}/${row.id}`);
},
onError: (e: Error) => message.error(e.message),
});
return (
<>
<PageHeader
title={t.title}
description={`${t.create}后用富文本编写正文,可指定全公司、某些部门或某些人。发布后员工在个人办公「公司公告」里查看。`}
extra={
<Button type="primary" loading={create.isPending} onClick={() => create.mutate()}>
{t.create}
</Button>
}
/>
<Table
className="ops-table"
rowKey="id"
loading={q.isLoading}
dataSource={items}
pagination={false}
locale={{ emptyText: `还没有${t.one}` }}
onRow={(r) => ({
style: { cursor: 'pointer' },
onClick: () => nav(`${base}/${r.id}`),
})}
columns={[
{ title: '标题', dataIndex: 'title', ellipsis: true },
{
title: '状态',
width: 100,
render: (_: unknown, r: NoticeRow) =>
r.status === 'PUBLISHED' ? <Tag color="green"></Tag> : <Tag>稿</Tag>,
},
{
title: '范围',
width: 140,
render: (_: unknown, r: NoticeRow) =>
r.audience === 'DEPT' ? (
<Tag></Tag>
) : r.audience === 'USER' ? (
<Tag></Tag>
) : (
<Tag></Tag>
),
},
{ title: '发布人', width: 120, render: (_: unknown, r: NoticeRow) => r.createdBy?.displayName || '—' },
{ title: '时间', width: 170, render: (_: unknown, r: NoticeRow) => fmtCreated(r.createdAt) },
{
title: '操作',
width: 140,
render: (_: unknown, r: NoticeRow) => (
<Space onClick={(e) => e.stopPropagation()}>
<Button type="link" onClick={() => nav(`${base}/${r.id}`)}>
</Button>
<Popconfirm
title={`确认删除这条${t.one}`}
onConfirm={async () => {
await api.delete(`/office/notices/${r.id}`);
message.success('已删除');
qc.invalidateQueries({ queryKey: qKey });
qc.invalidateQueries({ queryKey: ['/office/notices'] });
}}
>
<Button type="link" danger>
</Button>
</Popconfirm>
</Space>
),
},
]}
/>
</>
);
}
type Staff = { id: string; name: string; department?: string };
type Dept = { id: string; name: string; code?: string; parentId?: string | null };
function HrNoticeEditor({ kind, id }: { kind: string; id: string }) {
const nav = useNavigate();
const qc = useQueryClient();
const t = labels(kind);
const base = kind === 'POLICY' ? '/hr/policies' : '/hr/notices';
const [form] = Form.useForm();
const [html, setHtml] = useState('<p></p>');
const audience = Form.useWatch('audience', form);
const q = useQuery({
queryKey: ['/office/notices', id],
queryFn: () => api.get(`/office/notices/${id}`),
});
const staff = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') });
const depts = useQuery({ queryKey: ['/departments'], queryFn: () => api.get('/departments') });
const row = q.data?.data as NoticeRow | undefined;
const people = ((staff.data as { data?: Staff[] })?.data || []) as Staff[];
const departments = ((depts.data as { data?: Dept[] })?.data || []).filter((d) => d.code !== 'ROOT');
useEffect(() => {
if (!row) return;
form.setFieldsValue({
title: row.title === '未命名' ? '' : row.title,
audience: row.audience || 'ALL',
departmentIds: row.departmentIds || [],
userIds: row.userIds || [],
});
setHtml(row.content || '<p></p>');
}, [row, form]);
const payload = (status: string) => ({
title: form.getFieldValue('title') || (status === 'DRAFT' ? '未命名' : form.getFieldValue('title')),
content: html,
status,
audience: form.getFieldValue('audience') || 'ALL',
departmentIds: form.getFieldValue('departmentIds') || [],
userIds: form.getFieldValue('userIds') || [],
});
const save = useMutation({
mutationFn: (body: Record<string, unknown>) => api.patch(`/office/notices/${id}`, body),
onSuccess: async (_res, vars) => {
message.success(vars.status === 'PUBLISHED' ? `${t.one}已发布` : '草稿已保存');
await qc.invalidateQueries({ queryKey: ['/office/notices'] });
if (vars.status === 'PUBLISHED') nav(base);
},
onError: (e: Error) => message.error(e.message),
});
return (
<>
<PageHeader
title={row?.status === 'PUBLISHED' ? `编辑${t.one}` : t.create}
extra={
<Space>
<Button onClick={() => nav(base)}></Button>
<Button loading={save.isPending} onClick={() => save.mutate(payload('DRAFT'))}>
稿
</Button>
<Button type="primary" loading={save.isPending} onClick={() => save.mutate(payload('PUBLISHED'))}>
{t.publish}
</Button>
</Space>
}
/>
<Form form={form} layout="vertical" initialValues={{ audience: 'ALL' }}>
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请填写标题' }]}>
<Input placeholder={`${t.one}标题`} maxLength={120} />
</Form.Item>
<Form.Item name="audience" label="发送范围" extra="默认全公司。也可以只发给某些部门,或点名发给某些人。">
<Radio.Group
options={[
{ value: 'ALL', label: '全公司' },
{ value: 'DEPT', label: '按部门' },
{ value: 'USER', label: '按人员' },
]}
/>
</Form.Item>
{audience === 'DEPT' ? (
<Form.Item
name="departmentIds"
label="接收部门"
rules={[{ required: true, type: 'array', min: 1, message: '请选择部门' }]}
extra="选上级部门时,下级部门的人也能看到。"
>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder="可选多个部门"
options={topLevelSelectOptions(departments)}
/>
</Form.Item>
) : null}
{audience === 'USER' ? (
<Form.Item
name="userIds"
label="接收人员"
rules={[{ required: true, type: 'array', min: 1, message: '请选择人员' }]}
>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder="搜索姓名"
options={people.map((p) => ({
value: p.id,
label: `${p.name}${p.department ? `${p.department}` : ''}`,
}))}
/>
</Form.Item>
) : null}
<Form.Item label="正文" required>
<RichEditor value={html} onChange={setHtml} bizId={id} placeholder={`请编写${t.one}正文`} />
</Form.Item>
<BizFileUpload
bizType="NOTICE"
bizId={id}
files={nonImageFiles(row?.files)}
label="附件"
extra="可与正文一起发布,员工打开详情后下载。"
queryKey={['/office/notices', id]}
/>
</Form>
</>
);
}
+450
View File
@@ -0,0 +1,450 @@
import { Button, DatePicker, Input, InputNumber, Select, Space, Table, Tag, Typography, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import dayjs from 'dayjs';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import { useAuth } from '../store/auth';
import { PageHeader } from './ResourcePage';
import { canConfirmPay, canHr } from '../access';
import { descendantIds, topLevelNameByLeafName, topLevelSelectOptions } from '../deptTree';
import {
EMP_STATUS_META,
HR_APPLY_LABEL,
LEAVE_KIND_LABEL,
LEAVE_STATUS_META,
LIFE_KIND_LABEL,
PAYROLL_STATUS_META,
REST_LABEL,
money,
} from './hrStatus';
type Dept = {
id: string;
name: string;
code: string;
deptType: string;
parentId?: string | null;
parent?: { name: string } | null;
categoryCode?: string | null;
sortNo: number;
_count?: { employees: number; children: number };
};
function unwrapItems<T>(data: unknown): T[] {
const body = (data as { data?: unknown })?.data ?? data;
if (Array.isArray(body)) return body as T[];
if (body && typeof body === 'object' && 'items' in body) return ((body as { items: T[] }).items ?? []) as T[];
return [];
}
function canPerf(roles: string[]) {
return (
['admin', 'owner', 'hr', 'biz_director', 'tech_director', 'rd_director', '3d_director', 'video_director', 'material_director'].some(
(r) => roles.includes(r),
)
);
}
function useDepartments() {
return useQuery({
queryKey: ['/departments'],
queryFn: () => api.get('/departments'),
});
}
export function LifecyclePage() {
const events = useQuery({
queryKey: ['/employment-events'],
queryFn: () => api.get('/employment-events'),
});
const rows = unwrapItems<{
id: string;
kind: string;
fromStatus?: string;
toStatus?: string;
effectiveAt: string;
comment?: string;
createdAt?: string;
employee: { name: string; employeeNo: string };
fromDept?: { name: string } | null;
toDept?: { name: string } | null;
}>(events.data);
return (
<>
<PageHeader
title="入转调离"
description="这里只看审批通过后的流水:转正、调岗、晋升、离职等。员工在个人办公 · 人事申请里提交,人事在假勤结果里批,通过后自动落记录并回写档案。"
/>
<Table
className="ops-table"
rowKey="id"
loading={events.isLoading}
dataSource={rows}
scroll={{ x: 'max-content' }}
tableLayout="fixed"
columns={[
{ title: '员工', render: (_: unknown, r: (typeof rows)[0]) => `${r.employee?.name} ${r.employee?.employeeNo}` },
{ title: '类型', dataIndex: 'kind', width: 90, render: (v: string) => LIFE_KIND_LABEL[v] || v },
{ title: '原状态', dataIndex: 'fromStatus', width: 80, render: (v: string) => EMP_STATUS_META[v]?.text || v || '—' },
{ title: '新状态', dataIndex: 'toStatus', width: 80, render: (v: string) => EMP_STATUS_META[v]?.text || v || '—' },
{ title: '部门', render: (_: unknown, r: (typeof rows)[0]) => (r.fromDept && r.toDept && r.fromDept.name !== r.toDept.name ? `${r.fromDept.name}${r.toDept.name}` : r.toDept?.name || '—') },
{ title: '生效日', dataIndex: 'effectiveAt', width: 120, render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—') },
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '—') },
{ title: '说明', dataIndex: 'comment' },
]}
pagination={false}
locale={{ emptyText: '暂无数据' }}
/>
</>
);
}
export function AttendancePage() {
const qc = useQueryClient();
const roles = useAuth((s) => s.roles);
const [month, setMonth] = useStateMonth();
const q = useQuery({
queryKey: ['/attendance', month],
queryFn: () => api.get(`/attendance?yearMonth=${month}`),
});
const rows = unwrapItems<{
id: string;
employeeId: string;
workDays: number;
leaveDays: number;
sickDays: number;
overtimeHours: number;
compLeaveDays: number;
compLeaveHours: number;
remark?: string;
employee: { name: string; employeeNo: string; restSchedule: string };
}>(q.data);
const leaves = useQuery({
queryKey: ['leave-pending'],
queryFn: () => api.get('/leave-requests?bucket=pending'),
});
const punches = useQuery({
queryKey: ['/attendance/punches', month],
queryFn: () => api.get(`/attendance/punches?yearMonth=${month}`),
});
const pendingLeaves = unwrapItems<{
id: string;
kind: string;
startAt: string;
endAt: string;
days: string | number;
reason: string;
status: string;
employee?: { name: string; employeeNo: string };
}>(leaves.data);
const punchRows = unwrapItems<{
id: string;
kind: string;
punchedAt: string;
employee?: { name: string; employeeNo: string };
}>(punches.data);
const reviewMut = useMutation({
mutationFn: ({ id, result }: { id: string; result: 'APPROVED' | 'REJECTED' }) =>
api.post(`/leave-requests/${id}/review`, { result }),
onSuccess: () => {
message.success('已处理申请');
qc.invalidateQueries();
},
onError: (e: Error) => message.error(e.message),
});
return (
<>
<PageHeader
title="假勤结果"
description={
<>
<Link to="/system/attendance-rules"> </Link>
</>
}
extra={
<DatePicker picker="month" value={dayjs(`${month}-01`)} onChange={(d) => d && setMonth(d.format('YYYY-MM'))} />
}
/>
<Typography.Title level={5}></Typography.Title>
<Table
rowKey="id"
size="small"
loading={leaves.isLoading}
dataSource={pendingLeaves}
pagination={false}
style={{ marginBottom: 24 }}
columns={[
{ title: '员工', render: (_: unknown, r: (typeof pendingLeaves)[0]) => `${r.employee?.name || ''} ${r.employee?.employeeNo || ''}` },
{ title: '类型', width: 130, render: (_: unknown, r: (typeof pendingLeaves)[0]) => HR_APPLY_LABEL[r.kind] || LEAVE_KIND_LABEL[r.kind] || r.kind },
{ title: '开始', width: 140, render: (_: unknown, r: (typeof pendingLeaves)[0]) => dayjs(r.startAt).format('MM-DD HH:mm') },
{ title: '结束', width: 140, render: (_: unknown, r: (typeof pendingLeaves)[0]) => dayjs(r.endAt).format('MM-DD HH:mm') },
{
title: '时长',
width: 90,
render: (_: unknown, r: (typeof pendingLeaves)[0]) =>
r.kind === 'OVERTIME' ? `${r.days} 小时` : r.days,
},
{ title: '创建时间', width: 170, render: (_: unknown, r: (typeof pendingLeaves)[0] & { createdAt?: string }) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm:ss') : '—') },
{ title: '事由', dataIndex: 'reason', ellipsis: true },
{
title: '操作',
width: 140,
render: (_: unknown, r: (typeof pendingLeaves)[0]) =>
canHr(roles) ? (
<Space>
<Button type="link" onClick={() => reviewMut.mutate({ id: r.id, result: 'APPROVED' })}>
</Button>
<Button type="link" danger onClick={() => reviewMut.mutate({ id: r.id, result: 'REJECTED' })}>
</Button>
</Space>
) : (
<Tag color={LEAVE_STATUS_META[r.status]?.color}>{LEAVE_STATUS_META[r.status]?.text || r.status}</Tag>
),
},
]}
locale={{ emptyText: '没有待批人事申请' }}
/>
<Typography.Title level={5}></Typography.Title>
<Table
rowKey="id"
size="small"
loading={punches.isLoading}
dataSource={punchRows}
pagination={{ pageSize: 8 }}
style={{ marginBottom: 24 }}
columns={[
{ title: '员工', render: (_: unknown, r: (typeof punchRows)[0]) => `${r.employee?.name || ''} ${r.employee?.employeeNo || ''}` },
{ title: '类型', width: 80, render: (_: unknown, r: (typeof punchRows)[0]) => (r.kind === 'OUT' ? '下班' : '上班') },
{ title: '时间', width: 180, render: (_: unknown, r: (typeof punchRows)[0]) => dayjs(r.punchedAt).format('YYYY-MM-DD HH:mm:ss') },
]}
locale={{ emptyText: '本月还没有打卡' }}
/>
<Typography.Title level={5}></Typography.Title>
<Table
rowKey="id"
loading={q.isLoading}
dataSource={rows}
columns={[
{ title: '员工', render: (_: unknown, r: (typeof rows)[0]) => `${r.employee?.name} ${r.employee?.employeeNo}` },
{ title: '作息', width: 80, render: (_: unknown, r: (typeof rows)[0]) => REST_LABEL[r.employee?.restSchedule] || r.employee?.restSchedule },
{ title: '出勤', dataIndex: 'workDays', width: 80 },
{ title: '事假', dataIndex: 'leaveDays', width: 80 },
{ title: '病假', dataIndex: 'sickDays', width: 80 },
{ title: '加班小时', dataIndex: 'overtimeHours', width: 100 },
{ title: '用调休', dataIndex: 'compLeaveDays', width: 80 },
{ title: '调休结余小时', dataIndex: 'compLeaveHours', width: 120 },
{ title: '说明', dataIndex: 'remark' },
]}
pagination={false}
locale={{ emptyText: '本月还没有假勤统计' }}
/>
</>
);
}
export function PayrollPage() {
return <PayrollTable mode="payroll" />;
}
export function PerformancePage() {
return <PayrollTable mode="performance" />;
}
function PayrollTable({ mode }: { mode: 'payroll' | 'performance' }) {
const qc = useQueryClient();
const roles = useAuth((s) => s.roles);
const [month, setMonth] = useStateMonth();
const [keyword, setKeyword] = useState('');
const [deptId, setDeptId] = useState<string>();
const depts = useDepartments();
const deptRows = unwrapItems<Dept>(depts.data);
const q = useQuery({
queryKey: ['/payroll', month],
queryFn: () => api.get(`/payroll?yearMonth=${month}`),
});
const payload = (q.data?.data ?? {}) as {
run?: { id: string; status: string } | null;
items?: Record<string, unknown>[];
};
const items = (payload.items ?? []) as {
id: string;
name: string;
employeeNo: string;
departmentName?: string;
restSchedule: string;
employmentStatus: string;
basePay: number;
performancePay: number;
performanceRatio: number;
leaveDeduct: number;
socialPersonal: number;
housingFund: number;
tax: number;
grossPay: number;
netPay: number;
overtimeHours: number;
compLeaveHours: number;
}[];
const runMut = useMutation({
mutationFn: () => api.post('/payroll/run', { yearMonth: month }),
onSuccess: () => {
message.success('已按单休/双休核算,请核对后点「确认本月」');
qc.invalidateQueries({ queryKey: ['/payroll'] });
},
onError: (e: Error) => message.error(e.message),
});
const confirmMut = useMutation({
mutationFn: (id: string) => api.post(`/payroll/${id}/confirm`, {}),
onSuccess: () => {
message.success('已确认本月薪酬,不能再改');
qc.invalidateQueries({ queryKey: ['/payroll'] });
},
onError: (e: Error) => message.error(e.message),
});
const unconfirmMut = useMutation({
mutationFn: (id: string) => api.post(`/payroll/${id}/unconfirm`, {}),
onSuccess: () => {
message.success('已取消确认,可以重新核算');
qc.invalidateQueries({ queryKey: ['/payroll'] });
},
onError: (e: Error) => message.error(e.message),
});
const ratioMut = useMutation({
mutationFn: ({ id, performanceRatio }: { id: string; performanceRatio: number }) =>
api.patch(`/payroll-items/${id}`, { performanceRatio }),
onSuccess: () => {
message.success('已调整发放比例');
qc.invalidateQueries({ queryKey: ['/payroll'] });
},
onError: (e: Error) => message.error(e.message),
});
const draft = payload.run?.status === 'DRAFT';
const confirmed = payload.run?.status === 'CONFIRMED';
const shown = items.filter((r) => {
if (deptId) {
const set = descendantIds(deptRows, deptId);
const leaf = deptRows.find((d) => d.name === r.departmentName);
if (set && leaf && !set.has(leaf.id)) return false;
if (set && !leaf) {
const top = topLevelNameByLeafName(deptRows, r.departmentName);
if (top !== deptRows.find((d) => d.id === deptId)?.name) return false;
}
}
if (!keyword.trim()) return true;
const k = keyword.trim();
return r.name.includes(k) || r.employeeNo.includes(k) || (r.departmentName || '').includes(k);
});
const deptOpts = topLevelSelectOptions(deptRows);
return (
<>
<PageHeader
title={mode === 'performance' ? '绩效发放' : '薪酬核算'}
description={
mode === 'performance'
? '总监改本部门或下属的发放比例,改完商务部立刻能看到;已确认的月份再改会退回待确认。加班不发加班费,只转调休。'
: '软件研发双休按 21.75 天计薪,三维/摄制/物资单休按 25.75 天。外协部、保障部不核算。核算后必须点「确认本月」才锁定,默认都是未确认。'
}
extra={
<Space>
<DatePicker picker="month" value={dayjs(`${month}-01`)} onChange={(d) => d && setMonth(d.format('YYYY-MM'))} />
{payload.run ? (
<Tag color={PAYROLL_STATUS_META[payload.run.status]?.color}>{PAYROLL_STATUS_META[payload.run.status]?.text}</Tag>
) : (
<Tag></Tag>
)}
{mode === 'payroll' && canHr(roles) && !confirmed ? (
<Button loading={runMut.isPending} onClick={() => runMut.mutate()}>
{payload.run ? '重新核算' : '生成本月核算'}
</Button>
) : null}
{mode === 'payroll' && canConfirmPay(roles) && payload.run && draft ? (
<Button type="primary" loading={confirmMut.isPending} onClick={() => confirmMut.mutate(payload.run!.id)}>
</Button>
) : null}
{mode === 'payroll' && canConfirmPay(roles) && payload.run && confirmed ? (
<Button loading={unconfirmMut.isPending} onClick={() => unconfirmMut.mutate(payload.run!.id)}>
</Button>
) : null}
</Space>
}
/>
<div className="ops-toolbar" style={{ flexWrap: 'wrap', gap: 8 }}>
<Input.Search
allowClear
enterButton
className="yzl-search"
placeholder="搜索姓名、工号、部门"
style={{ maxWidth: 320 }}
onSearch={setKeyword}
onChange={(e) => {
if (!e.target.value) setKeyword('');
}}
/>
<Select
allowClear
placeholder="一级部门"
style={{ minWidth: 140 }}
value={deptId}
options={deptOpts}
onChange={setDeptId}
/>
</div>
<Table
rowKey="id"
loading={q.isLoading}
dataSource={shown}
columns={[
{ title: '姓名', dataIndex: 'name', width: 90 },
{ title: '工号', dataIndex: 'employeeNo', width: 100 },
{ title: '部门', dataIndex: 'departmentName', width: 110 },
{ title: '作息', dataIndex: 'restSchedule', width: 70, render: (v: string) => REST_LABEL[v] || v },
{ title: '基本应发', dataIndex: 'basePay', width: 110, render: (v: number) => money(v) },
{ title: '扣假', dataIndex: 'leaveDeduct', width: 90, render: (v: number) => money(v) },
{
title: '绩效比例',
dataIndex: 'performanceRatio',
width: 110,
render: (v: number, row) =>
mode === 'performance' && canPerf(roles) ? (
<InputNumber
min={0}
max={200}
value={v}
size="small"
onBlur={(e) => {
const next = Number((e.target as HTMLInputElement).value);
if (Number.isFinite(next) && next !== v) void ratioMut.mutateAsync({ id: row.id, performanceRatio: next });
}}
/>
) : (
`${v}%`
),
},
{ title: '绩效应发', dataIndex: 'performancePay', width: 110, render: (v: number) => money(v) },
{ title: '应发合计', dataIndex: 'grossPay', width: 110, render: (v: number) => money(v) },
{ title: '社保个人', dataIndex: 'socialPersonal', width: 100, render: (v: number) => money(v) },
{ title: '公积金', dataIndex: 'housingFund', width: 80, render: (v: number) => money(v) },
{ title: '个税', dataIndex: 'tax', width: 90, render: (v: number) => money(v) },
{ title: '实发', dataIndex: 'netPay', width: 110, render: (v: number) => money(v) },
]}
scroll={{ x: 1400 }}
pagination={false}
locale={{ emptyText: mode === 'performance' ? '请人事先生成本月核算' : '点「生成本月核算」按假勤结果出工资条' }}
/>
</>
);
}
function useStateMonth() {
const d = new Date();
const initial = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
return useState(initial);
}
+352
View File
@@ -0,0 +1,352 @@
import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, TimePicker, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import dayjs from 'dayjs';
import { Link } from 'react-router-dom';
import { api } from '../api/client';
import { useAuth } from '../store/auth';
import { FormDrawer } from './FormDrawer';
import { PageHeader } from './ResourcePage';
import { PunchLocationMap } from './PunchLocationMap';
import { REST_LABEL } from './hrStatus';
import { canHr } from '../access';
function unwrapPayload<T>(data: unknown): T {
return ((data as { data?: T })?.data ?? data) as T;
}
function unwrapItems<T>(data: unknown): T[] {
const body = (data as { data?: unknown })?.data ?? data;
if (Array.isArray(body)) return body as T[];
if (body && typeof body === 'object' && 'items' in body) return ((body as { items: T[] }).items ?? []) as T[];
return [];
}
type Settings = {
idMask: boolean;
autoAccount: boolean;
probationMonths: number;
defaultRest: string;
workStart: string;
workEnd: string;
};
type Rule = {
id: string;
name: string;
restSchedule: string;
workStart: string;
workEnd: string;
lateGraceMinutes: number;
punchPlace?: string | null;
punchAddress?: string | null;
punchLat?: string | number | null;
punchLng?: string | number | null;
punchRadiusMeters?: number;
enabled: boolean;
sortNo: number;
remark?: string | null;
};
export function HrParamsPage() {
const qc = useQueryClient();
const roles = useAuth((s) => s.roles);
const [form] = Form.useForm();
const q = useQuery({
queryKey: ['/hr-settings'],
queryFn: () => api.get('/hr-settings'),
});
useEffect(() => {
if (!q.data) return;
const s = unwrapPayload<Settings>(q.data);
form.setFieldsValue({
...s,
workStart: s.workStart ? dayjs(s.workStart, 'HH:mm') : undefined,
workEnd: s.workEnd ? dayjs(s.workEnd, 'HH:mm') : undefined,
});
}, [form, q.data]);
const mut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.patch('/hr-settings', body),
onSuccess: () => {
message.success('人事参数已保存');
qc.invalidateQueries({ queryKey: ['/hr-settings'] });
},
onError: (e: Error) => message.error(e.message),
});
return (
<>
<PageHeader
title="人事参数"
description="身份证脱敏、默认试用期和默认作息。入职开户请到人事设置 · 员工账号手工办理。"
extra={
canHr(roles) ? (
<Button
type="primary"
loading={mut.isPending}
onClick={async () => {
const v = await form.validateFields();
await mut.mutateAsync({
...v,
autoAccount: false,
workStart: v.workStart ? dayjs(v.workStart).format('HH:mm') : '09:00',
workEnd: v.workEnd ? dayjs(v.workEnd).format('HH:mm') : '18:00',
});
}}
>
</Button>
) : null
}
/>
<Form form={form} layout="vertical" className="hr-settings-grid" disabled={!canHr(roles) || q.isLoading}>
<Form.Item name="idMask" label="身份证号脱敏" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item
name="autoAccount"
label="入职自动开通登录账号"
valuePropName="checked"
extra="已停用:请到人事设置 · 员工账号中开户、设初始密码和重置密码。"
>
<Switch disabled />
</Form.Item>
<Form.Item name="probationMonths" label="默认试用期(月)" rules={[{ required: true }]}>
<InputNumber min={0} max={12} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="defaultRest" label="默认作息" rules={[{ required: true }]}>
<Select options={[{ value: 'SINGLE', label: '单休' }, { value: 'DOUBLE', label: '双休' }]} />
</Form.Item>
<Form.Item name="workStart" label="默认上班时间">
<TimePicker format="HH:mm" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="workEnd" label="默认下班时间">
<TimePicker format="HH:mm" style={{ width: '100%' }} />
</Form.Item>
</Form>
</>
);
}
export function AttendanceRulesPage() {
const qc = useQueryClient();
const roles = useAuth((s) => s.roles);
const [form] = Form.useForm();
const [editing, setEditing] = useState<Rule | 'new' | null>(null);
const q = useQuery({ queryKey: ['/attendance-rules'], queryFn: () => api.get('/attendance-rules') });
const rows = unwrapItems<Rule>(q.data);
const saveMut = useMutation({
mutationFn: ({ id, body }: { id?: string; body: Record<string, unknown> }) =>
id ? api.patch(`/attendance-rules/${id}`, body) : api.post('/attendance-rules', body),
onError: (e: Error) => message.error(e.message),
});
const delMut = useMutation({
mutationFn: (id: string) => api.delete(`/attendance-rules/${id}`),
onError: (e: Error) => message.error(e.message),
});
const openForm = (row?: Rule) => {
form.setFieldsValue(
row
? {
...row,
workStart: dayjs(row.workStart, 'HH:mm'),
workEnd: dayjs(row.workEnd, 'HH:mm'),
}
: { restSchedule: 'SINGLE', workStart: dayjs('09:00', 'HH:mm'), workEnd: dayjs('18:00', 'HH:mm'), lateGraceMinutes: 0, punchRadiusMeters: 300, enabled: true, sortNo: 99 },
);
setEditing(row || 'new');
};
return (
<>
<PageHeader
title="考勤设置"
description="维护班次时间和打卡地点,给后续手机端定位打卡用。假勤结果仍可在组织人事里按月导入。"
extra={
<Space>
<Link to="/hr/attendance"></Link>
{canHr(roles) ? (
<Button type="primary" onClick={() => openForm()}>
</Button>
) : null}
</Space>
}
/>
<FormDrawer
title={editing && editing !== 'new' ? '编辑班次' : '新建班次'}
open={Boolean(editing)}
onClose={() => setEditing(null)}
okText="提交保存"
okLoading={saveMut.isPending}
onOk={async () => {
const v = await form.validateFields();
if (v.punchLat == null || v.punchLng == null) {
message.warning('请先在地图上点选打卡位置');
return;
}
const row = editing && editing !== 'new' ? editing : undefined;
await saveMut.mutateAsync({
id: row?.id,
body: {
...v,
enabled: true,
workStart: dayjs(v.workStart).format('HH:mm'),
workEnd: dayjs(v.workEnd).format('HH:mm'),
},
});
message.success(row ? '班次已保存' : '班次已创建');
setEditing(null);
await qc.invalidateQueries({ queryKey: ['/attendance-rules'] });
}}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="班次名称" rules={[{ required: true }]}>
<Input placeholder="如 单休班次" />
</Form.Item>
<Form.Item name="restSchedule" label="作息" rules={[{ required: true }]}>
<Select options={[{ value: 'SINGLE', label: '单休' }, { value: 'DOUBLE', label: '双休' }]} />
</Form.Item>
<Form.Item name="workStart" label="上班时间" rules={[{ required: true }]}>
<TimePicker format="HH:mm" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="workEnd" label="下班时间" rules={[{ required: true }]}>
<TimePicker format="HH:mm" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="lateGraceMinutes" label="迟到宽限(分钟)">
<InputNumber min={0} max={60} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="punchPlace" label="打卡地点名称">
<Input placeholder="如 总部办公楼" />
</Form.Item>
<Form.Item label="打卡地址" extra="地址只用来跳到附近。准确位置请在地图上点选,不要依赖自动解析。">
<Space.Compact style={{ width: '100%' }}>
<Form.Item name="punchAddress" noStyle>
<Input placeholder="如 南京市秦淮区瑞金路" />
</Form.Item>
<Button
onClick={async () => {
const address = String(form.getFieldValue('punchAddress') || form.getFieldValue('punchPlace') || '').trim();
if (!address) {
message.warning('请先填写打卡地址,或直接点「地图选点」');
return;
}
try {
const res = (await api.post('/office/geo/geocode', { address })) as {
data?: { lat: number; lng: number; address?: string; title?: string };
};
const hit = res.data;
if (!hit) throw new Error('没有解析到坐标');
form.setFieldsValue({
punchLat: hit.lat,
punchLng: hit.lng,
});
message.info('已跳到附近,请在地图上点准确位置,并看紫色范围圈');
} catch (e) {
message.error(e instanceof Error ? e.message : '解析失败');
}
}}
>
</Button>
</Space.Compact>
</Form.Item>
<Form.Item name="punchLat" hidden>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="punchLng" hidden>
<InputNumber style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="punchRadiusMeters" label="有效半径(米)" extra="圈内才能打卡,建议 100300 米。">
<InputNumber min={50} max={2000} style={{ width: '100%' }} />
</Form.Item>
<Form.Item shouldUpdate>
{() => (
<PunchLocationMap
lat={form.getFieldValue('punchLat')}
lng={form.getFieldValue('punchLng')}
radiusMeters={form.getFieldValue('punchRadiusMeters')}
onPick={(hit) => {
form.setFieldsValue({
punchLat: hit.lat,
punchLng: hit.lng,
punchAddress: hit.address || form.getFieldValue('punchAddress'),
punchPlace: form.getFieldValue('punchPlace') || hit.title,
});
}}
/>
)}
</Form.Item>
<Form.Item shouldUpdate>
{() => {
const lat = form.getFieldValue('punchLat');
const lng = form.getFieldValue('punchLng');
return lat && lng ? (
<p style={{ color: 'var(--mute)', marginTop: -8 }}>
{Number(lat).toFixed(6)}, {Number(lng).toFixed(6)}
</p>
) : (
<p style={{ color: 'var(--mute)', marginTop: -8 }}></p>
);
}}
</Form.Item>
<Form.Item name="sortNo" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="remark" label="说明">
<Input />
</Form.Item>
</Form>
</FormDrawer>
<Table
rowKey="id"
loading={q.isLoading}
dataSource={rows}
pagination={false}
locale={{ emptyText: '还没有班次。点右上角新建。' }}
columns={[
{ title: '班次', dataIndex: 'name' },
{ title: '作息', dataIndex: 'restSchedule', width: 90, render: (v: string) => REST_LABEL[v] || v },
{ title: '上班', dataIndex: 'workStart', width: 90 },
{ title: '下班', dataIndex: 'workEnd', width: 90 },
{ title: '打卡地点', dataIndex: 'punchPlace', render: (_: string, r: Rule) => r.punchPlace || r.punchAddress || '未设' },
{ title: '半径', dataIndex: 'punchRadiusMeters', width: 90, render: (v: number) => (v ? `${v}` : '—') },
{ title: '迟到宽限', dataIndex: 'lateGraceMinutes', width: 100, render: (v: number) => `${v} 分钟` },
{ title: '说明', dataIndex: 'remark', render: (v: string) => v || '—' },
canHr(roles)
? {
title: '操作',
width: 140,
render: (_: unknown, row: Rule) => (
<Space>
<Button type="link" onClick={() => openForm(row)}>
</Button>
<Button
type="link"
danger
onClick={() =>
Modal.confirm({
title: '删除班次',
content: `确认删除「${row.name}」?`,
okButtonProps: { danger: true },
onOk: async () => {
await delMut.mutateAsync(row.id);
message.success('已删除');
await qc.invalidateQueries({ queryKey: ['/attendance-rules'] });
},
})
}
>
</Button>
</Space>
),
}
: { title: '', width: 1 },
]}
/>
</>
);
}
+191
View File
@@ -0,0 +1,191 @@
import { Button, DatePicker, Form, InputNumber, Select, Space, Tag, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { FormDrawer } from './FormDrawer';
import { ResourcePage } from './ResourcePage';
import { INVOICE_STATUS_META, money } from './financeStatus';
import { ApplyBucketBar } from './ApplyBucketBar';
type Contract = {
id: string;
contractNo: string;
name: string;
status: string;
amount: string | number;
milestones?: { id: string; name: string; ratio: string | number; invoice?: { id: string } | null }[];
};
export default function InvoiceListPage() {
const [params] = useSearchParams();
const bucket = params.get('bucket') || '';
const endpoint = bucket ? `/invoices?bucket=${bucket}` : '/invoices';
const qc = useQueryClient();
const [form] = Form.useForm();
const [creating, setCreating] = useState(false);
const contracts = useQuery({
queryKey: ['/contracts'],
queryFn: () => api.get('/contracts'),
});
const items = ((contracts.data?.data?.items ?? []) as Contract[]).filter((c) => c.status === 'APPROVED');
const createMut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/invoices', body),
onSuccess: () => {
message.success('已生成待开票');
qc.invalidateQueries();
},
onError: (e: Error) => message.error(e.message),
});
const actMut = useMutation({
mutationFn: ({ id, action, invoiceNo }: { id: string; action: 'issue' | 'receive'; invoiceNo?: string }) =>
api.post(`/invoices/${id}/${action}`, action === 'issue' ? { invoiceNo } : {}),
onSuccess: () => {
message.success('已更新');
qc.invalidateQueries();
},
onError: (e: Error) => message.error(e.message),
});
const openCreate = () => {
form.resetFields();
setCreating(true);
};
return (
<>
<FormDrawer
title="按收款条款开票"
open={creating}
onClose={() => setCreating(false)}
okText="提交保存"
okLoading={createMut.isPending}
onOk={async () => {
const v = await form.validateFields();
await createMut.mutateAsync({
...v,
dueAt: v.dueAt ? v.dueAt.toISOString() : undefined,
});
setCreating(false);
}}
>
<Form form={form} layout="vertical">
<Form.Item name="contractId" label="合同" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={items.map((c) => ({ value: c.id, label: `${c.contractNo} ${c.name}` }))}
/>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{() => {
const cid = form.getFieldValue('contractId');
const c = items.find((x) => x.id === cid);
const miles = (c?.milestones || []).filter((m) => !m.invoice);
return (
<Form.Item name="milestoneId" label="收款条款" rules={[{ required: true }]}>
<Select
options={miles.map((m) => ({
value: m.id,
label: `${m.name} ${m.ratio}%`,
}))}
/>
</Form.Item>
);
}}
</Form.Item>
<Form.Item name="amount" label="金额(空则按条款比例)">
<InputNumber min={0.01} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="dueAt" label="应收日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Form>
</FormDrawer>
<ResourcePage
title="开票收款"
description="合同里程碑开票:待开、已开、已回、逾期。同一条款不能重复开。"
endpoint={endpoint}
extra={
<ApplyBucketBar
basePath="/finance/invoices"
buckets={[
{ value: 'to_issue', label: '待开' },
{ value: 'overdue', label: '逾期' },
]}
extra={
<Button type="primary" onClick={openCreate}>
</Button>
}
/>
}
columns={[
{ title: '发票号', dataIndex: 'invoiceNo', render: (v: string) => v || '—' },
{
title: '合同',
render: (_: unknown, row: Record<string, unknown>) => {
const c = row.contract as { id?: string; contractNo?: string; name?: string } | null;
return c?.id ? <Link to={`/contract/${c.id}`}>{c.contractNo}{c.name ? ` ${c.name}` : ''}</Link> : '—';
},
},
{
title: '条款',
render: (_: unknown, row: Record<string, unknown>) => (row.milestone as { name?: string } | null)?.name || '—',
},
{ title: '金额', dataIndex: 'amount', width: 120, render: (v: string) => money(v) },
{
title: '合同额',
width: 110,
render: (_: unknown, row: Record<string, unknown>) => {
const c = row.contract as { amount?: string | number } | null;
return money(c?.amount);
},
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: string) => {
const s = INVOICE_STATUS_META[v] || { color: 'default', text: v };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '应收日',
dataIndex: 'dueAt',
width: 120,
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—'),
},
{
title: '操作',
width: 140,
render: (_: unknown, row: Record<string, unknown>) => (
<Space>
{row.status === 'TO_ISSUE' ? (
<Button
type="link"
size="small"
onClick={() => {
const invoiceNo = window.prompt('发票号');
if (invoiceNo) actMut.mutate({ id: String(row.id), action: 'issue', invoiceNo });
}}
>
</Button>
) : null}
{row.status === 'ISSUED' || row.status === 'OVERDUE' ? (
<Button type="link" size="small" onClick={() => actMut.mutate({ id: String(row.id), action: 'receive' })}>
</Button>
) : null}
</Space>
),
},
]}
/>
</>
);
}
+218
View File
@@ -0,0 +1,218 @@
import { Button, DatePicker, Form, Input, Popconfirm, Select, Space, Table, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { FormDrawer } from './FormDrawer';
import { PageHeader, unwrapList } from './ResourcePage';
import { BizFileUpload } from './FileAttachments';
type EmpOpt = {
id: string;
name: string;
employeeNo: string;
department?: string;
contractStartAt?: string | null;
contractEndAt?: string | null;
};
type FileRow = { id: string; fileName: string; size: number; mimeType: string };
type LaborRow = {
id: string;
contractNo?: string | null;
startAt?: string | null;
endAt?: string | null;
remark?: string | null;
createdAt?: string;
employee?: { id: string; name: string; employeeNo: string; department?: { name?: string } | null };
files?: FileRow[];
};
export default function LaborContractPage() {
const qc = useQueryClient();
const [form] = Form.useForm();
const [drawer, setDrawer] = useState<null | 'create' | { id: string }>(null);
const q = useQuery({
queryKey: ['/labor-contracts'],
queryFn: () => api.get('/labor-contracts'),
});
const body = (q.data as { data?: { items?: LaborRow[]; employees?: EmpOpt[] } })?.data;
const items = unwrapList(q.data).items as unknown as LaborRow[];
const employees = body?.employees || [];
const empOpts = useMemo(
() =>
employees.map((e) => ({
value: e.id,
label: `${e.name}${e.employeeNo}${e.department ? ` · ${e.department}` : ''}`,
emp: e,
})),
[employees],
);
const saveMut = useMutation({
mutationFn: async (body: Record<string, unknown>) => {
if (drawer && typeof drawer === 'object') {
return api.patch(`/labor-contracts/${drawer.id}`, body);
}
return api.post('/labor-contracts', body);
},
onSuccess: (res: { data?: { id?: string } }) => {
message.success('已保存人事合同');
qc.invalidateQueries({ queryKey: ['/labor-contracts'] });
const id = res.data?.id;
if (id && drawer === 'create') setDrawer({ id });
},
onError: (e: Error) => message.error(e.message),
});
const delMut = useMutation({
mutationFn: (id: string) => api.delete(`/labor-contracts/${id}`),
onSuccess: () => {
message.success('已删除');
qc.invalidateQueries({ queryKey: ['/labor-contracts'] });
},
onError: (e: Error) => message.error(e.message),
});
const currentId = drawer && typeof drawer === 'object' ? drawer.id : undefined;
const current = items.find((r) => r.id === currentId);
const openCreate = () => {
form.resetFields();
setDrawer('create');
};
const openEdit = (row: LaborRow) => {
form.setFieldsValue({
employeeId: row.employee?.id,
contractNo: row.contractNo,
startAt: row.startAt ? dayjs(row.startAt) : undefined,
endAt: row.endAt ? dayjs(row.endAt) : undefined,
remark: row.remark,
});
setDrawer({ id: row.id });
};
return (
<>
<PageHeader
title="人事合同"
description="上传员工劳动合同电子件。与投标合同、直签合同、其他合同分开,不走项目立项。"
extra={
<Button type="primary" onClick={openCreate}>
</Button>
}
/>
<FormDrawer
title={drawer === 'create' ? '新建人事合同' : '人事合同'}
open={Boolean(drawer)}
onClose={() => setDrawer(null)}
okText="提交保存"
okLoading={saveMut.isPending}
onOk={async () => {
const v = await form.validateFields();
await saveMut.mutateAsync({
...v,
startAt: v.startAt ? dayjs(v.startAt).format('YYYY-MM-DD') : undefined,
endAt: v.endAt ? dayjs(v.endAt).format('YYYY-MM-DD') : undefined,
});
if (currentId) setDrawer(null);
}}
>
<Form form={form} layout="vertical">
<Form.Item name="employeeId" label="员工" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
disabled={Boolean(currentId)}
options={empOpts}
onChange={(id: string) => {
const emp = employees.find((e) => e.id === id);
if (!emp) return;
form.setFieldsValue({
startAt: emp.contractStartAt ? dayjs(emp.contractStartAt) : undefined,
endAt: emp.contractEndAt ? dayjs(emp.contractEndAt) : undefined,
});
}}
/>
</Form.Item>
<Form.Item name="contractNo" label="合同编号">
<Input placeholder="劳动合同编号,可空" />
</Form.Item>
<Form.Item name="startAt" label="合同开始">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="endAt" label="合同结束">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
<BizFileUpload
bizType="EMPLOYEE_LABOR"
bizId={currentId}
files={current?.files}
label="电子劳动合同"
extra="保存后再上传 PDF 或扫描件。"
queryKey={['/labor-contracts']}
/>
</FormDrawer>
<Table
className="ops-table"
rowKey="id"
loading={q.isLoading}
dataSource={items}
pagination={{ defaultPageSize: 10, showSizeChanger: true, showTotal: (n) => `${n}` }}
scroll={{ x: 'max-content' }}
tableLayout="fixed"
columns={[
{ title: '员工', width: 110, render: (_: unknown, r: LaborRow) => r.employee?.name || '—' },
{ title: '工号', width: 110, render: (_: unknown, r: LaborRow) => r.employee?.employeeNo || '—' },
{
title: '部门',
width: 120,
render: (_: unknown, r: LaborRow) => r.employee?.department?.name || '—',
},
{ title: '合同编号', dataIndex: 'contractNo', width: 160, render: (v: string) => v || '—' },
{
title: '合同期限',
width: 200,
render: (_: unknown, r: LaborRow) => {
const a = r.startAt ? String(r.startAt).slice(0, 10) : '—';
const b = r.endAt ? String(r.endAt).slice(0, 10) : '—';
return `${a} ~ ${b}`;
},
},
{
title: '附件',
width: 80,
render: (_: unknown, r: LaborRow) => (r.files?.length ? `${r.files.length}` : '未传'),
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '—'),
},
{
title: '操作',
width: 140,
render: (_: unknown, r: LaborRow) => (
<Space>
<Button type="link" size="small" onClick={() => openEdit(r)}>
</Button>
<Popconfirm title="删除这份人事合同?" onConfirm={() => delMut.mutate(r.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
]}
/>
</>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { Typography } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import { api } from '../api/client';
import AmbientBackground from '../components/AmbientBackground';
import { BrandLogo } from '../components/BrandLogo';
import { SiteFooter } from '../components/SiteFooter';
type LegalDoc = {
kind: string;
title: string;
updatedAt: string;
operator: string;
product: string;
paragraphs: { heading?: string; body: string }[];
};
export default function LegalPage() {
const { kind } = useParams<{ kind: string }>();
const q = useQuery({
queryKey: ['/legal', kind],
queryFn: () => api.get(`/legal/${kind}`),
enabled: Boolean(kind),
});
const doc = (q.data?.data ?? q.data) as LegalDoc | undefined;
const other = kind === 'privacy' ? 'user-agreement' : 'privacy';
const otherTitle = kind === 'privacy' ? '用户协议' : '隐私政策';
return (
<div className="ops-login ops-legal">
<AmbientBackground />
<div className="ops-login-wrap ops-legal-wrap">
<div className="ops-login-card ops-legal-card">
<div className="login-mark">
<BrandLogo size={56} />
</div>
<h1>{doc?.title || (kind === 'privacy' ? '隐私政策' : '用户协议')}</h1>
<p className="login-sub">
{doc ? `${doc.operator} · ${doc.product} · 更新日期 ${doc.updatedAt}` : '加载中…'}
</p>
{q.isError ? (
<Typography.Paragraph type="danger"></Typography.Paragraph>
) : null}
{(doc?.paragraphs || []).map((p, i) => (
<section key={i} className="ops-legal-section">
{p.heading ? <h2>{p.heading}</h2> : null}
<p>{p.body}</p>
</section>
))}
<p className="ops-legal-nav">
<Link to="/login"></Link>
<span> · </span>
<Link to={`/legal/${other}`}>{otherTitle}</Link>
</p>
</div>
</div>
<SiteFooter />
</div>
);
}
+226
View File
@@ -0,0 +1,226 @@
import { useEffect } from 'react';
import { Alert, Button, Descriptions, Form, Input, Space, Tag, Typography, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
import { FormDrawer } from './FormDrawer';
import { CostObjectFields } from './CostObjectFields';
import { FileAttachments } from './FileAttachments';
import { belongLabel, CostTargetLink } from './FinanceCells';
import { COST_TYPE_LABEL, LOAN_STATUS_META, money } from './financeStatus';
type Loan = {
id: string;
loanNo: string;
costType: string;
amount: string | number;
remaining?: number;
purpose: string;
status: string;
remark?: string | null;
createdAt?: string;
bidCase?: { id: string; bidNo: string; name?: string; externalNo?: string } | null;
project?: { id?: string; projectNo: string; name?: string } | null;
applicant?: { displayName: string; employee?: { department?: { name?: string } } };
myActions?: { canEdit: boolean; canSubmit: boolean; canDelete?: boolean; canReview: boolean; canOffset: boolean };
};
export default function LoanDetailPage() {
const { id } = useParams();
const [params] = useSearchParams();
const location = useLocation();
const onDesk = location.pathname.startsWith('/finance');
const listPath = onDesk ? '/finance/loans' : '/office/expenses';
const selfPath = onDesk ? '/finance/loans' : '/office/loans';
const isNew = id === 'new' || !id;
const navigate = useNavigate();
const qc = useQueryClient();
const [form] = Form.useForm();
const q = useQuery({
queryKey: ['loan', id],
enabled: !isNew && Boolean(id),
queryFn: () => api.get(`/loans/${id}`),
});
const row = q.data?.data as Loan | undefined;
useEffect(() => {
if (isNew) {
form.setFieldsValue({
costType: params.get('costType') || 'BID',
bidCaseId: params.get('bidCaseId') || undefined,
projectId: params.get('projectId') || undefined,
purpose: params.get('purpose') || undefined,
});
return;
}
if (!row) return;
form.setFieldsValue({
costType: row.costType,
bidCaseId: row.bidCase?.id,
projectId: row.project ? (row as Loan & { project?: { id?: string } }).project?.id : undefined,
amount: Number(row.amount),
purpose: row.purpose,
remark: row.remark || undefined,
});
}, [isNew, row, form, params]);
const saveMut = useMutation({
mutationFn: (body: Record<string, unknown>) => (isNew ? api.post('/loans', body) : api.patch(`/loans/${id}`, body)),
onSuccess: async (res: { data?: { id?: string } }) => {
message.success('已保存');
await qc.invalidateQueries();
if (isNew && res.data?.id) navigate(`${selfPath}/${res.data.id}`, { replace: true });
},
onError: (e: Error) => message.error(e.message),
});
const submitMut = useMutation({
mutationFn: async (savedId?: string) => {
const target = savedId || id;
if (!target || target === 'new') throw new Error('请先保存');
return api.post(`/loans/${target}/submit`);
},
onSuccess: async () => {
message.success('已提交财务确认');
await qc.invalidateQueries();
},
onError: (e: Error) => message.error(e.message),
});
const reviewMut = useMutation({
mutationFn: (body: { result: 'APPROVED' | 'REJECTED'; comment?: string }) => api.post(`/loans/${id}/reviews`, body),
onSuccess: async () => {
message.success('已办理');
await qc.invalidateQueries();
},
onError: (e: Error) => message.error(e.message),
});
const delMut = useMutation({
mutationFn: () => api.delete(`/loans/${id}`),
onSuccess: () => {
message.success('已删除');
navigate(listPath);
},
onError: (e: Error) => message.error(e.message),
});
const meta = LOAN_STATUS_META[row?.status || 'DRAFT'] || LOAN_STATUS_META.DRAFT;
const editable = isNew || row?.myActions?.canEdit;
return (
<FormDrawer
title={isNew ? '申请借款' : row?.loanNo || '借款单'}
extra="取标差旅等先借款。报销时必须冲这笔借款,不能再领一笔。"
open
onClose={() => navigate(listPath)}
hideOk
createdAt={isNew ? undefined : row?.createdAt}
>
{row ? (
<Space style={{ marginBottom: 16 }} wrap>
<Tag color={meta.color}>{meta.text}</Tag>
<Tag>{COST_TYPE_LABEL[row.costType] || row.costType}</Tag>
<span> {money(row.remaining ?? row.amount)}</span>
</Space>
) : null}
{row?.myActions?.canReview ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="请确认借款。通过后申请人待办会出现「报销冲账」。"
action={
<Space>
<Button type="primary" onClick={() => reviewMut.mutate({ result: 'APPROVED' })}>
</Button>
<Button
danger
onClick={() => {
const comment = window.prompt('打回意见');
if (comment) reviewMut.mutate({ result: 'REJECTED', comment });
}}
>
</Button>
</Space>
}
/>
) : null}
{row?.myActions?.canOffset ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="借款未还,请报销冲账,不要另报一笔。"
action={
<Button type="primary">
<Link to={`/office/expenses/new?loanId=${row.id}&costType=${row.costType}&bidCaseId=${row.bidCase?.id || ''}`} style={{ color: 'inherit' }}>
</Link>
</Button>
}
/>
) : null}
{!editable && row ? (
<Descriptions bordered size="small" column={2}>
<Descriptions.Item label="金额">{money(row.amount)}</Descriptions.Item>
<Descriptions.Item label="未还">{money(row.remaining)}</Descriptions.Item>
<Descriptions.Item label="用途" span={2}>
{row.purpose}
</Descriptions.Item>
<Descriptions.Item label="申请人">{row.applicant?.displayName || '—'}</Descriptions.Item>
<Descriptions.Item label="归属">{belongLabel(row)}</Descriptions.Item>
<Descriptions.Item label="对象">
<CostTargetLink costType={row.costType} bid={row.bidCase} project={row.project} />
</Descriptions.Item>
<Descriptions.Item label="备注" span={2}>
{row.remark || '—'}
</Descriptions.Item>
</Descriptions>
) : (
<Form form={form} layout="vertical" style={{ maxWidth: 640 }} onFinish={(v) => saveMut.mutate(v)}>
<CostObjectFields />
<Form.Item name="purpose" label="用途" rules={[{ required: true, message: '请填写用途' }]}>
<Input placeholder="如:取标差旅、预办证照" />
</Form.Item>
<FileAttachments bizType="LOAN" bizId={isNew ? undefined : id} disabled={!editable} label="借据 / 凭证" />
<Space>
<Button type="primary" htmlType="submit" loading={saveMut.isPending}>
稿
</Button>
<Button
loading={submitMut.isPending}
onClick={async () => {
const v = await form.validateFields();
if (isNew) {
const res = await saveMut.mutateAsync(v);
const newId = (res as { data?: { id?: string } }).data?.id;
if (newId) await submitMut.mutateAsync(newId);
} else {
await saveMut.mutateAsync(v);
await submitMut.mutateAsync(id);
}
}}
>
</Button>
{row?.myActions?.canDelete ? (
<Button danger onClick={() => delMut.mutate()}>
</Button>
) : null}
</Space>
</Form>
)}
{!editable && row ? <FileAttachments bizType="LOAN" bizId={id} disabled label="借据 / 凭证" /> : null}
{!editable && row?.myActions?.canSubmit ? (
<Button type="primary" style={{ marginTop: 16 }} onClick={() => submitMut.mutate()}>
</Button>
) : null}
<Typography.Paragraph type="secondary" style={{ marginTop: 24 }}>
</Typography.Paragraph>
</FormDrawer>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { Alert, Button, Checkbox, Form, Input, Space } from 'antd';
import { LockOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import { useAuth } from '../store/auth';
import AmbientBackground from '../components/AmbientBackground';
import { BrandLogo } from '../components/BrandLogo';
import { MobileDownload } from '../components/MobileDownload';
import { SiteFooter } from '../components/SiteFooter';
export default function LoginPage() {
const login = useAuth((s) => s.login);
const navigate = useNavigate();
const [form] = Form.useForm();
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [sending, setSending] = useState(false);
const [wait, setWait] = useState(0);
const agreed = Form.useWatch('agreeTerms', form);
const optQ = useQuery({
queryKey: ['/auth/login-options'],
queryFn: () => api.get('/auth/login-options'),
retry: false,
});
const smsLogin = Boolean((optQ.data?.data as { smsLogin?: boolean } | undefined)?.smsLogin);
useEffect(() => {
if (wait <= 0) return;
const t = window.setTimeout(() => setWait((n) => n - 1), 1000);
return () => window.clearTimeout(t);
}, [wait]);
const sendCode = async () => {
const username = String(form.getFieldValue('username') || '').trim();
if (!username) {
setError('请先填写手机号');
return;
}
setError('');
setSending(true);
try {
await api.post('/auth/sms-code', { username });
setWait(60);
} catch (e) {
setError(e instanceof Error ? e.message : '验证码发送失败');
} finally {
setSending(false);
}
};
return (
<div className="ops-login">
<AmbientBackground />
<div className="ops-login-wrap">
<div className="ops-login-card">
<div className="login-mark">
<BrandLogo size={72} />
</div>
<h1></h1>
<p className="login-sub"></p>
{error ? <Alert type="error" message={error} style={{ marginBottom: 16 }} /> : null}
<Form
form={form}
layout="vertical"
onFinish={async (v) => {
if (!v.agreeTerms) {
setError('请先阅读并同意用户协议和隐私政策');
return;
}
setError('');
setLoading(true);
try {
await login(v.username, v.password, v.smsCode, true);
const next = useAuth.getState().needsOnboard ? '/office/onboard' : '/workbench';
navigate(next);
} catch (e) {
setError(e instanceof Error ? e.message : '登录失败');
} finally {
setLoading(false);
}
}}
>
<Form.Item name="username" label="手机号" rules={[{ required: true, message: '请填写手机号' }]}>
<Input prefix={<UserOutlined />} autoComplete="username" size="large" placeholder="请输入登录手机号" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请填写密码' }]}>
<Input.Password
prefix={<LockOutlined />}
autoComplete="current-password"
size="large"
placeholder="请输入密码"
/>
</Form.Item>
{smsLogin ? (
<Form.Item label="短信验证码" required>
<Space.Compact style={{ width: '100%' }}>
<Form.Item name="smsCode" noStyle rules={[{ required: true, message: '请填写短信验证码' }]}>
<Input prefix={<SafetyOutlined />} size="large" placeholder="6 位验证码" maxLength={6} />
</Form.Item>
<Button size="large" loading={sending} disabled={wait > 0} onClick={() => void sendCode()}>
{wait > 0 ? `${wait}s` : '获取验证码'}
</Button>
</Space.Compact>
</Form.Item>
) : null}
<Form.Item
name="agreeTerms"
valuePropName="checked"
initialValue={false}
style={{ marginBottom: 16 }}
rules={[
{
validator: async (_, v) => {
if (!v) throw new Error('请先阅读并同意用户协议和隐私政策');
},
},
]}
>
<Checkbox className="login-agree">
<Link to="/legal/user-agreement" target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>
</Link>
<Link to="/legal/privacy" target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>
</Link>
</Checkbox>
</Form.Item>
<Button
type="primary"
htmlType="submit"
block
size="large"
loading={loading}
disabled={!agreed}
className="gradient-btn"
>
</Button>
<div className="login-mobile-dl">
<MobileDownload placement="top" />
</div>
<div style={{ marginTop: 12, textAlign: 'center' }}>
<Link to="/login/desktop"></Link>
</div>
</Form>
</div>
</div>
<SiteFooter />
</div>
);
}
@@ -0,0 +1,53 @@
import { Alert, Button, Descriptions, Space } from 'antd';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
import { PageHeader } from './ResourcePage';
type ChannelRow = {
id?: string;
inboxEnabled?: string;
mobileImSdk?: string;
online?: number;
note?: string;
};
export default function MessagesChannelPage() {
const q = useQuery({
queryKey: ['/system/message-channel'],
queryFn: () => api.get('/system/message-channel'),
});
const rows = ((q.data as { data?: ChannelRow[] })?.data || q.data || []) as ChannelRow[];
const row = rows[0] || {};
return (
<>
<PageHeader
title="消息通道"
description="站内待办、审批提醒和即时通讯的运行状态。同事私聊请用「消息」;待办和审批走统一待办/待我审批。"
extra={
<Space>
<Link to="/office/im">
<Button type="default"></Button>
</Link>
<Link to="/office/todos">
<Button type="default"></Button>
</Link>
</Space>
}
/>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="Web 与手机 App 均已接入自研 IMMobileIMSDK + TCP 长连接)。通话依赖双方打开页面并走 STUN,失败时先确认网络而不是改弹层。"
/>
<Descriptions bordered size="small" column={1} style={{ maxWidth: 720 }}>
<Descriptions.Item label="站内待办 / 审批">{row.inboxEnabled || '已开启'}</Descriptions.Item>
<Descriptions.Item label="即时通讯">{row.mobileImSdk || '已启用'}</Descriptions.Item>
<Descriptions.Item label="当前在线">{typeof row.online === 'number' ? `${row.online}` : '—'}</Descriptions.Item>
<Descriptions.Item label="说明">{row.note || '—'}</Descriptions.Item>
</Descriptions>
</>
);
}
+156
View File
@@ -0,0 +1,156 @@
import { Button, DatePicker, Form, Input, InputNumber, Select, Space, Table, Tag, Typography, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { FormDrawer } from './FormDrawer';
import { PageHeader } from './ResourcePage';
import { HR_APPLY_LABEL, LEAVE_KIND_OPTIONS, LEAVE_STATUS_META } from './hrStatus';
import { useState } from 'react';
type PunchMine = { clockIn?: string | null; clockOut?: string | null; employeeId?: string | null };
type LeaveRow = {
id: string;
kind: string;
startAt: string;
endAt: string;
days: string | number;
reason: string;
status: string;
comment?: string | null;
};
export default function MyAttendancePage() {
const qc = useQueryClient();
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const punch = useQuery({
queryKey: ['attendance-mine'],
queryFn: () => api.get('/attendance/punches/mine'),
});
const leaves = useQuery({
queryKey: ['leave-mine'],
queryFn: () => api.get('/leave-requests?bucket=mine'),
});
const mine = punch.data?.data as PunchMine | undefined;
const items = ((leaves.data?.data as { items?: LeaveRow[] })?.items || []) as LeaveRow[];
const punchMut = useMutation({
mutationFn: (kind: string) => api.post('/attendance/punch', { kind }),
onSuccess: async () => {
message.success('打卡成功');
await qc.invalidateQueries({ queryKey: ['attendance-mine'] });
},
onError: (e: Error) => message.error(e.message),
});
const leaveMut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/leave-requests', body),
onSuccess: async () => {
message.success('请假已提交,人事或上级审批');
setOpen(false);
form.resetFields();
await qc.invalidateQueries({ queryKey: ['leave-mine'] });
},
onError: (e: Error) => message.error(e.message),
});
return (
<>
<PageHeader
title="打卡请假"
description="上班、下班各打一次。请假、出差提交后,人事或上级在假勤结果里批。通过后计入当月假勤,薪酬按假勤核算。"
extra={
<Button type="primary" onClick={() => setOpen(true)}>
/
</Button>
}
/>
<div className="ops-toolbar" style={{ marginBottom: 16 }}>
<Space wrap>
<Typography.Text>
{mine?.clockIn ? dayjs(mine.clockIn).format('HH:mm:ss') : '未打'}
</Typography.Text>
<Typography.Text>
{mine?.clockOut ? dayjs(mine.clockOut).format('HH:mm:ss') : '未打'}
</Typography.Text>
<Button
type="primary"
disabled={Boolean(mine?.clockIn) || !mine?.employeeId}
loading={punchMut.isPending}
onClick={() => punchMut.mutate('IN')}
>
</Button>
<Button
disabled={!mine?.clockIn || Boolean(mine?.clockOut) || !mine?.employeeId}
loading={punchMut.isPending}
onClick={() => punchMut.mutate('OUT')}
>
</Button>
</Space>
</div>
{!mine?.employeeId ? (
<Typography.Paragraph type="secondary"></Typography.Paragraph>
) : null}
<Typography.Title level={5}> / </Typography.Title>
<Table
rowKey="id"
size="small"
dataSource={items}
pagination={false}
columns={[
{ title: '类型', width: 140, render: (_: unknown, r: LeaveRow) => HR_APPLY_LABEL[r.kind] || r.kind },
{ title: '开始', width: 150, render: (_: unknown, r: LeaveRow) => dayjs(r.startAt).format('YYYY-MM-DD HH:mm') },
{ title: '结束', width: 150, render: (_: unknown, r: LeaveRow) => dayjs(r.endAt).format('YYYY-MM-DD HH:mm') },
{ title: '天数', dataIndex: 'days', width: 80 },
{ title: '事由', dataIndex: 'reason', ellipsis: true },
{
title: '状态',
width: 90,
render: (_: unknown, r: LeaveRow) => {
const s = LEAVE_STATUS_META[r.status] || { color: 'default', text: r.status };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
]}
locale={{ emptyText: '还没有请假记录' }}
/>
<FormDrawer
title="请假 / 出差"
open={open}
onClose={() => setOpen(false)}
okText="提交"
okLoading={leaveMut.isPending}
onOk={() => form.submit()}
>
<Form
form={form}
layout="vertical"
initialValues={{ kind: 'PERSONAL', range: [dayjs().startOf('day'), dayjs().endOf('day')] }}
onFinish={(v) =>
leaveMut.mutate({
kind: v.kind,
startAt: v.range[0].toISOString(),
endAt: v.range[1].toISOString(),
days: v.days,
reason: v.reason,
})
}
>
<Form.Item name="kind" label="类型" rules={[{ required: true }]}>
<Select options={LEAVE_KIND_OPTIONS} />
</Form.Item>
<Form.Item name="range" label="起止时间" rules={[{ required: true }]}>
<DatePicker.RangePicker showTime style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="days" label="天数(可不填,按起止自动算)">
<InputNumber min={0.5} step={0.5} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="reason" label="事由" rules={[{ required: true, message: '请填写事由' }]}>
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</FormDrawer>
</>
);
}
+226
View File
@@ -0,0 +1,226 @@
import { Button, Modal, Space, Table, Tabs, Tag, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, Outlet, useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import { PageHeader } from './ResourcePage';
import { belongLabel, CostTargetLink } from './FinanceCells';
import { EXPENSE_STATUS_META, LOAN_STATUS_META, expenseCategoryLabel, money } from './financeStatus';
type Expense = {
id: string;
claimNo: string;
amount: string | number;
costType: string;
status: string;
remark?: string | null;
purpose?: string | null;
expenseCategory?: string | null;
prepaid?: boolean;
payer?: { displayName?: string } | null;
submittedAt?: string;
createdAt?: string;
applicant?: { employee?: { department?: { name?: string } } } | null;
bidCase?: { id?: string; bidNo?: string; name?: string; party?: { name?: string } } | null;
project?: { id?: string; projectNo?: string; name?: string } | null;
};
type Loan = {
id: string;
loanNo: string;
amount: string | number;
remaining?: number;
purpose: string;
costType: string;
status: string;
remark?: string | null;
submittedAt?: string;
createdAt?: string;
applicant?: { employee?: { department?: { name?: string } } } | null;
bidCase?: { id?: string; bidNo?: string; name?: string } | null;
project?: { id?: string; projectNo?: string; name?: string } | null;
};
export default function MyExpensesPage() {
const navigate = useNavigate();
const qc = useQueryClient();
const q = useQuery({
queryKey: ['/office/my-expenses'],
queryFn: () => api.get('/office/my-expenses'),
});
const delExp = useMutation({
mutationFn: (id: string) => api.delete(`/expenses/${id}`),
onSuccess: () => {
message.success('已删除');
qc.invalidateQueries({ queryKey: ['/office/my-expenses'] });
},
onError: (e: Error) => message.error(e.message),
});
const expenses = (q.data?.data?.expenses ?? []) as Expense[];
const loans = (q.data?.data?.loans ?? []) as Loan[];
return (
<>
<PageHeader
title="我的费用"
description="只显示你本人提交的报销和借款。待确认、被打回的报销,以及未还借款都在这里。全公司单据请到财务收支 · 费用报销 / 借款管理。"
extra={
<Space>
<Button>
<Link to="/office/loans/new"></Link>
</Button>
<Button type="primary">
<Link to="/office/expenses/new" style={{ color: 'inherit' }}>
</Link>
</Button>
</Space>
}
/>
<Tabs
items={[
{
key: 'exp',
label: `报销(${expenses.length}`,
children: (
<Table
className="ops-table ops-table-click"
rowKey="id"
loading={q.isLoading}
dataSource={expenses}
scroll={{ x: 'max-content' }}
tableLayout="fixed"
onRow={(r) => ({
onClick: (e) => {
if ((e.target as HTMLElement).closest('a,button,.ant-btn')) return;
navigate(`/office/expenses/${r.id}`);
},
})}
locale={{ emptyText: '你还没有报销单。右上角「填报销」提交后会出现在这里。' }}
pagination={{
defaultPageSize: 10,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50],
showTotal: (n) => `${n}`,
}}
columns={[
{
title: '单号',
dataIndex: 'claimNo',
width: 160,
render: (v: string, r: Expense) => <Link to={`/office/expenses/${r.id}`}>{v}</Link>,
},
{ title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) },
{ title: '代付', width: 90, render: (_: unknown, r: Expense) => (r.prepaid ? r.payer?.displayName || '代付' : '自付') },
{ title: '类别', width: 80, render: (_: unknown, r: Expense) => expenseCategoryLabel(r.expenseCategory) },
{ title: '用途', ellipsis: true, render: (_: unknown, r: Expense) => r.purpose || '—' },
{ title: '归属', ellipsis: true, render: (_: unknown, r: Expense) => belongLabel(r) },
{ title: '对象', width: 150, render: (_: unknown, r: Expense) => <CostTargetLink costType={r.costType} bid={r.bidCase} project={r.project} /> },
{
title: '创建时间',
width: 170,
render: (_: unknown, r: Expense) =>
r.createdAt ? String(r.createdAt).replace('T', ' ').slice(0, 19) : String(r.submittedAt || '').slice(0, 10) || '—',
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: (v: string) => {
const s = EXPENSE_STATUS_META[v] || { color: 'default', text: v };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '操作',
width: 140,
render: (_: unknown, r: Expense) => (
<Space>
<Link to={`/office/expenses/${r.id}`}></Link>
{r.status === 'DRAFT' || r.status === 'REJECTED' ? (
<Button
type="link"
danger
onClick={() => {
Modal.confirm({
title: '删除这张报销?',
content: r.claimNo,
okText: '删除',
okButtonProps: { danger: true },
onOk: () => delExp.mutateAsync(r.id),
});
}}
>
</Button>
) : null}
</Space>
),
},
]}
/>
),
},
{
key: 'loan',
label: `借款(${loans.length}`,
children: (
<Table
className="ops-table ops-table-click"
rowKey="id"
loading={q.isLoading}
dataSource={loans}
scroll={{ x: 'max-content' }}
tableLayout="fixed"
onRow={(r) => ({
onClick: (e) => {
if ((e.target as HTMLElement).closest('a,button,.ant-btn')) return;
navigate(`/office/loans/${r.id}`);
},
})}
locale={{ emptyText: '你还没有借款。申请后由财务在「待我审批」确认,台账在财务收支 · 借支记录。' }}
pagination={{
defaultPageSize: 10,
showSizeChanger: true,
pageSizeOptions: [10, 20, 50],
showTotal: (n) => `${n}`,
}}
columns={[
{
title: '单号',
dataIndex: 'loanNo',
render: (v: string, r: Loan) => <Link to={`/office/loans/${r.id}`}>{v}</Link>,
},
{ title: '用途', dataIndex: 'purpose' },
{ title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) },
{ title: '未还', width: 110, render: (_: unknown, r: Loan) => money(r.remaining ?? r.amount) },
{ title: '归属', ellipsis: true, render: (_: unknown, r: Loan) => belongLabel(r) },
{ title: '对象', width: 150, render: (_: unknown, r: Loan) => <CostTargetLink costType={r.costType} bid={r.bidCase} project={r.project} /> },
{
title: '创建时间',
width: 170,
render: (_: unknown, r: Loan) =>
r.createdAt ? String(r.createdAt).replace('T', ' ').slice(0, 19) : '—',
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: (v: string) => {
const s = LOAN_STATUS_META[v] || { color: 'default', text: v };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '操作',
width: 90,
render: (_: unknown, r: Loan) => <Link to={`/office/loans/${r.id}`}></Link>,
},
]}
/>
),
},
]}
/>
<Outlet />
</>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { Button, Segmented, Space, Table, Tag } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
import { PageHeader, unwrapList } from './ResourcePage';
import { ApplyBucketBar, PROCESS_BUCKETS } from './ApplyBucketBar';
import { fmtCreated } from '../time';
import { zh } from '../labels';
import { HR_APPLY_LABEL } from './hrStatus';
const SOURCE_LABEL: Record<string, string> = {
HR: '人事',
SEAL: '用章',
OFFICE: '办公',
EXPENSE: '费用',
};
const KIND_LABEL: Record<string, string> = {
...HR_APPLY_LABEL,
SEAL: '用章',
ASSET_BUY: '资产购置',
ASSET_USE: '资产领用',
VEHICLE: '用车',
EXPENSE: '报销',
LOAN: '借支',
};
const BUCKET_META: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待审批' },
approved: { color: 'success', text: '已通过' },
rejected: { color: 'error', text: '已驳回' },
occupying: { color: 'blue', text: '占用中' },
out: { color: 'blue', text: '外带中' },
overdue: { color: 'error', text: '逾期未还' },
returned: { color: 'success', text: '已归还' },
};
type FlowRow = {
id: string;
source: string;
kind: string;
title: string;
status: string;
bucket: string;
createdAt: string;
path: string;
};
export default function MyFlowPage() {
const [params] = useSearchParams();
const navigate = useNavigate();
const bucket = params.get('bucket') || '';
const kind = params.get('kind') || '';
const qs = new URLSearchParams();
if (bucket) qs.set('bucket', bucket);
if (kind) qs.set('kind', kind);
const endpoint = `/office/flow${qs.toString() ? `?${qs}` : ''}`;
const q = useQuery({ queryKey: [endpoint], queryFn: () => api.get(endpoint) });
const items = unwrapList(q.data).items as unknown as FlowRow[];
const setKind = (v: string) => {
const next = new URLSearchParams();
if (bucket) next.set('bucket', bucket);
if (v) next.set('kind', v);
const s = next.toString();
navigate(s ? `/office/flow?${s}` : '/office/flow');
};
return (
<>
<PageHeader
title="我的申请"
description="跟踪自己提交的人事、用章、办公和费用。要新开一单,从右侧进对应发起页。"
extra={
<ApplyBucketBar
basePath="/office/flow"
buckets={[
...PROCESS_BUCKETS,
{ value: 'occupying', label: '占用中' },
{ value: 'out', label: '外带中' },
{ value: 'overdue', label: '逾期未还' },
]}
extra={
<Space>
<Button onClick={() => navigate('/office/hr-apply')}></Button>
<Button onClick={() => navigate('/office/seal-apply')}></Button>
<Button type="primary" onClick={() => navigate('/office/apply')}>
</Button>
</Space>
}
/>
}
/>
<div style={{ marginBottom: 16 }}>
<Segmented
value={kind}
onChange={(v) => setKind(String(v))}
options={[
{ label: '全部类型', value: '' },
{ label: '人事', value: 'HR' },
{ label: '用章', value: 'SEAL' },
{ label: '办公', value: 'OFFICE' },
{ label: '费用', value: 'EXPENSE' },
]}
/>
</div>
<Table
className="ops-table"
rowKey={(r) => `${r.source}-${r.id}`}
loading={q.isLoading}
dataSource={items}
pagination={{ defaultPageSize: 10, showSizeChanger: true, showTotal: (n) => `${n}` }}
columns={[
{ title: '来源', width: 80, render: (_: unknown, r: FlowRow) => SOURCE_LABEL[r.source] || r.source },
{ title: '类型', width: 110, render: (_: unknown, r: FlowRow) => KIND_LABEL[r.kind] || r.kind },
{
title: '事项',
ellipsis: true,
render: (_: unknown, r: FlowRow) => <Link to={r.path}>{r.title}</Link>,
},
{
title: '状态',
width: 110,
render: (_: unknown, r: FlowRow) => {
const s = BUCKET_META[r.bucket] || { color: 'default', text: zh(r.status) };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{ title: '创建时间', width: 170, render: (_: unknown, r: FlowRow) => fmtCreated(r.createdAt) },
]}
/>
</>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { Button, Spin, Tag } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { Link, useParams } from 'react-router-dom';
import { api } from '../api/client';
import { PageHeader } from './ResourcePage';
import { fmtCreated } from '../time';
import { NoticeHtml } from './NoticeHtml';
type NoticeDetail = {
id: string;
title: string;
content?: string;
kind?: string;
status?: string;
createdAt: string;
createdBy?: { displayName?: string };
files?: { id: string; fileName: string; size: number; mimeType: string; bizType?: string }[];
};
export default function NoticeDetailPage() {
const { id } = useParams();
const q = useQuery({
queryKey: ['/office/notices', id],
queryFn: () => api.get(`/office/notices/${id}`),
enabled: Boolean(id),
});
const row = q.data?.data as NoticeDetail | undefined;
if (q.isLoading) {
return (
<div style={{ display: 'grid', placeItems: 'center', minHeight: 240 }}>
<Spin />
</div>
);
}
if (!row) {
return (
<PageHeader
title="通知不存在"
extra={
<Link to="/office/notices">
<Button></Button>
</Link>
}
/>
);
}
return (
<>
<PageHeader
title={row.title}
description={`${row.createdBy?.displayName || '—'} · ${fmtCreated(row.createdAt)}`}
extra={
<Link to="/office/notices">
<Button></Button>
</Link>
}
/>
<div style={{ marginBottom: 16 }}>
<Tag color={row.kind === 'POLICY' ? 'blue' : 'purple'}>{row.kind === 'POLICY' ? '规章制度' : '通知公告'}</Tag>
</div>
<NoticeHtml html={row.content} files={row.files} />
</>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { useEffect, useRef } from 'react';
import { api } from '../api/client';
import { BizFileList, nonImageFiles } from './FileAttachments';
type FileRow = { id: string; fileName: string; size: number; mimeType: string; bizType?: string };
function toNoticeHtml(html?: string) {
const raw = html || '';
if (/<[a-z][\s\S]*>/i.test(raw)) return raw || '<p></p>';
const escaped = raw
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return `<p>${escaped.replace(/\n/g, '<br/>') || ''}</p>`;
}
export function NoticeHtml({ html, files }: { html?: string; files?: FileRow[] }) {
const ref = useRef<HTMLDivElement>(null);
const attach = nonImageFiles(files);
const markup = toNoticeHtml(html);
useEffect(() => {
const root = ref.current;
if (!root) return;
let cancelled = false;
const urls: string[] = [];
const run = async () => {
const imgs = Array.from(root.querySelectorAll('img'));
await Promise.all(
imgs.map(async (img) => {
const src = img.getAttribute('src') || '';
const m = src.match(/\/files\/([0-9a-f-]{36})/i);
if (!m) return;
try {
const blob = (await api.get(`/files/${m[1]}`, { responseType: 'blob' })) as unknown as Blob;
if (cancelled) return;
const url = URL.createObjectURL(blob);
urls.push(url);
img.src = url;
} catch {
/* keep original src */
}
}),
);
};
void run();
return () => {
cancelled = true;
urls.forEach((u) => URL.revokeObjectURL(u));
};
}, [html]);
return (
<div>
<div className="notice-html" ref={ref} dangerouslySetInnerHTML={{ __html: markup }} />
{attach.length ? (
<div style={{ marginTop: 24 }}>
<b></b>
<BizFileList files={attach} empty="没有附件" />
</div>
) : null}
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { Button, Table, Tag } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { Link, useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import { PageHeader, unwrapList } from './ResourcePage';
import { fmtCreated } from '../time';
type NoticeRow = {
id: string;
title: string;
kind?: string;
createdAt: string;
createdBy?: { displayName?: string };
};
export default function NoticePage() {
const nav = useNavigate();
const q = useQuery({
queryKey: ['/office/notices', 'inbox'],
queryFn: () => api.get('/office/notices'),
});
const items = unwrapList(q.data).items as unknown as NoticeRow[];
return (
<>
<PageHeader
title="公司公告"
description="查看发给你或全公司的通知和规章制度。点标题打开正文和附件。"
/>
<Table
className="ops-table"
rowKey="id"
loading={q.isLoading}
dataSource={items}
pagination={false}
locale={{ emptyText: '还没有公告。' }}
onRow={(r) => ({
style: { cursor: 'pointer' },
onClick: () => nav(`/office/notices/${r.id}`),
})}
columns={[
{
title: '类型',
width: 110,
render: (_: unknown, r: NoticeRow) =>
r.kind === 'POLICY' ? <Tag color="blue"></Tag> : <Tag color="purple"></Tag>,
},
{ title: '标题', dataIndex: 'title', ellipsis: true },
{ title: '发布人', width: 120, render: (_: unknown, r: NoticeRow) => r.createdBy?.displayName || '—' },
{ title: '时间', width: 170, render: (_: unknown, r: NoticeRow) => fmtCreated(r.createdAt) },
{
title: '详情',
width: 80,
render: (_: unknown, r: NoticeRow) => (
<Link to={`/office/notices/${r.id}`} onClick={(e) => e.stopPropagation()}>
<Button type="link"></Button>
</Link>
),
},
]}
/>
</>
);
}
+347
View File
@@ -0,0 +1,347 @@
import { Alert, Button, DatePicker, Form, Input, InputNumber, Popconfirm, Radio, Select, Space, Table, Tag, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom';
import { useMemo, useState } from 'react';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { useAuth } from '../store/auth';
import { FormDrawer } from './FormDrawer';
import { PageHeader, unwrapList } from './ResourcePage';
import { money } from './financeStatus';
import { fmtCreated, fmtDay } from '../time';
import { zh } from '../labels';
import { ApplyBucketBar, PROCESS_BUCKETS } from './ApplyBucketBar';
const KIND_LABEL: Record<string, string> = {
ASSET_BUY: '资产购置',
ASSET_USE: '资产领用',
VEHICLE: '用车',
};
const KIND_OPTIONS = [
{ value: 'ASSET_BUY', label: '资产购置' },
{ value: 'ASSET_USE', label: '资产领用' },
{ value: 'VEHICLE', label: '用车' },
];
type CatalogAsset = {
id: string;
name: string;
code?: string | null;
spec?: string | null;
kind: string;
status?: string;
usage?: string;
occupancies?: { id: string; holder: string }[];
};
type ApplyRow = {
id: string;
kind: string;
title: string;
reason?: string | null;
status: string;
assetKind?: string | null;
amount?: string | number | null;
destination?: string | null;
startAt?: string | null;
endAt?: string | null;
createdAt: string;
registeredAsset?: { name: string; code?: string | null; kind: string } | null;
project?: { projectNo: string; name: string } | null;
purchase?: { id: string; title: string; status: string } | null;
occupancy?: { id: string; status: string; assetName: string } | null;
inboundAsset?: { id: string; name: string; status: string; kind: string } | null;
applicant?: { displayName?: string };
};
const VEHICLE_NAME = /车|车辆|轿车|商务车|越野|皮卡|面包车/;
function assetLabel(a: CatalogAsset) {
const busy = a.occupancies?.length ? ` · ${a.occupancies[0].holder}在用` : '';
const pending = a.status === 'PENDING_IN' ? ' · 待入库' : '';
return `${a.name}${a.code ? `${a.code}` : ''}${busy}${pending}`;
}
export default function OfficeApplyPage() {
const qc = useQueryClient();
const roles = useAuth((s) => s.roles);
const canApprove = ['admin', 'owner', 'hr', 'biz_director'].some((r) => roles.includes(r));
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const [params] = useSearchParams();
const bucket = params.get('bucket') || '';
const kind = Form.useWatch('kind', form) as string | undefined;
const assetKind = Form.useWatch('assetKind', form) as string | undefined;
const catalogKind = kind === 'VEHICLE' ? 'FIXED' : assetKind;
const q = useQuery({
queryKey: ['/office/applies', 'mine', bucket],
queryFn: () => api.get(`/office/applies?scope=mine${bucket ? `&bucket=${bucket}` : ''}`),
});
const projects = useQuery({ queryKey: ['/projects'], queryFn: () => api.get('/projects') });
const catalog = useQuery({
queryKey: ['/registered-assets', catalogKind],
queryFn: () => api.get(`/registered-assets?kind=${catalogKind}`),
enabled: Boolean(catalogKind),
});
const items = unwrapList(q.data).items as ApplyRow[];
const projectOpts = (unwrapList(projects.data).items as { id: string; projectNo: string; name: string }[]).map((p) => ({
value: p.id,
label: `${p.projectNo} ${p.name}`,
}));
const assets = unwrapList(catalog.data).items as CatalogAsset[];
const assetOpts = useMemo(() => {
const usable = assets.filter((a) => a.status !== 'PENDING_IN');
const list =
kind === 'VEHICLE'
? usable
.filter((a) => a.usage === 'VEHICLE' || VEHICLE_NAME.test(`${a.name}${a.spec || ''}`))
.concat(usable.filter((a) => a.usage !== 'VEHICLE' && !VEHICLE_NAME.test(`${a.name}${a.spec || ''}`)))
: usable;
return list.map((a) => ({
value: a.id,
label: assetLabel(a),
disabled: kind !== 'ASSET_BUY' && Boolean(a.occupancies?.length),
}));
}, [assets, kind]);
const mut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/office/applies', body),
onSuccess: async () => {
message.success('已提交,负责人审批');
setOpen(false);
form.resetFields();
await qc.invalidateQueries({ queryKey: ['/office/applies'] });
},
onError: (e: Error) => message.error(e.message),
});
return (
<>
<PageHeader
title="办公申请"
description="购置、领用、用车在这里提交。购置通过后进入采购台账,领用/用车通过后占用对应台账资产。"
extra={
<ApplyBucketBar
basePath="/office/apply"
buckets={[...PROCESS_BUCKETS, { value: 'occupying', label: '占用中' }]}
extra={
<Button
type="primary"
onClick={() => {
form.resetFields();
form.setFieldsValue({ kind: 'ASSET_USE', assetKind: 'FIXED' });
setOpen(true);
}}
>
</Button>
}
/>
}
/>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="领用和用车必须先选数字资产或固定资产,再选台账里的具体资产。购置通过后会生成待入库台账和付款待办,到货后到资产管理确认入库。"
/>
<FormDrawer
title="办公申请"
open={open}
onClose={() => setOpen(false)}
okText="提交"
okLoading={mut.isPending}
onOk={async () => {
const v = await form.validateFields();
const range = v.range as [dayjs.Dayjs, dayjs.Dayjs] | undefined;
await mut.mutateAsync({
kind: v.kind,
title: v.title,
reason: v.reason,
assetKind: v.kind === 'VEHICLE' ? 'FIXED' : v.assetKind,
registeredAssetId: v.registeredAssetId,
amount: v.amount,
destination: v.destination,
projectId: v.projectId,
startAt: range?.[0] ? range[0].format('YYYY-MM-DD') : v.dueAt ? dayjs(v.dueAt).format('YYYY-MM-DD') : undefined,
endAt: range?.[1] ? range[1].format('YYYY-MM-DD') : v.dueAt ? dayjs(v.dueAt).format('YYYY-MM-DD') : undefined,
});
}}
>
<Form form={form} layout="vertical">
<Form.Item name="kind" label="申请类型" rules={[{ required: true }]}>
<Radio.Group
options={KIND_OPTIONS}
optionType="button"
onChange={() => form.setFieldsValue({ registeredAssetId: undefined, assetKind: form.getFieldValue('assetKind') || 'FIXED' })}
/>
</Form.Item>
{kind !== 'VEHICLE' ? (
<Form.Item name="assetKind" label="资产类型" rules={[{ required: true, message: '请先选择数字资产或固定资产' }]}>
<Radio.Group
optionType="button"
options={[
{ value: 'DIGITAL', label: '数字资产' },
{ value: 'FIXED', label: '固定资产' },
]}
onChange={() => form.setFieldsValue({ registeredAssetId: undefined })}
/>
</Form.Item>
) : null}
{kind === 'ASSET_USE' || kind === 'VEHICLE' ? (
<Form.Item
name="registeredAssetId"
label={kind === 'VEHICLE' ? '车辆(固定资产)' : '台账资产'}
rules={kind === 'ASSET_USE' ? [{ required: true, message: '请从台账选择资产' }] : undefined}
>
<Select
showSearch
optionFilterProp="label"
placeholder={kind === 'VEHICLE' ? '优先列出车辆用途的固定资产' : '先选类型,再选具体资产'}
options={assetOpts}
allowClear={kind === 'VEHICLE'}
/>
</Form.Item>
) : null}
{kind === 'ASSET_BUY' ? (
<>
<Form.Item name="title" label="购置内容" rules={[{ required: true }]}>
<Input placeholder="例如:摄影灯、渲染软件账号" />
</Form.Item>
<Form.Item name="amount" label="预估金额" rules={[{ required: true }]}>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
</Form.Item>
</>
) : null}
{kind === 'VEHICLE' ? (
<>
<Form.Item name="destination" label="目的地" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="range" label="用车时间" rules={[{ required: true }]}>
<DatePicker.RangePicker style={{ width: '100%' }} />
</Form.Item>
</>
) : null}
{kind === 'ASSET_USE' ? (
<Form.Item name="dueAt" label="预计交回">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
) : null}
<Form.Item name="projectId" label="关联项目">
<Select allowClear showSearch optionFilterProp="label" options={projectOpts} />
</Form.Item>
<Form.Item name="reason" label="事由" rules={[{ required: true }]}>
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</FormDrawer>
<Table
className="ops-table"
rowKey="id"
size="small"
loading={q.isLoading}
dataSource={items}
pagination={false}
scroll={{ x: 'max-content' }}
columns={[
{ title: '类型', width: 110, render: (_: unknown, r: ApplyRow) => KIND_LABEL[r.kind] || r.kind },
{ title: '事项', dataIndex: 'title', width: 200, ellipsis: true },
{
title: '资产',
width: 160,
render: (_: unknown, r: ApplyRow) =>
r.registeredAsset ? `${r.registeredAsset.name}${r.registeredAsset.code ? `${r.registeredAsset.code}` : ''}` : r.assetKind ? zh(r.assetKind) : '—',
},
{
title: '金额 / 行程',
width: 180,
render: (_: unknown, r: ApplyRow) => {
if (r.kind === 'ASSET_BUY') return money(r.amount);
if (r.kind === 'VEHICLE') {
return `${r.destination || '—'} ${r.startAt ? fmtDay(r.startAt) : ''}`.trim();
}
return r.endAt ? `交回 ${fmtDay(r.endAt)}` : '—';
},
},
{ title: '项目', width: 140, render: (_: unknown, r: ApplyRow) => r.project?.name || '—' },
{
title: '落地',
width: 140,
render: (_: unknown, r: ApplyRow) => {
if (r.inboundAsset) {
return (
<Link to={r.inboundAsset.kind === 'DIGITAL' ? '/asset/digital' : '/asset/fixed'}>
{r.inboundAsset.status === 'PENDING_IN' ? '待入库' : '已入库'}
</Link>
);
}
if (r.purchase) return <Link to="/project/purchases"></Link>;
if (r.occupancy) return <Link to="/project/assets">{r.occupancy.status === 'IN_USE' ? '占用中' : '已交回'}</Link>;
return '—';
},
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (v: string) => <Tag color={v === 'APPROVED' ? 'success' : v === 'REJECTED' ? 'error' : 'processing'}>{zh(v)}</Tag>,
},
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v: string) => fmtCreated(v) },
{
title: '操作',
width: 160,
render: (_: unknown, r: ApplyRow) => (
<Space>
{canApprove && r.status === 'PENDING' ? (
<>
<Button
type="link"
size="small"
onClick={async () => {
await api.post(`/office/applies/${r.id}/decide`, { result: 'APPROVED' });
message.success('已通过');
qc.invalidateQueries({ queryKey: ['/office/applies'] });
}}
>
</Button>
<Button
type="link"
size="small"
danger
onClick={async () => {
await api.post(`/office/applies/${r.id}/decide`, { result: 'REJECTED', comment: '驳回' });
message.success('已驳回');
qc.invalidateQueries({ queryKey: ['/office/applies'] });
}}
>
</Button>
</>
) : null}
{r.status === 'PENDING' ? (
<Popconfirm
title="确认删除这条申请?"
onConfirm={async () => {
await api.delete(`/office/applies/${r.id}`);
message.success('已删除');
qc.invalidateQueries({ queryKey: ['/office/applies'] });
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
) : null}
</Space>
),
},
]}
/>
</>
);
}
+355
View File
@@ -0,0 +1,355 @@
import { Button, Form, Input, Modal, Segmented, Space, Table, Tabs, Tag, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useSearchParams } from 'react-router-dom';
import { useState } from 'react';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { PageHeader } from './ResourcePage';
import { REVIEW_RESULT, statusMeta, tenderNo } from './bidStatus';
import { contractStatusMeta, officeItemPath } from './contractStatus';
import { EXPENSE_STATUS_META, LOAN_STATUS_META, expenseCategoryLabel, money } from './financeStatus';
import { HR_APPLY_LABEL, LEAVE_STATUS_META } from './hrStatus';
import { zh } from '../labels';
type Person = {
displayName?: string;
employee?: { name?: string; department?: { name?: string } };
} | null;
type Approval = {
id: string;
title: string;
bizType: string;
bizId?: string | null;
status: string;
createdAt: string;
bidCase?: {
id: string;
bidNo: string;
name: string;
status: string;
remark?: string | null;
summary?: string | null;
quoteAmount?: string | null;
createdBy?: Person;
party?: { name?: string } | null;
} | null;
contract?: {
id: string;
contractNo: string;
name: string;
status: string;
amount?: string | number;
remark?: string | null;
createdBy?: Person;
} | null;
expense?: {
id: string;
claimNo: string;
status: string;
amount?: string | number;
purpose?: string | null;
remark?: string | null;
expenseCategory?: string | null;
costType?: string;
applicant?: Person;
bidCase?: { name?: string; bidNo?: string; externalNo?: string } | null;
project?: { name?: string; projectNo?: string } | null;
} | null;
loan?: {
id: string;
loanNo: string;
status: string;
amount?: string | number;
purpose?: string | null;
remark?: string | null;
costType?: string;
applicant?: Person;
bidCase?: { name?: string; bidNo?: string; externalNo?: string } | null;
project?: { name?: string; projectNo?: string } | null;
} | null;
sealRequest?: { id: string; sealType: string; status: string; reason?: string; applicant?: Person } | null;
officeApply?: {
id: string;
kind: string;
title: string;
status: string;
amount?: string | number | null;
reason?: string | null;
destination?: string | null;
assetKind?: string | null;
applicant?: Person;
registeredAsset?: { name: string; code?: string | null } | null;
} | null;
leaveRequest?: {
id: string;
kind: string;
status: string;
days?: number | string | null;
reason?: string | null;
startAt?: string | null;
endAt?: string | null;
comment?: string | null;
employee?: { name?: string; department?: { name?: string } | null } | null;
} | null;
};
function personName(p?: Person) {
return p?.displayName || p?.employee?.name || '—';
}
function personDept(p?: Person) {
return p?.employee?.department?.name || '—';
}
function approvalApplicant(r: Approval) {
return (
r.leaveRequest?.employee?.name ||
personName(r.expense?.applicant || r.loan?.applicant || r.contract?.createdBy || r.bidCase?.createdBy || r.sealRequest?.applicant || r.officeApply?.applicant)
);
}
function approvalDept(r: Approval) {
return (
r.leaveRequest?.employee?.department?.name ||
personDept(r.expense?.applicant || r.loan?.applicant || r.contract?.createdBy || r.bidCase?.createdBy || r.sealRequest?.applicant || r.officeApply?.applicant)
);
}
function approvalAmount(r: Approval) {
const n = r.expense?.amount ?? r.loan?.amount ?? r.contract?.amount ?? r.bidCase?.quoteAmount ?? r.officeApply?.amount;
return n === undefined || n === null || n === '' ? '—' : money(n);
}
function linkedName(r: Approval) {
return (
r.expense?.bidCase?.name ||
r.expense?.project?.name ||
r.loan?.bidCase?.name ||
r.loan?.project?.name ||
r.bidCase?.name ||
r.contract?.name ||
''
);
}
function approvalTitle(r: Approval) {
if (r.expense) return r.expense.purpose || linkedName(r) || r.expense.claimNo;
if (r.loan) return r.loan.purpose || linkedName(r) || r.loan.loanNo;
if (r.contract) return r.contract.name || r.contract.contractNo;
if (r.bidCase) return r.bidCase.name;
if (r.sealRequest) return r.sealRequest.reason || '用印';
if (r.officeApply) return r.officeApply.title;
return r.title;
}
function approvalCategory(r: Approval) {
if (r.leaveRequest) return HR_APPLY_LABEL[r.leaveRequest.kind] || zh(r.leaveRequest.kind);
if (r.expense) return expenseCategoryLabel(r.expense.expenseCategory);
if (r.loan) return '借支';
if (r.officeApply) return zh(r.officeApply.kind);
return '—';
}
function approvalPurpose(r: Approval) {
if (r.leaveRequest) {
const hours = r.leaveRequest.kind === 'OVERTIME';
const span = r.leaveRequest.days != null && r.leaveRequest.days !== '' ? `${r.leaveRequest.days}${hours ? ' 小时' : ' 天'}` : '';
return [r.leaveRequest.reason, span].filter(Boolean).join(' · ') || '—';
}
if (r.expense) return r.expense.purpose || linkedName(r) || '—';
if (r.loan) return r.loan.purpose || '—';
if (r.officeApply) return r.officeApply.reason || r.officeApply.destination || r.officeApply.registeredAsset?.name || '—';
return linkedName(r) || '—';
}
function approvalRemark(r: Approval) {
const raw = r.leaveRequest?.comment || r.expense?.remark || r.loan?.remark || r.contract?.remark || r.bidCase?.remark || '';
return raw.trim() || '—';
}
function approvalNo(r: Approval) {
if (r.expense) return r.expense.claimNo;
if (r.loan) return r.loan.loanNo;
if (r.contract) return r.contract.contractNo;
if (r.bidCase) return tenderNo(r.bidCase);
return '—';
}
export default function OfficeApprovalsPage() {
const qc = useQueryClient();
const [form] = Form.useForm();
const [params] = useSearchParams();
const [kind, setKind] = useState(params.get('biz') === 'loan' ? 'LOAN_APPROVAL' : 'all');
const q = useQuery({
queryKey: ['/office/approvals'],
queryFn: () => api.get('/office/approvals'),
});
const items = ((q.data?.data ?? []) as Approval[]).filter((r) => (kind === 'all' ? true : r.bizType === kind));
const pending = items.filter((r) => r.status === 'PENDING');
const done = items.filter((r) => r.status !== 'PENDING');
const mut = useMutation({
mutationFn: ({ id, result, comment }: { id: string; result: 'APPROVED' | 'REJECTED'; comment?: string }) =>
api.post(`/office/approvals/${id}/decide`, { result, comment }),
onSuccess: async () => {
message.success('已办理');
await qc.invalidateQueries();
},
onError: (e: Error) => message.error(e.message),
});
const decide = (row: Approval, result: 'APPROVED' | 'REJECTED') => {
if (result === 'REJECTED') {
form.resetFields();
Modal.confirm({
title: '驳回意见',
content: (
<Form form={form} layout="vertical" style={{ marginTop: 12 }}>
<Form.Item name="comment" label="原因" rules={[{ required: true, message: '驳回请填写意见' }]}>
<Input.TextArea rows={3} />
</Form.Item>
</Form>
),
okButtonProps: { danger: true },
okText: '确认驳回',
onOk: async () => {
const v = await form.validateFields();
await mut.mutateAsync({ id: row.id, result: 'REJECTED', comment: v.comment });
},
});
return;
}
mut.mutate({ id: row.id, result: 'APPROVED' });
};
const columns = [
{ title: '标题', width: 200, ellipsis: true, render: (_: unknown, r: Approval) => approvalTitle(r) },
{ title: '类型', dataIndex: 'bizType', width: 90, render: (v: string) => zh(v) },
{ title: '编号', width: 150, render: (_: unknown, r: Approval) => approvalNo(r) },
{ title: '申请人', width: 90, render: (_: unknown, r: Approval) => approvalApplicant(r) },
{ title: '部门', width: 110, render: (_: unknown, r: Approval) => approvalDept(r) },
{ title: '金额', width: 110, render: (_: unknown, r: Approval) => approvalAmount(r) },
{ title: '类别', width: 80, render: (_: unknown, r: Approval) => approvalCategory(r) },
{ title: '用途', width: 200, ellipsis: true, render: (_: unknown, r: Approval) => approvalPurpose(r) },
{ title: '备注', width: 160, ellipsis: true, render: (_: unknown, r: Approval) => approvalRemark(r) },
{
title: '事项状态',
width: 100,
render: (_: unknown, r: Approval) => {
if (r.leaveRequest) {
const s = LEAVE_STATUS_META[r.leaveRequest.status] || { color: 'default', text: r.leaveRequest.status };
return <Tag color={s.color}>{s.text}</Tag>;
}
if (r.expense) {
const s = EXPENSE_STATUS_META[r.expense.status] || { color: 'default', text: r.expense.status };
return <Tag color={s.color}>{s.text}</Tag>;
}
if (r.loan) {
const s = LOAN_STATUS_META[r.loan.status] || { color: 'default', text: r.loan.status };
return <Tag color={s.color}>{s.text}</Tag>;
}
if (r.contract) {
const s = contractStatusMeta(r.contract.status);
return <Tag color={s.color}>{s.text}</Tag>;
}
if (!r.bidCase) return '—';
const s = statusMeta(r.bidCase.status);
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '审批',
dataIndex: 'status',
width: 90,
render: (v: string) => {
const s = REVIEW_RESULT[v] || { color: 'default', text: v };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作',
width: 180,
render: (_: unknown, r: Approval) => (
<Space>
{officeItemPath(r) ? <Link to={officeItemPath(r)!}></Link> : null}
{r.status === 'PENDING' ? (
<>
<Button type="link" size="small" onClick={() => decide(r, 'APPROVED')}>
</Button>
<Button type="link" size="small" danger onClick={() => decide(r, 'REJECTED')}>
</Button>
</>
) : null}
</Space>
),
},
];
return (
<>
<PageHeader
title="待我审批"
description="投标会签、合同、报销、借支、人事申请、办公申请都在这里办理。人事加班/请假发给汇报对象和部门总监,不会群发给所有管理员。"
extra={
<Segmented
value={kind}
onChange={(v) => setKind(String(v))}
options={[
{ label: '全部', value: 'all' },
{ label: '借支', value: 'LOAN_APPROVAL' },
{ label: '报销', value: 'EXPENSE_APPROVAL' },
{ label: '合同', value: 'CONTRACT_APPROVAL' },
{ label: '办公申请', value: 'OFFICE_APPLY' },
{ label: '人事申请', value: 'HR_LEAVE' },
]}
/>
}
/>
<Tabs
items={[
{
key: 'pending',
label: `待办(${pending.length}`,
children: (
<Table
className="ops-table"
rowKey="id"
loading={q.isLoading}
dataSource={pending}
scroll={{ x: 1680 }}
locale={{ emptyText: '没有待办审批。' }}
columns={columns}
pagination={{ defaultPageSize: 10, showSizeChanger: true, showTotal: (n) => `${n}` }}
/>
),
},
{
key: 'done',
label: `已办(${done.length}`,
children: (
<Table
className="ops-table"
rowKey="id"
loading={q.isLoading}
dataSource={done}
scroll={{ x: 1680 }}
locale={{ emptyText: '还没有已办记录。' }}
columns={columns}
pagination={{ defaultPageSize: 10, showSizeChanger: true, showTotal: (n) => `${n}` }}
/>
),
},
]}
/>
</>
);
}
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useState } from 'react';
import { Alert, Button, Form, Input, Segmented, Select, Space, Typography, message } from 'antd';
import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
import { PageHeader } from './ResourcePage';
import { topLevelSelectOptions } from '../deptTree';
type Staff = { id: string; name: string; department?: string; mobile?: string | null };
type Dept = { id: string; name: string; parentId?: string | null; code?: string };
type Target = 'dept' | 'people' | 'extra';
function previewSms(sign: string, template: string, content: string) {
const sample = content.trim() || '……';
const body = template
.replace(/\$\{name\}/g, '同事')
.replace(/\$\{content\}/g, sample)
.replace(/\$\{others\}/g, sample)
.replace(/\$\{project_name\}/g, sample)
.replace(/\$\{task_name\}/g, sample)
.replace(/\$\{[^}]+\}/g, '……');
return `${sign}${body}`;
}
export default function OfficeSmsPage() {
const [form] = Form.useForm();
const [target, setTarget] = useState<Target>('dept');
const opt = useQuery({ queryKey: ['/sms/office-options'], queryFn: () => api.get('/sms/office-options') });
const staff = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') });
const depts = useQuery({ queryKey: ['/departments'], queryFn: () => api.get('/departments') });
const info = (opt.data?.data ?? {}) as {
configured?: boolean;
defaultSign?: string;
officeTemplate?: string;
templateContent?: string;
contentLimit?: number;
presets?: { code: string; name: string }[];
note?: string;
};
const people = ((staff.data as { data?: Staff[] })?.data || []) as Staff[];
const departments = ((depts.data as { data?: Dept[] })?.data || []).filter((d) => d.code !== 'ROOT');
const sign = info.defaultSign || '江苏风影随行科技';
const template = info.templateContent || '办公通知:${content}';
const limit = info.contentLimit || 35;
const content = Form.useWatch('content', form) as string | undefined;
useEffect(() => {
if (!info.officeTemplate) return;
form.setFieldsValue({ templateCode: info.officeTemplate || info.presets?.[0]?.code });
}, [info.officeTemplate, form]);
const send = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/sms/send', body),
onSuccess: (res) => {
const n = (res as { data?: { sent?: number } })?.data?.sent;
message.success(n ? `已提交发送(${n} 个号码)` : '已提交发送');
form.setFieldsValue({ content: '', extraPhones: '', departmentIds: undefined, userIds: undefined });
},
onError: (e: Error) => message.error(e.message),
});
return (
<>
<PageHeader
title="发送工作通知短信"
description="先选发给谁:一个部门、点名同事,或系统外手机号。一次只发一种。"
/>
{info.configured === false ? (
<Alert type="warning" showIcon style={{ marginBottom: 16 }} message={info.note || '短信通道未配置'} />
) : (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="你只填中间那句通知。发出去会套进「办公通知」模板,前面自动带公司签名。"
/>
)}
<Form
form={form}
layout="vertical"
style={{ maxWidth: 640 }}
initialValues={{ templateCode: info.officeTemplate || info.presets?.[0]?.code }}
onFinish={(v) => {
const body: Record<string, unknown> = {
signName: sign,
templateCode: info.officeTemplate,
content: v.content,
};
if (target === 'dept') body.departmentIds = v.departmentIds;
else if (target === 'people') body.userIds = v.userIds;
else body.extraPhones = v.extraPhones;
send.mutate(body);
}}
>
<div style={{ marginBottom: 16 }}>
<Segmented
value={target}
onChange={(v) => setTarget(v as Target)}
options={[
{ label: '按部门', value: 'dept' },
{ label: '按同事', value: 'people' },
{ label: '系统外号码', value: 'extra' },
]}
/>
</div>
{target === 'dept' ? (
<Form.Item
name="departmentIds"
label="发给哪个部门"
extra="选中的部门里,在职且有手机号的人都会收到。选上级时下级也会收到。"
rules={[{ required: true, type: 'array', min: 1, message: '请选择部门' }]}
>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder="选择部门"
options={topLevelSelectOptions(departments)}
/>
</Form.Item>
) : null}
{target === 'people' ? (
<Form.Item
name="userIds"
label="发给哪些同事"
extra="只发给你点名的人,可多选。"
rules={[{ required: true, type: 'array', min: 1, message: '请选择同事' }]}
>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
placeholder="搜索姓名"
options={people.map((p) => ({
value: p.id,
label: `${p.name}${p.department ? `${p.department}` : ''}${p.mobile ? ` ${p.mobile}` : ''}`,
}))}
/>
</Form.Item>
) : null}
{target === 'extra' ? (
<Form.Item
name="extraPhones"
label="系统外手机号"
extra="不在通讯录里的号码,用逗号、空格或换行分开。"
rules={[{ required: true, message: '请填写手机号' }]}
>
<Input.TextArea rows={3} placeholder="例如 1380000000013900000000" />
</Form.Item>
) : null}
<Form.Item
name="content"
label="通知内容"
extra={`只填要告诉对方的那句话,大约 ${limit} 字。不要写成完整短信。`}
rules={[
{ required: true, message: '请填写通知内容' },
{ max: limit, message: `运营商模板最多 ${limit}` },
]}
>
<Input.TextArea rows={3} placeholder="例如:请明日上午9点到会议室开会" maxLength={limit} showCount />
</Form.Item>
<div
style={{
padding: '12px 14px',
background: 'var(--ant-color-fill-alter, #fafafa)',
border: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
borderRadius: 8,
marginBottom: 16,
}}
>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
<div style={{ marginTop: 6, lineHeight: 1.6 }}>{previewSms(sign, template, content || '')}</div>
</div>
<Form.Item label="短信签名">
<Typography.Text>{sign}</Typography.Text>
<div style={{ color: 'var(--mute)', fontSize: 12, marginTop: 4 }}>
</div>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={send.isPending} disabled={info.configured === false}>
</Button>
</Space>
</Form.Item>
</Form>
</>
);
}
+135
View File
@@ -0,0 +1,135 @@
import { Space, Table, Tag } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { PageHeader } from './ResourcePage';
import { statusMeta } from './bidStatus';
import { contractStatusMeta } from './contractStatus';
import { officeItemPath, projectStatusMeta } from './projectStatus';
import { zh } from '../labels';
const TODO_STATUS: Record<string, { color: string; text: string }> = {
OPEN: { color: 'processing', text: '待办' },
DONE: { color: 'success', text: '已办' },
CANCELLED: { color: 'default', text: '已取消' },
};
type Todo = {
id: string;
title: string;
bizType: string;
bizId?: string | null;
status: string;
dueAt?: string | null;
createdAt: string;
bidCase?: { id: string; bidNo: string; name: string; status: string; remark?: string | null } | null;
contract?: { id: string; contractNo: string; name: string; status: string; remark?: string | null } | null;
expense?: { id: string; claimNo: string; status: string; amount?: string | number; remark?: string | null; applicant?: { displayName?: string } | null } | null;
loan?: { id: string; loanNo: string; status: string; amount?: string | number; purpose?: string | null; applicant?: { displayName?: string } | null } | null;
project?: { id: string; projectNo: string; name: string; status: string } | null;
sealRequest?: { id: string; sealType: string; status: string; reason?: string; applicant?: { displayName?: string } | null } | null;
borrow?: { id: string; itemName: string; status: string; borrower?: string } | null;
officeApply?: { id: string; kind: string; title: string; status: string } | null;
};
export default function OfficeTodosPage() {
const q = useQuery({
queryKey: ['/office/todos'],
queryFn: () => api.get('/office/todos'),
});
const items = (q.data?.data ?? []) as Todo[];
return (
<>
<PageHeader title="待办事项" description="取标、制标、印封、开标反馈、完善合同草稿、报销冲账等事务。开标结果和投标费用在「开标反馈」里登记。审批类请到「待我审批」。" />
<Table
className="ops-table"
rowKey="id"
loading={q.isLoading}
dataSource={items}
scroll={{ x: 'max-content' }}
tableLayout="fixed"
columns={[
{ title: '标题', dataIndex: 'title', width: 240, ellipsis: true },
{ title: '类型', dataIndex: 'bizType', width: 120, render: (v: string) => zh(v) },
{
title: '编号',
width: 150,
render: (_: unknown, r: Todo) =>
r.expense?.claimNo ||
r.loan?.loanNo ||
r.contract?.contractNo ||
r.project?.projectNo ||
r.sealRequest?.sealType ||
r.borrow?.itemName ||
r.bidCase?.bidNo ||
'—',
},
{
title: '相关人',
width: 90,
render: (_: unknown, r: Todo) =>
r.expense?.applicant?.displayName || r.loan?.applicant?.displayName || r.sealRequest?.applicant?.displayName || r.borrow?.borrower || '—',
},
{
title: '说明',
ellipsis: true,
render: (_: unknown, r: Todo) =>
r.expense?.remark || r.loan?.purpose || r.contract?.remark || r.bidCase?.remark || r.sealRequest?.reason || '—',
},
{
title: '事项状态',
width: 110,
render: (_: unknown, r: Todo) => {
if (r.contract) {
const s = contractStatusMeta(r.contract.status);
return <Tag color={s.color}>{s.text}</Tag>;
}
if (r.project) {
const s = projectStatusMeta(r.project.status);
return <Tag color={s.color}>{s.text}</Tag>;
}
if (!r.bidCase) return '—';
const s = statusMeta(r.bidCase.status);
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '任务状态',
dataIndex: 'status',
width: 90,
render: (v: string) => {
const s = TODO_STATUS[v] || { color: 'default', text: v };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '截止',
dataIndex: 'dueAt',
width: 170,
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '—'),
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '—'),
},
{
title: '操作',
width: 90,
render: (_: unknown, r: Todo) => {
const path = officeItemPath(r);
return path ? (
<Space>
<Link to={path}>{r.status === 'OPEN' ? '办理' : '查看'}</Link>
</Space>
) : null;
},
},
]}
/>
</>
);
}
+351
View File
@@ -0,0 +1,351 @@
import { Alert, Button, Card, Col, DatePicker, Form, Input, Row, Select, Steps, Typography, message } from 'antd';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { useAuth } from '../store/auth';
import AmbientBackground from '../components/AmbientBackground';
import { EDUCATION_LABEL, GENDER_LABEL, ID_TYPE_LABEL, MARITAL_LABEL } from './hrStatus';
import { IdScanUpload } from './FileAttachments';
import { SiteFooter } from '../components/SiteFooter';
const opts = (map: Record<string, string>) => Object.entries(map).map(([value, label]) => ({ value, label }));
function firstError(e: unknown) {
if (e && typeof e === 'object' && 'errorFields' in e) {
const msg = (e as { errorFields?: { errors?: string[] }[] }).errorFields?.[0]?.errors?.[0];
if (msg) return msg;
}
if (e instanceof Error && e.message) return e.message;
return '请检查填写内容';
}
function pane(active: boolean): CSSProperties {
return { display: active ? 'block' : 'none' };
}
export default function OnboardPage() {
const navigate = useNavigate();
const auth = useAuth();
const [pwdForm] = Form.useForm();
const [basicForm] = Form.useForm();
const [eduForm] = Form.useForm();
const [payForm] = Form.useForm();
const hasEmployee = Boolean(auth.employeeId);
const last = hasEmployee ? 4 : 0;
const [cur, setCur] = useState(() => {
if (auth.mustChangePassword) return 0;
if (hasEmployee && (!auth.idPhotoFileId || !auth.idFrontFileId || !auth.idBackFileId)) return 2;
return hasEmployee ? 1 : 0;
});
const [loading, setLoading] = useState(false);
const filled = useRef(false);
const steps = hasEmployee
? [{ title: '修改密码' }, { title: '基本信息' }, { title: '证件影像' }, { title: '教育信息' }, { title: '财务信息' }]
: [{ title: '修改密码' }];
const fillEmployee = async () => {
if (filled.current || !hasEmployee) return;
filled.current = true;
const me = await api.get('/auth/me');
const emp = (me.data?.employee || {}) as Record<string, string | null>;
basicForm.setFieldsValue({
name: emp.name,
gender: emp.gender,
mobile: emp.mobile,
email: emp.email,
birthday: emp.birthday ? dayjs(emp.birthday) : undefined,
ethnicity: emp.ethnicity,
maritalStatus: emp.maritalStatus,
emergencyName: emp.emergencyName,
emergencyPhone: emp.emergencyPhone,
hukouAddress: emp.hukouAddress,
liveAddress: emp.liveAddress,
idType: emp.idType || 'ID_CARD',
idNo: emp.idNo,
idIssuer: emp.idIssuer,
idValidFrom: emp.idValidFrom ? dayjs(emp.idValidFrom) : undefined,
idValidTo: emp.idValidTo ? dayjs(emp.idValidTo) : undefined,
});
eduForm.setFieldsValue({
education: emp.education,
school: emp.school,
major: emp.major,
graduatedAt: emp.graduatedAt ? dayjs(emp.graduatedAt) : undefined,
});
payForm.setFieldsValue({ bankName: emp.bankName, bankAccount: emp.bankAccount });
};
useEffect(() => {
if (hasEmployee) void fillEmployee();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasEmployee]);
if (auth.roles.includes('admin') || !auth.needsOnboard) {
return <Navigate to="/workbench" replace />;
}
const refreshFiles = async () => {
await auth.loadSession();
};
const finishProfile = async () => {
if (!hasEmployee) {
await api.patch('/auth/onboard-profile', {});
await auth.loadSession();
message.success('已完成初次设置');
navigate('/workbench', { replace: true });
return;
}
if (!auth.idPhotoFileId) throw new Error('请上传证件照');
if (!auth.idFrontFileId) throw new Error('请上传身份证正面');
if (!auth.idBackFileId) throw new Error('请上传身份证反面');
const basic = await basicForm.validateFields();
const edu = await eduForm.validateFields();
const pay = await payForm.validateFields();
await api.patch('/auth/onboard-profile', {
...basic,
...edu,
...pay,
birthday: basic.birthday ? dayjs(basic.birthday).format('YYYY-MM-DD') : undefined,
idValidFrom: basic.idValidFrom ? dayjs(basic.idValidFrom).format('YYYY-MM-DD') : undefined,
idValidTo: basic.idValidTo ? dayjs(basic.idValidTo).format('YYYY-MM-DD') : undefined,
graduatedAt: edu.graduatedAt ? dayjs(edu.graduatedAt).format('YYYY-MM-DD') : undefined,
});
await auth.loadSession();
message.success('档案已提交,岗位等信息由专人补全');
navigate('/workbench', { replace: true });
};
const next = async () => {
setLoading(true);
try {
if (cur === 0) {
if (auth.mustChangePassword) {
const v = await pwdForm.validateFields();
if (v.newPassword !== v.confirm) {
throw new Error('两次输入的新密码不一致');
}
await api.patch('/auth/password', { oldPassword: v.oldPassword, newPassword: v.newPassword });
message.success('密码已更新');
await auth.loadSession();
}
if (!hasEmployee) {
await finishProfile();
return;
}
await fillEmployee();
setCur(1);
return;
}
if (cur === 1) {
await basicForm.validateFields();
setCur(2);
return;
}
if (cur === 2) {
await refreshFiles();
const me = useAuth.getState();
if (!me.idPhotoFileId) throw new Error('请上传证件照');
if (!me.idFrontFileId) throw new Error('请上传身份证正面');
if (!me.idBackFileId) throw new Error('请上传身份证反面');
setCur(3);
return;
}
if (cur === 3) {
await eduForm.validateFields();
setCur(4);
return;
}
await finishProfile();
} catch (e) {
message.error(firstError(e));
} finally {
setLoading(false);
}
};
return (
<div className="ops-login" style={{ alignItems: 'flex-start', paddingTop: 48 }}>
<AmbientBackground />
<div style={{ width: 'min(720px, 94vw)', margin: '0 auto', position: 'relative', flex: 1 }}>
<Card>
<Typography.Title level={4} style={{ marginTop: 0 }}>
</Typography.Title>
<Typography.Paragraph type="secondary">
</Typography.Paragraph>
<Steps current={cur} items={steps} size="small" style={{ marginBottom: 24 }} />
<div style={pane(cur === 0)}>
<Form form={pwdForm} layout="vertical" preserve>
{auth.mustChangePassword ? (
<>
<Alert type="warning" showIcon style={{ marginBottom: 16 }} message="系统管理员以外的账号首次登录必须修改密码。" />
<Form.Item name="oldPassword" label="当前密码" rules={[{ required: true, message: '请输入当前密码' }]}>
<Input.Password />
</Form.Item>
<Form.Item name="newPassword" label="新密码" rules={[{ required: true, min: 6, message: '新密码至少 6 位' }]}>
<Input.Password />
</Form.Item>
<Form.Item name="confirm" label="确认新密码" rules={[{ required: true, message: '请再次输入新密码' }]}>
<Input.Password />
</Form.Item>
</>
) : (
<Alert type="success" showIcon message="密码已修改,点击下一步继续。" />
)}
</Form>
</div>
{hasEmployee ? (
<>
<div style={pane(cur === 1)}>
<Form form={basicForm} layout="vertical" preserve>
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请填写姓名' }]}>
<Input />
</Form.Item>
<Form.Item name="gender" label="性别" rules={[{ required: true, message: '请选择性别' }]}>
<Select options={opts(GENDER_LABEL)} />
</Form.Item>
<Form.Item name="mobile" label="手机号码" rules={[{ required: true, message: '请填写手机号码' }]}>
<Input />
</Form.Item>
<Form.Item name="email" label="邮箱">
<Input />
</Form.Item>
<Form.Item name="birthday" label="生日">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="ethnicity" label="民族">
<Input />
</Form.Item>
<Form.Item name="maritalStatus" label="婚姻状态">
<Select allowClear options={opts(MARITAL_LABEL)} />
</Form.Item>
<Form.Item name="emergencyName" label="紧急联系人">
<Input />
</Form.Item>
<Form.Item name="emergencyPhone" label="紧急联系电话">
<Input />
</Form.Item>
<Form.Item name="hukouAddress" label="户籍地址">
<Input />
</Form.Item>
<Form.Item name="liveAddress" label="居住地址">
<Input />
</Form.Item>
<Typography.Title level={5}></Typography.Title>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="idType" label="证件类型" rules={[{ required: true, message: '请选择证件类型' }]}>
<Select options={opts(ID_TYPE_LABEL)} placeholder="如身份证、护照、港澳台通行证" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="idNo" label="证件号码" rules={[{ required: true, message: '请填写证件号码' }]}>
<Input />
</Form.Item>
</Col>
<Col span={24}>
<Form.Item name="idIssuer" label="发证机关" rules={[{ required: true, message: '请填写发证机关' }]}>
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="idValidFrom" label="有效期起" rules={[{ required: true, message: '请选择有效期起' }]}>
<DatePicker style={{ width: '100%' }} placeholder="请选择日期" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="idValidTo" label="有效期止" rules={[{ required: true, message: '请选择有效期止' }]}>
<DatePicker style={{ width: '100%' }} placeholder="请选择日期" />
</Form.Item>
</Col>
</Row>
</Form>
</div>
<div style={pane(cur === 2)}>
<Alert type="warning" showIcon style={{ marginBottom: 16 }} message="必须上传证件照、身份证正面和反面,才能进入下一步。" />
<Form layout="vertical">
<Form.Item label="证件照" required>
<IdScanUpload
label="证件照"
bizType="USER_ID_PHOTO"
bizId={auth.id}
fileId={auth.idPhotoFileId}
onDone={refreshFiles}
/>
</Form.Item>
<Form.Item label="身份证正面" required>
<IdScanUpload
label="身份证正面"
bizType="EMPLOYEE_ID_FRONT"
bizId={auth.employeeId || ''}
fileId={auth.idFrontFileId}
onDone={refreshFiles}
/>
</Form.Item>
<Form.Item label="身份证反面" required>
<IdScanUpload
label="身份证反面"
bizType="EMPLOYEE_ID_BACK"
bizId={auth.employeeId || ''}
fileId={auth.idBackFileId}
onDone={refreshFiles}
/>
</Form.Item>
</Form>
</div>
<div style={pane(cur === 3)}>
<Form form={eduForm} layout="vertical" preserve>
<Form.Item name="education" label="学历" rules={[{ required: true, message: '请选择学历' }]}>
<Select options={opts(EDUCATION_LABEL)} />
</Form.Item>
<Form.Item name="school" label="毕业院校">
<Input />
</Form.Item>
<Form.Item name="major" label="专业">
<Input />
</Form.Item>
<Form.Item name="graduatedAt" label="毕业日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Form>
</div>
<div style={pane(cur === 4)}>
<Form form={payForm} layout="vertical" preserve>
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="请填写南京银行卡信息" />
<Form.Item
name="bankName"
label="开户银行"
extra="请填写南京银行卡开户行"
rules={[{ required: true, message: '请填写南京银行卡开户行' }]}
>
<Input placeholder="例如:南京银行某某支行" />
</Form.Item>
<Form.Item
name="bankAccount"
label="银行账号"
extra="请填写南京银行卡号"
rules={[{ required: true, message: '请填写南京银行卡号' }]}
>
<Input />
</Form.Item>
</Form>
</div>
</>
) : null}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
{cur > 0 && hasEmployee ? (
<Button onClick={() => setCur((s) => Math.max(0, s - 1))}></Button>
) : null}
<Button type="primary" loading={loading} onClick={() => void next()}>
{cur === last ? '完成' : '下一步'}
</Button>
</div>
</Card>
</div>
<SiteFooter />
</div>
);
}
File diff suppressed because it is too large Load Diff
+195
View File
@@ -0,0 +1,195 @@
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Tree, Typography, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState, type Key } from 'react';
import { api } from '../api/client';
import { useAuth } from '../store/auth';
import { PageHeader } from './ResourcePage';
import { canHr } from '../access';
import { DEPT_TYPE_LABEL } from './hrStatus';
type Dept = {
id: string;
name: string;
code: string;
deptType: string;
parentId?: string | null;
parent?: { name: string } | null;
categoryCode?: string | null;
sortNo: number;
_count?: { employees: number; children: number };
};
function unwrapItems<T>(data: unknown): T[] {
const body = (data as { data?: unknown })?.data ?? data;
if (Array.isArray(body)) return body as T[];
if (body && typeof body === 'object' && 'items' in body) return ((body as { items: T[] }).items ?? []) as T[];
return [];
}
export default function OrganizationPage() {
const qc = useQueryClient();
const roles = useAuth((s) => s.roles);
const depts = useQuery({ queryKey: ['/departments'], queryFn: () => api.get('/departments') });
const items = unwrapItems<Dept>(depts.data);
const [selected, setSelected] = useState<string>();
const [form] = Form.useForm();
const createMut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/departments', body),
onError: (e: Error) => message.error(e.message),
});
const patchMut = useMutation({
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) => api.patch(`/departments/${id}`, body),
onError: (e: Error) => message.error(e.message),
});
const delMut = useMutation({
mutationFn: (id: string) => api.delete(`/departments/${id}`),
onError: (e: Error) => message.error(e.message),
});
const apply = (id?: string, asNewUnder?: string) => {
setSelected(id);
if (!id) {
form.resetFields();
form.setFieldsValue({ deptType: 'TECH', sortNo: 99, parentId: asNewUnder });
return;
}
const row = items.find((d) => d.id === id);
form.setFieldsValue({
name: row?.name,
code: row?.code,
deptType: row?.deptType,
parentId: row?.parentId || undefined,
categoryCode: row?.categoryCode,
sortNo: row?.sortNo ?? 0,
});
};
const treeData = useMemo(() => {
const byParent = new Map<string, Dept[]>();
const roots: Dept[] = [];
const ids = new Set(items.map((d) => d.id));
for (const d of items) {
const pid = d.parentId ? String(d.parentId) : '';
if (pid && ids.has(pid)) {
const list = byParent.get(pid) || [];
list.push(d);
byParent.set(pid, list);
} else roots.push(d);
}
const walk = (d: Dept): { key: string; title: string; children: unknown[] } => ({
key: d.id,
title: `${d.name}${DEPT_TYPE_LABEL[d.deptType] || d.deptType} · ${d._count?.employees ?? 0}人)`,
children: (byParent.get(d.id) || []).sort((a, b) => a.sortNo - b.sortNo).map(walk),
});
return roots.sort((a, b) => a.sortNo - b.sortNo).map(walk);
}, [items]);
const save = async () => {
const v = await form.validateFields();
if (selected) {
await patchMut.mutateAsync({ id: selected, body: v });
message.success('部门已保存');
} else {
const saved = (await createMut.mutateAsync(v)) as { data?: { id?: string } };
message.success('部门已创建');
await qc.invalidateQueries({ queryKey: ['/departments'] });
apply(saved.data?.id);
return;
}
await qc.invalidateQueries({ queryKey: ['/departments'] });
};
const remove = () => {
if (!selected) return;
const name = items.find((d) => d.id === selected)?.name;
Modal.confirm({
title: '删除部门',
content: `确认删除「${name || selected}」?下级和在职员工需先处理完。`,
okButtonProps: { danger: true },
onOk: async () => {
await delMut.mutateAsync(selected);
message.success('已删除');
form.resetFields();
setSelected(undefined);
await qc.invalidateQueries({ queryKey: ['/departments'] });
},
});
};
return (
<>
<PageHeader
title="组织管理"
description="维护公司组织与部门。人员档案、权限和审批按部门使用;人事里只管员工档案。"
/>
<div className="org-page">
<Card
size="small"
title="组织树"
loading={depts.isLoading}
extra={
canHr(roles) ? (
<Space>
<Button size="small" onClick={() => apply()}>
</Button>
<Button size="small" disabled={!selected} onClick={() => apply(undefined, selected)}>
</Button>
</Space>
) : null
}
>
{treeData.length ? (
<Tree
defaultExpandAll
selectedKeys={selected ? [selected] : []}
treeData={treeData as never}
onSelect={(keys: Key[]) => apply(keys[0] ? String(keys[0]) : undefined)}
/>
) : (
<Typography.Paragraph type="secondary"></Typography.Paragraph>
)}
</Card>
<Card size="small" title={selected ? '编辑部门' : '新建部门'}>
<Form form={form} layout="vertical">
<Form.Item name="name" label="部门名称" rules={[{ required: true, message: '请填写名称' }]}>
<Input placeholder="如 软件研发部门" />
</Form.Item>
<Form.Item name="code" label="编码" rules={[{ required: true, message: '请填写编码' }]}>
<Input placeholder="如 TECH_SOFTWARE" />
</Form.Item>
<Form.Item name="deptType" label="类型" rules={[{ required: true }]}>
<Select options={Object.entries(DEPT_TYPE_LABEL).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="parentId" label="上级部门">
<Select
allowClear
placeholder="无(作为顶级)"
options={items.filter((d) => d.id !== selected).map((d) => ({ value: d.id, label: d.name }))}
/>
</Form.Item>
<Form.Item name="categoryCode" label="技术分类编码">
<Input placeholder="如 CAT_SOFTWARE,仅技术部门需要" />
</Form.Item>
<Form.Item name="sortNo" label="排序">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
{canHr(roles) ? (
<Space>
<Button type="primary" loading={createMut.isPending || patchMut.isPending} onClick={() => void save()}>
</Button>
{selected ? (
<Button danger onClick={remove}>
</Button>
) : null}
</Space>
) : null}
</Form>
</Card>
</div>
</>
);
}
+271
View File
@@ -0,0 +1,271 @@
import { Table, Tag } from 'antd';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { QUAL_BUCKET_META } from './QualificationListPage';
import { tenderNo } from './bidStatus';
import { WeatherGreeting } from './WeatherGreeting';
type WarningQual = { id: string; name: string; expiresAt?: string | null; bucket: string };
type WarningBorrow = { id: string; itemName: string; borrower: string; dueAt?: string | null };
type WarningSeal = { id: string; sealType: string; reason: string; returnAt?: string | null };
type WarningOpen = {
id: string;
bidNo: string;
externalNo?: string | null;
name: string;
openAt?: string | null;
openPlace?: string | null;
bidders?: string | null;
};
type TileTone = 'normal' | 'warn' | 'alert';
function Tile({
to,
label,
value,
hint,
tone = 'normal',
}: {
to: string;
label: string;
value: number;
hint: string;
tone?: TileTone;
}) {
return (
<Link to={to} className={`wb-tile${tone === 'warn' ? ' is-warn' : tone === 'alert' ? ' is-alert' : ''}`}>
<div className="label">{label}</div>
<div className="value">{value}</div>
<div className="hint">{hint}</div>
</Link>
);
}
export default function OverviewPage() {
const q = useQuery({
queryKey: ['office-overview'],
queryFn: () => api.get('/office/overview'),
});
const noticesQ = useQuery({
queryKey: ['/office/notices'],
queryFn: () => api.get('/office/notices', { params: { kind: 'ANNOUNCEMENT' } }),
});
const d = q.data?.data as
| {
pendingTodos?: number;
pendingApprovals?: number;
myPending?: number;
canBid?: boolean;
canSeal?: boolean;
canApprove?: boolean;
activeBids?: number;
overdueTodos?: number;
expiringQualifications?: number;
overdueBorrows?: number;
overdueSeals?: number;
upcomingOpens?: number;
warnings?: {
qualifications: WarningQual[];
borrows: WarningBorrow[];
seals: WarningSeal[];
opens: WarningOpen[];
};
}
| undefined;
const warn = d?.warnings;
const canBid = Boolean(d?.canBid);
const canSeal = Boolean(d?.canSeal);
const notices = ((noticesQ.data?.data as { items?: { id: string; title: string; createdAt: string }[] })?.items || []).slice(0, 5);
return (
<div className="ops-workbench">
<div className="wb-head">
<div>
<p className="wb-kicker"></p>
<h1 className="wb-title"></h1>
</div>
<WeatherGreeting />
</div>
<div className="wb-metrics">
<Tile to="/office/todos" label="待办事项" value={d?.pendingTodos ?? 0} hint="点进去办理" />
<Tile to="/office/approvals" label="待我审批" value={d?.pendingApprovals ?? 0} hint="会签 / 合同 / 报销 / 办公申请" />
<Tile to="/office/flow" label="我的申请" value={d?.myPending ?? 0} hint="人事、用章、办公、费用" />
{canBid ? <Tile to="/bid/screening" label="在途投标" value={d?.activeBids ?? 0} hint="筛选到开标" /> : null}
{canBid ? (
<Tile
to="/office/calendar"
label="近两周开标"
value={d?.upcomingOpens ?? 0}
hint="日历里看完整日程"
tone={(d?.upcomingOpens ?? 0) > 0 ? 'warn' : 'normal'}
/>
) : null}
{canSeal ? (
<Tile
to="/seal/qualifications"
label="资质到期"
value={d?.expiringQualifications ?? 0}
hint="即将到期或已过期"
tone={(d?.expiringQualifications ?? 0) > 0 ? 'warn' : 'normal'}
/>
) : null}
{canSeal ? (
<Tile
to="/seal/borrows?bucket=overdue"
label="证照未还"
value={d?.overdueBorrows ?? 0}
hint="外带超期"
tone={(d?.overdueBorrows ?? 0) > 0 ? 'alert' : 'normal'}
/>
) : null}
{canSeal ? (
<Tile
to="/seal/requests?bucket=overdue"
label="印章未还"
value={d?.overdueSeals ?? 0}
hint="外带超期"
tone={(d?.overdueSeals ?? 0) > 0 ? 'alert' : 'normal'}
/>
) : null}
<Tile
to="/office/calendar"
label="超期待办"
value={d?.overdueTodos ?? 0}
hint="已过约定日期"
tone={(d?.overdueTodos ?? 0) > 0 ? 'alert' : 'normal'}
/>
</div>
<div className="wb-boards">
<section className="wb-board">
<header>
<b></b>
<Link to="/office/notices"></Link>
</header>
{notices.length ? (
notices.map((n) => (
<div className="wb-row" key={n.id}>
<Link className="name" to={`/office/notices/${n.id}`}>
{n.title}
</Link>
<span className="meta">{n.createdAt ? dayjs(n.createdAt).format('MM-DD') : ''}</span>
</div>
))
) : (
<div className="wb-empty"></div>
)}
</section>
{canBid ? (
<section className="wb-board">
<header>
<b></b>
<Link to="/office/calendar"></Link>
</header>
{warn?.opens?.length ? (
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={warn.opens}
columns={[
{
title: '编号',
width: 160,
render: (_: unknown, o: WarningOpen) => <Link to={`/bid/cases/${o.id}`}>{tenderNo(o)}</Link>,
},
{ title: '项目名称', dataIndex: 'name', ellipsis: true },
{
title: '开标日程',
width: 130,
render: (_: unknown, o: WarningOpen) => (o.openAt ? dayjs(o.openAt).format('MM-DD HH:mm') : '—'),
},
{ title: '开标地点', dataIndex: 'openPlace', width: 180, ellipsis: true, render: (v: string) => v || '—' },
{ title: '投标人', dataIndex: 'bidders', width: 140, ellipsis: true, render: (v: string) => v || '未派' },
]}
/>
) : (
<div className="wb-empty"></div>
)}
</section>
) : null}
{canSeal ? (
<section className="wb-board">
<header>
<b></b>
<Link to="/seal/qualifications"></Link>
</header>
{warn?.qualifications?.length ? (
warn.qualifications.map((o) => {
const s = QUAL_BUCKET_META[o.bucket] || { color: 'default', text: o.bucket };
return (
<div className="wb-row" key={o.id}>
<Link className="name" to="/seal/qualifications">
{o.name}
</Link>
<Tag color={s.color}>{s.text}</Tag>
<span className="meta">{o.expiresAt ? dayjs(o.expiresAt).format('YYYY-MM-DD') : ''}</span>
</div>
);
})
) : (
<div className="wb-empty"></div>
)}
</section>
) : null}
{canSeal ? (
<section className="wb-board">
<header>
<b></b>
<Link to="/seal/borrows"></Link>
</header>
{warn?.borrows?.length ? (
warn.borrows.map((o) => (
<div className="wb-row" key={o.id}>
<Link className="name" to="/seal/borrows">
{o.itemName}
</Link>
<span className="meta">
{o.borrower}
{o.dueAt ? ` · 应还 ${dayjs(o.dueAt).format('MM-DD')}` : ''}
</span>
</div>
))
) : (
<div className="wb-empty"></div>
)}
</section>
) : null}
{canSeal ? (
<section className="wb-board">
<header>
<b></b>
<Link to="/seal/requests"></Link>
</header>
{warn?.seals?.length ? (
warn.seals.map((o) => (
<div className="wb-row" key={o.id}>
<Link className="name" to="/seal/requests">
{o.sealType}
</Link>
<span className="meta">
{o.reason}
{o.returnAt ? ` · 应还 ${dayjs(o.returnAt).format('MM-DD')}` : ''}
</span>
</div>
))
) : (
<div className="wb-empty"></div>
)}
</section>
) : null}
</div>
</div>
);
}
+573
View File
@@ -0,0 +1,573 @@
import { Button, Form, Input, Select, Space, Switch, Table, Tag, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { fmtCreated } from '../time';
import { FormDrawer } from './FormDrawer';
import { PageHeader } from './ResourcePage';
type Contact = { id?: string; name: string; phone?: string | null; title?: string | null };
type Party = {
id: string;
name: string;
creditNo?: string | null;
level: string;
riskFlag?: boolean;
remark?: string | null;
contacts?: Contact[];
createdAt?: string;
};
type Entity = {
id: string;
name: string;
shortName: string;
creditNo?: string | null;
isDefault?: boolean;
createdAt?: string;
};
type ContactRow = Contact & { id: string; partyId: string; party?: { id: string; name: string; creditNo?: string | null } };
function unwrapList<T>(data: unknown): T[] {
const body = (data as { data?: unknown })?.data ?? data;
if (Array.isArray(body)) return body as T[];
if (body && typeof body === 'object' && 'items' in body) return ((body as { items: T[] }).items ?? []) as T[];
return [];
}
const LEVELS = [
{ value: 'A', label: 'A' },
{ value: 'B', label: 'B' },
{ value: 'C', label: 'C' },
];
export function TenderPartyPage() {
const qc = useQueryClient();
const [form] = Form.useForm();
const [open, setOpen] = useState(false);
const [view, setView] = useState<Party | null>(null);
const [editing, setEditing] = useState<Party | null>(null);
const q = useQuery({ queryKey: ['/parties'], queryFn: () => api.get('/parties?pageSize=100') });
const items = unwrapList<Party>(q.data);
const save = useMutation({
mutationFn: (body: Record<string, unknown>) =>
editing ? api.patch(`/parties/${editing.id}`, body) : api.post('/parties', body),
onSuccess: () => {
message.success(editing ? '已保存单位' : '已新增单位');
qc.invalidateQueries({ queryKey: ['/parties'] });
qc.invalidateQueries({ queryKey: ['/contacts'] });
setOpen(false);
},
onError: (e: Error) => message.error(e.message),
});
const del = useMutation({
mutationFn: (id: string) => api.delete(`/parties/${id}`),
onSuccess: () => {
message.success('已删除');
qc.invalidateQueries({ queryKey: ['/parties'] });
qc.invalidateQueries({ queryKey: ['/contacts'] });
},
onError: (e: Error) => message.error(e.message),
});
const openForm = (row?: Party) => {
setEditing(row || null);
setOpen(true);
};
useEffect(() => {
if (!open) return;
form.setFieldsValue(
editing
? {
name: editing.name,
creditNo: editing.creditNo || undefined,
level: editing.level || 'B',
riskFlag: editing.riskFlag,
remark: editing.remark || undefined,
contacts: editing.contacts?.length ? editing.contacts : [{ name: '' }],
}
: { level: 'B', contacts: [{ name: '' }] },
);
}, [open, editing, form]);
return (
<>
<PageHeader
title="招标单位"
description="招标人、业主。社会信用代码和联系人一起维护,投标时可直接选用。"
extra={
<Button type="primary" onClick={() => openForm()}>
</Button>
}
/>
<Table
className="ops-table ops-table-click"
rowKey="id"
loading={q.isLoading}
dataSource={items}
onRow={(r) => ({
onClick: (e) => {
if ((e.target as HTMLElement).closest('a,button,.ant-btn')) return;
setView(r);
},
})}
scroll={{ x: 'max-content' }}
tableLayout="fixed"
columns={[
{ title: '名称', dataIndex: 'name', width: 240, ellipsis: true },
{ title: '社会信用代码', dataIndex: 'creditNo', width: 220, render: (v: string) => v || '—' },
{ title: '等级', dataIndex: 'level', width: 70 },
{
title: '联系人',
width: 220,
ellipsis: true,
render: (_: unknown, r: Party) =>
r.contacts?.length
? r.contacts.map((c) => `${c.name}${c.phone ? ` ${c.phone}` : ''}`).join('')
: '—',
},
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v: string) => fmtCreated(v) },
{
title: '操作',
width: 180,
render: (_: unknown, r: Party) => (
<Space>
<Button type="link" onClick={() => setView(r)}>
</Button>
<Button type="link" onClick={() => openForm(r)}>
</Button>
<Button type="link" danger onClick={() => del.mutate(r.id)}>
</Button>
</Space>
),
},
]}
/>
<FormDrawer
title={editing ? '编辑招标单位' : '新增招标单位'}
open={open}
onClose={() => setOpen(false)}
width={720}
createdAt={editing?.createdAt}
okText="提交保存"
okLoading={save.isPending}
onOk={async () => {
try {
const v = await form.validateFields();
await save.mutateAsync({
...v,
contacts: (v.contacts || []).filter((c: Contact) => c?.name),
});
} catch (e) {
if (e instanceof Error && e.message) message.error(e.message);
}
}}
>
<Form form={form} layout="vertical" style={{ marginTop: 12 }}>
<Form.Item name="name" label="单位名称" rules={[{ required: true, message: '请填写单位名称' }]}>
<Input />
</Form.Item>
<Form.Item name="creditNo" label="统一社会信用代码">
<Input placeholder="18 位社会信用代码" maxLength={18} />
</Form.Item>
<Form.Item name="level" label="等级">
<Select options={LEVELS} />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
<Form.List name="contacts">
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<Space key={field.key} align="baseline" style={{ display: 'flex', marginBottom: 8 }} wrap>
<Form.Item name={[field.name, 'id']} hidden>
<Input />
</Form.Item>
<Form.Item name={[field.name, 'name']}>
<Input placeholder="联系人" />
</Form.Item>
<Form.Item name={[field.name, 'phone']}>
<Input placeholder="电话" />
</Form.Item>
<Form.Item name={[field.name, 'title']}>
<Input placeholder="职务" />
</Form.Item>
<Button type="link" onClick={() => remove(field.name)}>
</Button>
</Space>
))}
<Button type="dashed" onClick={() => add()} block>
</Button>
</>
)}
</Form.List>
</Form>
</FormDrawer>
<FormDrawer title="单位详情" width={640} open={Boolean(view)} onClose={() => setView(null)} hideOk createdAt={view?.createdAt}>
{view ? (
<div>
<p>{view.name}</p>
<p>{view.creditNo || '—'}</p>
<p>{view.level}</p>
<p>{view.remark || '—'}</p>
<p></p>
<ul>
{(view.contacts || []).map((c) => (
<li key={c.id || c.name}>
{c.name} {c.phone || ''} {c.title || ''}
</li>
))}
{!view.contacts?.length ? <li></li> : null}
</ul>
</div>
) : null}
</FormDrawer>
</>
);
}
export function LegalEntityPage() {
const qc = useQueryClient();
const [form] = Form.useForm();
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<Entity | null>(null);
const q = useQuery({ queryKey: ['/legal-entities'], queryFn: () => api.get('/legal-entities') });
const items = unwrapList<Entity>(q.data);
const save = useMutation({
mutationFn: (body: Record<string, unknown>) =>
editing ? api.patch(`/legal-entities/${editing.id}`, body) : api.post('/legal-entities', body),
onSuccess: () => {
message.success(editing ? '已保存主体' : '已新增主体');
qc.invalidateQueries({ queryKey: ['/legal-entities'] });
setOpen(false);
},
onError: (e: Error) => message.error(e.message),
});
const del = useMutation({
mutationFn: (id: string) => api.delete(`/legal-entities/${id}`),
onSuccess: () => {
message.success('已删除');
qc.invalidateQueries({ queryKey: ['/legal-entities'] });
},
onError: (e: Error) => message.error(e.message),
});
const openForm = (row?: Entity) => {
setEditing(row || null);
setOpen(true);
};
useEffect(() => {
if (!open) return;
form.setFieldsValue(editing || { isDefault: false });
}, [open, editing, form]);
return (
<>
<PageHeader
title="参与主体"
description="投标和签合同时用哪家公司的名义。社会信用代码要填全。"
extra={
<Button type="primary" onClick={() => openForm()}>
</Button>
}
/>
<Table
className="ops-table"
rowKey="id"
loading={q.isLoading}
dataSource={items}
scroll={{ x: 'max-content' }}
tableLayout="fixed"
columns={[
{ title: '全称', dataIndex: 'name', width: 240, ellipsis: true },
{ title: '简称', dataIndex: 'shortName', width: 140 },
{ title: '社会信用代码', dataIndex: 'creditNo', width: 220, render: (v: string) => v || '—' },
{
title: '默认',
width: 80,
render: (_: unknown, r: Entity) => (r.isDefault ? <Tag color="success"></Tag> : '—'),
},
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v: string) => fmtCreated(v) },
{
title: '操作',
width: 140,
render: (_: unknown, r: Entity) => (
<Space>
<Button type="link" onClick={() => openForm(r)}>
</Button>
<Button type="link" danger onClick={() => del.mutate(r.id)}>
</Button>
</Space>
),
},
]}
/>
<FormDrawer
title={editing ? '编辑主体' : '新增主体'}
open={open}
onClose={() => setOpen(false)}
createdAt={editing?.createdAt}
okText="提交保存"
okLoading={save.isPending}
onOk={async () => {
try {
await save.mutateAsync(await form.validateFields());
} catch (e) {
if (e instanceof Error && e.message) message.error(e.message);
}
}}
>
<Form form={form} layout="vertical" style={{ marginTop: 12 }}>
<Form.Item name="name" label="主体全称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="shortName" label="简称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="creditNo" label="统一社会信用代码">
<Input maxLength={18} />
</Form.Item>
<Form.Item name="isDefault" label="设为默认" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</FormDrawer>
</>
);
}
export function ContactDirectoryPage() {
const qc = useQueryClient();
const [form] = Form.useForm();
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<ContactRow | null>(null);
const q = useQuery({ queryKey: ['/contacts'], queryFn: () => api.get('/contacts') });
const parties = useQuery({ queryKey: ['/parties'], queryFn: () => api.get('/parties?pageSize=100') });
const items = unwrapList<ContactRow>(q.data);
const partyOpts = unwrapList<Party>(parties.data).map((p) => ({ value: p.id, label: p.name }));
const save = useMutation({
mutationFn: (body: Record<string, unknown>) =>
editing ? api.patch(`/contacts/${editing.id}`, body) : api.post('/contacts', body),
onSuccess: () => {
message.success(editing ? '已保存联系人' : '已新增联系人');
qc.invalidateQueries({ queryKey: ['/contacts'] });
qc.invalidateQueries({ queryKey: ['/parties'] });
setOpen(false);
},
onError: (e: Error) => message.error(e.message),
});
const del = useMutation({
mutationFn: (id: string) => api.delete(`/contacts/${id}`),
onSuccess: () => {
message.success('已删除');
qc.invalidateQueries({ queryKey: ['/contacts'] });
qc.invalidateQueries({ queryKey: ['/parties'] });
},
onError: (e: Error) => message.error(e.message),
});
const openForm = (row?: ContactRow) => {
setEditing(row || null);
setOpen(true);
};
useEffect(() => {
if (!open) return;
if (editing) {
form.setFieldsValue({
partyId: editing.partyId,
name: editing.name,
phone: editing.phone || undefined,
title: editing.title || undefined,
});
} else {
form.resetFields();
}
}, [open, editing, form]);
return (
<>
<PageHeader
title="联系名录"
description="招标单位的联系人。也可以在招标单位里一起维护。"
extra={
<Button type="primary" onClick={() => openForm()}>
</Button>
}
/>
<Table
className="ops-table"
rowKey="id"
loading={q.isLoading}
dataSource={items}
scroll={{ x: 'max-content' }}
tableLayout="fixed"
columns={[
{ title: '所属单位', width: 220, ellipsis: true, render: (_: unknown, r: ContactRow) => r.party?.name || '—' },
{ title: '姓名', dataIndex: 'name', width: 120 },
{ title: '电话', dataIndex: 'phone', width: 140, render: (v: string) => v || '—' },
{ title: '职务', dataIndex: 'title', width: 140, render: (v: string) => v || '—' },
{
title: '操作',
width: 140,
render: (_: unknown, r: ContactRow) => (
<Space>
<Button type="link" onClick={() => openForm(r)}>
</Button>
<Button type="link" danger onClick={() => del.mutate(r.id)}>
</Button>
</Space>
),
},
]}
/>
<FormDrawer
title={editing ? '编辑联系人' : '新增联系人'}
open={open}
onClose={() => setOpen(false)}
okText="提交保存"
okLoading={save.isPending}
onOk={async () => {
try {
await save.mutateAsync(await form.validateFields());
} catch (e) {
if (e instanceof Error && e.message) message.error(e.message);
}
}}
>
<Form key={editing?.id || 'new'} form={form} layout="vertical" style={{ marginTop: 12 }}>
<Form.Item name="partyId" label="所属单位" rules={[{ required: true, message: '请选择单位' }]}>
<Select
showSearch
optionFilterProp="label"
options={partyOpts}
getPopupContainer={(n) => n.parentElement || document.body}
/>
</Form.Item>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="电话">
<Input />
</Form.Item>
<Form.Item name="title" label="职务">
<Input />
</Form.Item>
</Form>
</FormDrawer>
</>
);
}
export function DictsPage() {
const qc = useQueryClient();
const [form] = Form.useForm();
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<{ id: string; dictType: string; label: string; value: string; sortNo: number } | null>(null);
const q = useQuery({ queryKey: ['/system/dicts'], queryFn: () => api.get('/system/dicts') });
const items = unwrapList<{ id: string; dictType: string; label: string; value: string; sortNo: number }>(q.data);
const save = useMutation({
mutationFn: (body: Record<string, unknown>) =>
editing ? api.patch(`/system/dicts/${editing.id}`, body) : api.post('/system/dicts', body),
onSuccess: () => {
message.success(editing ? '已保存' : '已新增');
qc.invalidateQueries({ queryKey: ['/system/dicts'] });
setOpen(false);
},
onError: (e: Error) => message.error(e.message),
});
const del = useMutation({
mutationFn: (id: string) => api.delete(`/system/dicts/${id}`),
onSuccess: () => {
message.success('已删除');
qc.invalidateQueries({ queryKey: ['/system/dicts'] });
},
onError: (e: Error) => message.error(e.message),
});
return (
<>
<PageHeader
title="数据字典"
description="项目类型、招标方式、费用类别都可以在这里改,不用改代码。"
extra={
<Button
type="primary"
onClick={() => {
setEditing(null);
form.resetFields();
setOpen(true);
}}
>
</Button>
}
/>
<Table
rowKey="id"
loading={q.isLoading}
dataSource={items}
columns={[
{ title: '类型', dataIndex: 'dictType', width: 180 },
{ title: '名称', dataIndex: 'label' },
{ title: '值', dataIndex: 'value' },
{ title: '排序', dataIndex: 'sortNo', width: 80 },
{
title: '操作',
width: 140,
render: (_: unknown, r) => (
<Space>
<Button
type="link"
onClick={() => {
setEditing(r);
form.setFieldsValue(r);
setOpen(true);
}}
>
</Button>
<Button type="link" danger onClick={() => del.mutate(r.id)}>
</Button>
</Space>
),
},
]}
/>
<FormDrawer
title={editing ? '编辑字典' : '新增字典'}
open={open}
onClose={() => setOpen(false)}
okText="提交保存"
okLoading={save.isPending}
onOk={async () => save.mutateAsync(await form.validateFields())}
>
<Form form={form} layout="vertical" style={{ marginTop: 12 }}>
<Form.Item name="dictType" label="类型" rules={[{ required: true }]}>
<Input placeholder="如 expenseCategory、projectType" />
</Form.Item>
<Form.Item name="label" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="value" label="值" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="sortNo" label="排序">
<Input type="number" />
</Form.Item>
</Form>
</FormDrawer>
</>
);
}
+192
View File
@@ -0,0 +1,192 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button, Space, Spin } from 'antd';
import * as pdfjsLib from 'pdfjs-dist';
import type { PDFDocumentProxy } from 'pdfjs-dist';
import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
function toPdfBytes(data: ArrayBuffer) {
const raw = new Uint8Array(data);
const limit = Math.min(raw.length, 2048);
let start = 0;
for (let i = 0; i < limit - 3; i++) {
if (raw[i] === 0x25 && raw[i + 1] === 0x50 && raw[i + 2] === 0x44 && raw[i + 3] === 0x46) {
start = i;
break;
}
}
const slice = raw.subarray(start);
const copy = new Uint8Array(slice.byteLength);
copy.set(slice);
return copy;
}
let workerReady: Promise<void> | null = null;
function ensurePdfWorker() {
if (workerReady) return workerReady;
workerReady = (async () => {
try {
const res = await fetch(pdfWorker);
const buf = await res.arrayBuffer();
const url = URL.createObjectURL(new Blob([buf], { type: 'text/javascript' }));
pdfjsLib.GlobalWorkerOptions.workerSrc = url;
} catch {
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker;
}
})();
return workerReady;
}
function pdfOptions(bytes: Uint8Array) {
const origin = typeof window !== 'undefined' ? window.location.origin : '';
return {
data: bytes.slice(0),
cMapUrl: `${origin}/pdfjs/cmaps/`,
cMapPacked: true,
standardFontDataUrl: `${origin}/pdfjs/standard_fonts/`,
disableAutoFetch: true,
disableStream: true,
stopAtErrors: false,
isEvalSupported: true,
useSystemFonts: true,
};
}
export function PdfPreview({ data, fileName }: { data: ArrayBuffer; fileName: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [numPages, setNumPages] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const pdfRef = useRef<PDFDocumentProxy | null>(null);
const pdfBytes = useMemo(() => toPdfBytes(data), [data]);
const blobUrl = useMemo(
() => URL.createObjectURL(new Blob([pdfBytes], { type: 'application/pdf' })),
[pdfBytes],
);
useEffect(() => {
return () => URL.revokeObjectURL(blobUrl);
}, [blobUrl]);
useEffect(() => {
let cancelled = false;
let task: ReturnType<typeof pdfjsLib.getDocument> | null = null;
setLoading(true);
setError(null);
setNumPages(0);
setPage(1);
const open = async () => {
await ensurePdfWorker();
if (cancelled) return;
task = pdfjsLib.getDocument(pdfOptions(pdfBytes));
try {
const pdf = await task.promise;
if (cancelled) {
pdf.destroy();
return;
}
pdfRef.current = pdf;
setNumPages(pdf.numPages);
setLoading(false);
} catch {
if (!cancelled) {
setError('pdfjs');
setLoading(false);
}
}
};
void open();
return () => {
cancelled = true;
task?.destroy();
pdfRef.current?.destroy();
pdfRef.current = null;
};
}, [pdfBytes]);
useEffect(() => {
const pdf = pdfRef.current;
const canvas = canvasRef.current;
if (!pdf || !canvas || !numPages) return;
let cancelled = false;
const render = async () => {
const p = await pdf.getPage(page);
if (cancelled) return;
const viewport = p.getViewport({ scale: 1.5 });
const ctx = canvas.getContext('2d');
if (!ctx) return;
canvas.width = viewport.width;
canvas.height = viewport.height;
await p.render({ canvasContext: ctx, viewport }).promise;
};
void render().catch(() => {
if (!cancelled) setError('pdfjs');
});
return () => {
cancelled = true;
};
}, [page, numPages]);
const openInTab = () => {
window.open(blobUrl, '_blank', 'noopener');
};
const download = () => {
const a = document.createElement('a');
a.href = blobUrl;
a.download = fileName || '附件.pdf';
a.click();
};
if (error) {
return (
<div>
<p style={{ color: '#cf1322', marginBottom: 8 }}>
</p>
<Space style={{ marginBottom: 12 }}>
<Button type="primary" onClick={openInTab}>
</Button>
<Button onClick={download}></Button>
</Space>
<embed src={blobUrl} type="application/pdf" style={{ width: '100%', height: '70vh', border: 0, background: '#525659' }} />
</div>
);
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 12 }}>
<div style={{ fontWeight: 600, wordBreak: 'break-all' }}>{fileName}</div>
<Space wrap>
<Button size="small" onClick={openInTab}>
</Button>
<Button size="small" onClick={download}>
</Button>
{numPages > 1 ? (
<>
<Button size="small" disabled={page <= 1} onClick={() => setPage((n) => Math.max(1, n - 1))}>
</Button>
<span>
{page} / {numPages}
</span>
<Button size="small" disabled={page >= numPages} onClick={() => setPage((n) => Math.min(numPages, n + 1))}>
</Button>
</>
) : null}
</Space>
</div>
<div style={{ overflow: 'auto', maxHeight: '70vh', textAlign: 'center', background: '#525659', padding: 12 }}>
{loading ? <Spin /> : null}
<canvas ref={canvasRef} style={{ maxWidth: '100%', height: 'auto', display: loading ? 'none' : 'inline-block' }} />
</div>
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
import { Avatar, Button, Form, Input, Upload, message } from 'antd';
import { CameraOutlined, IdcardOutlined, UserOutlined } from '@ant-design/icons';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { api } from '../api/client';
import { downloadBizFile } from './FileAttachments';
import { FormDrawer } from './FormDrawer';
import { useAuth } from '../store/auth';
type Me = {
id: string;
displayName: string;
username: string;
avatarFileId?: string | null;
idPhotoFileId?: string | null;
employee?: { mobile?: string | null; name?: string | null } | null;
};
async function uploadUserFile(bizType: string, bizId: string, file: File) {
const fd = new FormData();
fd.append('file', file);
fd.append('bizType', bizType);
fd.append('bizId', bizId);
await api.post('/files', fd);
}
export function ProfileDrawer({
open,
mode,
onClose,
}: {
open: boolean;
mode: 'profile' | 'password' | 'photo';
onClose: () => void;
}) {
const qc = useQueryClient();
const refresh = useAuth((s) => s.loadSession);
const [form] = Form.useForm();
const me = useQuery({
queryKey: ['auth-me'],
enabled: open,
queryFn: () => api.get('/auth/me'),
});
const row = me.data?.data as Me | undefined;
useEffect(() => {
if (row) form.setFieldsValue({ displayName: row.displayName });
}, [row, form]);
const saveProfile = useMutation({
mutationFn: (body: { displayName: string }) => api.patch('/auth/profile', body),
onSuccess: async () => {
message.success('个人资料已保存');
await qc.invalidateQueries({ queryKey: ['auth-me'] });
await refresh();
onClose();
},
onError: (e: Error) => message.error(e.message),
});
const savePass = useMutation({
mutationFn: (body: { oldPassword: string; newPassword: string }) => api.patch('/auth/password', body),
onSuccess: () => {
message.success('密码已修改,下次请用新密码登录');
form.resetFields();
onClose();
},
onError: (e: Error) => message.error(e.message),
});
const title = mode === 'password' ? '修改密码' : mode === 'photo' ? '上传个人证件照' : '修改个人资料';
return (
<FormDrawer
title={title}
open={open}
onClose={onClose}
width={480}
onOk={() => form.submit()}
okText={mode === 'password' ? '确认改密' : '保存'}
okLoading={saveProfile.isPending || savePass.isPending}
>
{mode === 'password' ? (
<Form
form={form}
layout="vertical"
onFinish={(v) => {
if (v.newPassword !== v.confirm) {
message.error('两次新密码不一致');
return;
}
savePass.mutate({ oldPassword: v.oldPassword, newPassword: v.newPassword });
}}
>
<Form.Item name="oldPassword" label="当前密码" rules={[{ required: true, message: '请填写当前密码' }]}>
<Input.Password />
</Form.Item>
<Form.Item
name="newPassword"
label="新密码"
rules={[
{ required: true, message: '请填写新密码' },
{ min: 6, message: '至少 6 位' },
]}
>
<Input.Password />
</Form.Item>
<Form.Item name="confirm" label="确认新密码" rules={[{ required: true, message: '请再填一次新密码' }]}>
<Input.Password />
</Form.Item>
</Form>
) : (
<Form form={form} layout="vertical" onFinish={(v) => saveProfile.mutate({ displayName: v.displayName })}>
<Form.Item label="头像">
<Upload
showUploadList={false}
accept="image/*"
beforeUpload={() => true}
customRequest={async (opt) => {
if (!row?.id) return;
try {
await uploadUserFile('USER_AVATAR', row.id, opt.file as File);
message.success('头像已更新');
await qc.invalidateQueries({ queryKey: ['auth-me'] });
await refresh();
opt.onSuccess?.({}, new XMLHttpRequest());
} catch (e) {
message.error((e as Error).message);
opt.onError?.(e as Error);
}
}}
>
<Button icon={<CameraOutlined />}></Button>
</Upload>
</Form.Item>
<Form.Item name="displayName" label="姓名" rules={[{ required: true, message: '请填写姓名' }]}>
<Input />
</Form.Item>
<Form.Item label="登录手机号">
<Input value={row?.username || row?.employee?.mobile || ''} disabled />
</Form.Item>
<Form.Item
label="个人证件照"
required
extra="必须上传本人证件照。上传后会同步到组织人事 · 在职员工档案,人事可查看。"
>
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
<Upload
showUploadList={false}
accept="image/*"
beforeUpload={() => true}
customRequest={async (opt) => {
if (!row?.id) return;
try {
await uploadUserFile('USER_ID_PHOTO', row.id, opt.file as File);
message.success('证件照已上传');
await qc.invalidateQueries({ queryKey: ['auth-me'] });
await refresh();
opt.onSuccess?.({}, new XMLHttpRequest());
} catch (e) {
message.error((e as Error).message);
opt.onError?.(e as Error);
}
}}
>
<Button type="primary" icon={<IdcardOutlined />}>
</Button>
</Upload>
{row?.idPhotoFileId ? (
<Button type="link" onClick={() => downloadBizFile(row.idPhotoFileId!, '证件照')}>
</Button>
) : (
<span style={{ color: '#cf1322' }}></span>
)}
</div>
</Form.Item>
</Form>
)}
</FormDrawer>
);
}
export function UserAvatar({ size = 28 }: { size?: number }) {
const id = useAuth((s) => s.id);
const avatarFileId = useAuth((s) => s.avatarFileId);
const [src, setSrc] = useState<string>();
useEffect(() => {
if (!avatarFileId) {
setSrc(undefined);
return;
}
let alive = true;
let url = '';
void (async () => {
const blob = (await api.get(`/files/${avatarFileId}`, { responseType: 'blob' })) as unknown as Blob;
url = URL.createObjectURL(blob);
if (alive) setSrc(url);
})();
return () => {
alive = false;
if (url) URL.revokeObjectURL(url);
};
}, [avatarFileId, id]);
return <Avatar size={size} src={src} icon={<UserOutlined />} />;
}
+915
View File
@@ -0,0 +1,915 @@
import { useEffect } from 'react';
import {
Alert,
Button,
Card,
DatePicker,
Descriptions,
Form,
Input,
InputNumber,
Modal,
Progress,
Select,
Space,
Table,
Tag,
Typography,
Upload,
message,
} from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate, useParams } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { PageHeader } from './ResourcePage';
import { FormDrawer } from './FormDrawer';
import { BizFileList, downloadBizFile } from './FileAttachments';
import { BUDGET_CATEGORY_LABEL, EXPENSE_STATUS_META, money } from './financeStatus';
import {
ACCEPT_KIND_LABEL,
TASK_STATUS_META,
projectStatusMeta,
} from './projectStatus';
type UserRef = { id: string; displayName: string; username?: string };
type Usage = {
hours: number;
labor: number;
travel: number;
techTravel?: number;
purchase: number;
presalesCost?: number;
businessUsed?: number;
techUsed?: number;
contractAmount?: number;
suggested?: { business: number; tech: number };
hourRate: number;
limits: {
labor: number | null;
travel: number | null;
purchase: number | null;
business?: number | null;
tech?: number | null;
};
};
type ProjectDetail = {
id: string;
projectNo: string;
name: string;
status: string;
projectType?: string;
amount?: string | number;
presalesCost: string | number;
hourRate: string | number;
createdAt?: string;
files?: { id: string; fileName: string; size: number; mimeType: string; bizType?: string }[];
internalAcceptedAt?: string | null;
customerAcceptedAt?: string | null;
contract?: {
id: string;
contractNo: string;
amount: string | number;
bidCase?: { id: string; bidNo: string; name: string } | null;
milestones: {
id: string;
name: string;
ratio: string | number;
invoice?: { id: string; status: string } | null;
}[];
} | null;
tasks: {
id: string;
name: string;
department?: string | null;
status: string;
dueAt?: string | null;
dept?: { id: string; name: string } | null;
assignee?: UserRef | null;
}[];
timesheets: {
id: string;
hours: string | number;
costAmount: string | number;
workDate: string;
note?: string | null;
user?: UserRef;
task?: { name: string } | null;
}[];
budgets: { category: string; amount: string | number }[];
acceptances: {
id: string;
kind: string;
result: string;
comment?: string | null;
decidedAt: string;
actor?: UserRef;
}[];
acceptMaterials?: {
id: string;
name: string;
remark?: string | null;
required: boolean;
sortNo: number;
uploaded: boolean;
files: { id: string; fileName: string; size: number; mimeType: string }[];
}[];
acceptMaterialsReady?: boolean;
expenses?: {
id: string;
claimNo: string;
amount: string | number;
status: string;
budgetCategory?: string | null;
purpose?: string | null;
createdAt?: string;
applicant?: UserRef;
}[];
purchases?: {
id: string;
title: string;
amount: string | number;
status: string;
createdAt?: string;
applicant?: UserRef | null;
}[];
usage: Usage;
myActions: {
canSetBudget: boolean;
canManageWbs: boolean;
canFillTimesheet: boolean;
canAcceptInternal: boolean;
canAcceptCustomer: boolean;
canEditMaterials?: boolean;
};
};
function budgetBar(used: number, limit: number | null) {
if (limit === null) return <Typography.Text type="secondary"></Typography.Text>;
const pct = limit <= 0 ? (used > 0 ? 100 : 0) : Math.min(100, Math.round((used / limit) * 100));
return (
<div>
<Progress percent={pct} status={used - limit > 0.001 ? 'exception' : 'normal'} />
<Typography.Text>
{money(used)} / {money(limit)}
</Typography.Text>
</div>
);
}
const PURCHASE_STATUS_META: Record<string, { color: string; text: string }> = {
DRAFT: { color: 'default', text: '草稿' },
PENDING: { color: 'processing', text: '待确认' },
APPROVED: { color: 'success', text: '已通过' },
REJECTED: { color: 'error', text: '被打回' },
};
export default function ProjectDetailPage({
id: idProp,
embedded,
focus = 'full',
}: {
id?: string;
embedded?: boolean;
focus?: 'full' | 'acceptance';
} = {}) {
const { id: routeId } = useParams();
const id = idProp || routeId;
const navigate = useNavigate();
const qc = useQueryClient();
const [budgetForm] = Form.useForm();
const [taskForm] = Form.useForm();
const [sheetForm] = Form.useForm();
const [materialForm] = Form.useForm();
const q = useQuery({
queryKey: ['project', id],
enabled: Boolean(id),
queryFn: () => api.get(`/projects/${id}`),
});
const depts = useQuery({
queryKey: ['/departments'],
queryFn: () => api.get('/departments'),
});
const staff = useQuery({
queryKey: ['/staff'],
queryFn: () => api.get('/staff'),
});
const row = q.data?.data as ProjectDetail | undefined;
const acceptanceOnly = focus === 'acceptance';
useEffect(() => {
if (!row) return;
budgetForm.setFieldsValue({
amount: Number(row.amount) || row.usage.contractAmount || undefined,
projectType: row.projectType || undefined,
business: row.usage.limits.business ?? row.usage.suggested?.business ?? undefined,
tech: row.usage.limits.tech ?? row.usage.suggested?.tech ?? undefined,
});
}, [row, budgetForm]);
const invalidate = () => qc.invalidateQueries();
const budgetMut = useMutation({
mutationFn: async (v: {
amount?: number;
projectType?: string;
business?: number;
tech?: number;
}) => {
if (v.amount !== undefined || v.projectType) {
await api.patch(`/projects/${id}`, { amount: v.amount, projectType: v.projectType });
}
const items = [
v.business !== undefined && v.business !== null ? { category: 'BUSINESS', amount: v.business } : null,
v.tech !== undefined && v.tech !== null ? { category: 'TECH', amount: v.tech } : null,
].filter(Boolean);
if (!items.length) return;
return api.put(`/projects/${id}/budgets`, { items });
},
onSuccess: async () => {
message.success('预算已保存');
await invalidate();
},
onError: (e: Error) => message.error(e.message),
});
const syncMut = useMutation({
mutationFn: () => {
const amount = Number(budgetForm.getFieldValue('amount'));
return api.post(`/projects/${id}/budgets/sync`, Number.isFinite(amount) && amount > 0 ? { amount } : {});
},
onSuccess: async () => {
message.success('已按总额均分商务、技术预算,花费已从工时/报销/采购同步');
await invalidate();
},
onError: (e: Error) => message.error(e.message),
});
const materialMut = useMutation({
mutationFn: (v: { name: string; required?: boolean }) => api.post(`/projects/${id}/accept-materials`, v),
onSuccess: async () => {
message.success('已加入清单');
materialForm.resetFields();
await invalidate();
},
onError: (e: Error) => message.error(e.message),
});
const seedMatMut = useMutation({
mutationFn: () => api.post(`/projects/${id}/accept-materials/seed`),
onSuccess: async () => {
message.success('已带入常用验收材料');
await invalidate();
},
onError: (e: Error) => message.error(e.message),
});
const taskMut = useMutation({
mutationFn: (v: Record<string, unknown>) =>
api.post(`/projects/${id}/tasks`, {
...v,
dueAt: v.dueAt ? dayjs(v.dueAt as string).format('YYYY-MM-DD') : undefined,
}),
onSuccess: async () => {
message.success('已拆任务');
taskForm.resetFields();
await invalidate();
},
onError: (e: Error) => message.error(e.message),
});
const doneMut = useMutation({
mutationFn: (taskId: string) => api.post(`/projects/${id}/tasks/${taskId}/done`),
onSuccess: async () => {
message.success('任务已完成');
await invalidate();
},
onError: (e: Error) => message.error(e.message),
});
const sheetMut = useMutation({
mutationFn: (v: Record<string, unknown>) =>
api.post('/timesheets', {
projectId: id,
...v,
workDate: v.workDate ? dayjs(v.workDate as string).format('YYYY-MM-DD') : undefined,
}),
onSuccess: async () => {
message.success('工时已计入技术成本');
sheetForm.resetFields();
await invalidate();
},
onError: (e: Error) => message.error(e.message),
});
const acceptMut = useMutation({
mutationFn: (body: { kind: 'INTERNAL' | 'CUSTOMER'; result: 'APPROVED' | 'REJECTED'; comment?: string }) =>
api.post(`/projects/${id}/accept`, body),
onSuccess: async (res: { data?: { invoicesCreated?: number; status?: string } }) => {
const n = res.data?.invoicesCreated || 0;
message.success(n ? `验收通过,已生成 ${n} 张待开票` : '已办理验收');
await invalidate();
await qc.invalidateQueries({ queryKey: ['/invoices'] });
},
onError: (e: Error) => message.error(e.message),
});
if (q.isError) {
return (
<Alert
type="error"
message={(q.error as Error).message || '无法打开项目'}
action={
<Button onClick={() => navigate('/project/list')} size="small">
</Button>
}
/>
);
}
if (!row) return <Typography.Paragraph></Typography.Paragraph>;
const meta = projectStatusMeta(row.status);
const deptOpts = ((depts.data?.data ?? []) as { id: string; name: string; code: string }[])
.filter((d) => d.code !== 'ROOT')
.map((d) => ({ value: d.id, label: d.name }));
const staffOpts = ((staff.data?.data ?? []) as { id: string; name: string }[]).map((s) => ({
value: s.id,
label: s.name,
}));
const decide = (kind: 'INTERNAL' | 'CUSTOMER', result: 'APPROVED' | 'REJECTED') => {
if (result === 'REJECTED') {
Modal.confirm({
title: `${ACCEPT_KIND_LABEL[kind]}不通过`,
content: (
<Input.TextArea id="accept-comment" rows={3} placeholder="请填写意见" />
),
okButtonProps: { danger: true },
onOk: () => {
const comment = (document.getElementById('accept-comment') as HTMLTextAreaElement | null)?.value;
if (!comment?.trim()) {
message.error('不通过请填写意见');
return Promise.reject();
}
return acceptMut.mutateAsync({ kind, result: 'REJECTED', comment });
},
});
return;
}
acceptMut.mutate({ kind, result: 'APPROVED' });
};
const page = (
<>
<PageHeader
title={row.name}
description={row.projectNo}
extra={
<Space>
{acceptanceOnly ? null : (
<Button>
<Link to={`/office/expenses/new?costType=PROJECT&projectId=${row.id}`}></Link>
</Button>
)}
{embedded ? null : (
<Button>
<Link to={acceptanceOnly ? '/project/acceptance' : '/project/list'}>
{acceptanceOnly ? '返回验收' : '返回台账'}
</Link>
</Button>
)}
</Space>
}
/>
<Space style={{ marginBottom: 16 }} wrap>
<Tag color={meta.color}>{meta.text}</Tag>
{row.contract ? <Link to={`/contract/${row.contract.id}`}>{row.contract.contractNo}</Link> : null}
{row.contract?.bidCase ? (
<Link to={`/bid/cases/${row.contract.bidCase.id}`}>{row.contract.bidCase.bidNo}</Link>
) : null}
</Space>
{row.myActions.canAcceptInternal ? (
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="内部验收:技术确认交付物后,再交给商务做客户验收。"
action={
<Space>
<Button type="primary" onClick={() => decide('INTERNAL', 'APPROVED')}>
</Button>
<Button danger onClick={() => decide('INTERNAL', 'REJECTED')}>
</Button>
</Space>
}
/>
) : null}
{row.myActions.canAcceptCustomer ? (
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message={
row.acceptMaterialsReady
? '客户验收通过后,未开票的收款条款会生成待开票。'
: '客户验收前请先在下面写好材料清单,并逐份上传。材料不齐不能点通过。'
}
action={
<Space>
<Button type="primary" disabled={!row.acceptMaterialsReady} onClick={() => decide('CUSTOMER', 'APPROVED')}>
</Button>
<Button danger onClick={() => decide('CUSTOMER', 'REJECTED')}>
</Button>
</Space>
}
/>
) : null}
{acceptanceOnly ? null : (
<>
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
<Descriptions.Item label="项目类别">{row.projectType || '—'}</Descriptions.Item>
<Descriptions.Item label="项目总额">{money(row.amount || row.usage.contractAmount || row.contract?.amount)}</Descriptions.Item>
<Descriptions.Item label="均分规则"></Descriptions.Item>
<Descriptions.Item label="创建时间">{row.createdAt ? dayjs(row.createdAt).format('YYYY-MM-DD HH:mm:ss') : '—'}</Descriptions.Item>
<Descriptions.Item label="已填工时">{row.usage.hours} </Descriptions.Item>
</Descriptions>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 12, marginBottom: 16 }}>
<Card size="small" title="商务预算" extra={money(row.usage.limits.business)}>
{budgetBar(row.usage.businessUsed ?? Number(row.presalesCost) + row.usage.travel, row.usage.limits.business ?? null)}
<Descriptions bordered size="small" column={1} style={{ marginTop: 12 }}>
<Descriptions.Item label="售前成本">{money(row.presalesCost)}</Descriptions.Item>
<Descriptions.Item label="商务差旅">{money(row.usage.travel)}</Descriptions.Item>
</Descriptions>
</Card>
<Card size="small" title="技术预算" extra={money(row.usage.limits.tech)}>
{budgetBar(row.usage.techUsed ?? row.usage.labor + row.usage.purchase + (row.usage.techTravel || 0), row.usage.limits.tech ?? null)}
<Descriptions bordered size="small" column={1} style={{ marginTop: 12 }}>
<Descriptions.Item label="人工 / 工时">{money(row.usage.labor)}</Descriptions.Item>
<Descriptions.Item label="技术差旅">{money(row.usage.techTravel)}</Descriptions.Item>
<Descriptions.Item label="采购">{money(row.usage.purchase)}</Descriptions.Item>
</Descriptions>
</Card>
</div>
{row.myActions.canSetBudget ? (
<Form
form={budgetForm}
layout="inline"
style={{ marginBottom: 24, rowGap: 12 }}
onFinish={(v) => budgetMut.mutate(v)}
>
<Form.Item name="projectType" label="项目类别">
<Select
style={{ width: 120 }}
options={[
{ label: '软件', value: '软件' },
{ label: '三维', value: '三维' },
{ label: '摄制', value: '摄制' },
{ label: '物资', value: '物资' },
{ label: '集成', value: '集成' },
]}
/>
</Form.Item>
<Form.Item name="amount" label="项目总额">
<InputNumber min={0} precision={2} placeholder="可改总额" />
</Form.Item>
<Form.Item name="business" label="商务预算">
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item name="tech" label="技术预算">
<InputNumber min={0} precision={2} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={budgetMut.isPending}>
</Button>
<Button loading={syncMut.isPending} onClick={() => syncMut.mutate()}>
</Button>
</Space>
</Form.Item>
</Form>
) : null}
<div style={{ marginBottom: 24 }}>
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
<Typography.Paragraph type="secondary">
宿
</Typography.Paragraph>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={row.expenses || []}
locale={{ emptyText: '还没有挂到本项目的报销。点右上角「报销」登记。' }}
columns={[
{
title: '单号',
width: 150,
render: (_: unknown, r: NonNullable<ProjectDetail['expenses']>[0]) => (
<Link to={`/office/expenses/${r.id}`}>{r.claimNo}</Link>
),
},
{
title: '类别',
width: 80,
render: (_: unknown, r: NonNullable<ProjectDetail['expenses']>[0]) =>
BUDGET_CATEGORY_LABEL[r.budgetCategory || ''] || r.budgetCategory || '报销',
},
{ title: '事由', dataIndex: 'purpose', ellipsis: true },
{
title: '金额',
width: 110,
render: (_: unknown, r: NonNullable<ProjectDetail['expenses']>[0]) => money(r.amount),
},
{
title: '状态',
width: 90,
render: (_: unknown, r: NonNullable<ProjectDetail['expenses']>[0]) => {
const s = EXPENSE_STATUS_META[r.status] || { color: 'default', text: r.status };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '申请人',
width: 100,
render: (_: unknown, r: NonNullable<ProjectDetail['expenses']>[0]) => r.applicant?.displayName || '—',
},
{
title: '时间',
width: 120,
render: (_: unknown, r: NonNullable<ProjectDetail['expenses']>[0]) =>
r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD') : '—',
},
]}
/>
<Typography.Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
</Typography.Text>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={row.purchases || []}
locale={{ emptyText: '本项目还没有采购申请。' }}
columns={[
{ title: '名称', dataIndex: 'title', ellipsis: true },
{
title: '金额',
width: 110,
render: (_: unknown, r: NonNullable<ProjectDetail['purchases']>[0]) => money(r.amount),
},
{
title: '状态',
width: 90,
render: (_: unknown, r: NonNullable<ProjectDetail['purchases']>[0]) => {
const s = PURCHASE_STATUS_META[r.status] || { color: 'default', text: r.status };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '申请人',
width: 100,
render: (_: unknown, r: NonNullable<ProjectDetail['purchases']>[0]) => r.applicant?.displayName || '—',
},
{
title: '时间',
width: 120,
render: (_: unknown, r: NonNullable<ProjectDetail['purchases']>[0]) =>
r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD') : '—',
},
]}
/>
</div>
</>
)}
<div style={{ marginBottom: 24 }}>
<Typography.Title level={5} style={{ marginTop: 0 }}>
</Typography.Title>
<Typography.Paragraph type="secondary">
{row.acceptMaterials?.length
? ` 已上传 ${(row.acceptMaterials || []).filter((m) => m.uploaded).length} / ${row.acceptMaterials.length}`
: ''}
</Typography.Paragraph>
{row.myActions.canEditMaterials ? (
<Form
form={materialForm}
layout="inline"
style={{ marginBottom: 12, rowGap: 12 }}
onFinish={(v) => materialMut.mutate(v)}
>
<Form.Item name="name" rules={[{ required: true, message: '请填写材料名称' }]}>
<Input placeholder="例如:验收报告、交付清单" style={{ width: 260 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={materialMut.isPending}>
</Button>
{!(row.acceptMaterials || []).length ? (
<Button loading={seedMatMut.isPending} onClick={() => seedMatMut.mutate()}>
</Button>
) : null}
</Space>
</Form.Item>
</Form>
) : null}
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={row.acceptMaterials || []}
locale={{ emptyText: '还没有材料。先写需要哪些,再依次上传。' }}
columns={[
{
title: '序号',
width: 60,
render: (_: unknown, __: unknown, i: number) => i + 1,
},
{
title: '材料名称',
render: (_: unknown, r) =>
row.myActions.canEditMaterials ? (
<Input
defaultValue={r.name}
onBlur={async (e) => {
const name = e.target.value.trim();
if (!name || name === r.name) return;
await api.patch(`/projects/${id}/accept-materials/${r.id}`, { name });
message.success('已改名称');
await invalidate();
}}
/>
) : (
r.name
),
},
{
title: '必备',
width: 80,
render: (_: unknown, r) =>
row.myActions.canEditMaterials ? (
<Button
type="link"
onClick={async () => {
await api.patch(`/projects/${id}/accept-materials/${r.id}`, { required: !r.required });
await invalidate();
}}
>
{r.required ? '是' : '否'}
</Button>
) : r.required ? (
'是'
) : (
'否'
),
},
{
title: '文件',
width: 220,
render: (_: unknown, r) => {
const f = r.files?.[0];
return (
<Space wrap>
{f ? (
<Button type="link" onClick={() => downloadBizFile(f.id, f.fileName)}>
{f.fileName}
</Button>
) : (
<Tag color={r.required ? 'warning' : 'default'}>{r.required ? '未上传' : '选传'}</Tag>
)}
{row.myActions.canEditMaterials ? (
<Upload
showUploadList={false}
beforeUpload={() => true}
customRequest={async (opt) => {
try {
const fd = new FormData();
fd.append('file', opt.file as File);
fd.append('bizType', 'PROJECT_ACCEPT');
fd.append('bizId', r.id);
await api.post('/files', fd);
message.success('已上传');
await invalidate();
} catch (e) {
message.error((e as Error).message || '上传失败');
}
}}
>
<Button type="link">{f ? '更换' : '上传'}</Button>
</Upload>
) : null}
</Space>
);
},
},
{
title: '操作',
width: 80,
render: (_: unknown, r) =>
row.myActions.canEditMaterials ? (
<Button
type="link"
danger
onClick={async () => {
await api.delete(`/projects/${id}/accept-materials/${r.id}`);
message.success('已从清单去掉');
await invalidate();
}}
>
</Button>
) : null,
},
]}
/>
</div>
{acceptanceOnly ? null : (
<>
<div style={{ marginBottom: 24 }}>
<Typography.Text strong></Typography.Text>
<BizFileList files={row.files} empty="没有从生产带过来的附件。" />
</div>
<Typography.Title level={5}></Typography.Title>
{row.myActions.canManageWbs ? (
<Form
form={taskForm}
layout="inline"
style={{ marginBottom: 12, rowGap: 12 }}
onFinish={(v) => taskMut.mutate(v)}
>
<Form.Item name="name" rules={[{ required: true, message: '请填任务' }]}>
<Input placeholder="任务名称" />
</Form.Item>
<Form.Item name="departmentId" rules={[{ required: true, message: '请选部门' }]}>
<Select options={deptOpts} placeholder="部门" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="assigneeId">
<Select allowClear showSearch optionFilterProp="label" options={staffOpts} placeholder="负责人" style={{ width: 140 }} />
</Form.Item>
<Form.Item name="dueAt">
<DatePicker placeholder="截止" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={taskMut.isPending}>
</Button>
</Form.Item>
</Form>
) : null}
<Table
rowKey="id"
size="small"
pagination={false}
style={{ marginBottom: 24 }}
dataSource={row.tasks}
columns={[
{ title: '任务', dataIndex: 'name' },
{ title: '部门', render: (_: unknown, r: ProjectDetail['tasks'][0]) => r.dept?.name || r.department || '—' },
{ title: '负责人', width: 110, render: (_: unknown, r: ProjectDetail['tasks'][0]) => r.assignee?.displayName || '—' },
{
title: '截止',
width: 120,
render: (_: unknown, r: ProjectDetail['tasks'][0]) => (r.dueAt ? dayjs(r.dueAt).format('YYYY-MM-DD') : '—'),
},
{
title: '状态',
width: 100,
render: (_: unknown, r: ProjectDetail['tasks'][0]) => {
const s = TASK_STATUS_META[r.status] || { color: 'default', text: r.status };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '操作',
width: 90,
render: (_: unknown, r: ProjectDetail['tasks'][0]) =>
row.myActions.canManageWbs && r.status !== 'DONE' ? (
<Button type="link" size="small" onClick={() => doneMut.mutate(r.id)}>
</Button>
) : null,
},
]}
/>
<Typography.Title level={5}></Typography.Title>
{row.myActions.canFillTimesheet ? (
<Form
form={sheetForm}
layout="inline"
style={{ marginBottom: 12, rowGap: 12 }}
onFinish={(v) => sheetMut.mutate(v)}
>
<Form.Item name="taskId">
<Select
allowClear
options={row.tasks.map((t) => ({ value: t.id, label: t.name }))}
placeholder="任务(可选)"
style={{ width: 180 }}
/>
</Form.Item>
<Form.Item name="workDate" rules={[{ required: true, message: '请选日期' }]}>
<DatePicker />
</Form.Item>
<Form.Item name="hours" rules={[{ required: true, message: '请填工时' }]}>
<InputNumber min={0.5} max={24} step={0.5} placeholder="小时" />
</Form.Item>
<Form.Item name="note">
<Input placeholder="说明" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={sheetMut.isPending}>
</Button>
</Form.Item>
</Form>
) : null}
<Table
rowKey="id"
size="small"
pagination={false}
style={{ marginBottom: 24 }}
dataSource={row.timesheets}
columns={[
{ title: '人员', width: 110, render: (_: unknown, r: ProjectDetail['timesheets'][0]) => r.user?.displayName || '—' },
{ title: '任务', render: (_: unknown, r: ProjectDetail['timesheets'][0]) => r.task?.name || '—' },
{
title: '日期',
width: 120,
render: (_: unknown, r: ProjectDetail['timesheets'][0]) => dayjs(r.workDate).format('YYYY-MM-DD'),
},
{ title: '工时', dataIndex: 'hours', width: 80 },
{ title: '技术成本', width: 110, render: (_: unknown, r: ProjectDetail['timesheets'][0]) => money(r.costAmount) },
{ title: '说明', dataIndex: 'note' },
]}
/>
</>
)}
{acceptanceOnly ? null : (
<>
<Typography.Title level={5}></Typography.Title>
<Table
rowKey="id"
size="small"
pagination={false}
style={{ marginBottom: 16 }}
dataSource={row.acceptances}
locale={{ emptyText: '尚未验收' }}
columns={[
{ title: '类型', width: 120, render: (_: unknown, r: ProjectDetail['acceptances'][0]) => ACCEPT_KIND_LABEL[r.kind] || r.kind },
{
title: '结果',
width: 90,
render: (_: unknown, r: ProjectDetail['acceptances'][0]) => (
<Tag color={r.result === 'APPROVED' ? 'success' : 'error'}>{r.result === 'APPROVED' ? '通过' : '不通过'}</Tag>
),
},
{ title: '办理人', width: 110, render: (_: unknown, r: ProjectDetail['acceptances'][0]) => r.actor?.displayName || '—' },
{
title: '时间',
width: 170,
render: (_: unknown, r: ProjectDetail['acceptances'][0]) => dayjs(r.decidedAt).format('YYYY-MM-DD HH:mm'),
},
{ title: '意见', dataIndex: 'comment' },
]}
/>
{row.contract?.milestones?.length ? (
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={row.contract.milestones}
columns={[
{ title: '收款条款', dataIndex: 'name' },
{ title: '比例', dataIndex: 'ratio', width: 80, render: (v: string) => `${v}%` },
{
title: '开票',
width: 120,
render: (_: unknown, m: NonNullable<ProjectDetail['contract']>['milestones'][0]) =>
m.invoice ? <Tag>{m.invoice.status === 'TO_ISSUE' ? '待开' : m.invoice.status}</Tag> : '未生成',
},
]}
/>
) : null}
</>
)}
</>
);
if (embedded) return page;
return (
<FormDrawer title={row.name} extra={row.projectNo} open hideOk width="min(1120px, 86vw)" createdAt={row.createdAt} onClose={() => navigate('/project/list')}>
{page}
</FormDrawer>
);
}
+387
View File
@@ -0,0 +1,387 @@
import { Button, Form, Input, InputNumber, Progress, Select, Tag, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { FormDrawer } from './FormDrawer';
import ProjectDetailPage from './ProjectDetailPage';
import { ResourcePage } from './ResourcePage';
import { money } from './financeStatus';
import { projectStatusMeta } from './projectStatus';
import { api } from '../api/client';
type Usage = {
labor: number;
travel: number;
techTravel?: number;
purchase: number;
businessUsed?: number;
techUsed?: number;
limits: {
labor: number | null;
travel: number | null;
purchase: number | null;
business?: number | null;
tech?: number | null;
};
};
function bar(used: number, limit: number | null) {
if (limit === null || limit === undefined) return '未设预算';
const pct = limit <= 0 ? (used > 0 ? 100 : 0) : Math.min(100, Math.round((used / limit) * 100));
return (
<div>
<Progress percent={pct} size="small" status={used - limit > 0.001 ? 'exception' : 'normal'} />
<span>
{money(used)} / {money(limit)}
</span>
</div>
);
}
export default function ProjectListPage({
title,
description,
bucket,
mode = 'list',
}: {
title: string;
description?: string;
bucket?: string;
mode?: 'list' | 'budget' | 'acceptance';
}) {
const [openRow, setOpenRow] = useState<Record<string, unknown>>();
const [creating, setCreating] = useState(false);
const [projectType, setProjectType] = useState<string>();
const [form] = Form.useForm();
const qc = useQueryClient();
const params = new URLSearchParams();
if (bucket) params.set('bucket', bucket);
if (projectType) params.set('projectType', projectType);
const endpoint = params.toString() ? `/projects?${params}` : '/projects';
const typeOpts = [
{ label: '软件', value: '软件' },
{ label: '三维', value: '三维' },
{ label: '摄制', value: '摄制' },
{ label: '物资', value: '物资' },
{ label: '集成', value: '集成' },
];
const contracts = useQuery({
queryKey: ['/contracts', 'project-create'],
queryFn: () => api.get('/contracts?pageSize=200'),
enabled: bucket === 'ledger',
});
const contractItems = ((contracts.data as { data?: { items?: Record<string, unknown>[] } })?.data?.items ||
[]) as {
id: string;
contractNo?: string;
name: string;
amount?: number | string;
partyA?: string | null;
kind?: string;
projects?: unknown[];
}[];
const contractOpts = contractItems
.filter((c) => !c.projects?.length)
.map((c) => ({
value: c.id,
label: `${c.contractNo || '未编号'} ${c.name}${c.kind === 'DIRECT' ? '(直签)' : ''}`,
}));
const createMut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/projects', body),
onSuccess: () => {
message.success('已建立项目台账');
qc.invalidateQueries({ queryKey: [endpoint] });
qc.invalidateQueries({ queryKey: ['/contracts', 'project-create'] });
},
onError: (e: Error) => message.error(e.message),
});
const budgetCols =
mode === 'budget'
? [
{
title: '商务',
width: 180,
render: (_: unknown, row: Record<string, unknown>) => {
const u = row.usage as Usage | undefined;
return bar(u?.businessUsed ?? (u?.travel ?? 0), u?.limits?.business ?? null);
},
},
{
title: '技术',
width: 180,
render: (_: unknown, row: Record<string, unknown>) => {
const u = row.usage as Usage | undefined;
return bar(u?.techUsed ?? ((u?.labor ?? 0) + (u?.purchase ?? 0) + (u?.techTravel ?? 0)), u?.limits?.tech ?? null);
},
},
]
: [];
const acceptance = mode === 'acceptance';
const ledgerCols = acceptance
? [
{ title: '项目号', dataIndex: 'projectNo', width: 150 },
{
title: '类别',
dataIndex: 'projectType',
width: 80,
render: (v: string) => v || '—',
},
{
title: '名称',
dataIndex: 'name',
width: 280,
ellipsis: true,
render: (v: string, row: Record<string, unknown>) => (
<Button type="link" onClick={() => setOpenRow(row)}>
{v}
</Button>
),
},
{
title: '验收材料',
width: 110,
render: (_: unknown, row: Record<string, unknown>) => {
const n = Number(row.acceptMaterialCount || 0);
return n ? `${n}` : '尚未列入';
},
},
{
title: '状态',
dataIndex: 'status',
width: 120,
render: (v: string) => {
const s = projectStatusMeta(v);
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '操作',
width: 110,
render: (_: unknown, row: Record<string, unknown>) => (
<Button type="link" onClick={() => setOpenRow(row)}>
</Button>
),
},
]
: [
{ title: '项目号', dataIndex: 'projectNo', width: 150 },
{
title: '类别',
dataIndex: 'projectType',
width: 80,
render: (v: string) => v || '—',
},
{
title: '名称',
dataIndex: 'name',
width: 240,
ellipsis: true,
render: (v: string, row: Record<string, unknown>) => (
<Button type="link" onClick={() => setOpenRow(row)}>
{v}
</Button>
),
},
{
title: '合同号',
width: 150,
render: (_: unknown, row: Record<string, unknown>) => {
const c = row.contract as { id?: string; contractNo?: string } | null;
return c?.contractNo || '—';
},
},
{
title: '招标单位',
width: 180,
ellipsis: true,
render: (_: unknown, row: Record<string, unknown>) => {
const c = row.contract as { bidCase?: { party?: { name?: string } } } | null;
return (row.partyName as string) || c?.bidCase?.party?.name || '—';
},
},
{
title: '预算成本',
width: 110,
render: (_: unknown, row: Record<string, unknown>) => money((row.budgetTotal as number) ?? row.presalesCost),
},
{
title: '合同额',
width: 110,
render: (_: unknown, row: Record<string, unknown>) => {
const c = row.contract as { amount?: string | number } | null;
return money((row.amount as number) || c?.amount);
},
},
{
title: '已发生成本',
width: 120,
render: (_: unknown, row: Record<string, unknown>) => {
const u = row.usage as Usage | undefined;
const used =
(u?.businessUsed ?? 0) + (u?.techUsed ?? (u?.labor ?? 0) + (u?.travel ?? 0) + (u?.purchase ?? 0));
return money(used);
},
},
{
title: '报销',
width: 70,
render: (_: unknown, row: Record<string, unknown>) => Number(row.expenseCount || 0),
},
{
title: '状态',
dataIndex: 'status',
width: 120,
render: (v: string) => {
const s = projectStatusMeta(v);
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: (v: string) => (v ? String(v).replace('T', ' ').slice(0, 19) : '—'),
},
...budgetCols,
{
title: '操作',
width: 90,
render: (_: unknown, row: Record<string, unknown>) => (
<Button type="link" onClick={() => setOpenRow(row)}>
</Button>
),
},
];
return (
<>
<ResourcePage
title={title}
description={description}
searchPlaceholder="搜索项目号、名称"
exportName={title}
extra={
bucket === 'ledger' ? (
<Button
type="primary"
onClick={() => {
form.resetFields();
form.setFieldsValue({ status: 'NOT_STARTED' });
setCreating(true);
}}
>
</Button>
) : undefined
}
emptyText={
acceptance
? '台账里把验收材料加入清单后,项目会出现在这里。不必在本页新增。'
: '还没有项目。直签或补登可用右上角「新增」。'
}
toolbarExtra={
<Select
allowClear
placeholder="项目类别"
style={{ minWidth: 140 }}
value={projectType}
options={typeOpts}
onChange={setProjectType}
/>
}
filters={[
{
key: 'status',
label: '状态',
options: [
{ label: '未开始', value: 'NOT_STARTED' },
{ label: '执行中', value: 'ACTIVE' },
{ label: '待验收', value: 'INTERNAL_ACCEPTED' },
{ label: '已验收', value: 'ACCEPTED' },
{ label: '已完成', value: 'DONE' },
],
},
]}
onRowClick={(row) => setOpenRow(row)}
endpoint={endpoint}
columns={ledgerCols}
/>
<FormDrawer
title="新建项目"
open={creating}
onClose={() => setCreating(false)}
okText="提交保存"
okLoading={createMut.isPending}
onOk={async () => {
const v = await form.validateFields();
await createMut.mutateAsync(v);
setCreating(false);
}}
>
<Form
form={form}
layout="vertical"
onValuesChange={(changed) => {
if (!changed.contractId) return;
const c = contractItems.find((x) => x.id === changed.contractId);
if (!c) return;
form.setFieldsValue({
name: form.getFieldValue('name') || c.name,
partyName: c.partyA || undefined,
amount: c.amount ? Number(c.amount) : undefined,
});
}}
>
<Form.Item name="name" label="项目名称" rules={[{ required: true, message: '请填写项目名称' }]}>
<Input placeholder="直签、补登历史项目都可在这里建" />
</Form.Item>
<Form.Item name="contractId" label="关联合同" extra="直签合同、其他合同未立项的都可以挂上。没有合同可空着。">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="可选,优先选直签合同"
options={contractOpts}
/>
</Form.Item>
<Form.Item name="partyName" label="甲方 / 客户">
<Input placeholder="没有合同时报招标单位或甲方" />
</Form.Item>
<Form.Item name="projectType" label="项目类别" rules={[{ required: true, message: '请选择项目类别' }]}>
<Select options={typeOpts} placeholder="软件 / 三维 / 摄制 / 物资 / 集成" />
</Form.Item>
<Form.Item
name="amount"
label="项目总额"
extra="有合同金额会按商务、技术各一半生成预算;没有合同可手填总额。"
>
<InputNumber min={0} precision={2} style={{ width: '100%' }} placeholder="合同额或项目总额" />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select
options={[
{ value: 'NOT_STARTED', label: '未开始' },
{ value: 'ACTIVE', label: '执行中' },
]}
/>
</Form.Item>
</Form>
</FormDrawer>
<FormDrawer
title={acceptance ? '验收材料' : '项目办理'}
open={Boolean(openRow)}
onClose={() => setOpenRow(undefined)}
hideOk
width="min(1120px, 86vw)"
extra={
acceptance
? '只看本项目要交的验收材料。清单在台账办理里加入后,项目会出现在本页。'
: '点项目名称或办理即可打开详情。报销、预算、花费都在这里看。'
}
createdAt={openRow?.createdAt as string | undefined}
>
{openRow ? <ProjectDetailPage id={String(openRow.id)} embedded focus={acceptance ? 'acceptance' : 'full'} /> : null}
</FormDrawer>
</>
);
}
+205
View File
@@ -0,0 +1,205 @@
import { Button, Space, message } from 'antd';
import { useEffect, useRef, useState } from 'react';
import { api } from '../api/client';
type Hit = { lat: number; lng: number; title?: string; address?: string };
declare global {
interface Window {
TMap?: {
Map: new (el: HTMLElement, opts: Record<string, unknown>) => TMapInst;
LatLng: new (lat: number, lng: number) => unknown;
MultiMarker: new (opts: Record<string, unknown>) => { setGeometries: (g: unknown[]) => void; destroy?: () => void };
MultiCircle: new (opts: Record<string, unknown>) => { setGeometries: (g: unknown[]) => void; destroy?: () => void };
CircleStyle: new (opts: Record<string, unknown>) => unknown;
};
}
}
type TMapInst = {
on: (ev: string, fn: (e: { latLng?: { getLat: () => number; getLng: () => number } }) => void) => void;
setCenter: (p: unknown) => void;
destroy?: () => void;
};
function loadGl(key: string) {
if (window.TMap) return Promise.resolve();
return new Promise<void>((resolve, reject) => {
const existed = document.querySelector(`script[data-tmap="gl"]`) as HTMLScriptElement | null;
if (existed) {
existed.addEventListener('load', () => resolve());
existed.addEventListener('error', () => reject(new Error('地图脚本加载失败')));
return;
}
const s = document.createElement('script');
s.src = `https://map.qq.com/api/gljs?v=1.exp&key=${encodeURIComponent(key)}`;
s.async = true;
s.dataset.tmap = 'gl';
s.onload = () => resolve();
s.onerror = () => reject(new Error('地图脚本加载失败'));
document.head.appendChild(s);
});
}
export function PunchLocationMap({
lat,
lng,
radiusMeters,
onPick,
}: {
lat?: number | string | null;
lng?: number | string | null;
radiusMeters?: number;
onPick: (hit: Hit) => void;
}) {
const box = useRef<HTMLDivElement>(null);
const mapRef = useRef<TMapInst | null>(null);
const markerRef = useRef<{ setGeometries: (g: unknown[]) => void; destroy?: () => void } | null>(null);
const circleRef = useRef<{ setGeometries: (g: unknown[]) => void; destroy?: () => void } | null>(null);
const [key, setKey] = useState('');
const [picker, setPicker] = useState(false);
const latN = lat == null || lat === '' ? NaN : Number(lat);
const lngN = lng == null || lng === '' ? NaN : Number(lng);
const hasPoint = Number.isFinite(latN) && Number.isFinite(lngN);
const radius = Math.max(50, Number(radiusMeters) || 300);
useEffect(() => {
void api.get('/office/geo/map-config').then((res) => {
const k = (res as { data?: { key?: string } }).data?.key || '';
setKey(k);
});
}, []);
useEffect(() => {
if (!key || !box.current) return;
let dead = false;
void (async () => {
try {
await loadGl(key);
if (dead || !box.current || !window.TMap) return;
const center = hasPoint ? new window.TMap.LatLng(latN, lngN) : new window.TMap.LatLng(32.041, 118.78);
if (!mapRef.current) {
const map = new window.TMap.Map(box.current, { center, zoom: hasPoint ? 16 : 12 });
mapRef.current = map;
map.on('click', (e) => {
const p = e.latLng;
if (!p) return;
void applyPick(p.getLat(), p.getLng());
});
} else {
mapRef.current.setCenter(center);
}
const TMap = window.TMap;
if (!markerRef.current) {
markerRef.current = new TMap.MultiMarker({
map: mapRef.current,
geometries: hasPoint ? [{ id: 'punch', position: center }] : [],
});
} else if (hasPoint) {
markerRef.current.setGeometries([{ id: 'punch', position: center }]);
}
if (!circleRef.current) {
circleRef.current = new TMap.MultiCircle({
map: mapRef.current,
styles: {
default: new TMap.CircleStyle({
color: 'rgba(124,58,237,0.18)',
showBorder: true,
borderColor: '#7c3aed',
borderWidth: 2,
}),
},
geometries: hasPoint ? [{ id: 'r', center, radius }] : [],
});
} else {
circleRef.current.setGeometries(hasPoint ? [{ id: 'r', center, radius }] : []);
}
} catch {
/* locpicker 仍可用 */
}
})();
return () => {
dead = true;
};
}, [key, latN, lngN, radius, hasPoint]);
useEffect(() => {
return () => {
markerRef.current?.destroy?.();
circleRef.current?.destroy?.();
mapRef.current?.destroy?.();
markerRef.current = null;
circleRef.current = null;
mapRef.current = null;
};
}, []);
useEffect(() => {
const onMsg = (ev: MessageEvent) => {
const loc = ev.data as { module?: string; latlng?: { lat: number; lng: number }; poiaddress?: string; poiname?: string };
if (!loc || loc.module !== 'locationPicker' || !loc.latlng) return;
void applyPick(Number(loc.latlng.lat), Number(loc.latlng.lng), loc.poiname, loc.poiaddress);
setPicker(false);
};
window.addEventListener('message', onMsg);
return () => window.removeEventListener('message', onMsg);
}, []);
async function applyPick(lat: number, lng: number, title?: string, address?: string) {
let hit: Hit = { lat, lng, title, address };
try {
const res = (await api.post('/office/geo/reverse', { lat, lng })) as {
data?: { address?: string; title?: string };
};
hit = {
lat,
lng,
title: title || res.data?.title,
address: res.data?.address || address,
};
} catch {
/* 坐标已够用 */
}
onPick(hit);
message.success('已选中打卡点,请看范围圈是否盖住楼');
}
return (
<div>
<Space wrap style={{ marginBottom: 8 }}>
<Button type="primary" onClick={() => setPicker(true)} disabled={!key}>
</Button>
<span style={{ color: 'var(--mute)', fontSize: 12 }}>
</span>
</Space>
<div ref={box} style={{ width: '100%', height: 280, borderRadius: 8, overflow: 'hidden', background: '#eef2ff' }} />
{picker && key ? (
<div
style={{
position: 'fixed',
inset: 0,
zIndex: 4000,
background: 'rgba(15,23,42,.45)',
display: 'grid',
placeItems: 'center',
padding: 24,
}}
>
<div style={{ width: 'min(720px, 100%)', height: 'min(560px, 80vh)', background: '#fff', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<div style={{ padding: '8px 12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<strong></strong>
<Button onClick={() => setPicker(false)}></Button>
</div>
<iframe
title="地图选点"
style={{ flex: 1, border: 0 }}
src={`https://apis.map.qq.com/tools/locpicker?search=1&type=1&key=${encodeURIComponent(key)}&referer=fengyingoa`}
/>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,357 @@
import { Button, DatePicker, Form, Input, Popconfirm, Select, Space, Table, Tag, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { FormDrawer } from './FormDrawer';
import { PageHeader, unwrapList } from './ResourcePage';
import { ApplyBucketBar } from './ApplyBucketBar';
export const QUAL_BUCKET_META: Record<string, { color: string; text: string }> = {
valid: { color: 'success', text: '有效' },
expiring: { color: 'gold', text: '即将到期' },
expired: { color: 'error', text: '已过期' },
};
export const BORROW_BUCKET_META: Record<string, { color: string; text: string }> = {
borrowed: { color: 'blue', text: '外带中' },
due: { color: 'gold', text: '应还' },
overdue: { color: 'error', text: '未还逾期' },
returned: { color: 'success', text: '已归还' },
};
export const SEAL_BUCKET_META: Record<string, { color: string; text: string }> = {
pending: { color: 'processing', text: '待审批' },
approved: { color: 'success', text: '已通过' },
rejected: { color: 'error', text: '已驳回' },
out: { color: 'blue', text: '外带中' },
overdue: { color: 'error', text: '外带未还' },
returned: { color: 'success', text: '已归还' },
};
export default function QualificationListPage() {
const [params] = useSearchParams();
const bucket = params.get('bucket') || '';
const endpoint = bucket ? `/qualifications?bucket=${bucket}` : '/qualifications';
const qc = useQueryClient();
const [form] = Form.useForm();
const [drawer, setDrawer] = useState<null | 'create' | { id: string }>(null);
const q = useQuery({
queryKey: [endpoint],
queryFn: () => api.get(endpoint),
});
const { items } = unwrapList(q.data);
const mut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/qualifications', body),
onSuccess: () => {
message.success('已登记');
qc.invalidateQueries();
},
onError: (e: Error) => message.error(e.message),
});
const openCreate = () => {
form.resetFields();
setDrawer('create');
};
return (
<>
<PageHeader
title="资质台账"
description="30 天内到期会标成即将到期,过期单独列出,方便投标当天核对。"
extra={
<ApplyBucketBar
basePath="/seal/qualifications"
buckets={[
{ value: 'valid', label: '有效' },
{ value: 'expiring', label: '即将到期' },
{ value: 'expired', label: '已过期' },
]}
extra={
<Button type="primary" onClick={openCreate}>
</Button>
}
/>
}
/>
<FormDrawer
title={drawer === 'create' ? '登记资质' : '编辑资质'}
open={Boolean(drawer)}
onClose={() => setDrawer(null)}
okText="提交保存"
okLoading={mut.isPending}
onOk={async () => {
const v = await form.validateFields();
const body = {
...v,
expiresAt: v.expiresAt ? dayjs(v.expiresAt).format('YYYY-MM-DD') : undefined,
};
if (drawer === 'create') await mut.mutateAsync(body);
else if (drawer && typeof drawer === 'object') {
await api.patch(`/qualifications/${drawer.id}`, body);
message.success('已保存');
qc.invalidateQueries();
}
setDrawer(null);
}}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="资质名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="keeper" label="保管人">
<Input />
</Form.Item>
<Form.Item name="expiresAt" label="到期日">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Form>
</FormDrawer>
<Table
className="ops-table"
rowKey={(r) => String(r.id)}
loading={q.isLoading}
dataSource={items}
scroll={{ x: 'max-content' }}
tableLayout="fixed"
columns={[
{ title: '资质', dataIndex: 'name', width: 220, ellipsis: true },
{ title: '编码', dataIndex: 'code', width: 140 },
{ title: '保管人', dataIndex: 'keeper', width: 100 },
{
title: '到期日',
dataIndex: 'expiresAt',
width: 120,
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—'),
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '—'),
},
{
title: '预警',
dataIndex: 'bucket',
width: 110,
render: (v: string) => {
const s = QUAL_BUCKET_META[v] || { color: 'default', text: v || '—' };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '操作',
width: 140,
render: (_: unknown, r: Record<string, unknown>) => (
<Space>
<Button
type="link"
onClick={() => {
form.setFieldsValue({
name: r.name,
code: r.code,
keeper: r.keeper,
expiresAt: r.expiresAt ? dayjs(String(r.expiresAt)) : undefined,
});
setDrawer({ id: String(r.id) });
}}
>
</Button>
<Popconfirm
title="确认删除这条资质?"
onConfirm={async () => {
await api.delete(`/qualifications/${r.id}`);
message.success('已删除');
qc.invalidateQueries();
}}
>
<Button type="link" danger>
</Button>
</Popconfirm>
</Space>
),
},
]}
/>
</>
);
}
export function BorrowListPage() {
const [params] = useSearchParams();
const bucket = params.get('bucket') || '';
const endpoint = bucket ? `/credential-borrows?bucket=${bucket}` : '/credential-borrows';
const qc = useQueryClient();
const [form] = Form.useForm();
const [creating, setCreating] = useState(false);
const q = useQuery({ queryKey: [endpoint], queryFn: () => api.get(endpoint) });
const quals = useQuery({ queryKey: ['/qualifications'], queryFn: () => api.get('/qualifications') });
const staff = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') });
const bids = useQuery({ queryKey: ['/bid-cases'], queryFn: () => api.get('/bid-cases') });
const { items } = unwrapList(q.data);
const createMut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/credential-borrows', body),
onSuccess: () => {
message.success('已登记外带');
qc.invalidateQueries();
},
onError: (e: Error) => message.error(e.message),
});
const retMut = useMutation({
mutationFn: (id: string) => api.post(`/credential-borrows/${id}/return`),
onSuccess: () => {
message.success('已归还');
qc.invalidateQueries();
},
onError: (e: Error) => message.error(e.message),
});
const openCreate = () => {
form.resetFields();
setCreating(true);
};
return (
<>
<PageHeader
title="证照借用"
description="外带必须登记预计归还。到期未还会在总览里预警。"
extra={
<ApplyBucketBar
basePath="/seal/borrows"
buckets={[
{ value: 'borrowed', label: '外带中' },
{ value: 'due', label: '应还' },
{ value: 'overdue', label: '逾期未还' },
{ value: 'returned', label: '已归还' },
]}
extra={
<Button type="primary" onClick={openCreate}>
</Button>
}
/>
}
/>
<FormDrawer
title="登记证照外带"
open={creating}
onClose={() => setCreating(false)}
okText="提交保存"
okLoading={createMut.isPending}
onOk={async () => {
const v = await form.validateFields();
await createMut.mutateAsync({ ...v, dueAt: v.dueAt ? dayjs(v.dueAt).format('YYYY-MM-DD') : undefined });
setCreating(false);
}}
>
<Form form={form} layout="vertical">
<Form.Item name="itemName" label="证照 / 物品" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="qualificationId" label="关联资质">
<Select
allowClear
showSearch
optionFilterProp="label"
options={((quals.data?.data?.items ?? []) as { id: string; name: string }[]).map((x) => ({
value: x.id,
label: x.name,
}))}
/>
</Form.Item>
<Form.Item name="borrowerId" label="借用人" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={((staff.data?.data ?? []) as { id: string; name: string }[]).map((s) => ({
value: s.id,
label: s.name,
}))}
/>
</Form.Item>
<Form.Item name="bidCaseId" label="关联投标">
<Select
allowClear
showSearch
optionFilterProp="label"
options={((bids.data?.data?.items ?? []) as { id: string; bidNo: string; name: string }[]).map((b) => ({
value: b.id,
label: `${b.bidNo} ${b.name}`,
}))}
/>
</Form.Item>
<Form.Item name="dueAt" label="预计归还" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="remark" label="说明">
<Input />
</Form.Item>
</Form>
</FormDrawer>
<Table
rowKey={(r) => String(r.id)}
loading={q.isLoading}
dataSource={items}
columns={[
{ title: '物品', dataIndex: 'itemName' },
{ title: '借用人', dataIndex: 'borrower', width: 110 },
{
title: '资质',
width: 180,
render: (_: unknown, row: Record<string, unknown>) =>
(row.qualification as { name?: string } | null)?.name || '—',
},
{
title: '应还',
dataIndex: 'dueAt',
width: 120,
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—'),
},
{
title: '状态',
dataIndex: 'bucket',
width: 110,
render: (v: string) => {
const s = BORROW_BUCKET_META[v] || { color: 'default', text: v };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '操作',
width: 140,
render: (_: unknown, row: Record<string, unknown>) => (
<Space>
{row.status !== 'RETURNED' ? (
<Button type="link" size="small" onClick={() => retMut.mutate(String(row.id))}>
</Button>
) : null}
<Popconfirm
title="确认删除这条借用记录?"
onConfirm={async () => {
await api.delete(`/credential-borrows/${row.id}`);
message.success('已删除');
qc.invalidateQueries();
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
]}
/>
</>
);
}
+154
View File
@@ -0,0 +1,154 @@
const PALETTE = ['#5b4dff', '#7c6cff', '#3d8bfd', '#2bb673', '#f5a524', '#e5484d', '#8b8fa3', '#14b8a6'];
export type Slice = { label: string; value: number; color?: string };
function withColor(items: Slice[]): Required<Slice>[] {
return items.map((s, i) => ({ ...s, color: s.color || PALETTE[i % PALETTE.length], value: Number(s.value) || 0 }));
}
export function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="rpt-card">
<div className="rpt-card-title">{title}</div>
{children}
</div>
);
}
export function PieChart({ items, size = 180 }: { items: Slice[]; size?: number }) {
const data = withColor(items).filter((s) => s.value > 0);
const total = data.reduce((s, i) => s + i.value, 0) || 1;
const r = size / 2 - 8;
const cx = size / 2;
const cy = size / 2;
let a = -Math.PI / 2;
const arcs = data.map((s) => {
const sweep = (s.value / total) * Math.PI * 2;
const x1 = cx + r * Math.cos(a);
const y1 = cy + r * Math.sin(a);
a += sweep;
const x2 = cx + r * Math.cos(a);
const y2 = cy + r * Math.sin(a);
const large = sweep > Math.PI ? 1 : 0;
return { ...s, d: `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} Z` };
});
return (
<div className="rpt-pie">
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
{arcs.length ? (
arcs.map((s) => <path key={s.label} d={s.d} fill={s.color} />)
) : (
<circle cx={cx} cy={cy} r={r} fill="#ecebff" />
)}
<circle cx={cx} cy={cy} r={r * 0.52} fill="#fff" />
</svg>
<ul>
{withColor(items).map((s) => (
<li key={s.label}>
<i style={{ background: s.color }} />
<span>{s.label}</span>
<b>{s.value}</b>
</li>
))}
</ul>
</div>
);
}
export function FunnelChart({ items }: { items: Slice[] }) {
const data = withColor(items);
const max = Math.max(...data.map((s) => s.value), 1);
return (
<div className="rpt-funnel">
{data.map((s) => {
const w = 36 + (s.value / max) * 64;
return (
<div key={s.label} className="rpt-funnel-row">
<div className="rpt-funnel-bar" style={{ width: `${w}%`, background: s.color }}>
{s.value}
</div>
<span>{s.label}</span>
</div>
);
})}
</div>
);
}
export function BarChart({ items, unit = '' }: { items: Slice[]; unit?: string }) {
const data = withColor(items);
const max = Math.max(...data.map((s) => Math.abs(s.value)), 1);
return (
<div className="rpt-bars">
{data.map((s) => (
<div key={s.label} className="rpt-bar-row">
<span className="rpt-bar-label" title={s.label}>
{s.label}
</span>
<div className="rpt-bar-track">
<div
className="rpt-bar-fill"
style={{
width: `${(Math.abs(s.value) / max) * 100}%`,
background: s.value < 0 ? '#e5484d' : s.color,
}}
/>
</div>
<b>
{s.value}
{unit}
</b>
</div>
))}
</div>
);
}
export function TreeMap({ items }: { items: Slice[] }) {
const data = withColor(items).filter((s) => s.value > 0).sort((a, b) => b.value - a.value);
const total = data.reduce((s, i) => s + i.value, 0) || 1;
return (
<div className="rpt-tree">
{data.map((s) => (
<div
key={s.label}
className="rpt-tree-cell"
style={{ flexGrow: s.value, flexBasis: `${Math.max((s.value / total) * 100, 12)}%`, background: s.color }}
>
<b>{s.label}</b>
<span>{s.value}</span>
</div>
))}
{data.length === 0 ? <div className="rpt-tree-empty"></div> : null}
</div>
);
}
export function LineChart({ items }: { items: Slice[] }) {
const data = withColor(items);
const w = 520;
const h = 180;
const pad = 28;
const max = Math.max(...data.map((s) => s.value), 1);
const min = Math.min(...data.map((s) => s.value), 0);
const span = max - min || 1;
const pts = data.map((s, i) => {
const x = pad + (i * (w - pad * 2)) / Math.max(data.length - 1, 1);
const y = h - pad - ((s.value - min) / span) * (h - pad * 2);
return { ...s, x, y };
});
const line = pts.map((p) => `${p.x},${p.y}`).join(' ');
return (
<svg className="rpt-line" viewBox={`0 0 ${w} ${h}`} width="100%" height={h}>
<polyline fill="none" stroke="#5b4dff" strokeWidth="2.5" points={line} />
{pts.map((p) => (
<g key={p.label}>
<circle cx={p.x} cy={p.y} r="4" fill="#5b4dff" />
<text x={p.x} y={h - 8} textAnchor="middle" fontSize="10" fill="#6b6f80">
{p.label.length > 6 ? `${p.label.slice(0, 6)}` : p.label}
</text>
</g>
))}
</svg>
);
}
+382
View File
@@ -0,0 +1,382 @@
import type { Key, ReactNode } from 'react';
import { useMemo, useState } from 'react';
import { Alert, Button, Checkbox, DatePicker, Form, Input, Modal, Radio, Select, Space, Table, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { MouseEvent } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../api/client';
import { fmtCreated } from '../time';
import { colsFromTable, downloadExcel, type ExportCol } from './exportExcel';
function colTitle(c: { title?: unknown }) {
return typeof c.title === 'string' ? c.title : '';
}
/** 列都给宽度,避免名称列被撑出大片空白、右侧挤成一团。没有创建时间的列表自动补上。 */
export function withListDefaults(columns: ColumnsType<Record<string, unknown>>): ColumnsType<Record<string, unknown>> {
const mapped = columns.map((c) => {
if ('children' in c && c.children) return c;
const title = colTitle(c);
const next = { ...c, ellipsis: c.ellipsis ?? true };
if (!next.width) {
if (/名称|事由|用途|标题|说明/.test(title)) next.width = 240;
else if (/编号|单号|合同号/.test(title)) next.width = 170;
else next.width = 120;
}
return next;
});
const hasCreated = mapped.some(
(c) => colTitle(c).includes('创建时间') || (c as { dataIndex?: string }).dataIndex === 'createdAt',
);
if (hasCreated) return mapped;
const created = {
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: (v: string) => fmtCreated(v),
};
const actionIdx = mapped.findIndex((c) => /操作/.test(colTitle(c)));
if (actionIdx >= 0) return [...mapped.slice(0, actionIdx), created, ...mapped.slice(actionIdx)];
return [...mapped, created];
}
const { Title, Paragraph } = Typography;
export function PageHeader({
title,
extra,
description,
}: {
title: string;
extra?: ReactNode;
description?: ReactNode;
}) {
return (
<div className="ops-page-head">
<div>
<Title level={4} className="yzl-page-title" style={{ margin: 0 }}>
{title}
</Title>
{description ? (
<Paragraph type="secondary" style={{ margin: '8px 0 0' }}>
{description}
</Paragraph>
) : null}
</div>
{extra}
</div>
);
}
export function unwrapList(data: unknown): { items: Record<string, unknown>[]; total?: number } {
const body = (data as { data?: unknown })?.data ?? data;
if (Array.isArray(body)) return { items: body as Record<string, unknown>[] };
if (body && typeof body === 'object' && 'items' in body) {
const page = body as { items: Record<string, unknown>[]; total?: number };
return { items: page.items ?? [], total: page.total };
}
return { items: [] };
}
function withPage(endpoint: string, page: number, pageSize: number, keyword: string) {
const u = new URL(endpoint, 'http://local');
u.searchParams.set('page', String(page));
u.searchParams.set('pageSize', String(pageSize));
if (keyword.trim()) u.searchParams.set('keyword', keyword.trim());
else u.searchParams.delete('keyword');
return `${u.pathname}${u.search}`;
}
export const LIST_PAGE_SIZE = 10;
export type ListFilter = {
key: string;
label: string;
type?: 'select' | 'dateRange';
options?: { label: string; value: string }[];
};
export function ResourcePage({
title,
description,
endpoint,
columns,
extra,
searchPlaceholder = '搜索单号、名称',
emptyText = '还没有记录。用右上角按钮新增。',
onRowClick,
toolbarExtra,
filters,
exportName,
exportColumns,
}: {
title: string;
description?: string;
endpoint: string;
columns: ColumnsType<Record<string, unknown>>;
extra?: ReactNode;
searchPlaceholder?: string;
emptyText?: string;
onRowClick?: (row: Record<string, unknown>) => void;
toolbarExtra?: ReactNode;
filters?: ListFilter[];
exportName?: string;
exportColumns?: ExportCol[];
}) {
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(LIST_PAGE_SIZE);
const [draft, setDraft] = useState('');
const [keyword, setKeyword] = useState('');
const [filterVals, setFilterVals] = useState<Record<string, string | undefined>>({});
const [exportOpen, setExportOpen] = useState(false);
const [exportKeys, setExportKeys] = useState<string[]>([]);
const [exportScope, setExportScope] = useState<'selected' | 'filtered' | 'all'>('filtered');
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const url = withPage(endpoint, page, pageSize, keyword);
const q = useQuery({
queryKey: [endpoint, page, pageSize, keyword],
queryFn: () => api.get(url),
retry: 1,
});
const { items: rawItems, total } = unwrapList(q.data);
const items = useMemo(() => {
return rawItems.filter((row) =>
(filters || []).every((f) => {
const v = filterVals[f.key];
if (!v) return true;
if (f.type === 'dateRange') {
const [a, b] = v.split('/');
const raw = String(row[f.key] || '');
if (!raw) return false;
const day = raw.slice(0, 10);
if (a && day < a) return false;
if (b && day > b) return false;
return true;
}
return String(row[f.key] ?? '') === v;
}),
);
}, [rawItems, filters, filterVals]);
const listCols = useMemo(() => withListDefaults(columns), [columns]);
const xcols = exportColumns || colsFromTable(listCols as { title?: unknown; dataIndex?: unknown; key?: unknown }[]);
const matchFilters = (row: Record<string, unknown>) =>
(filters || []).every((f) => {
const v = filterVals[f.key];
if (!v) return true;
if (f.type === 'dateRange') {
const [a, b] = v.split('/');
const raw = String(row[f.key] || '');
if (!raw) return false;
const day = raw.slice(0, 10);
if (a && day < a) return false;
if (b && day > b) return false;
return true;
}
return String(row[f.key] ?? '') === v;
});
const doExport = async () => {
const picked = xcols.filter((c) => exportKeys.includes(c.key));
if (!picked.length) return;
let rows = items;
if (exportScope === 'selected') {
const set = new Set(selectedKeys.map(String));
rows = items.filter((r) => set.has(String(r.id ?? r.code ?? r.name)));
if (!rows.length) {
return;
}
} else if (exportScope === 'all') {
const all = unwrapList(await api.get(withPage(endpoint, 1, 5000, keyword))).items;
rows = all.filter(matchFilters);
}
downloadExcel(exportName || title, picked, rows);
setExportOpen(false);
};
return (
<>
<PageHeader title={title} description={description} extra={extra} />
<div className="ops-toolbar" style={{ flexWrap: 'wrap', gap: 8 }}>
<Input.Search
allowClear
enterButton
value={draft}
placeholder={searchPlaceholder}
onChange={(e) => setDraft(e.target.value)}
onSearch={(v) => {
setKeyword(v);
setPage(1);
}}
className="yzl-search"
style={{ maxWidth: 360 }}
/>
{(filters || []).map((f) =>
f.type === 'dateRange' ? (
<DatePicker.RangePicker
key={f.key}
style={{ width: 240 }}
placeholder={[`${f.label}`, `${f.label}`]}
onChange={(vals) => {
setFilterVals((prev) => ({
...prev,
[f.key]: vals?.[0] && vals[1] ? `${vals[0].format('YYYY-MM-DD')}/${vals[1].format('YYYY-MM-DD')}` : undefined,
}));
setPage(1);
}}
/>
) : (
<Select
key={f.key}
allowClear
placeholder={f.label}
style={{ minWidth: 140 }}
value={filterVals[f.key]}
options={f.options}
onChange={(v) => {
setFilterVals((prev) => ({ ...prev, [f.key]: v }));
setPage(1);
}}
/>
),
)}
{toolbarExtra}
<Button
onClick={() => {
setExportKeys(xcols.map((c) => c.key));
setExportScope(selectedKeys.length ? 'selected' : 'filtered');
setExportOpen(true);
}}
>
Excel
</Button>
</div>
<Modal
title={`导出 ${title}`}
open={exportOpen}
onCancel={() => setExportOpen(false)}
onOk={() => void doExport()}
okText="开始导出"
>
<p style={{ marginBottom: 8 }}></p>
<Radio.Group
value={exportScope}
onChange={(e) => setExportScope(e.target.value)}
style={{ marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 6 }}
>
<Radio value="selected" disabled={!selectedKeys.length}>
{selectedKeys.length}
</Radio>
<Radio value="filtered"></Radio>
<Radio value="all"></Radio>
</Radio.Group>
<div>
<Checkbox
indeterminate={exportKeys.length > 0 && exportKeys.length < xcols.length}
checked={exportKeys.length === xcols.length}
onChange={(e) => setExportKeys(e.target.checked ? xcols.map((c) => c.key) : [])}
>
</Checkbox>
<Checkbox.Group
style={{ display: 'flex', flexDirection: 'column', marginTop: 8, gap: 6 }}
value={exportKeys}
onChange={(v) => setExportKeys(v as string[])}
options={xcols.map((c) => ({ label: c.title, value: c.key }))}
/>
</div>
</Modal>
{q.isError ? (
<Alert
type="error"
showIcon
style={{ marginBottom: 12 }}
message="列表加载失败"
description={(q.error as Error).message || '请稍后重试'}
action={<Button size="small" onClick={() => void q.refetch()}></Button>}
/>
) : null}
<Table
className={onRowClick ? 'ops-table ops-table-click' : 'ops-table'}
rowKey={(r) => String(r.id ?? r.code ?? r.name)}
rowSelection={{
selectedRowKeys: selectedKeys,
onChange: (keys) => setSelectedKeys(keys as Key[]),
}}
loading={q.isLoading && !q.isError}
columns={listCols}
scroll={{ x: 'max-content' }}
tableLayout="fixed"
dataSource={items}
onRow={
onRowClick
? (row) => ({
onClick: (e: MouseEvent) => {
if ((e.target as HTMLElement).closest('a,button,.ant-btn')) return;
onRowClick(row);
},
})
: undefined
}
locale={{ emptyText }}
pagination={{
current: page,
pageSize,
total: total ?? items.length,
showSizeChanger: true,
showQuickJumper: true,
pageSizeOptions: [10, 20, 50, 100],
showTotal: (n) => `${n}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
</>
);
}
export function SimpleCreateForm({
endpoint,
fields,
onOk,
}: {
endpoint: string;
fields: { name: string; label: string; required?: boolean }[];
onOk?: () => void;
}) {
const [form] = Form.useForm();
const qc = useQueryClient();
const mut = useMutation({
mutationFn: (values: Record<string, string>) => api.post(endpoint, values),
onSuccess: () => {
form.resetFields();
qc.invalidateQueries();
onOk?.();
},
});
return (
<Form
form={form}
layout="inline"
onFinish={(v) => mut.mutate(v)}
style={{ marginBottom: 16 }}
>
{fields.map((f) => (
<Form.Item
key={f.name}
name={f.name}
label={f.label}
rules={f.required ? [{ required: true }] : undefined}
>
<Input />
</Form.Item>
))}
<Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={mut.isPending}>
</Button>
</Space>
</Form.Item>
</Form>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useState } from 'react';
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
import '@wangeditor/editor/dist/css/style.css';
import { message } from 'antd';
import { uploadBizFile } from './FileAttachments';
type Props = {
value?: string;
onChange?: (html: string) => void;
bizId?: string;
placeholder?: string;
readOnly?: boolean;
};
export default function RichEditor({ value = '', onChange, bizId, placeholder, readOnly }: Props) {
const [editor, setEditor] = useState<IDomEditor | null>(null);
useEffect(() => {
return () => {
if (editor == null) return;
editor.destroy();
setEditor(null);
};
}, [editor]);
const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: ['insertVideo', 'uploadVideo', 'group-video', 'emotion'],
};
const editorConfig: Partial<IEditorConfig> = {
placeholder: placeholder || '请输入正文',
readOnly: Boolean(readOnly) || !bizId,
MENU_CONF: {
uploadImage: {
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
if (!bizId) {
message.warning('请先保存草稿再插入图片');
return;
}
try {
const row = await uploadBizFile('NOTICE', bizId, file);
insertFn(`/api/v1/files/${row.id}`, file.name, `/api/v1/files/${row.id}`);
} catch (e) {
message.error(e instanceof Error ? e.message : '图片上传失败');
}
},
},
},
};
return (
<div className="notice-editor">
<Toolbar editor={editor} defaultConfig={toolbarConfig} mode="default" style={{ borderBottom: '1px solid #e8e8ef' }} />
<Editor
defaultConfig={editorConfig}
value={value}
onCreated={setEditor}
onChange={(ed) => {
const next = ed.getHtml();
if (next !== value) onChange?.(next);
}}
mode="default"
style={{ height: 420, overflowY: 'hidden' }}
/>
</div>
);
}
+295
View File
@@ -0,0 +1,295 @@
import { Button, Checkbox, DatePicker, Form, Input, Popconfirm, Select, Space, Table, Tag, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { useAuth } from '../store/auth';
import { FormDrawer } from './FormDrawer';
import { PageHeader, unwrapList } from './ResourcePage';
import { SEAL_BUCKET_META } from './QualificationListPage';
import { ApplyBucketBar, PROCESS_BUCKETS } from './ApplyBucketBar';
type SealRow = {
id: string;
sealType: string;
reason: string;
takeOut: boolean;
status: string;
bucket: string;
returnAt?: string | null;
returnedAt?: string | null;
bidCase?: { id: string; bidNo: string } | null;
contract?: { id: string; contractNo: string } | null;
applicant?: { displayName: string } | null;
myActions?: { canReview: boolean; canReturn: boolean };
createdAt?: string;
};
const SEAL_TYPE_OPTIONS = ['公章', '合同章', '法人章', '法人签名章', '项目负责人章', '其他章'].map((v) => ({
value: v,
label: v,
}));
export default function SealRequestListPage({ mode = 'register' }: { mode?: 'apply' | 'register' }) {
const [params] = useSearchParams();
const bucket = params.get('bucket') || '';
const mine = mode === 'apply';
const endpoint = mine
? `/seal-requests?scope=mine${bucket ? `&bucket=${bucket}` : ''}`
: bucket
? `/seal-requests?bucket=${bucket}`
: '/seal-requests';
const userId = useAuth((s) => s.id);
const qc = useQueryClient();
const [form] = Form.useForm();
const [creating, setCreating] = useState(false);
const q = useQuery({ queryKey: [endpoint], queryFn: () => api.get(endpoint) });
const bids = useQuery({ queryKey: ['/bid-cases'], queryFn: () => api.get('/bid-cases') });
const contracts = useQuery({ queryKey: ['/contracts'], queryFn: () => api.get('/contracts') });
const staff = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') });
const staffOpts = ((staff.data as { data?: { id: string; name: string; department?: string }[] })?.data || []).map(
(s) => ({
value: s.id,
label: `${s.name}${s.department ? `${s.department}` : ''}`,
}),
);
const { items } = unwrapList(q.data) as { items: SealRow[] };
const createMut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/seal-requests', body),
onSuccess: () => {
message.success('已提交用章申请');
qc.invalidateQueries();
},
onError: (e: Error) => message.error(e.message),
});
const actMut = useMutation({
mutationFn: ({
id,
action,
result,
comment,
}: {
id: string;
action: 'review' | 'return';
result?: 'APPROVED' | 'REJECTED';
comment?: string;
}) =>
action === 'return'
? api.post(`/seal-requests/${id}/return`)
: api.post(`/seal-requests/${id}/reviews`, { result, comment }),
onSuccess: () => {
message.success('已办理');
qc.invalidateQueries();
},
onError: (e: Error) => message.error(e.message),
});
const openCreate = () => {
form.resetFields();
form.setFieldsValue({ applicantId: userId });
setCreating(true);
};
return (
<>
<PageHeader
title={mine ? '用章申请' : '用章登记'}
description={
mine
? '个人发起用章。可关联合同或投标,外带需填写预计归还日期。'
: '证照用章登记台账。外带经审批后未按期归还会预警。'
}
extra={
<ApplyBucketBar
basePath={mine ? '/office/seal-apply' : '/seal/requests'}
buckets={[
...PROCESS_BUCKETS,
{ value: 'out', label: '外带中' },
{ value: 'overdue', label: '逾期未还' },
{ value: 'returned', label: '已归还' },
]}
extra={
<Button type="primary" onClick={openCreate}>
</Button>
}
/>
}
/>
<FormDrawer
title="申请用章"
open={creating}
onClose={() => setCreating(false)}
okText="提交保存"
okLoading={createMut.isPending}
onOk={async () => {
const v = await form.validateFields();
await createMut.mutateAsync({
...v,
returnAt: v.returnAt ? dayjs(v.returnAt).format('YYYY-MM-DD') : undefined,
});
setCreating(false);
}}
>
<Form form={form} layout="vertical">
<Form.Item name="applicantId" label="申请人" rules={[{ required: true, message: '请选择申请人' }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="选择公司内部员工"
options={staffOpts}
disabled
/>
</Form.Item>
<Form.Item name="approverId" label="审批人" rules={[{ required: true, message: '请选择审批人' }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="选择本次用章审批人"
options={staffOpts}
/>
</Form.Item>
<Form.Item name="sealType" label="印章类型" rules={[{ required: true }]}>
<Select options={SEAL_TYPE_OPTIONS} placeholder="公章 / 合同章 / 法人章" />
</Form.Item>
<Form.Item name="reason" label="事由" rules={[{ required: true }]}>
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="bidCaseId" label="关联投标">
<Select
allowClear
showSearch
optionFilterProp="label"
options={((bids.data?.data?.items ?? []) as { id: string; bidNo: string; name: string }[]).map((b) => ({
value: b.id,
label: `${b.bidNo} ${b.name}`,
}))}
/>
</Form.Item>
<Form.Item name="contractId" label="关联合同">
<Select
allowClear
showSearch
optionFilterProp="label"
options={((contracts.data?.data?.items ?? []) as { id: string; contractNo: string; name: string }[]).map(
(c) => ({ value: c.id, label: `${c.contractNo} ${c.name}` }),
)}
/>
</Form.Item>
<Form.Item name="takeOut" valuePropName="checked">
<Checkbox></Checkbox>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{() =>
form.getFieldValue('takeOut') ? (
<Form.Item name="returnAt" label="预计归还" rules={[{ required: true, message: '外带请填写预计归还' }]}>
<DatePicker style={{ width: '100%' }} />
</Form.Item>
) : null
}
</Form.Item>
</Form>
</FormDrawer>
<Table
className="ops-table"
rowKey="id"
loading={q.isLoading}
dataSource={items}
pagination={{
defaultPageSize: 10,
showSizeChanger: true,
showTotal: (n) => `${n}`,
}}
scroll={{ x: 'max-content' }}
tableLayout="fixed"
columns={[
{ title: '印章', dataIndex: 'sealType', width: 110 },
{
title: '申请人',
width: 90,
render: (_: unknown, r: SealRow) => r.applicant?.displayName || '—',
},
{ title: '事由', dataIndex: 'reason', width: 240, ellipsis: true },
{
title: '关联',
width: 160,
render: (_: unknown, r: SealRow) =>
r.contract ? (
<Link to={`/contract/${r.contract.id}`}>{r.contract.contractNo}</Link>
) : r.bidCase ? (
<Link to={`/bid/cases/${r.bidCase.id}`}>{r.bidCase.bidNo}</Link>
) : (
'—'
),
},
{ title: '外带', dataIndex: 'takeOut', width: 70, render: (v: boolean) => (v ? '是' : '否') },
{
title: '应还',
dataIndex: 'returnAt',
width: 120,
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—'),
},
{
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '—'),
},
{
title: '状态',
dataIndex: 'bucket',
width: 110,
render: (v: string) => {
const s = SEAL_BUCKET_META[v] || { color: 'default', text: v };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '操作',
width: 220,
render: (_: unknown, r: SealRow) => (
<Space>
{r.myActions?.canReview ? (
<>
<Button type="link" size="small" onClick={() => actMut.mutate({ id: r.id, action: 'review', result: 'APPROVED' })}>
</Button>
<Button
type="link"
size="small"
danger
onClick={() => {
const comment = window.prompt('驳回意见');
if (comment) actMut.mutate({ id: r.id, action: 'review', result: 'REJECTED', comment });
}}
>
</Button>
</>
) : null}
{r.myActions?.canReturn ? (
<Button type="link" size="small" onClick={() => actMut.mutate({ id: r.id, action: 'return' })}>
</Button>
) : null}
<Popconfirm
title="确认删除这条用章记录?"
onConfirm={async () => {
await api.delete(`/seal-requests/${r.id}`);
message.success('已删除');
qc.invalidateQueries();
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
]}
/>
</>
);
}
+577
View File
@@ -0,0 +1,577 @@
import {
Alert,
Button,
Card,
Descriptions,
Form,
Input,
Modal,
Select,
Space,
Switch,
Table,
Tabs,
Tag,
AutoComplete,
message,
} from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../api/client';
import { PageHeader } from './ResourcePage';
import { StatusTag } from '../StatusTag';
type SignRow = {
signName?: string;
SignName?: string;
auditStatus?: string | number;
AuditStatus?: string | number;
createDate?: string;
CreateDate?: string;
reason?: string;
Reason?: string;
signSource?: number;
SignSource?: number;
signType?: number;
SignType?: number;
businessType?: string;
BusinessType?: string;
};
type TplRow = {
templateCode?: string;
TemplateCode?: string;
templateName?: string;
TemplateName?: string;
auditStatus?: string | number;
AuditStatus?: string | number;
templateContent?: string;
TemplateContent?: string;
templateType?: number;
TemplateType?: number;
createDate?: string;
CreateDate?: string;
reason?: string;
Reason?: string;
};
const SIGN_SOURCE: Record<number, string> = {
0: '企事业单位全称或简称',
1: '工信部备案网站全称或简称',
2: 'App 全称或简称',
3: '公众号或小程序全称或简称',
4: '电商平台店铺名',
5: '商标全称或简称',
};
const SIGN_TYPE: Record<number, string> = { 0: '验证码', 1: '通用' };
const TPL_TYPE: Record<number, string> = {
0: '验证码',
1: '通知短信',
2: '推广短信',
3: '国际/港澳台',
7: '数字短信',
};
function auditText(v: unknown) {
const s = String(v ?? '');
if (s === '1' || s === 'AUDIT_STATE_PASS' || s === 'PASS') return { color: 'success', text: '已通过' };
if (s === '0' || s === 'AUDIT_STATE_INIT' || s === 'AUDITING') return { color: 'processing', text: '审核中' };
if (s === '2' || s === 'AUDIT_STATE_NOT_PASS' || s === 'NOT_PASS') return { color: 'error', text: '未通过' };
if (s === '3' || s === 'CANCEL' || s === 'AUDIT_STATE_CANCEL') return { color: 'default', text: '已取消' };
return { color: 'default', text: s ? zh(s) : '—' };
}
function unwrapList<T>(data: unknown, keys: string[]): T[] {
const body = (data as { data?: unknown })?.data ?? data;
if (!body || typeof body !== 'object') return [];
const obj = body as Record<string, unknown>;
for (const k of keys) {
const v = obj[k];
if (Array.isArray(v)) return v as T[];
}
if (Array.isArray(body)) return body as T[];
return [];
}
function zh(s: string) {
return s;
}
function dash(v?: string | null) {
return v && String(v).trim() ? v : '未绑定';
}
export default function SmsPage() {
const qc = useQueryClient();
const cfg = useQuery({ queryKey: ['/sms/config'], queryFn: () => api.get('/sms/config') });
const signs = useQuery({
queryKey: ['/sms/signs'],
queryFn: () => api.get('/sms/signs'),
retry: false,
});
const tpls = useQuery({
queryKey: ['/sms/templates'],
queryFn: () => api.get('/sms/templates'),
retry: false,
});
const quals = useQuery({
queryKey: ['/sms/qualifications'],
queryFn: () => api.get('/sms/qualifications'),
retry: false,
});
const logs = useQuery({ queryKey: ['/sms/logs'], queryFn: () => api.get('/sms/logs') });
const events = useQuery({ queryKey: ['/sms/events'], queryFn: () => api.get('/sms/events') });
const [signForm] = Form.useForm();
const [tplForm] = Form.useForm();
const info = (cfg.data?.data ?? {}) as {
configured?: boolean;
defaultSign?: string;
note?: string;
region?: string;
endpoint?: string;
ramLogin?: string;
accessKeyIdMasked?: string;
secretConfigured?: boolean;
securityPhone?: string;
securityEmail?: string;
presets?: { code: string; name: string }[];
};
const signRows = unwrapList<SignRow>(signs.data, ['smsSignList', 'SmsSignList']);
const tplRows = unwrapList<TplRow>(tpls.data, ['smsTemplateList', 'SmsTemplateList']);
const qualRows = unwrapList<{
qualificationId?: string;
QualificationId?: string;
qualificationName?: string;
QualificationName?: string;
companyName?: string;
CompanyName?: string;
state?: string;
State?: string;
}>(quals.data, ['qualificationInfos', 'QualificationInfos', 'records']);
const logRows = unwrapList<{
id: string;
phones: string;
signName: string;
templateCode: string;
eventKey?: string;
code: string;
message?: string;
createdAt: string;
}>(logs.data, ['items']);
const eventPack = (events.data?.data ?? {}) as {
configured?: boolean;
defaultSign?: string;
presets?: { code: string; name: string }[];
items?: {
key: string;
group: string;
name: string;
when: string;
to: string;
enabled: boolean;
automatic?: boolean;
templateCode: string;
signName: string;
sample: Record<string, string>;
vars?: string;
}[];
};
const eventRows = eventPack.items || [];
const eventMut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/sms/events', body),
onSuccess: () => {
message.success('已保存步骤关联');
qc.invalidateQueries({ queryKey: ['/sms/events'] });
},
onError: (e: Error) => message.error(e.message),
});
const aliyunErr = (signs.error as Error | undefined)?.message || (tpls.error as Error | undefined)?.message;
const signMut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/sms/signs', body),
onSuccess: () => {
message.success('已提交签名申请,阿里云审核中');
qc.invalidateQueries({ queryKey: ['/sms/signs'] });
},
onError: (e: Error) => message.error(e.message),
});
const tplMut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/sms/templates', body),
onSuccess: () => {
message.success('已提交模板申请,阿里云审核中');
qc.invalidateQueries({ queryKey: ['/sms/templates'] });
},
onError: (e: Error) => message.error(e.message),
});
const passedQuals = qualRows
.map((q) => ({
value: String(q.qualificationId || q.QualificationId || ''),
label: String(q.qualificationName || q.QualificationName || q.companyName || q.CompanyName || q.qualificationId || ''),
}))
.filter((q) => q.value);
return (
<>
<PageHeader
title="短信设置"
description="登录验证码、投标待办等步骤在这里开关。手工群发请到个人办公「发送短信」。签名和模板仍走阿里云审核。"
extra={<Tag color={info.configured ? 'success' : 'error'}>{info.configured ? '已配置密钥' : '未配置密钥'}</Tag>}
/>
{!info.configured ? <Alert type="warning" showIcon message="尚未配置阿里云短信密钥" style={{ marginBottom: 12 }} /> : null}
{aliyunErr ? (
<Alert type="error" showIcon message="阿里云短信接口不可用" description={aliyunErr} style={{ marginBottom: 12 }} />
) : null}
<Tabs
items={[
{
key: 'events',
label: '步骤关联',
children: (
<>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="只有登录和投标步骤可自动发短信;公告、日报、报销、人事、用章、工作安排等一律只能到「发送工作通知短信」手工发送。投标短信只发给当前步骤实际选定的办理人。"
/>
<Table
className="ops-table"
rowKey="key"
loading={events.isLoading}
dataSource={eventRows}
pagination={false}
columns={[
{ title: '分组', dataIndex: 'group', width: 90 },
{ title: '步骤', dataIndex: 'name', width: 140 },
{ title: '何时发', dataIndex: 'when' },
{ title: '发给谁', dataIndex: 'to', width: 140 },
{
title: '变量',
dataIndex: 'vars',
width: 180,
render: (_: unknown, r) => r.vars || Object.keys(r.sample || {}).join('、') || '—',
},
{
title: '模板',
dataIndex: 'templateCode',
width: 220,
render: (_: unknown, r) => (
<AutoComplete
value={r.templateCode}
style={{ width: '100%' }}
options={(eventPack.presets || info.presets || []).map((p) => ({
value: p.code,
label: `${p.name}${p.code}`,
}))}
onBlur={(e) => {
const v = (e.target as HTMLInputElement).value?.trim();
if (v && v !== r.templateCode) eventMut.mutate({ eventKey: r.key, templateCode: v });
}}
onSelect={(v) => eventMut.mutate({ eventKey: r.key, templateCode: String(v) })}
disabled={r.automatic === false}
/>
),
},
{
title: '启用',
dataIndex: 'enabled',
width: 80,
render: (v: boolean, r) => (
<Switch
checked={v}
disabled={r.automatic === false}
loading={eventMut.isPending}
onChange={(enabled) => eventMut.mutate({ eventKey: r.key, enabled })}
/>
),
},
]}
/>
</>
),
},
{
key: 'account',
label: '账号设置',
children: (
<Card size="small" className="sms-account" title="阿里云 RAM / 短信通道">
<Descriptions bordered size="small" column={1}>
<Descriptions.Item label="RAM 登录名称">{info.ramLogin || '—'}</Descriptions.Item>
<Descriptions.Item label="登录密码">使</Descriptions.Item>
<Descriptions.Item label="AccessKey ID">{info.accessKeyIdMasked || '—'}</Descriptions.Item>
<Descriptions.Item label="AccessKey Secret">
{info.secretConfigured ? '已配置(密文,不在页面展示)' : '未配置'}
</Descriptions.Item>
<Descriptions.Item label="地域">{info.region || 'cn-hangzhou'}</Descriptions.Item>
<Descriptions.Item label="接口地址">{info.endpoint || 'dysmsapi.aliyuncs.com'}</Descriptions.Item>
<Descriptions.Item label="默认短信签名">{info.defaultSign || '—'}</Descriptions.Item>
<Descriptions.Item label="SecurityPhoneDevice">{dash(info.securityPhone)}</Descriptions.Item>
<Descriptions.Item label="SecurityEmailDevice">{dash(info.securityEmail)}</Descriptions.Item>
</Descriptions>
<p style={{ marginTop: 12, color: 'var(--mute)', fontSize: 12 }}>{info.note}</p>
</Card>
),
},
{
key: 'signs',
label: '签名',
children: (
<>
<Space style={{ marginBottom: 12 }}>
<Button
type="primary"
onClick={() => {
signForm.resetFields();
signForm.setFieldsValue({
signName: info.defaultSign,
signSource: 0,
signType: 1,
qualificationId: passedQuals[0]?.value,
remark: '江苏风影随行科技办公平台向内部员工发送工作通知,签名为本公司全称或简称。',
});
Modal.confirm({
title: '申请短信签名',
width: 520,
icon: null,
content: (
<Form form={signForm} layout="vertical" style={{ marginTop: 12 }}>
<Form.Item name="signName" label="签名名称" rules={[{ required: true }]} extra="2–12 个字,不要带括号和测试字样。">
<Input />
</Form.Item>
<Form.Item name="signSource" label="签名来源" rules={[{ required: true }]}>
<Select
options={Object.entries(SIGN_SOURCE).map(([value, label]) => ({
value: Number(value),
label,
}))}
/>
</Form.Item>
<Form.Item name="signType" label="签名类型">
<Select
options={[
{ value: 1, label: '通用' },
{ value: 0, label: '验证码' },
]}
/>
</Form.Item>
<Form.Item
name="qualificationId"
label="企业资质"
extra="从阿里云「资质管理」已通过的记录里选。没有请先在控制台做资质认证。"
>
{passedQuals.length ? (
<Select options={passedQuals} allowClear placeholder="选择已通过的资质" />
) : (
<Input placeholder="填写资质编号 QualificationId" />
)}
</Form.Item>
<Form.Item name="remark" label="申请说明">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
),
onOk: async () => signMut.mutateAsync(await signForm.validateFields()),
});
}}
>
</Button>
<Button onClick={() => signs.refetch()}></Button>
</Space>
<Table
className="ops-table"
rowKey={(r) => String(r.signName || r.SignName)}
loading={signs.isLoading}
dataSource={signRows}
pagination={false}
locale={{ emptyText: '还没有签名,点申请签名。' }}
columns={[
{ title: '签名', render: (_: unknown, r: SignRow) => r.signName || r.SignName },
{
title: '来源',
width: 180,
render: (_: unknown, r: SignRow) =>
SIGN_SOURCE[Number(r.signSource ?? r.SignSource)] || r.businessType || r.BusinessType || '—',
},
{
title: '类型',
width: 90,
render: (_: unknown, r: SignRow) => SIGN_TYPE[Number(r.signType ?? r.SignType)] || '—',
},
{
title: '审核',
width: 110,
render: (_: unknown, r: SignRow) => {
const s = auditText(r.auditStatus ?? r.AuditStatus);
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{ title: '申请时间', width: 170, render: (_: unknown, r: SignRow) => r.createDate || r.CreateDate || '—' },
{ title: '原因', render: (_: unknown, r: SignRow) => r.reason || r.Reason || '—' },
]}
/>
</>
),
},
{
key: 'templates',
label: '模板',
children: (
<>
<Space style={{ marginBottom: 12 }}>
<Button
type="primary"
onClick={() => {
tplForm.resetFields();
tplForm.setFieldsValue({
templateName: '办公工作通知',
templateType: 1,
relatedSignName: info.defaultSign,
templateContent: '办公通知:${content}',
templateRule: '{"content":"others"}',
applySceneContent: 'https://oa.fysxkj.com',
remark:
'江苏风影随行科技办公平台向内部员工发送会议、审批待办、工作汇报、用章、报销等到岗通知。变量 content 为通知摘要,例如「请明日上午9点参加项目例会」。',
});
Modal.confirm({
title: '向阿里云申请短信模板',
width: 640,
icon: null,
content: (
<Form form={tplForm} layout="vertical" style={{ marginTop: 12 }}>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message={'有变量必须填 TemplateRule,例如 {"content":"others"}。模板通过后不能改内容,只能重新申请。'}
/>
<Form.Item name="templateName" label="模板名称" rules={[{ required: true }]}>
<Input maxLength={30} />
</Form.Item>
<Form.Item name="templateType" label="短信类型" rules={[{ required: true }]}>
<Select
options={Object.entries(TPL_TYPE).map(([value, label]) => ({
value: Number(value),
label,
}))}
/>
</Form.Item>
<Form.Item
name="relatedSignName"
label="关联签名"
extra="只能选已通过的签名。此处关联仅用于审核,发送时仍用步骤关联里的签名。"
>
<Input />
</Form.Item>
<Form.Item
name="templateContent"
label="模板内容"
rules={[{ required: true }]}
extra="变量写成 ${content}。通知类 others 最长 35 字。"
>
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item
name="templateRule"
label="变量属性 TemplateRule"
extra='模板有变量时必填 JSON。常用:others 通知正文/名称;user_nick 姓名;other_number2 单号;numberCaptcha 验证码;time 时间;address 地址;name 个人姓名。'
>
<Input.TextArea rows={2} placeholder='{"content":"others"}' />
</Form.Item>
<Form.Item name="applySceneContent" label="业务场景">
<Input placeholder="https://oa.fysxkj.com" />
</Form.Item>
<Form.Item
name="remark"
label="申请说明"
extra="写清使用场景,并给出填入变量后的完整短信示例,否则容易被拒。"
>
<Input.TextArea rows={3} />
</Form.Item>
</Form>
),
onOk: async () => {
const v = await tplForm.validateFields();
const content = String(v.templateContent || '');
const hasVar = /\$\{[a-zA-Z0-9_]+\}/.test(content);
if (hasVar && !String(v.templateRule || '').trim()) {
throw new Error('模板含变量时必须填写 TemplateRule');
}
return tplMut.mutateAsync(v);
},
});
}}
>
</Button>
<Button onClick={() => tpls.refetch()}></Button>
</Space>
<Table
className="ops-table"
rowKey={(r) => String(r.templateCode || r.TemplateCode)}
loading={tpls.isLoading}
dataSource={tplRows}
pagination={false}
locale={{ emptyText: '还没有模板,点申请模板,或先用已有编号发送。' }}
columns={[
{ title: '模板编号', width: 160, render: (_: unknown, r: TplRow) => r.templateCode || r.TemplateCode },
{ title: '名称', render: (_: unknown, r: TplRow) => r.templateName || r.TemplateName },
{
title: '类型',
width: 110,
render: (_: unknown, r: TplRow) => TPL_TYPE[Number(r.templateType ?? r.TemplateType)] || '—',
},
{
title: '审核',
width: 110,
render: (_: unknown, r: TplRow) => {
const s = auditText(r.auditStatus ?? r.AuditStatus);
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{ title: '内容', ellipsis: true, render: (_: unknown, r: TplRow) => r.templateContent || r.TemplateContent || '—' },
{ title: '原因', ellipsis: true, render: (_: unknown, r: TplRow) => r.reason || r.Reason || '—' },
]}
/>
</>
),
},
{
key: 'logs',
label: '发送记录',
children: (
<Table
className="ops-table"
rowKey="id"
loading={logs.isLoading}
dataSource={logRows.length ? logRows : ((logs.data?.data ?? []) as typeof logRows)}
pagination={false}
locale={{ emptyText: '还没有从本系统发出的短信。' }}
columns={[
{ title: '手机号', dataIndex: 'phones', width: 180 },
{ title: '签名', dataIndex: 'signName', width: 160 },
{ title: '模板', dataIndex: 'templateCode', width: 150 },
{ title: '步骤', dataIndex: 'eventKey', width: 140, render: (v: string) => v || '手工发送' },
{
title: '结果',
dataIndex: 'code',
width: 90,
render: (v: string) => <StatusTag value={v} />,
},
{ title: '说明', dataIndex: 'message', render: (v: string) => v || '—' },
{
title: '时间',
dataIndex: 'createdAt',
width: 170,
render: (v: string) => (v ? String(v).replace('T', ' ').slice(0, 19) : '—'),
},
]}
/>
),
},
]}
/>
</>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { Button, Result } from 'antd';
import { Link } from 'react-router-dom';
export function NotFoundPage() {
return (
<Result
status="404"
title="页面不存在"
extra={
<Button type="primary">
<Link to="/workbench"></Link>
</Button>
}
/>
);
}
export function ForbiddenPage() {
return (
<Result
status="403"
title="没有权限"
extra={
<Button type="primary">
<Link to="/workbench"></Link>
</Button>
}
/>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { Button, DatePicker, Form, Input, InputNumber, Popconfirm, Select, Space, Table, Tag, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { PageHeader } from './ResourcePage';
import { TASK_STATUS_META } from './projectStatus';
type Task = {
id: string;
name: string;
department?: string | null;
status: string;
dueAt?: string | null;
project?: { id: string; projectNo: string; name: string };
dept?: { name: string } | null;
assignee?: { displayName: string } | null;
};
export default function TaskListPage() {
const qc = useQueryClient();
const q = useQuery({
queryKey: ['/project-tasks'],
queryFn: () => api.get('/project-tasks'),
});
const items = (q.data?.data ?? []) as Task[];
const doneMut = useMutation({
mutationFn: (row: Task) => api.post(`/projects/${row.project?.id}/tasks/${row.id}/done`),
onSuccess: () => {
message.success('已完成');
qc.invalidateQueries();
},
onError: (e: Error) => message.error(e.message),
});
return (
<>
<PageHeader title="任务分解" description="按可配置部门拆 WBS。请到项目详情里新建任务。" />
<Table
className="ops-table"
rowKey="id"
loading={q.isLoading}
dataSource={items}
scroll={{ x: 'max-content' }}
tableLayout="fixed"
columns={[
{
title: '项目',
width: 180,
render: (_: unknown, r: Task) =>
r.project ? <Link to={`/project/${r.project.id}`}>{r.project.projectNo}</Link> : '—',
},
{ title: '任务', dataIndex: 'name', width: 240, ellipsis: true },
{
title: '部门',
width: 140,
render: (_: unknown, r: Task) => r.dept?.name || r.department || '—',
},
{
title: '负责人',
width: 110,
render: (_: unknown, r: Task) => r.assignee?.displayName || '—',
},
{
title: '截止',
dataIndex: 'dueAt',
width: 120,
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—'),
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (v: string) => {
const s = TASK_STATUS_META[v] || { color: 'default', text: v };
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '操作',
width: 140,
render: (_: unknown, r: Task) => (
<Space>
{r.project ? <Link to={`/project/${r.project.id}`}></Link> : null}
{r.status !== 'DONE' && r.project ? (
<Button type="link" size="small" onClick={() => doneMut.mutate(r)}>
</Button>
) : null}
</Space>
),
},
]}
/>
</>
);
}
export function TimesheetPage() {
const qc = useQueryClient();
const [form] = Form.useForm();
const projectId = Form.useWatch('projectId', form);
const projects = useQuery({
queryKey: ['/projects'],
queryFn: () => api.get('/projects'),
});
const detail = useQuery({
queryKey: ['project', projectId],
enabled: Boolean(projectId),
queryFn: () => api.get(`/projects/${projectId}`),
});
const q = useQuery({
queryKey: ['/timesheets'],
queryFn: () => api.get('/timesheets'),
});
const items = (q.data?.data ?? []) as {
id: string;
hours: string | number;
workDate: string;
note?: string | null;
costAmount?: string | number;
project?: { id: string; projectNo: string; name: string };
task?: { name: string } | null;
user?: { displayName: string };
}[];
const projectOpts = ((projects.data?.data?.items ?? []) as { id: string; projectNo: string; name: string }[]).map(
(p) => ({ value: p.id, label: `${p.projectNo} ${p.name}` }),
);
const taskOpts = ((detail.data?.data?.tasks ?? []) as { id: string; name: string }[]).map((t) => ({
value: t.id,
label: t.name,
}));
const mut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/timesheets', body),
onSuccess: () => {
message.success('已记入技术成本');
form.resetFields();
qc.invalidateQueries();
},
onError: (e: Error) => message.error(e.message),
});
return (
<>
<PageHeader title="工时填报" description="工时按填报人薪酬自动折算人工成本,超预算红线会被拦截。" />
<Form
form={form}
layout="inline"
style={{ marginBottom: 16, rowGap: 12 }}
onFinish={(v) =>
mut.mutate({
...v,
workDate: v.workDate ? dayjs(v.workDate).format('YYYY-MM-DD') : undefined,
})
}
>
<Form.Item name="projectId" rules={[{ required: true, message: '请选择项目' }]}>
<Select showSearch optionFilterProp="label" options={projectOpts} placeholder="项目" style={{ width: 240 }} />
</Form.Item>
<Form.Item name="taskId">
<Select allowClear options={taskOpts} placeholder="任务(可选)" style={{ width: 180 }} />
</Form.Item>
<Form.Item name="workDate" rules={[{ required: true, message: '请选日期' }]}>
<DatePicker />
</Form.Item>
<Form.Item name="hours" rules={[{ required: true, message: '请填工时' }]}>
<InputNumber min={0.5} max={24} step={0.5} placeholder="小时" />
</Form.Item>
<Form.Item name="note">
<Input placeholder="说明" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={mut.isPending}>
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
loading={q.isLoading}
dataSource={items}
columns={[
{
title: '项目',
render: (_: unknown, r: (typeof items)[0]) =>
r.project ? <Link to={`/project/${r.project.id}`}>{r.project.projectNo}</Link> : '—',
},
{ title: '任务', render: (_: unknown, r: (typeof items)[0]) => r.task?.name || '—' },
{ title: '人员', width: 110, render: (_: unknown, r: (typeof items)[0]) => r.user?.displayName || '—' },
{
title: '日期',
dataIndex: 'workDate',
width: 120,
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—'),
},
{ title: '工时', dataIndex: 'hours', width: 80 },
{ title: '技术成本', dataIndex: 'costAmount', width: 110 },
{ title: '说明', dataIndex: 'note' },
{
title: '操作',
width: 80,
render: (_: unknown, r: (typeof items)[0]) => (
<Popconfirm
title="确认删除这条工时?"
onConfirm={async () => {
await api.delete(`/timesheets/${r.id}`);
message.success('已删除');
qc.invalidateQueries({ queryKey: ['/timesheets'] });
}}
>
<Button type="link" danger>
</Button>
</Popconfirm>
),
},
]}
/>
</>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { api } from '../api/client';
type Weather = {
greeting?: string;
city?: string;
weather?: string;
temperature?: number;
advice?: string;
summary?: string;
};
export function WeatherGreeting() {
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null);
useEffect(() => {
const fallback = window.setTimeout(() => setCoords((c) => c ?? { lat: 0, lng: 0 }), 4000);
if (!navigator.geolocation) return () => window.clearTimeout(fallback);
navigator.geolocation.getCurrentPosition(
(p) => {
window.clearTimeout(fallback);
setCoords({ lat: p.coords.latitude, lng: p.coords.longitude });
},
() => {
window.clearTimeout(fallback);
setCoords({ lat: 0, lng: 0 });
},
{ timeout: 8000, maximumAge: 600000 },
);
return () => window.clearTimeout(fallback);
}, []);
const q = useQuery({
queryKey: ['/office/weather', coords?.lat, coords?.lng],
enabled: coords !== null,
queryFn: () =>
api.get('/office/weather', {
params: coords && coords.lat && coords.lng ? { lat: coords.lat, lng: coords.lng } : {},
}),
staleTime: 10 * 60 * 1000,
});
const d = q.data?.data as Weather | undefined;
return (
<div className="wb-weather">
<div className="wb-hello">{d?.greeting || '新的一天开始了,祝您工作愉快。'}</div>
<div className="wb-wx">
{d?.summary || (q.isLoading ? '正在读取天气…' : '天气暂不可用')}
</div>
</div>
);
}
+129
View File
@@ -0,0 +1,129 @@
import { Button, DatePicker, Form, Input, Popconfirm, Select, Table, Tag, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import dayjs from 'dayjs';
import { api } from '../api/client';
import { FormDrawer } from './FormDrawer';
import { PageHeader, unwrapList } from './ResourcePage';
import { fmtCreated, fmtDay } from '../time';
type Row = {
id: string;
title: string;
content: string;
status: string;
dueAt?: string | null;
createdAt: string;
assignee?: { displayName?: string } | null;
};
export default function WorkAssignPage() {
const qc = useQueryClient();
const q = useQuery({ queryKey: ['/office/work-assignments'], queryFn: () => api.get('/office/work-assignments') });
const staff = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') });
const { items } = unwrapList(q.data) as { items: Row[] };
const [form] = Form.useForm();
const [creating, setCreating] = useState(false);
const staffOpts = ((staff.data as { data?: { id: string; name: string; department?: string }[] })?.data || []).map(
(s) => ({
value: s.id,
label: `${s.name}${s.department ? `${s.department}` : ''}`,
}),
);
const mut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/office/work-assignments', body),
onSuccess: () => {
message.success('已分配工作');
qc.invalidateQueries({ queryKey: ['/office/work-assignments'] });
},
onError: (e: Error) => message.error(e.message),
});
return (
<>
<PageHeader
title="工作安排"
description="仅总监、项目经理和公司负责人可见,用于给下属分配工作。执行人会在待办事项中收到。"
extra={
<Button
type="primary"
onClick={() => {
form.resetFields();
setCreating(true);
}}
>
</Button>
}
/>
<FormDrawer
title="分配工作"
open={creating}
onClose={() => setCreating(false)}
okText="提交"
okLoading={mut.isPending}
onOk={async () => {
const v = await form.validateFields();
await mut.mutateAsync({
...v,
dueAt: v.dueAt ? dayjs(v.dueAt).toISOString() : undefined,
});
setCreating(false);
}}
>
<Form form={form} layout="vertical">
<Form.Item name="title" label="工作标题" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="assigneeId" label="执行人" rules={[{ required: true, message: '请选择下属' }]}>
<Select showSearch optionFilterProp="label" options={staffOpts} />
</Form.Item>
<Form.Item name="dueAt" label="截止日期">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="content" label="工作说明">
<Input.TextArea rows={4} />
</Form.Item>
</Form>
</FormDrawer>
<Table
className="ops-table"
rowKey="id"
loading={q.isLoading}
dataSource={items}
pagination={false}
columns={[
{ title: '标题', dataIndex: 'title', width: 220 },
{ title: '执行人', width: 110, render: (_: unknown, r: Row) => r.assignee?.displayName || '—' },
{ title: '说明', dataIndex: 'content', ellipsis: true, render: (v: string) => v || '—' },
{ title: '截止', dataIndex: 'dueAt', width: 120, render: (v: string) => (v ? fmtDay(v) : '—') },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (v: string) => <Tag color={v === 'DONE' ? 'success' : 'processing'}>{v === 'DONE' ? '已完成' : '进行中'}</Tag>,
},
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v: string) => fmtCreated(v) },
{
title: '操作',
width: 90,
render: (_: unknown, r: Row) => (
<Popconfirm
title="确认删除这条工作安排?"
onConfirm={async () => {
await api.delete(`/office/work-assignments/${r.id}`);
message.success('已删除');
qc.invalidateQueries({ queryKey: ['/office/work-assignments'] });
}}
>
<Button type="link" danger>
</Button>
</Popconfirm>
),
},
]}
/>
</>
);
}
+185
View File
@@ -0,0 +1,185 @@
export const BID_STATUS_META: Record<string, { color: string; text: string }> = {
TECH_INITIAL: { color: 'processing', text: '技术初审' },
FETCH: { color: 'processing', text: '取标' },
TECH_FINAL: { color: 'processing', text: '技术终审' },
DRAFTING: { color: 'gold', text: '制标' },
REVIEW1: { color: 'gold', text: '商务一审' },
REVIEW2: { color: 'gold', text: '商务二审' },
PRINT: { color: 'gold', text: '印封' },
LIST: { color: 'blue', text: '投标执行' },
PENDING: { color: 'blue', text: '待开标' },
WON: { color: 'success', text: '中标' },
LOST: { color: 'default', text: '未中标' },
FAILED: { color: 'default', text: '流标废标' },
TERMINATED: { color: 'error', text: '已终止' },
};
export const REVIEW_RESULT: Record<string, { color: string; text: string }> = {
PENDING: { color: 'processing', text: '待办理' },
APPROVED: { color: 'success', text: '通过' },
REJECTED: { color: 'error', text: '驳回' },
CANCELLED: { color: 'default', text: '已取消' },
};
export const ACTION_LABEL: Record<string, string> = {
CREATE: '发起筛选',
CREATE_SPECIAL: '发起特殊筛选',
ASSIGN_FETCHER: '派取标人',
INITIAL_APPROVE: '初审同意',
INITIAL_REJECT: '初审驳回',
INITIAL_PASSED: '初审会签通过',
FETCH_DONE: '完成取标',
FINAL_APPROVE: '终审同意',
FINAL_REJECT: '终审否决',
FINAL_PASSED: '终审通过',
MANUAL_TERMINATE: '终止投标',
RESTORE_TERMINATED: '恢复终止投标',
ASSIGN_DRAFTER: '派制标人',
SUBMIT_DRAFT: '提交标书',
REVIEW1_APPROVE: '一审同意',
REVIEW1_REJECT: '一审打回制标',
REVIEW1_PASSED: '一审通过',
REVIEW2_APPROVE: '二审同意',
REVIEW2_REJECT: '二审打回制标',
REVIEW2_PASSED: '二审通过',
PRINT_DONE: '完成印封',
EXECUTION_DONE: '补全投标执行',
ROLLBACK: '退回上一步',
UPDATE_COMPETITOR_WIN: '登记对方中标信息',
UPDATE_LEGAL_ENTITY: '修改参与主体',
};
export const STAGE_LABEL: Record<string, string> = {
TECH_INITIAL: '技术初审',
TECH_FINAL: '技术终审',
REVIEW1: '商务一审',
REVIEW2: '商务二审',
};
export const ASSIGN_ROLE_LABEL: Record<string, string> = {
DRAFTER: '制标人',
REVIEW1: '一审人',
BIDDER: '投标人',
FETCHER: '取标人',
COORDINATOR: '特殊协调人',
};
export const COST_LABEL: Record<string, string> = {
DEPT: '部门费用',
ADMIN: '行政费用',
MARKETING: '营销费用',
BID: '投标成本',
PROJECT: '正式项目',
};
export function statusMeta(status: string) {
return BID_STATUS_META[status] || { color: 'default', text: status };
}
export function listPath(status: string, sourceType?: string) {
if (sourceType === 'SPECIAL') {
if (['TECH_INITIAL', 'FETCH', 'TECH_FINAL'].includes(status)) return '/bid/special-screening';
if (['DRAFTING', 'REVIEW1', 'REVIEW2', 'PRINT'].includes(status)) return '/bid/special-drafting';
}
if (['TECH_INITIAL', 'FETCH', 'TECH_FINAL'].includes(status)) return '/bid/screening';
if (['DRAFTING', 'REVIEW1', 'REVIEW2', 'PRINT'].includes(status)) return '/bid/drafting';
if (['LIST', 'PENDING'].includes(status)) return '/bid/execution';
if (['WON', 'LOST', 'FAILED'].includes(status)) return '/bid/result';
if (status === 'TERMINATED') return '/bid/terminated';
return '/bid/screening';
}
/** 优先公告招标编号;没有正式编号时再显示系统号,避免表格出现「—」或「无」。 */
export function tenderNo(b?: { externalNo?: string | null; bidNo?: string | null } | null) {
if (!b) return '—';
const clean = (raw?: string | null) => {
const n = (raw || '').trim().replace(/[)]+$/, '');
if (!n || n === '无' || n === '-' || n === '—') return '';
return n;
};
const official = clean(b.externalNo);
const sys = clean(b.bidNo);
const isInternal = (n: string) => /^BID-\d{4}-/.test(n) || /^TB-/.test(n) || /^XM-/.test(n);
if (official && !isInternal(official)) return official;
if (sys && !isInternal(sys)) return sys;
return official || sys || '—';
}
export const OPEN_METHOD_OPTIONS = [
{ label: '线下开标', value: '线下开标' },
{ label: '线上开标', value: '线上开标' },
{ label: '可邮寄', value: '可邮寄' },
{ label: '其他', value: '其他' },
];
export const TENDER_METHOD_OPTIONS = [
{ label: '公开招标', value: '公开招标' },
{ label: '邀请招标', value: '邀请招标' },
{ label: '询价', value: '询价' },
{ label: '竞谈【综合评分】', value: '竞谈【综合评分】' },
{ label: '竞谈【最低价中】', value: '竞谈【最低价中】' },
{ label: '单一来源', value: '单一来源' },
{ label: '其他', value: '其他' },
];
const TENDER_METHOD_VALUES = new Set(TENDER_METHOD_OPTIONS.map((o) => o.value));
/** 把旧名称归到现在的 7 个选项。 */
export function normalizeTenderMethod(stored?: string | null) {
const s = (stored || '').trim();
if (!s) return '';
if (TENDER_METHOD_VALUES.has(s)) return s;
if (s.includes('最低价')) return '竞谈【最低价中】';
if (s.includes('综合评分') || s.includes('综合评审') || s.includes('竞谈综合')) return '竞谈【综合评分】';
if (s.includes('竞争性谈判') || s === '竞谈') return '竞谈【综合评分】';
if (s.includes('公开招标')) return '公开招标';
if (s.includes('邀请招标')) return '邀请招标';
if (s.includes('询价')) return '询价';
if (s.includes('单一来源')) return '单一来源';
return '其他';
}
/** 库里没存招标方式时,从项目名称里认。 */
export function inferTenderMethod(name?: string | null, stored?: string | null) {
const fromStore = normalizeTenderMethod(stored);
if (fromStore) return fromStore;
const n = name || '';
if (n.includes('询价')) return '询价';
if (n.includes('邀请招标')) return '邀请招标';
if (n.includes('单一来源')) return '单一来源';
if (n.includes('最低价')) return '竞谈【最低价中】';
if (n.includes('综合评分') || n.includes('综合评审') || n.includes('竞谈综合')) return '竞谈【综合评分】';
if (n.includes('竞争性谈判') || n.includes('竞谈')) return '竞谈【综合评分】';
if (n.includes('公开招标')) return '公开招标';
return '';
}
export const NAS_BID_DIR = '/files/商务部/标书文件';
export const NAS_BID_PLACEHOLDER = '/files/商务部/标书文件/20260821XX项目标书文件';
export const TYPE_REVIEW_ROLES: Record<string, string[]> = {
: ['rd_director', 'tech_director'],
: ['3d_director', 'tech_director'],
: ['video_director', 'tech_director'],
: ['material_director', 'tech_director'],
: ['rd_director', '3d_director', 'video_director', 'material_director'],
};
export function stepIndex(status: string) {
const order = [
'TECH_INITIAL',
'FETCH',
'TECH_FINAL',
'DRAFTING',
'REVIEW1',
'REVIEW2',
'PRINT',
'LIST',
'PENDING',
'WON',
];
if (status === 'TERMINATED') return 2;
if (status === 'LOST' || status === 'FAILED') return 8;
const i = order.indexOf(status);
return i >= 0 ? i : 0;
}
+45
View File
@@ -0,0 +1,45 @@
export const CONTRACT_STATUS_META: Record<string, { color: string; text: string }> = {
DRAFT: { color: 'default', text: '草稿' },
PENDING: { color: 'processing', text: '待审批' },
APPROVED: { color: 'success', text: '已通过' },
REJECTED: { color: 'error', text: '已驳回' },
};
export const CONTRACT_ACTION_LABEL: Record<string, string> = {
CREATE: '生成合同草稿',
UPDATE: '修改合同',
MILESTONES: '更新收款条款',
SEAL_REQUEST: '申请用章',
SUBMIT: '提交审批',
APPROVE: '审批同意',
REJECT: '审批驳回',
APPROVED: '审批通过',
};
export function contractStatusMeta(status: string) {
return CONTRACT_STATUS_META[status] || { color: 'default', text: status };
}
/** 正式合同号;系统自动号或空的显示待填。 */
export function officialContractNo(raw?: string | null) {
const n = (raw || '').trim();
if (!n || n === '待填' || n === '-' || n === '—') return '';
return n;
}
export function contractNoText(raw?: string | null) {
return officialContractNo(raw) || '待填正式编号';
}
export function contractHandler(row: {
createdBy?: { displayName?: string; username?: string } | null;
bidCase?: { createdBy?: { displayName?: string } | null } | null;
}) {
const by = row.createdBy;
if (by?.displayName && by.displayName !== '系统管理员' && by.username !== 'admin') {
return by.displayName;
}
return row.bidCase?.createdBy?.displayName || '—';
}
export { officeItemPath } from './projectStatus';
+59
View File
@@ -0,0 +1,59 @@
function xmlEsc(v: unknown) {
return String(v ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function cellText(v: unknown) {
if (v == null || v === '') return '';
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return String(v);
if (v instanceof Date) return v.toISOString().slice(0, 19).replace('T', ' ');
if (typeof v === 'object' && v && 'name' in v) return String((v as { name?: unknown }).name ?? '');
if (typeof v === 'object' && v && 'displayName' in v) return String((v as { displayName?: unknown }).displayName ?? '');
return String(v);
}
export type ExportCol = { key: string; title: string; pick?: (row: Record<string, unknown>) => unknown };
export function pickCell(row: Record<string, unknown>, col: ExportCol) {
if (col.pick) return cellText(col.pick(row));
const raw = row[col.key];
return cellText(raw);
}
export function downloadExcel(fileName: string, columns: ExportCol[], rows: Record<string, unknown>[]) {
const headers = columns.map((c) => `<Cell><Data ss:Type="String">${xmlEsc(c.title)}</Data></Cell>`).join('');
const body = rows
.map((row) => {
const cells = columns
.map((c) => `<Cell><Data ss:Type="String">${xmlEsc(pickCell(row, c))}</Data></Cell>`)
.join('');
return `<Row>${cells}</Row>`;
})
.join('');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<?mso-application progid="Excel.Sheet"?>
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
<Worksheet ss:Name="导出"><Table>
<Row>${headers}</Row>
${body}
</Table></Worksheet>
</Workbook>`;
const blob = new Blob([`\uFEFF${xml}`], { type: 'application/vnd.ms-excel;charset=utf-8' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `${fileName.replace(/[\\/:*?"<>|]/g, '_')}.xls`;
a.click();
URL.revokeObjectURL(a.href);
}
export function colsFromTable(columns: { title?: unknown; dataIndex?: unknown; key?: unknown }[]): ExportCol[] {
return columns
.map((c) => ({
key: String(c.dataIndex || c.key || ''),
title: typeof c.title === 'string' ? c.title : String(c.title || ''),
}))
.filter((c) => c.key && c.title && c.title !== '操作');
}
+112
View File
@@ -0,0 +1,112 @@
export const BUDGET_CATEGORY_LABEL: Record<string, string> = {
LABOR: '人工',
TRAVEL: '差旅(商务)',
PURCHASE: '采购',
BUSINESS: '商务',
TECH: '差旅(技术)',
};
export const EXPENSE_LINE_KIND_LABEL: Record<string, string> = {
AIR: '机票',
HSR: '高铁票',
TRAIN: '火车票',
TAXI: '打车',
HOTEL: '住宿',
MEAL: '餐饮',
OFFICE: '办公',
OTHER: '其他',
};
export const EXPENSE_LINE_KIND_OPTIONS = Object.entries(EXPENSE_LINE_KIND_LABEL).map(([value, label]) => ({
value,
label,
}));
export const COST_TYPE_LABEL: Record<string, string> = {
BID: '投标',
PROJECT: '正式项目',
DEPT: '部门',
ADMIN: '行政',
MARKETING: '营销',
};
export const EXPENSE_STATUS_META: Record<string, { color: string; text: string }> = {
DRAFT: { color: 'default', text: '草稿' },
PENDING: { color: 'processing', text: '待确认' },
APPROVED: { color: 'success', text: '已通过' },
REJECTED: { color: 'error', text: '被打回' },
};
export const LOAN_STATUS_META: Record<string, { color: string; text: string }> = {
DRAFT: { color: 'default', text: '草稿' },
PENDING: { color: 'processing', text: '待确认' },
OPEN: { color: 'warning', text: '未还' },
OFFSET: { color: 'success', text: '已冲账' },
REJECTED: { color: 'error', text: '被打回' },
};
export const BOND_TYPE_LABEL: Record<string, string> = {
TENDER: '投标保证金',
PERFORMANCE: '履约保证金',
WARRANTY: '项目质保金',
};
export const BOND_BUCKET_META: Record<string, { color: string; text: string }> = {
unpaid: { color: 'default', text: '未缴纳' },
paid: { color: 'blue', text: '已缴纳' },
due: { color: 'gold', text: '应退' },
overdue: { color: 'error', text: '逾期' },
returned: { color: 'success', text: '已退回' },
};
export const INVOICE_STATUS_META: Record<string, { color: string; text: string }> = {
TO_ISSUE: { color: 'default', text: '待开' },
ISSUED: { color: 'processing', text: '已开' },
RECEIVED: { color: 'success', text: '已回' },
OVERDUE: { color: 'error', text: '逾期' },
};
export function expenseCategoryLabel(v?: string | number | null) {
if (v === undefined || v === null || v === '') return '—';
const key = String(v);
const map: Record<string, string> = {
meal: '餐饮',
meals: '餐饮',
food: '餐饮',
catering: '餐饮',
traffic: '交通',
transport: '交通',
transportation: '交通',
travel: '交通',
hotel: '住宿',
stay: '住宿',
lodging: '住宿',
office: '办公',
entertain: '招待',
entertainment: '招待',
other: '其他',
: '交通',
: '餐饮',
宿: '住宿',
: '办公',
: '招待',
: '其他',
};
return map[key] || map[key.toLowerCase()] || key;
}
export function money(v?: string | number | null) {
if (v === undefined || v === null || v === '') return '—';
const n = Number(v);
return Number.isFinite(n) ? n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(v);
}
export function applicantName(row: Record<string, unknown> | { applicant?: { displayName?: string; employee?: { name?: string; department?: { name?: string } } } | null }) {
const a = (row as { applicant?: { displayName?: string; employee?: { name?: string } } | null }).applicant;
return a?.displayName || a?.employee?.name || '—';
}
export function applicantDept(row: Record<string, unknown>) {
const a = row.applicant as { employee?: { department?: { name?: string } } } | null;
return a?.employee?.department?.name || '—';
}
+188
View File
@@ -0,0 +1,188 @@
export const REST_LABEL: Record<string, string> = {
SINGLE: '单休',
DOUBLE: '双休',
};
export const GENDER_LABEL: Record<string, string> = {
MALE: '男',
FEMALE: '女',
};
export const MARITAL_LABEL: Record<string, string> = {
SINGLE: '未婚',
MARRIED: '已婚',
DIVORCED: '离异',
OTHER: '其他',
};
export const PERSON_CATEGORY_LABEL: Record<string, string> = {
REGULAR: '正式人员',
INFORMAL: '非正式人员',
INTERN: '实习',
PART_TIME: '兼职',
OUTSOURCE: '外包',
};
export const JOB_TYPE_LABEL: Record<string, string> = {
TECH: '技术',
BIZ: '商务',
ADMIN: '行政',
FINANCE: '财务',
MANAGE: '管理',
OTHER: '其他',
};
export const EDUCATION_LABEL: Record<string, string> = {
HIGH_SCHOOL: '高中及以下',
COLLEGE: '专科',
BACHELOR: '本科',
MASTER: '硕士',
DOCTOR: '博士',
};
export const ID_TYPE_LABEL: Record<string, string> = {
ID_CARD: '居民身份证',
PASSPORT: '护照',
HK_MACAO: '港澳居民来往内地通行证',
TAIWAN: '台湾居民来往大陆通行证',
RESIDENCE: '港澳台居民居住证',
FOREIGN_PR: '外国人永久居留身份证',
OTHER: '其他',
};
export const EMP_STATUS_META: Record<string, { color: string; text: string }> = {
INTERN: { color: 'blue', text: '实习' },
PROBATION: { color: 'gold', text: '试用' },
REGULAR: { color: 'success', text: '正式' },
LEFT: { color: 'default', text: '离职' },
};
export const LIFE_KIND_LABEL: Record<string, string> = {
HIRE: '入职',
INTERN: '实习',
PROBATION: '试用',
REGULARIZE: '转正',
TRANSFER: '调岗',
PROMOTE: '晋升',
LEAVE: '离职',
};
export const DEPT_TYPE_LABEL: Record<string, string> = {
ADMIN: '行政',
BUSINESS: '商务',
TECH: '技术',
FINANCE: '财务',
HR: '人事',
};
export const DEPT_TITLE_BY_TYPE: Record<string, string[]> = {
ADMIN: ['负责人', '系统管理员', '普通岗位'],
BUSINESS: ['商务总监', '商务专员'],
TECH: ['项目经理', '普通岗位'],
FINANCE: ['财务经理', '会计', '出纳'],
HR: ['人事经理', '人事专员'],
};
export const DEPT_TITLE_BY_CATEGORY: Record<string, string[]> = {
CAT_SOFTWARE: ['研发总监', '研发技术岗', '项目经理'],
CAT_3D: ['三维总监', '三维技术岗'],
CAT_FILM: ['摄制总监', '摄制技术岗'],
CAT_MATERIAL: ['物资总监', '物资技术岗'],
};
export const LEAVE_KIND_LABEL: Record<string, string> = {
STATUTORY: '法定节假日',
ANNUAL: '带薪年休假',
SICK: '病假',
WORK_INJURY: '工伤假',
MATERNITY: '产假',
PRENATAL: '产前检查假',
BREASTFEEDING: '哺乳假',
PATERNITY: '陪产假',
PARENTAL: '育儿假',
ONLY_CHILD_CARE: '独生子女护理假',
MARRIAGE: '婚假',
BEREAVEMENT: '丧假',
PERSONAL: '事假',
COMP: '调休',
};
export const LEAVE_KIND_HINT: Record<string, string> = {
STATUTORY: '全国统一 11 天,视同出勤,全额发薪。',
ANNUAL: '按累计社会工龄:满 1 年 5 天、满 10 年 10 天、满 20 年 15 天,视同出勤。',
SICK: '医疗期病假,不低于当地最低工资 80%,不按全薪。',
WORK_INJURY: '停工留薪期一般不超过 12 个月,原待遇由单位按月支付。',
MATERNITY: '国家基础 98 天,各地可延长,津贴多由生育保险支付。',
PRENATAL: '孕期产检所需时间视同出勤。',
BREASTFEEDING: '婴儿未满 1 周岁,工作日内每日 1 小时哺乳时间。',
PATERNITY: '男方护理假,天数以本省条例为准,视同出勤。',
PARENTAL: '子女幼年期间父母双方每年各享有,天数以本省条例为准。',
ONLY_CHILD_CARE: '部分省市规定,独生子女父母住院护理,带薪。',
MARRIAGE: '国家基础 3 天,多数省市有延长。',
BEREAVEMENT: '直系亲属身故通常 13 天。',
PERSONAL: '无薪,按企业内部制度。薪酬按月薪÷21.75×天数扣款。',
COMP: '加班转入的调休,从剩余调休时长中扣减。',
};
export const LEAVE_KIND_OPTIONS: { value: string; label: string }[] = [
{ value: 'ANNUAL', label: '带薪年休假' },
{ value: 'SICK', label: '病假' },
{ value: 'WORK_INJURY', label: '工伤假' },
{ value: 'MATERNITY', label: '产假' },
{ value: 'PRENATAL', label: '产前检查假' },
{ value: 'BREASTFEEDING', label: '哺乳假' },
{ value: 'PATERNITY', label: '陪产假' },
{ value: 'PARENTAL', label: '育儿假' },
{ value: 'ONLY_CHILD_CARE', label: '独生子女护理假' },
{ value: 'MARRIAGE', label: '婚假' },
{ value: 'BEREAVEMENT', label: '丧假' },
{ value: 'PERSONAL', label: '事假' },
{ value: 'COMP', label: '调休' },
];
export const LEAVE_KIND_CODES = Object.keys(LEAVE_KIND_LABEL);
export const HR_APPLY_OPTIONS: { value: string; label: string }[] = [
...LEAVE_KIND_OPTIONS,
{ value: 'BUSINESS', label: '出差' },
{ value: 'OUT', label: '外出' },
{ value: 'OVERTIME', label: '加班' },
{ value: 'MAKEUP', label: '补卡' },
{ value: 'PROMOTE', label: '晋升' },
{ value: 'TRANSFER', label: '转岗' },
{ value: 'RESIGN', label: '离职' },
];
export const HR_APPLY_LABEL: Record<string, string> = {
...LEAVE_KIND_LABEL,
BUSINESS: '出差',
OVERTIME: '加班',
MAKEUP: '补卡',
OUT: '外出',
REGULARIZE: '转正',
PROMOTE: '晋升',
TRANSFER: '转岗',
RESIGN: '离职',
OTHER: '其他',
};
export function isLeaveApplyKind(kind?: string) {
return Boolean(kind && (LEAVE_KIND_CODES.includes(kind) || kind === 'BUSINESS' || kind === 'OUT'));
}
export const LEAVE_STATUS_META: Record<string, { color: string; text: string }> = {
PENDING: { color: 'processing', text: '待审批' },
APPROVED: { color: 'success', text: '已通过' },
REJECTED: { color: 'error', text: '已驳回' },
};
export const PAYROLL_STATUS_META: Record<string, { color: string; text: string }> = {
DRAFT: { color: 'warning', text: '待确认' },
CONFIRMED: { color: 'success', text: '已确认' },
};
export function money(v?: string | number | null) {
if (v === undefined || v === null || v === '') return '—';
const n = Number(v);
return Number.isFinite(n) ? n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(v);
}
+129
View File
@@ -0,0 +1,129 @@
import { useEffect, useRef, useState } from 'react';
import { Button, Modal, Space, Typography, message } from 'antd';
import { RemoteTrack, Room, RoomEvent, Track } from 'livekit-client';
import { api } from '../../api/client';
import { useAuth } from '../../store/auth';
export type CallInfo = { id: string; kind: string; title?: string; initiatorId: string; conversationId?: string | null; memberIds?: string[] };
/** Desktop LiveKit call surface. OA owns the call state; LiveKit owns media and reconnects. */
export function ImCallModal({ call, onClose }: { call: CallInfo; peerId?: string; onClose: () => void }) {
const me = useAuth((s) => s.id);
const localRef = useRef<HTMLVideoElement>(null);
const remoteRef = useRef<HTMLVideoElement>(null);
const audioRef = useRef<HTMLAudioElement>(null);
const roomRef = useRef<Room | null>(null);
const started = useRef(false);
const [accepted, setAccepted] = useState(call.initiatorId === me);
const [status, setStatus] = useState(call.initiatorId === me ? '等待对方接听…' : '对方邀请你通话');
const [seconds, setSeconds] = useState(0);
const [connected, setConnected] = useState(false);
const video = call.kind !== 'audio';
useEffect(() => {
if (!accepted || started.current) return;
started.current = true;
let stopped = false;
let timer: number | undefined;
let statePoll: number | undefined;
const room = new Room({ adaptiveStream: true, dynacast: true });
roomRef.current = room;
const stop = (text?: string) => {
if (stopped) return;
stopped = true;
if (timer) window.clearInterval(timer);
if (statePoll) window.clearInterval(statePoll);
room.disconnect();
if (text) setStatus(text);
};
room
.on(RoomEvent.ParticipantConnected, () => { setConnected(true); setStatus('通话中'); })
.on(RoomEvent.ParticipantDisconnected, () => stop('通话已结束'))
.on(RoomEvent.Reconnecting, () => setStatus('网络恢复中…'))
.on(RoomEvent.Reconnected, () => setStatus('通话中'))
.on(RoomEvent.Disconnected, () => { if (!stopped) setStatus('通话已断开'); })
.on(RoomEvent.TrackSubscribed, (track: RemoteTrack) => {
if (track.kind === Track.Kind.Video && remoteRef.current) track.attach(remoteRef.current);
if (track.kind === Track.Kind.Audio && audioRef.current) track.attach(audioRef.current);
setConnected(true);
setStatus('通话中');
});
const start = async () => {
try {
await api.post(`/im/calls/${call.id}/join`);
const tokenRes = await api.post(`/im/calls/${call.id}/livekit-token`);
const auth = (tokenRes as { data?: { url?: string; token?: string } }).data;
if (!auth?.url || !auth.token) throw new Error('音视频服务未配置');
await room.connect(auth.url, auth.token);
await room.localParticipant.setMicrophoneEnabled(true);
if (video) {
const publication = await room.localParticipant.setCameraEnabled(true);
if (publication?.track && localRef.current) publication.track.attach(localRef.current);
}
if (room.remoteParticipants.size > 0) { setConnected(true); setStatus('通话中'); }
timer = window.setInterval(() => setSeconds((v) => v + 1), 1000);
statePoll = window.setInterval(async () => {
try {
const res = await api.get(`/im/calls/${call.id}`);
const data = (res as { data?: { status?: string; peerRejected?: boolean; peerJoined?: boolean } }).data;
if (data?.status === 'ended') stop(data.peerRejected ? '对方拒绝接听' : '通话已结束');
else if (data?.peerJoined) { setConnected(true); setStatus('通话中'); }
} catch { /* transient network failure */ }
}, 2000);
} catch (e) {
setStatus(e instanceof Error ? e.message : '无法接通音视频服务');
}
};
void start();
return () => stop();
}, [accepted, call.id, video]);
useEffect(() => {
if (connected || !accepted) return;
const timeout = window.setTimeout(async () => {
try { await api.post(`/im/calls/${call.id}/end`); } catch { /* ignore */ }
setStatus('无人接听');
}, 45_000);
return () => window.clearTimeout(timeout);
}, [accepted, connected, call.id]);
const hangup = async (reject = false) => {
try { await api.post(`/im/calls/${call.id}/${reject ? 'reject' : 'end'}`); } catch { /* ignore */ }
roomRef.current?.disconnect();
onClose();
};
const duration = `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
return (
<Modal open title={call.title || (video ? '视频通话' : '语音通话')} onCancel={() => void hangup(!accepted)} footer={null} width={video ? 720 : 420} destroyOnClose>
<Typography.Paragraph type="secondary">{connected ? `${status} · ${duration}` : status}</Typography.Paragraph>
<div style={{ display: 'grid', gridTemplateColumns: video ? '1fr 1fr' : '1fr', gap: 12 }}>
{video ? <video ref={localRef} autoPlay muted playsInline style={{ width: '100%', background: '#111', borderRadius: 8, minHeight: 220 }} /> : null}
{video ? <video ref={remoteRef} autoPlay playsInline style={{ width: '100%', background: '#111', borderRadius: 8, minHeight: 220 }} /> : null}
<audio ref={audioRef} autoPlay />
</div>
<Space style={{ marginTop: 16 }}>
{!accepted ? <Button danger onClick={() => void hangup(true)}></Button> : null}
{!accepted ? <Button type="primary" onClick={() => setAccepted(true)}></Button> : null}
{accepted ? <Button danger type="primary" onClick={() => void hangup()}></Button> : null}
</Space>
</Modal>
);
}
export function useIncomingCall(onRing: (call: CallInfo) => void) {
const id = useAuth((s) => s.id);
useEffect(() => {
if (!id) return;
let last = '';
const t = window.setInterval(async () => {
try {
const res = await api.get('/im/calls/incoming');
const rows = ((res as { data?: CallInfo[] }).data || []) as CallInfo[];
const hit = rows.find((c) => c.initiatorId !== id && c.id !== last);
if (hit) { last = hit.id; onRing(hit); message.info(hit.title || '来电'); }
} catch { /* transient network failure */ }
}, 2500);
return () => window.clearInterval(t);
}, [id, onRing]);
}
+320
View File
@@ -0,0 +1,320 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Button, Form, Input, List, Modal, Select, Space, Typography, message } from 'antd';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../../api/client';
import { wgs84ToGcj02 } from '../../geo/gcj02';
import { useAuth } from '../../store/auth';
import { PageHeader } from '../ResourcePage';
import { useSearchParams } from 'react-router-dom';
import { ImCallModal, useIncomingCall, type CallInfo } from './ImCallModal';
type Conv = {
id: string;
type: string;
name: string;
peerId?: string;
peerName?: string;
lastText?: string;
lastAt?: string;
unread?: number;
memberCount?: number;
};
type Msg = {
id: string;
fromUserId: string;
body: string;
kind: string;
contentType?: string;
createdAt: string;
readCount?: number;
};
type Staff = { id: string; name: string; department?: string };
export default function ImPage() {
const me = useAuth((s) => s.id);
const qc = useQueryClient();
const [params] = useSearchParams();
const [active, setActive] = useState<Conv | null>(null);
const [text, setText] = useState('');
const [groupOpen, setGroupOpen] = useState(false);
const [call, setCall] = useState<CallInfo | null>(null);
const bottom = useRef<HTMLDivElement>(null);
const [form] = Form.useForm();
const convQ = useQuery({
queryKey: ['/im/conversations'],
queryFn: () => api.get('/im/conversations'),
refetchInterval: 5000,
});
const convs = ((convQ.data as { data?: Conv[] })?.data || []) as Conv[];
useEffect(() => {
const peerId = params.get('peerId');
if (!peerId) return;
const hit = convs.find((c) => c.peerId === peerId);
setActive((cur) => {
if (cur && cur.peerId !== peerId && cur.id) return cur;
if (hit) return hit;
if (cur?.peerId === peerId) return cur;
const name = params.get('name') || '同事';
return { id: '', type: 'direct', name, peerId, peerName: name };
});
}, [params, convs]);
const msgQ = useQuery({
queryKey: ['/im/messages', active?.id, active?.peerId],
queryFn: () =>
api.get('/im/messages', {
params: active?.id ? { conversationId: active.id } : { peerId: active?.peerId },
}),
enabled: Boolean(active),
refetchInterval: 2500,
});
const items = (((msgQ.data as { data?: { items?: Msg[] } })?.data?.items || []) as Msg[]);
const staffQ = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') });
const staff = ((staffQ.data as { data?: Staff[] })?.data || []) as Staff[];
useEffect(() => {
bottom.current?.scrollIntoView({ behavior: 'smooth' });
}, [items.length]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'PrintScreen' && active) {
void api.post('/im/events/screenshot', { conversationId: active.id, peerId: active.peerId });
}
};
window.addEventListener('keyup', onKey);
return () => window.removeEventListener('keyup', onKey);
}, [active]);
const onRing = useCallback((c: CallInfo) => setCall(c), []);
useIncomingCall(onRing);
const sendMut = useMutation({
mutationFn: (body: Record<string, unknown>) => api.post('/im/messages', body),
onSuccess: async () => {
setText('');
await qc.invalidateQueries({ queryKey: ['/im/messages'] });
await qc.invalidateQueries({ queryKey: ['/im/conversations'] });
},
onError: (e: Error) => message.error(e.message),
});
const send = () => {
const body = text.trim();
if (!body || !active) return;
sendMut.mutate({ conversationId: active.id, peerId: active.peerId, body });
};
const sendLocation = async () => {
if (!active) return;
const loc = await new Promise<GeolocationPosition | null>((resolve) => {
if (!navigator.geolocation) {
resolve(null);
return;
}
navigator.geolocation.getCurrentPosition((p) => resolve(p), () => resolve(null), {
enableHighAccuracy: true,
timeout: 16000,
maximumAge: 0,
});
});
if (!loc) {
message.error('没有拿到定位,请允许浏览器位置权限');
return;
}
const gcj = wgs84ToGcj02(loc.coords.latitude, loc.coords.longitude);
let address = `${gcj.lat.toFixed(5)}, ${gcj.lng.toFixed(5)}`;
try {
const geo = await api.post('/office/geo/reverse', { lat: gcj.lat, lng: gcj.lng });
const data = (geo as { data?: { address?: string } }).data;
if (data?.address) address = data.address;
} catch {
/* 坐标仍可发 */
}
sendMut.mutate({
conversationId: active.id,
peerId: active.peerId,
contentType: 'location',
body: address,
meta: { lat: gcj.lat, lng: gcj.lng, address },
});
};
const startCall = async (kind: 'audio' | 'video' | 'meeting') => {
if (!active) return;
try {
const res = await api.post('/im/calls', {
conversationId: active.id,
peerId: active.peerId,
kind,
title: kind === 'meeting' ? `${active.name} 会议` : undefined,
});
const data = (res as { data?: CallInfo }).data;
if (data) setCall(data);
} catch (e) {
message.error(e instanceof Error ? e.message : '无法发起通话');
}
};
const createGroup = async (v: { name: string; memberIds: string[] }) => {
try {
const res = await api.post('/im/groups', v);
const data = (res as { data?: Conv }).data;
setGroupOpen(false);
form.resetFields();
await qc.invalidateQueries({ queryKey: ['/im/conversations'] });
if (data) setActive({ ...data, type: 'group', name: v.name });
} catch (e) {
message.error(e instanceof Error ? e.message : '建群失败');
}
};
const staffOpts = useMemo(
() => staff.filter((s) => s.id !== me).map((s) => ({ value: s.id, label: `${s.name}${s.department ? `${s.department}` : ''}` })),
[staff, me],
);
return (
<>
<PageHeader
title="消息"
description="同事私聊、群聊、已读、定位和音视频。截屏会通知对方。大文件仍走附件,不走聊天通道。"
extra={
<Button type="primary" onClick={() => setGroupOpen(true)}>
</Button>
}
/>
<div
style={{
display: 'grid',
gridTemplateColumns: '280px 1fr',
minHeight: 560,
border: '1px solid rgba(199,196,215,0.55)',
borderRadius: 12,
overflow: 'hidden',
background: '#fff',
}}
>
<div style={{ borderRight: '1px solid #eee', overflow: 'auto' }}>
<List
dataSource={convs}
locale={{ emptyText: '还没有会话。从员工通讯点同事,或发起群聊。' }}
renderItem={(c) => (
<List.Item
onClick={() => setActive(c)}
style={{
cursor: 'pointer',
background: active?.id === c.id ? 'rgba(107,56,212,0.08)' : undefined,
padding: '10px 16px',
}}
>
<List.Item.Meta
title={
<Space>
<span>{c.name || c.peerName}</span>
{c.type === 'group' ? <Typography.Text type="secondary"></Typography.Text> : null}
{c.unread ? <Typography.Text type="danger">{c.unread}</Typography.Text> : null}
</Space>
}
description={c.lastText || ' '}
/>
</List.Item>
)}
/>
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{active ? (
<>
<div style={{ padding: '10px 16px', borderBottom: '1px solid #eee', display: 'flex', justifyContent: 'space-between' }}>
<Typography.Text strong>
{active.name || active.peerName}
{active.type === 'group' ? `${active.memberCount || ''}人)` : ''}
</Typography.Text>
<Space>
<Button size="small" onClick={() => void startCall('audio')}>
</Button>
<Button size="small" onClick={() => void startCall('video')}>
</Button>
{active.type === 'group' ? (
<Button size="small" onClick={() => void startCall('meeting')}>
</Button>
) : null}
</Space>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: 16 }}>
{items.map((m) => {
const mine = m.fromUserId === me;
if (m.kind === 'screenshot' || m.contentType === 'system') {
return (
<div key={m.id} style={{ textAlign: 'center', color: '#b2b2b2', fontSize: 12, margin: '10px 0' }}>
{m.body || '有人截屏了'}
</div>
);
}
let extra = '';
if (m.contentType === 'location' || m.kind === 'location') extra = '📍 ';
return (
<div key={m.id} style={{ display: 'flex', justifyContent: mine ? 'flex-end' : 'flex-start', marginBottom: 10 }}>
<div
style={{
maxWidth: 420,
padding: '8px 12px',
borderRadius: 12,
background: mine ? '#6b38d4' : '#f4f2fa',
color: mine ? '#fff' : '#191c1e',
}}
>
<div>
{extra}
{m.body}
</div>
<div style={{ fontSize: 11, opacity: 0.7, marginTop: 4 }}>
{m.createdAt?.slice(0, 16).replace('T', ' ')}
{mine ? ` · ${m.readCount ? `已读 ${m.readCount}` : '未读'}` : ''}
</div>
</div>
</div>
);
})}
<div ref={bottom} />
</div>
<div style={{ padding: 12, borderTop: '1px solid #eee' }}>
<Space.Compact style={{ width: '100%' }}>
<Button onClick={() => void sendLocation()}></Button>
<Input value={text} onChange={(e) => setText(e.target.value)} onPressEnter={send} placeholder="发一条消息,Enter 发送" />
<Button type="primary" loading={sendMut.isPending} onClick={send}>
</Button>
</Space.Compact>
</div>
</>
) : (
<div style={{ margin: 'auto', color: '#64748b' }}></div>
)}
</div>
</div>
<Modal title="发起群聊" open={groupOpen} onCancel={() => setGroupOpen(false)} onOk={() => form.submit()} okText="创建">
<Form form={form} layout="vertical" onFinish={createGroup}>
<Form.Item name="name" label="群名称" rules={[{ required: true, message: '请填写群名称' }]}>
<Input />
</Form.Item>
<Form.Item name="memberIds" label="成员" rules={[{ required: true, message: '请选择同事' }]}>
<Select mode="multiple" showSearch optionFilterProp="label" options={staffOpts} />
</Form.Item>
</Form>
</Modal>
{call ? <ImCallModal call={call} peerId={active?.peerId} onClose={() => setCall(null)} /> : null}
</>
);
}
+47
View File
@@ -0,0 +1,47 @@
export const PROJECT_STATUS_META: Record<string, { color: string; text: string }> = {
NOT_STARTED: { color: 'default', text: '未开始' },
ACTIVE: { color: 'processing', text: '执行中' },
INTERNAL_ACCEPTED: { color: 'gold', text: '待验收' },
ACCEPTED: { color: 'success', text: '已验收' },
DONE: { color: 'cyan', text: '已完成' },
};
export const BUDGET_CATEGORY_LABEL: Record<string, string> = {
LABOR: '人工',
TRAVEL: '差旅(商务)',
PURCHASE: '采购',
BUSINESS: '商务',
TECH: '差旅(技术)',
};
export const TASK_STATUS_META: Record<string, { color: string; text: string }> = {
OPEN: { color: 'processing', text: '进行中' },
DONE: { color: 'success', text: '已完成' },
};
export const ACCEPT_KIND_LABEL: Record<string, string> = {
INTERNAL: '内部验收',
CUSTOMER: '客户验收',
};
export function projectStatusMeta(status: string) {
return PROJECT_STATUS_META[status] || { color: 'default', text: status };
}
export function officeItemPath(row: { bizType?: string | null; bizId?: string | null }) {
if (!row.bizId) return null;
if (row.bizType === 'CONTRACT_APPROVAL' || row.bizType === 'CONTRACT_DRAFT') return `/contract/${row.bizId}`;
if (row.bizType === 'EXPENSE_APPROVAL') return `/finance/expenses/${row.bizId}`;
if (row.bizType === 'LOAN_APPROVAL') return `/finance/loans/${row.bizId}`;
if (row.bizType === 'LOAN_OFFSET') return `/office/loans/${row.bizId}`;
if (row.bizType === 'PROJECT_TIMESHEET' || row.bizType === 'PROJECT_ACCEPT') return `/project/${row.bizId}`;
if (row.bizType === 'SEAL_APPROVAL' || row.bizType === 'SEAL_RETURN') return '/seal/requests';
if (row.bizType === 'CREDENTIAL_RETURN') return '/seal/borrows';
if (row.bizType === 'BID_OPEN') return `/office/bid-open/${row.bizId}`;
if (row.bizType === 'WORK_ASSIGN') return '/office/todos';
if (row.bizType === 'OFFICE_APPLY') return '/office/apply';
if (row.bizType === 'TRAVEL_EXPENSE' || row.bizType === 'EXPENSE_DRAFT') return `/office/expenses/${row.bizId}`;
if (row.bizType === 'HR_LEAVE') return `/office/leaves/${row.bizId}`;
if ((row.bizType || '').startsWith('BID')) return `/bid/cases/${row.bizId}`;
return null;
}

Some files were not shown because too many files have changed in this diff Show More