Files
dukang/apps/mini-user/src/pages/invoice-titles/index.tsx
T
jacy 233ed0af3b
CI / verify (pull_request) Has been cancelled
v3.5.1 版本更新
2026-08-19 15:54:51 +08:00

361 lines
12 KiB
TypeScript

import { useCallback, useEffect, useState } from 'react';
import { View, Text, Input, Switch, Image } from '@tarojs/components';
import '../../styles/invoice.css';
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import { request, toast } from '../../lib/api';
import { usePageView } from '../../lib/usePageView';
import {
INVOICE_TITLE_TYPE_LABELS,
type InvoiceTitleType,
type UpsertInvoiceTitleRequest,
type UserInvoiceTitleDto,
} from '@dukang/shared-types';
import iconEdit from '../../assets/icons/编辑.png';
import iconDefault from '../../assets/icons/默认.png';
import iconDelete from '../../assets/icons/删除.png';
const isH5 = process.env.TARO_ENV === 'h5';
const ACTION_ICONS = {
edit: iconEdit,
star: iconDefault,
trash: iconDelete,
} as const;
type EditDraft = UpsertInvoiceTitleRequest & { id?: string };
function TitleActionIcon({
kind,
label,
onClick,
}: {
kind: keyof typeof ACTION_ICONS;
label: string;
onClick: () => void;
}) {
return (
<View
className="invoice-title-icon-btn"
hoverClass="invoice-title-icon-btn--hover"
aria-label={label}
onClick={onClick}
>
<Image className="invoice-title-icon-img" src={ACTION_ICONS[kind]} mode="aspectFit" />
</View>
);
}
const EMPTY_DRAFT: EditDraft = {
titleType: 'PERSONAL',
titleName: '',
taxNo: '',
email: '',
phone: '',
addressPhone: '',
bankAccount: '',
isDefault: false,
};
export default function InvoiceTitlesPage() {
usePageView('user_invoice_titles_view');
const [titles, setTitles] = useState<UserInvoiceTitleDto[]>([]);
const [loading, setLoading] = useState(true);
const [sheetOpen, setSheetOpen] = useState(false);
const [draft, setDraft] = useState<EditDraft>(EMPTY_DRAFT);
const [saving, setSaving] = useState(false);
const load = useCallback(() => {
setLoading(true);
return request<UserInvoiceTitleDto[]>('/trade/invoice-titles')
.then((data) => setTitles(Array.isArray(data) ? data : []))
.catch((e) => {
toast(e instanceof Error ? e.message : '加载失败');
setTitles([]);
})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
void load();
}, [load]);
useDidShow(() => {
void load();
});
usePullDownRefresh(() => {
void load().finally(() => Taro.stopPullDownRefresh());
});
function openCreate() {
setDraft({ ...EMPTY_DRAFT });
setSheetOpen(true);
}
function openEdit(t: UserInvoiceTitleDto) {
setDraft({
id: t.id,
titleType: t.titleType,
titleName: t.titleName,
taxNo: t.taxNo ?? '',
email: t.email ?? '',
phone: t.phone ?? '',
addressPhone: t.addressPhone ?? '',
bankAccount: t.bankAccount ?? '',
isDefault: t.isDefault,
});
setSheetOpen(true);
}
async function save() {
if (saving) return;
const titleName = draft.titleName.trim();
if (!titleName) {
toast('请填写发票抬头名称');
return;
}
if (draft.titleType === 'ENTERPRISE' && !draft.taxNo?.trim()) {
toast('企业抬头须填写税号');
return;
}
setSaving(true);
try {
const payload: UpsertInvoiceTitleRequest = {
titleType: draft.titleType,
titleName,
taxNo: draft.taxNo?.trim() || null,
email: draft.email?.trim() || null,
phone: draft.phone?.trim() || null,
addressPhone: draft.addressPhone?.trim() || null,
bankAccount: draft.bankAccount?.trim() || null,
isDefault: !!draft.isDefault,
};
if (draft.id) {
await request(`/trade/invoice-titles/${draft.id}`, {
method: 'PUT',
data: payload,
});
toast('已更新', 'success');
} else {
await request('/trade/invoice-titles', { method: 'POST', data: payload });
toast('已添加', 'success');
}
setSheetOpen(false);
await load();
} catch (e) {
toast(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function setDefault(t: UserInvoiceTitleDto) {
try {
await request(`/trade/invoice-titles/${t.id}`, {
method: 'PUT',
data: {
titleType: t.titleType,
titleName: t.titleName,
taxNo: t.taxNo,
email: t.email,
phone: t.phone,
addressPhone: t.addressPhone,
bankAccount: t.bankAccount,
isDefault: true,
},
});
toast('已设为默认', 'success');
await load();
} catch (e) {
toast(e instanceof Error ? e.message : '设置失败');
}
}
async function remove(id: string) {
const ok = await Taro.showModal({ title: '提示', content: '确认删除该发票抬头?' });
if (!ok.confirm) return;
try {
await request(`/trade/invoice-titles/${id}`, { method: 'DELETE' });
toast('已删除', 'success');
await load();
} catch (e) {
toast(e instanceof Error ? e.message : '删除失败');
}
}
return (
<PageShell variant="sub" className="invoice-titles-page">
{isH5 ? (
<SubPageHeader
title="发票抬头"
onBack={() => Taro.navigateBack().catch(() => Taro.switchTab({ url: '/pages/mine/index' }))}
/>
) : null}
<View className="invoice-titles-body">
{loading ? <View className="u-empty">加载中…</View> : null}
{!loading && titles.length === 0 ? (
<View className="u-empty">暂无发票抬头,点击右上角添加</View>
) : null}
{!loading &&
titles.map((t) => (
<View key={t.id} className="invoice-title-card">
<View className="invoice-title-card-head">
<Text className="invoice-title-name">{t.titleName}</Text>
<Text className="invoice-title-type">
{INVOICE_TITLE_TYPE_LABELS[t.titleType] ?? t.titleType}
</Text>
{t.isDefault ? <Text className="invoice-title-default">默认</Text> : null}
</View>
{t.taxNo ? <Text className="invoice-title-line">税号:{t.taxNo}</Text> : null}
{t.email ? <Text className="invoice-title-line">邮箱:{t.email}</Text> : null}
{t.phone ? <Text className="invoice-title-line">电话:{t.phone}</Text> : null}
{t.addressPhone ? (
<Text className="invoice-title-line">地址电话:{t.addressPhone}</Text>
) : null}
{t.bankAccount ? (
<Text className="invoice-title-line">开户行账号:{t.bankAccount}</Text>
) : null}
<View className="invoice-title-actions">
<TitleActionIcon kind="edit" label="编辑" onClick={() => openEdit(t)} />
{!t.isDefault ? (
<TitleActionIcon kind="star" label="设为默认" onClick={() => void setDefault(t)} />
) : null}
<TitleActionIcon kind="trash" label="删除" onClick={() => void remove(t.id)} />
</View>
</View>
))}
</View>
<View className="invoice-titles-fab" onClick={openCreate}>
<Text>+ 添加抬头</Text>
</View>
{sheetOpen ? (
<View className="invoice-title-sheet-mask">
<View className="invoice-title-sheet-mask-backdrop" onClick={() => !saving && setSheetOpen(false)} />
<View className="invoice-title-sheet">
<Text className="invoice-title-sheet-title">
{draft.id ? '编辑发票抬头' : '添加发票抬头'}
</Text>
<View className="invoice-title-field">
<Text className="invoice-title-label">抬头类型</Text>
<View className="invoice-title-type-row">
{(['PERSONAL', 'ENTERPRISE'] as InvoiceTitleType[]).map((tp) => (
<Text
key={tp}
className={`invoice-title-type-chip${
draft.titleType === tp ? ' active' : ''
}`}
onClick={() => setDraft({ ...draft, titleType: tp })}
>
{INVOICE_TITLE_TYPE_LABELS[tp]}
</Text>
))}
</View>
</View>
<View className="invoice-title-field">
<Text className="invoice-title-label">抬头名称</Text>
<Input
className="invoice-title-input"
maxlength={128}
placeholder="个人姓名或企业名称"
value={draft.titleName}
onInput={(e) => setDraft({ ...draft, titleName: e.detail.value })}
/>
</View>
{draft.titleType === 'ENTERPRISE' ? (
<View className="invoice-title-field">
<Text className="invoice-title-label">税号</Text>
<Input
className="invoice-title-input"
maxlength={32}
placeholder="企业税号"
value={draft.taxNo || ''}
onInput={(e) => setDraft({ ...draft, taxNo: e.detail.value })}
/>
</View>
) : null}
<View className="invoice-title-field">
<Text className="invoice-title-label">接收邮箱</Text>
<Input
className="invoice-title-input"
maxlength={128}
placeholder="电子发票将发送至此邮箱"
value={draft.email || ''}
onInput={(e) => setDraft({ ...draft, email: e.detail.value })}
/>
</View>
<View className="invoice-title-field">
<Text className="invoice-title-label">联系电话</Text>
<Input
className="invoice-title-input"
type="number"
maxlength={20}
placeholder="选填"
value={draft.phone || ''}
onInput={(e) => setDraft({ ...draft, phone: e.detail.value })}
/>
</View>
{draft.titleType === 'ENTERPRISE' ? (
<>
<View className="invoice-title-field">
<Text className="invoice-title-label">地址电话</Text>
<Input
className="invoice-title-input"
maxlength={256}
placeholder="专用发票需要,选填"
value={draft.addressPhone || ''}
onInput={(e) => setDraft({ ...draft, addressPhone: e.detail.value })}
/>
</View>
<View className="invoice-title-field">
<Text className="invoice-title-label">开户行账号</Text>
<Input
className="invoice-title-input"
maxlength={256}
placeholder="专用发票需要,选填"
value={draft.bankAccount || ''}
onInput={(e) => setDraft({ ...draft, bankAccount: e.detail.value })}
/>
</View>
</>
) : null}
<View className="invoice-title-default-row">
<Text className="invoice-title-label">设为默认抬头</Text>
<Switch
checked={!!draft.isDefault}
color="#A61D24"
onChange={(e) => setDraft({ ...draft, isDefault: !!e.detail.value })}
/>
</View>
<View className="invoice-title-sheet-actions">
<Text
className="invoice-title-sheet-cancel"
onClick={() => !saving && setSheetOpen(false)}
>
取消
</Text>
<Text
className={`invoice-title-sheet-save${saving ? ' is-disabled' : ''}`}
onClick={() => { if (!saving) void save(); }}
>
{saving ? '保存中…' : '保存'}
</Text>
</View>
</View>
</View>
) : null}
</PageShell>
);
}