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
+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>
);
}