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:
@@ -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>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user