v4.0.18版本提交

This commit is contained in:
2026-09-08 10:07:34 +08:00
parent 4bdb09068c
commit 23ba639e9b
46 changed files with 1922 additions and 162 deletions
+2
View File
@@ -36,6 +36,7 @@ import StoreBillsPage from './pages/StoreBillsPage';
import PartnerBillsPage from './pages/PartnerBillsPage';
import WineryBillsPage from './pages/WineryBillsPage';
import LogisticsBillsPage from './pages/LogisticsBillsPage';
import BankAccountsPage from './pages/BankAccountsPage';
import TicketsPage from './pages/TicketsPage';
import SupportTicketsPage from './pages/SupportTicketsPage';
import InvoicesPage from './pages/InvoicesPage';
@@ -133,6 +134,7 @@ export default function App() {
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
<Route path="/finance/bank-accounts" element={<BankAccountsPage />} />
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
<Route
@@ -86,6 +86,7 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/finance/partner-bills', label: '合伙人账单' },
{ key: '/finance/winery-bills', label: '酒厂账单' },
{ key: '/finance/logistics-bills', label: '物流对账' },
{ key: '/finance/bank-accounts', label: '银行账户' },
],
},
{
@@ -211,6 +212,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
'/finance/partner-bills': 'finance',
'/finance/winery-bills': 'finance',
'/finance/logistics-bills': 'finance',
'/finance/bank-accounts': 'finance',
'benefit-group': 'benefit',
'/benefit/coupons': 'benefit',
'/benefit/ledgers': 'benefit',
+2
View File
@@ -28,6 +28,8 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'STORE_AUDIT', label: '门店审核' },
{ value: 'STORE_ACCOUNT_CREATE', label: '新增门店账户' },
{ value: 'STORE_ACCOUNT_UPDATE', label: '编辑门店账户' },
{ value: 'STORE_ACCOUNT_STAFF_CREATE', label: '新增门店子账号' },
{ value: 'STORE_ACCOUNT_STAFF_UPDATE', label: '编辑门店子账号' },
{ value: 'STORE_ACCOUNT_STAFF_DELETE', label: '删除门店子账号' },
{ value: 'STORE_CATEGORY_CREATE', label: '新增门店分类' },
{ value: 'STORE_CATEGORY_UPDATE', label: '编辑门店分类' },
@@ -0,0 +1,389 @@
import { useEffect, useMemo, useState } from 'react';
import {
Button,
Form,
Input,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
FINANCE_BANK_ACCOUNT_TYPE_LABELS,
FINANCE_BANK_ACCOUNT_TYPES,
type FinanceBankAccountDto,
type FinanceBankAccountType,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE } from '../lib/constants';
import { downloadBase64File } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
type CityOption = { id: string; name: string };
type ExportResult = {
filename: string;
mimeType: string;
contentBase64: string;
count: number;
};
type OtherForm = {
name?: string;
bankAccountName: string;
bankAccountNo: string;
bankBranch?: string;
remark?: string;
};
const TYPE_COLORS: Record<FinanceBankAccountType, string> = {
STORE: 'blue',
WINERY: 'gold',
PARTNER: 'purple',
LOGISTICS: 'cyan',
OTHER: 'default',
};
function otherNumericId(id: string) {
return id.startsWith('OTHER:') ? id.slice('OTHER:'.length) : id;
}
export default function BankAccountsPage() {
const [filterForm] = Form.useForm<{
type?: FinanceBankAccountType;
cityId?: string;
keyword?: string;
}>();
const [otherForm] = Form.useForm<OtherForm>();
const [remarkForm] = Form.useForm<{ remark?: string }>();
const [filters, setFilters] = useState({ type: '', cityId: '', keyword: '' });
const [cities, setCities] = useState<CityOption[]>([]);
const [otherOpen, setOtherOpen] = useState(false);
const [editing, setEditing] = useState<FinanceBankAccountDto | null>(null);
const [remarkTarget, setRemarkTarget] = useState<FinanceBankAccountDto | null>(null);
const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState<'xlsx' | 'pdf' | null>(null);
useEffect(() => {
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setCities(res.items ?? []))
.catch(() => setCities([]));
}, []);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<FinanceBankAccountDto>(
'/admin/finance/bank-accounts',
() => {
const qs = new URLSearchParams();
if (filters.type) qs.set('type', filters.type);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.keyword) qs.set('keyword', filters.keyword);
return qs;
},
[filters.type, filters.cityId, filters.keyword],
);
function openCreate() {
setEditing(null);
otherForm.resetFields();
setOtherOpen(true);
}
function openEdit(row: FinanceBankAccountDto) {
setEditing(row);
otherForm.setFieldsValue({
name: row.ownerName === row.bankAccountName ? undefined : row.ownerName,
bankAccountName: row.bankAccountName,
bankAccountNo: row.bankAccountNo,
bankBranch: row.bankBranch ?? undefined,
remark: row.remark ?? undefined,
});
setOtherOpen(true);
}
function openRemark(row: FinanceBankAccountDto) {
setRemarkTarget(row);
remarkForm.setFieldsValue({ remark: row.remark ?? undefined });
}
async function saveOther() {
const values = await otherForm.validateFields();
setSaving(true);
try {
if (editing) {
await request(`/admin/finance/bank-accounts/other/${otherNumericId(editing.id)}`, {
method: 'PUT',
body: JSON.stringify(values),
});
message.success('已保存');
} else {
await request('/admin/finance/bank-accounts', {
method: 'POST',
body: JSON.stringify(values),
});
message.success('已新增');
}
setOtherOpen(false);
await reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function saveRemark() {
if (!remarkTarget) return;
const values = await remarkForm.validateFields();
setSaving(true);
try {
await request(`/admin/finance/bank-accounts/${encodeURIComponent(remarkTarget.id)}/remark`, {
method: 'PUT',
body: JSON.stringify({ remark: values.remark ?? '' }),
});
message.success('备注已保存');
setRemarkTarget(null);
await reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function removeOther(row: FinanceBankAccountDto) {
try {
await request(`/admin/finance/bank-accounts/other/${otherNumericId(row.id)}`, { method: 'DELETE' });
message.success('已删除');
await reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
async function exportFile(format: 'xlsx' | 'pdf') {
setExporting(format);
try {
const qs = new URLSearchParams();
qs.set('format', format);
if (filters.type) qs.set('type', filters.type);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.keyword) qs.set('keyword', filters.keyword);
const result = await request<ExportResult>(`/admin/finance/bank-accounts/export?${qs}`);
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
message.success(`已导出 ${result.count}`);
} catch (e) {
message.error(e instanceof Error ? e.message : '导出失败');
} finally {
setExporting(null);
}
}
const typeOptions = useMemo(
() => FINANCE_BANK_ACCOUNT_TYPES.map((value) => ({ value, label: FINANCE_BANK_ACCOUNT_TYPE_LABELS[value] })),
[],
);
const baseColumns: ColumnsType<FinanceBankAccountDto> = [
{
title: '类型',
dataIndex: 'type',
width: 88,
render: (type: FinanceBankAccountType) => (
<Tag color={TYPE_COLORS[type]}>{FINANCE_BANK_ACCOUNT_TYPE_LABELS[type]}</Tag>
),
},
{ title: '归属', dataIndex: 'ownerName', width: 180 },
{ title: '城市', dataIndex: 'cityName', width: 100, render: (v?: string | null) => v || '—' },
{ title: '户名', dataIndex: 'bankAccountName', width: 140 },
{ title: '银行账号', dataIndex: 'bankAccountNo', width: 180 },
{ title: '开户行', dataIndex: 'bankBranch', width: 180, render: (v?: string | null) => v || '—' },
{
title: '默认',
dataIndex: 'isDefault',
width: 72,
render: (v: boolean | undefined, row) => (row.type === 'STORE' ? (v ? '是' : '否') : '—'),
},
{ title: '备注', dataIndex: 'remark', width: 200, render: (v?: string | null) => v || '—' },
{
title: '操作',
key: 'actions',
width: 160,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => openRemark(row)}>
</Button>
{row.editable ? (
<>
<Button type="link" size="small" onClick={() => openEdit(row)}>
</Button>
<Popconfirm
title={`确认删除账户「${row.ownerName}」?`}
okText="删除"
okButtonProps={{ danger: true }}
onConfirm={() => void removeOther(row)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</>
) : null}
</Space>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-bank-accounts', baseColumns, {
page,
pageSize,
});
return (
<div>
{settingsModal}
<AdminListHeader
title="银行账户"
description="汇总门店、酒厂、合伙人、物流的有效收款账户;可登记不挂门店的其他账户。打款仍以各业务源账户为准。"
settings={settingsButton}
actions={
<Button type="primary" onClick={openCreate}>
</Button>
}
/>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters({
type: v.type || '',
cityId: v.cityId || '',
keyword: v.keyword?.trim() || '',
});
setPage(1);
}}
>
<Form.Item name="type" label="类型">
<Select allowClear style={{ width: 140 }} options={typeOptions} />
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 160 }}
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="keyword" label="查找">
<Input allowClear style={{ width: 220 }} placeholder="户名 / 账号 / 开户行 / 归属 / 备注" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button
onClick={() => {
filterForm.resetFields();
setFilters({ type: '', cityId: '', keyword: '' });
setPage(1);
}}
>
</Button>
</Form.Item>
<Form.Item>
<Button loading={exporting === 'xlsx'} onClick={() => void exportFile('xlsx')}>
Excel
</Button>
</Form.Item>
<Form.Item>
<Button loading={exporting === 'pdf'} onClick={() => void exportFile('pdf')}>
PDF
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title={editing ? '编辑账户' : '新增账户'}
open={otherOpen}
onCancel={() => setOtherOpen(false)}
onOk={() => void saveOther()}
confirmLoading={saving}
destroyOnClose
>
<Form form={otherForm} layout="vertical">
<Form.Item name="name" label="归属名称">
<Input placeholder="选填,如供应商或内部账户名称" maxLength={128} />
</Form.Item>
<Form.Item name="bankAccountName" label="户名" rules={[{ required: true, message: '请填写户名' }]}>
<Input maxLength={64} />
</Form.Item>
<Form.Item
name="bankAccountNo"
label="银行账号"
rules={[
{ required: true, message: '请填写银行账号' },
{ pattern: /^\d{8,32}$/, message: '银行账号须为 8~32 位数字' },
]}
>
<Input maxLength={32} />
</Form.Item>
<Form.Item name="bankBranch" label="开户行">
<Input maxLength={128} />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} maxLength={256} />
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑备注"
open={!!remarkTarget}
onCancel={() => setRemarkTarget(null)}
onOk={() => void saveRemark()}
confirmLoading={saving}
destroyOnClose
>
<Form form={remarkForm} layout="vertical">
<Form.Item name="remark" label="备注">
<Input.TextArea rows={4} maxLength={256} placeholder="仅财务目录可见,不改写来源账户" />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -246,7 +246,7 @@ export default function HqPermissionsPage() {
=
//
/
//
//
</Typography.Paragraph>
<Tabs
+195 -48
View File
@@ -3,6 +3,13 @@ import {
Button, Checkbox, Descriptions, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
STORE_STAFF_DEFAULT_PERMISSIONS,
STORE_STAFF_PERMISSION_LABELS,
STORE_STAFF_ROLE_LABELS,
StoreStaffRole,
type AdminStoreStaffItem,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, STORE_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
@@ -10,7 +17,6 @@ import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
type StoreBrief = { id: string; name: string; status: string; cityName?: string };
type Row = {
@@ -27,14 +33,22 @@ type Row = {
bankBranch?: string | null;
store?: StoreBrief | null;
stores?: StoreBrief[];
staff?: Array<{ id: string; name: string; phone: string; status: string; storeIds?: string[] }>;
staff?: AdminStoreStaffItem[];
};
type StoreOption = { id: string; name: string };
const STAFF_ROLE_OPTIONS = Object.entries(STORE_STAFF_ROLE_LABELS).map(([value, label]) => ({ value, label }));
const STAFF_PERMISSION_OPTIONS = STORE_STAFF_DEFAULT_PERMISSIONS.map((value) => ({
value,
label: STORE_STAFF_PERMISSION_LABELS[value],
}));
export default function StoreAccountsPage() {
const [form] = Form.useForm();
const [createForm] = Form.useForm();
const [staffForm] = Form.useForm();
const [staffEditForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string | boolean>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/store-accounts',
@@ -50,9 +64,14 @@ export default function StoreAccountsPage() {
const [detail, setDetail] = useState<Row | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [staffOpen, setStaffOpen] = useState(false);
const [staffEditOpen, setStaffEditOpen] = useState(false);
const [editingStaffId, setEditingStaffId] = useState<string | null>(null);
const [stores, setStores] = useState<StoreOption[]>([]);
const [deletingStaffId, setDeletingStaffId] = useState<string | null>(null);
const parentStoreOptions = (detail?.stores ?? []).map((s) => ({ value: s.id, label: s.name }));
async function loadStores() {
const res = await request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setStores(res.items);
@@ -64,6 +83,34 @@ export default function StoreAccountsPage() {
void reload();
}
function openAddStaff() {
if (!detail) return;
if (!detail.stores?.length) {
message.warning('请先为该主账号绑定门店');
return;
}
staffForm.resetFields();
staffForm.setFieldsValue({
staffRole: StoreStaffRole.CASHIER,
permissions: [...STORE_STAFF_DEFAULT_PERMISSIONS],
storeIds: detail.stores.map((s) => s.id),
});
setStaffOpen(true);
}
function openEditStaff(staff: AdminStoreStaffItem) {
setEditingStaffId(staff.id);
staffEditForm.setFieldsValue({
name: staff.name,
phone: staff.phone,
staffRole: staff.staffRole ?? StoreStaffRole.CASHIER,
status: staff.status,
permissions: staff.permissions?.length ? staff.permissions : [...STORE_STAFF_DEFAULT_PERMISSIONS],
storeIds: staff.storeIds?.length ? staff.storeIds : (staff.stores ?? []).map((s) => s.id),
});
setStaffEditOpen(true);
}
async function deleteStaff(staffId: string) {
if (!detail) return;
setDeletingStaffId(staffId);
@@ -151,13 +198,58 @@ export default function StoreAccountsPage() {
const { columns, settingsButton, settingsModal } = useAdminListColumns('store-accounts', baseColumns, { page, pageSize });
const staffColumns: ColumnsType<AdminStoreStaffItem> = [
{ title: '姓名', dataIndex: 'name', width: 90 },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '角色',
dataIndex: 'staffRole',
width: 80,
render: (role) => STORE_STAFF_ROLE_LABELS[role as StoreStaffRole] || role || '—',
},
{
title: '门店',
render: (_, staff) =>
staff.stores?.length ? staff.stores.map((s) => s.name).join('、') : '—',
},
{
title: '状态',
dataIndex: 'status',
width: 70,
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
},
{
title: '操作',
width: 120,
render: (_, staff) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => openEditStaff(staff)}>
</Button>
<Popconfirm
title="确认删除该子账号?"
description={`${staff.name}${staff.phone})删除后将无法登录门店端`}
okText="删除"
okButtonProps={{ danger: true, loading: deletingStaffId === staff.id }}
cancelText="取消"
onConfirm={() => void deleteStaff(staff.id)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
{settingsModal}
<AdminListHeader
title="门店账户"
settings={settingsButton}
description="主账号可绑定多家门店;收款信息挂在主账号;「新建账户」用于补录无主账号门店"
description="主账号可绑定多家门店;收款信息挂在主账号;详情内可管理店员子账号"
actions={
<Button
type="primary"
@@ -212,13 +304,13 @@ export default function StoreAccountsPage() {
/>
<Drawer
title="门店主账号"
width={520}
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
detail && (
<Select
defaultValue={detail.status}
value={detail.status}
style={{ width: 100 }}
options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
onChange={async (status) => {
@@ -227,7 +319,7 @@ export default function StoreAccountsPage() {
body: JSON.stringify({ status }),
});
message.success('已更新');
void reload();
await refreshDetail(detail.id);
}}
/>
)
@@ -251,48 +343,22 @@ export default function StoreAccountsPage() {
{!detail.stores?.length ? '—' : null}
</Descriptions.Item>
</Descriptions>
{detail.staff?.length ? (
<>
<Typography.Title level={5} style={{ marginTop: 24 }}></Typography.Title>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.staff}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '手机', dataIndex: 'phone' },
{
title: '状态',
dataIndex: 'status',
render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag>,
},
{
title: '操作',
width: 80,
render: (_, staff) => (
<Popconfirm
title="确认删除该子账号?"
description={`${staff.name}${staff.phone})删除后将无法登录门店端`}
okText="删除"
okButtonProps={{ danger: true, loading: deletingStaffId === staff.id }}
cancelText="取消"
onConfirm={() => void deleteStaff(staff.id)}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
),
},
]}
/>
</>
) : (
<Typography.Paragraph type="secondary" style={{ marginTop: 24, marginBottom: 0 }}>
</Typography.Paragraph>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 24, marginBottom: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{detail.staff?.length ?? 0}·
</Typography.Text>
<Button size="small" type="primary" onClick={openAddStaff}>
</Button>
</div>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={detail.staff ?? []}
columns={staffColumns}
locale={{ emptyText: '暂无子账号' }}
/>
</>
)}
</Drawer>
@@ -321,6 +387,87 @@ export default function StoreAccountsPage() {
<Form.Item name="phone" label="手机" rules={[{ required: true }]}><Input /></Form.Item>
</Form>
</Modal>
<Modal
title={detail ? `添加子账号 · ${detail.name}` : '添加子账号'}
open={staffOpen}
onCancel={() => setStaffOpen(false)}
onOk={async () => {
if (!detail) return;
const v = await staffForm.validateFields();
await request(`/admin/store-accounts/${detail.id}/staff`, {
method: 'POST',
body: JSON.stringify(v),
});
message.success('子账号已创建');
setStaffOpen(false);
await refreshDetail(detail.id);
}}
>
<Form form={staffForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item
name="phone"
label="登录手机"
rules={[
{ required: true },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
]}
>
<Input maxLength={11} />
</Form.Item>
<Form.Item name="staffRole" label="角色" rules={[{ required: true }]}>
<Select options={STAFF_ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="storeIds" label="可管门店" rules={[{ required: true, type: 'array', min: 1, message: '请至少绑定一家门店' }]}>
<Select mode="multiple" options={parentStoreOptions} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={STAFF_PERMISSION_OPTIONS} />
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑子账号"
open={staffEditOpen}
onCancel={() => setStaffEditOpen(false)}
onOk={async () => {
if (!detail || !editingStaffId) return;
const v = await staffEditForm.validateFields();
await request(`/admin/store-accounts/${detail.id}/staff/${editingStaffId}`, {
method: 'PUT',
body: JSON.stringify(v),
});
message.success('子账号已更新');
setStaffEditOpen(false);
await refreshDetail(detail.id);
}}
>
<Form form={staffEditForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item
name="phone"
label="登录手机"
rules={[
{ required: true },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
]}
>
<Input maxLength={11} />
</Form.Item>
<Form.Item name="staffRole" label="角色" rules={[{ required: true }]}>
<Select options={STAFF_ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="storeIds" label="可管门店" rules={[{ required: true, type: 'array', min: 1, message: '请至少绑定一家门店' }]}>
<Select mode="multiple" options={parentStoreOptions} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={STAFF_PERMISSION_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
+19 -13
View File
@@ -17,19 +17,25 @@ export function storeStarCount(rating?: number | string | null): number {
return Math.min(5, Math.max(1, Math.round(n)));
}
/** 省市区县 + 详细地址 拼接;空则回退占位文案 */
export function fullStoreAddress(
store: {
province?: string | null;
cityName?: string | null;
city?: string | null;
district?: string | null;
address?: string | null;
},
fallback = '地址待完善',
): string {
const city = store.cityName || store.city || '';
const text = `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim();
export type StoreAddressParts = {
province?: string | null;
cityName?: string | null;
city?: string | null;
district?: string | null;
address?: string | null;
};
function addressPart(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
/** 省 + 市 + 区 + 详细地址原样拼接;详细地址已含省市区时不去重 */
export function fullStoreAddress(store: StoreAddressParts, fallback = '地址待完善'): string {
const province = addressPart(store.province);
const city = addressPart(store.cityName) || addressPart(store.city);
const district = addressPart(store.district);
const detail = addressPart(store.address);
const text = `${province}${city}${district}${detail}`;
return text || fallback;
}
@@ -21,6 +21,7 @@ import { toMoneyNumber } from '../../lib/money';
import { maskPhone, toDialablePhone } from '../../lib/phone';
import { track } from '../../lib/analytics';
import {
fullStoreAddress,
storeCategoryTags,
storeStarCount,
type StoreCategoryTreeNode,
@@ -115,8 +116,7 @@ function envPhotoUrls(store: Store) {
}
function fullAddress(store: Store) {
const city = store.cityName || store.city || '';
return `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim();
return fullStoreAddress(store, '');
}
function pickStoreId(raw?: string | null) {
@@ -447,8 +447,7 @@ export default function StoreDetailPage() {
<View className="store-detail-row">
<Text className="store-detail-meta store-detail-meta--flex">
{store.district ? `${store.district} · ` : ''}
{store.address || '地址待完善'}
{fullStoreAddress(store)}
</Text>
<Text className="store-detail-action" onClick={openMap}>
@@ -177,6 +177,7 @@
flex: 1;
min-width: 0;
margin-bottom: 0;
word-break: break-word;
}
.store-detail-row {