Compare commits
5 Commits
d11757c854
...
ac1e0a9f13
| Author | SHA1 | Date | |
|---|---|---|---|
| ac1e0a9f13 | |||
| 2a208d5dea | |||
| 82c447b193 | |||
| 75765bf9d4 | |||
| 6479365d6a |
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { Layout, Menu, Typography, Button, Space } from 'antd';
|
import { Layout, Menu, Typography, Button, Space } from 'antd';
|
||||||
import type { MenuProps } from 'antd';
|
import type { MenuProps } from 'antd';
|
||||||
@@ -18,11 +18,14 @@ import {
|
|||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
AccountBookOutlined,
|
AccountBookOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
|
||||||
import { clearAuth, request, type HqProfile } from '../lib/api';
|
import { clearAuth, request, type HqProfile } from '../lib/api';
|
||||||
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
|
||||||
|
|
||||||
const { Header, Sider, Content } = Layout;
|
const { Header, Sider, Content } = Layout;
|
||||||
|
|
||||||
|
type MenuItem = NonNullable<MenuProps['items']>[number];
|
||||||
|
|
||||||
const MENU_ITEMS: MenuProps['items'] = [
|
const MENU_ITEMS: MenuProps['items'] = [
|
||||||
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
|
||||||
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
{ key: '/users', icon: <UserOutlined />, label: '用户' },
|
||||||
@@ -111,6 +114,75 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||||
|
const map: Record<string, string | 'system_settings_any'> = {
|
||||||
|
'/': 'dashboard',
|
||||||
|
'/users': 'users',
|
||||||
|
'/wechat-bindings': 'wechat_bindings',
|
||||||
|
'products-group': 'products',
|
||||||
|
'/products': 'products',
|
||||||
|
'/product-detail-templates': 'products',
|
||||||
|
'/orders': 'orders',
|
||||||
|
'/promo-codes': 'promo_codes',
|
||||||
|
'stores-group': 'stores',
|
||||||
|
'/stores': 'stores',
|
||||||
|
'/store-categories': 'stores',
|
||||||
|
'/store-accounts': 'stores',
|
||||||
|
'/store-media': 'stores',
|
||||||
|
'partners-group': 'partners',
|
||||||
|
'/cities': 'partners',
|
||||||
|
'/city-partners': 'partners',
|
||||||
|
'/city-warehouses': 'partners',
|
||||||
|
'/fulfillment-providers': 'partners',
|
||||||
|
'finance-group': 'finance',
|
||||||
|
'/finance/store-bills': 'finance',
|
||||||
|
'/finance/partner-bills': 'finance',
|
||||||
|
'/finance/winery-bills': 'finance',
|
||||||
|
'benefit-group': 'benefit',
|
||||||
|
'/benefit/coupons': 'benefit',
|
||||||
|
'/benefit/ledgers': 'benefit',
|
||||||
|
'/redeem-records': 'benefit',
|
||||||
|
'/redeem-pending': 'benefit',
|
||||||
|
'/redeem/debug': 'benefit',
|
||||||
|
'deliveries-group': 'deliveries',
|
||||||
|
'/deliveries': 'deliveries',
|
||||||
|
'/deliveries/xiaofeixia': 'deliveries',
|
||||||
|
'/tickets': 'tickets',
|
||||||
|
'/invoices': 'invoices',
|
||||||
|
'/resources': 'resources',
|
||||||
|
'logs-group': 'logs',
|
||||||
|
'/logs/users': 'logs',
|
||||||
|
'/logs/stores': 'logs',
|
||||||
|
'/logs/partners': 'logs',
|
||||||
|
'/logs/hq': 'logs',
|
||||||
|
'/logs/third-party': 'logs',
|
||||||
|
'/hq-permissions': 'hq_permissions',
|
||||||
|
'/system-settings': 'system_settings_any',
|
||||||
|
'/hq-accounts': 'hq_accounts',
|
||||||
|
};
|
||||||
|
const need = map[key];
|
||||||
|
if (!need) return true;
|
||||||
|
if (need === 'system_settings_any') return hasAnySystemSettingsPermission(permissionKeys);
|
||||||
|
return permissionKeys.includes(need);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): MenuProps['items'] {
|
||||||
|
if (!items) return items;
|
||||||
|
return items
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== 'object' || !('key' in item)) return item;
|
||||||
|
const key = String(item.key);
|
||||||
|
if ('children' in item && Array.isArray(item.children)) {
|
||||||
|
if (!menuAllowed(key, permissionKeys)) return null;
|
||||||
|
const children = filterMenuItems(item.children as MenuProps['items'], permissionKeys);
|
||||||
|
if (!children?.length) return null;
|
||||||
|
return { ...item, children } as MenuItem;
|
||||||
|
}
|
||||||
|
return menuAllowed(key, permissionKeys) ? item : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean) as MenuProps['items'];
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -138,6 +210,12 @@ export default function AdminLayout() {
|
|||||||
? '/promo-codes'
|
? '/promo-codes'
|
||||||
: location.pathname;
|
: location.pathname;
|
||||||
|
|
||||||
|
const menuItems = useMemo(() => {
|
||||||
|
if (!profile) return MENU_ITEMS;
|
||||||
|
if (profile.adminRole === 'SUPER_ADMIN') return MENU_ITEMS;
|
||||||
|
return filterMenuItems(MENU_ITEMS, profile.permissionKeys ?? []);
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
|
||||||
<Sider
|
<Sider
|
||||||
@@ -156,8 +234,8 @@ export default function AdminLayout() {
|
|||||||
theme="dark"
|
theme="dark"
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[selectedKey]}
|
selectedKeys={[selectedKey]}
|
||||||
defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'finance-group', 'benefit-group', 'logs-group', 'deliveries-group']}
|
defaultOpenKeys={[]}
|
||||||
items={MENU_ITEMS}
|
items={menuItems}
|
||||||
onClick={({ key }) => {
|
onClick={({ key }) => {
|
||||||
if (key.startsWith('/')) navigate(key);
|
if (key.startsWith('/')) navigate(key);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type HqProfile = {
|
|||||||
name: string;
|
name: string;
|
||||||
adminRole: string;
|
adminRole: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
permissionKeys?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getToken() {
|
export function getToken() {
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
|
|||||||
{ value: 'DELIVERY_UPDATE', label: '编辑配送单' },
|
{ value: 'DELIVERY_UPDATE', label: '编辑配送单' },
|
||||||
{ value: 'TICKET_APPROVE', label: '工单通过' },
|
{ value: 'TICKET_APPROVE', label: '工单通过' },
|
||||||
{ value: 'TICKET_REJECT', label: '工单驳回' },
|
{ value: 'TICKET_REJECT', label: '工单驳回' },
|
||||||
|
{ value: 'TICKET_CREATE', label: '创建工单' },
|
||||||
|
{ value: 'INVOICE_CREATE', label: '创建发票申请' },
|
||||||
|
{ value: 'INVOICE_ISSUE', label: '开具发票' },
|
||||||
|
{ value: 'INVOICE_REJECT', label: '驳回发票' },
|
||||||
{ value: 'STORE_PAYOUT_CONFIRM', label: '门店打款确认' },
|
{ value: 'STORE_PAYOUT_CONFIRM', label: '门店打款确认' },
|
||||||
{ value: 'STORE_PAYOUT_BATCH_CONFIRM', label: '批量门店打款' },
|
{ value: 'STORE_PAYOUT_BATCH_CONFIRM', label: '批量门店打款' },
|
||||||
{ value: 'STORE_BILL_CONFIRM', label: '门店对账单确认打款' },
|
{ value: 'STORE_BILL_CONFIRM', label: '门店对账单确认打款' },
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export type StoreCreateForm = {
|
|||||||
city?: string;
|
city?: string;
|
||||||
district: string;
|
district: string;
|
||||||
districtCode?: string;
|
districtCode?: string;
|
||||||
|
categoryParentId?: string;
|
||||||
|
categoryId: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
address: string;
|
address: string;
|
||||||
@@ -23,11 +25,22 @@ const PHONE_RE = /^1\d{10}$/;
|
|||||||
const BANK_RE = /^\d{16,19}$/;
|
const BANK_RE = /^\d{16,19}$/;
|
||||||
|
|
||||||
export function validateStoreCreateStep1(
|
export function validateStoreCreateStep1(
|
||||||
form: Pick<StoreCreateForm, 'partnerAccountId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
|
form: Pick<
|
||||||
|
StoreCreateForm,
|
||||||
|
| 'partnerAccountId'
|
||||||
|
| 'cityId'
|
||||||
|
| 'regionCodes'
|
||||||
|
| 'categoryId'
|
||||||
|
| 'name'
|
||||||
|
| 'phone'
|
||||||
|
| 'address'
|
||||||
|
| 'intro'
|
||||||
|
>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.partnerAccountId) return '请选择开城合伙人';
|
if (!form.partnerAccountId) return '请选择开城合伙人';
|
||||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||||
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
|
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
|
||||||
|
if (!form.categoryId?.trim()) return '请选择门店分类(细类)';
|
||||||
if (!form.name?.trim()) return '请填写门店名称';
|
if (!form.name?.trim()) return '请填写门店名称';
|
||||||
if (!form.phone?.trim()) return '请填写门店手机号';
|
if (!form.phone?.trim()) return '请填写门店手机号';
|
||||||
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Col,
|
Col,
|
||||||
|
Divider,
|
||||||
Form,
|
Form,
|
||||||
Row,
|
Row,
|
||||||
Select,
|
Select,
|
||||||
@@ -33,6 +34,8 @@ type AccountPermRes = {
|
|||||||
|
|
||||||
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
|
const ROLE_LABELS = Object.fromEntries(HQ_ADMIN_ROLES.map((r) => [r.value, r.label]));
|
||||||
|
|
||||||
|
const CATALOG_GROUPS = [...new Set(HQ_PERMISSION_CATALOG.map((p) => p.group ?? '其他'))];
|
||||||
|
|
||||||
function PermissionChecklist({
|
function PermissionChecklist({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -49,13 +52,24 @@ function PermissionChecklist({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(checked) => onChange(checked as string[])}
|
onChange={(checked) => onChange(checked as string[])}
|
||||||
>
|
>
|
||||||
<Row gutter={[8, 8]}>
|
{CATALOG_GROUPS.map((group) => {
|
||||||
{HQ_PERMISSION_CATALOG.map((item) => (
|
const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group);
|
||||||
<Col key={item.key} span={8}>
|
return (
|
||||||
<Checkbox value={item.key}>{item.label}</Checkbox>
|
<div key={group} style={{ marginBottom: 16 }}>
|
||||||
</Col>
|
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||||
))}
|
{group}
|
||||||
</Row>
|
</Typography.Text>
|
||||||
|
<Row gutter={[8, 8]}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Col key={item.key} span={8}>
|
||||||
|
<Checkbox value={item.key}>{item.label}</Checkbox>
|
||||||
|
</Col>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
<Divider style={{ margin: '12px 0 0' }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Checkbox.Group>
|
</Checkbox.Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -159,6 +173,7 @@ export default function HqPermissionsPage() {
|
|||||||
<Typography.Title level={4}>权限分配</Typography.Title>
|
<Typography.Title level={4}>权限分配</Typography.Title>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限(超级管理员始终拥有全部权限)。
|
按角色配置基础权限;按用户可追加专属权限。最终生效权限 = 角色权限 ∪ 用户权限(超级管理员始终拥有全部权限)。
|
||||||
|
「系统设置」已拆分为各配置分组;「财务」对应门店/合伙人/酒厂账单。
|
||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -231,7 +246,7 @@ export default function HqPermissionsPage() {
|
|||||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
|
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
|
||||||
return (
|
return (
|
||||||
<Tag key={key} color="blue">
|
<Tag key={key} color="blue">
|
||||||
{item?.label || key}
|
{item?.group ? `${item.group}·${item.label}` : item?.label || key}
|
||||||
</Tag>
|
</Tag>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -244,7 +259,11 @@ export default function HqPermissionsPage() {
|
|||||||
<span>合并生效:</span>
|
<span>合并生效:</span>
|
||||||
{previewEffectiveKeys.map((key) => {
|
{previewEffectiveKeys.map((key) => {
|
||||||
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === (key as HqPermissionKey));
|
||||||
return <Tag key={key}>{item?.label || key}</Tag>;
|
return (
|
||||||
|
<Tag key={key}>
|
||||||
|
{item?.group ? `${item.group}·${item.label}` : item?.label || key}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</Space>
|
</Space>
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
Input,
|
Input,
|
||||||
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
|
Space,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -47,6 +49,19 @@ type Row = {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CreateFormValues = {
|
||||||
|
orderNo: string;
|
||||||
|
titleType: InvoiceTitleType;
|
||||||
|
invoiceKind: InvoiceKind;
|
||||||
|
titleName: string;
|
||||||
|
taxNo?: string;
|
||||||
|
addressPhone?: string;
|
||||||
|
bankAccount?: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
remark?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export default function InvoicesPage() {
|
export default function InvoicesPage() {
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
@@ -61,6 +76,11 @@ export default function InvoicesPage() {
|
|||||||
const [detail, setDetail] = useState<Row | null>(null);
|
const [detail, setDetail] = useState<Row | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createForm] = Form.useForm<CreateFormValues>();
|
||||||
|
const invoiceKind = Form.useWatch('invoiceKind', createForm);
|
||||||
|
const titleType = Form.useWatch('titleType', createForm);
|
||||||
|
|
||||||
async function openDetail(id: string) {
|
async function openDetail(id: string) {
|
||||||
setDetail(await request(`/admin/invoices/${id}`));
|
setDetail(await request(`/admin/invoices/${id}`));
|
||||||
@@ -98,6 +118,36 @@ export default function InvoicesPage() {
|
|||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
const values = await createForm.validateFields();
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await request('/admin/invoices', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
orderNo: values.orderNo.trim(),
|
||||||
|
titleType: values.titleType,
|
||||||
|
invoiceKind: values.invoiceKind,
|
||||||
|
titleName: values.titleName.trim(),
|
||||||
|
taxNo: values.taxNo?.trim() || undefined,
|
||||||
|
addressPhone: values.addressPhone?.trim() || undefined,
|
||||||
|
bankAccount: values.bankAccount?.trim() || undefined,
|
||||||
|
email: values.email.trim(),
|
||||||
|
phone: values.phone.trim(),
|
||||||
|
remark: values.remark?.trim() || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('发票申请已创建');
|
||||||
|
setCreateOpen(false);
|
||||||
|
createForm.resetFields();
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '创建失败');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
|
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
|
||||||
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
|
||||||
@@ -138,7 +188,30 @@ export default function InvoicesPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>发票管理</Typography.Title>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
发票管理
|
||||||
|
</Typography.Title>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={() => {
|
||||||
|
createForm.setFieldsValue({
|
||||||
|
titleType: 'PERSONAL',
|
||||||
|
invoiceKind: 'NORMAL',
|
||||||
|
});
|
||||||
|
setCreateOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
创建发票申请
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Form
|
<Form
|
||||||
layout="inline"
|
layout="inline"
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
@@ -187,7 +260,7 @@ export default function InvoicesPage() {
|
|||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
detail?.status === 'PENDING' ? (
|
detail?.status === 'PENDING' ? (
|
||||||
<>
|
<Space>
|
||||||
<Upload
|
<Upload
|
||||||
accept="image/*,.pdf"
|
accept="image/*,.pdf"
|
||||||
showUploadList={false}
|
showUploadList={false}
|
||||||
@@ -196,14 +269,14 @@ export default function InvoicesPage() {
|
|||||||
return false;
|
return false;
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button type="primary" loading={uploading} style={{ marginRight: 8 }}>
|
<Button type="primary" loading={uploading}>
|
||||||
上传并开票
|
上传并开票
|
||||||
</Button>
|
</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
<Button danger onClick={() => void reject()}>
|
<Button danger onClick={() => void reject()}>
|
||||||
驳回
|
驳回
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</Space>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -242,6 +315,110 @@ export default function InvoicesPage() {
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="创建发票申请"
|
||||||
|
open={createOpen}
|
||||||
|
onCancel={() => setCreateOpen(false)}
|
||||||
|
onOk={() => void submitCreate()}
|
||||||
|
confirmLoading={creating}
|
||||||
|
destroyOnClose
|
||||||
|
okText="提交"
|
||||||
|
width={520}
|
||||||
|
>
|
||||||
|
<Form form={createForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="orderNo"
|
||||||
|
label="订单号"
|
||||||
|
rules={[{ required: true, message: '请填写已完成订单号' }]}
|
||||||
|
extra="仅已完成订单可开票"
|
||||||
|
>
|
||||||
|
<Input placeholder="订单号" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="invoiceKind"
|
||||||
|
label="发票类型"
|
||||||
|
rules={[{ required: true, message: '请选择发票类型' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={(Object.keys(INVOICE_KIND_LABELS) as InvoiceKind[]).map((k) => ({
|
||||||
|
value: k,
|
||||||
|
label: INVOICE_KIND_LABELS[k],
|
||||||
|
}))}
|
||||||
|
onChange={(k: InvoiceKind) => {
|
||||||
|
if (k === 'SPECIAL') createForm.setFieldValue('titleType', 'ENTERPRISE');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="titleType"
|
||||||
|
label="抬头类型"
|
||||||
|
rules={[{ required: true, message: '请选择抬头类型' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={(Object.keys(INVOICE_TITLE_TYPE_LABELS) as InvoiceTitleType[]).map((t) => ({
|
||||||
|
value: t,
|
||||||
|
label: INVOICE_TITLE_TYPE_LABELS[t],
|
||||||
|
disabled: invoiceKind === 'SPECIAL' && t === 'PERSONAL',
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="titleName"
|
||||||
|
label="抬头名称"
|
||||||
|
rules={[{ required: true, message: '请填写抬头名称' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="个人姓名或企业全称" />
|
||||||
|
</Form.Item>
|
||||||
|
{(titleType === 'ENTERPRISE' || invoiceKind === 'SPECIAL') && (
|
||||||
|
<Form.Item
|
||||||
|
name="taxNo"
|
||||||
|
label="税号"
|
||||||
|
rules={[{ required: true, message: '企业抬头须填写税号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="纳税人识别号" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
{invoiceKind === 'SPECIAL' && (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
name="addressPhone"
|
||||||
|
label="地址电话"
|
||||||
|
rules={[{ required: true, message: '专用发票须填写地址电话' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="注册地址及电话" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="bankAccount"
|
||||||
|
label="开户行账号"
|
||||||
|
rules={[{ required: true, message: '专用发票须填写开户行账号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="开户行及账号" />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Form.Item
|
||||||
|
name="email"
|
||||||
|
label="接收邮箱"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: '请填写邮箱' },
|
||||||
|
{ type: 'email', message: '邮箱格式不正确' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input placeholder="发票发送邮箱" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="phone"
|
||||||
|
label="手机号"
|
||||||
|
rules={[{ required: true, message: '请填写手机号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="联系手机" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input.TextArea rows={2} maxLength={512} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,6 +263,7 @@ type StoreRow = {
|
|||||||
cityRef?: { name: string; code: string };
|
cityRef?: { name: string; code: string };
|
||||||
partner?: { companyName: string };
|
partner?: { companyName: string };
|
||||||
account?: { phone: string; name: string; status: string };
|
account?: { phone: string; name: string; status: string };
|
||||||
|
category?: { id: string; name: string; parentId?: string | null } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PartnerOption = { id: string; companyName: string };
|
type PartnerOption = { id: string; companyName: string };
|
||||||
@@ -272,6 +273,12 @@ type CityOption = {
|
|||||||
code: string;
|
code: string;
|
||||||
partnerBindings?: Array<{ partnerAccountId: string; partnerCompanyName?: string }>;
|
partnerBindings?: Array<{ partnerAccountId: string; partnerCompanyName?: string }>;
|
||||||
};
|
};
|
||||||
|
type CategoryNode = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status?: string;
|
||||||
|
children?: CategoryNode[];
|
||||||
|
};
|
||||||
|
|
||||||
export default function StoresPage() {
|
export default function StoresPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -301,11 +308,27 @@ export default function StoresPage() {
|
|||||||
const [createError, setCreateError] = useState('');
|
const [createError, setCreateError] = useState('');
|
||||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||||
const [cities, setCities] = useState<CityOption[]>([]);
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
|
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
||||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||||
|
|
||||||
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
|
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
|
||||||
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
|
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||||
const selectedCityId = Form.useWatch('cityId', createForm);
|
const selectedCityId = Form.useWatch('cityId', createForm);
|
||||||
|
const selectedCategoryParentId = Form.useWatch('categoryParentId', createForm);
|
||||||
|
|
||||||
|
const categoryParentOptions = useMemo(
|
||||||
|
() =>
|
||||||
|
categoryTree
|
||||||
|
.filter((n) => n.status !== 'INACTIVE')
|
||||||
|
.map((n) => ({ value: n.id, label: n.name })),
|
||||||
|
[categoryTree],
|
||||||
|
);
|
||||||
|
const categoryChildOptions = useMemo(() => {
|
||||||
|
const parent = categoryTree.find((n) => n.id === selectedCategoryParentId);
|
||||||
|
return (parent?.children ?? [])
|
||||||
|
.filter((n) => n.status !== 'INACTIVE')
|
||||||
|
.map((n) => ({ value: n.id, label: n.name }));
|
||||||
|
}, [categoryTree, selectedCategoryParentId]);
|
||||||
|
|
||||||
function bindRegionSelection(codes: string[], partnerAccountId?: string) {
|
function bindRegionSelection(codes: string[], partnerAccountId?: string) {
|
||||||
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
|
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
|
||||||
@@ -337,16 +360,21 @@ export default function StoresPage() {
|
|||||||
setOptionsLoading(true);
|
setOptionsLoading(true);
|
||||||
try {
|
try {
|
||||||
const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`;
|
const qs = `pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`;
|
||||||
const [p, c] = await Promise.all([
|
const [p, c, cats] = await Promise.all([
|
||||||
request<Paginated<PartnerOption>>(`/admin/partners?${qs}`),
|
request<Paginated<PartnerOption>>(`/admin/partners?${qs}`),
|
||||||
request<Paginated<CityOption>>(`/admin/cities?${qs}`),
|
request<Paginated<CityOption>>(`/admin/cities?${qs}`),
|
||||||
|
request<CategoryNode[]>('/admin/store-categories'),
|
||||||
]);
|
]);
|
||||||
setPartners(p.items);
|
setPartners(p.items);
|
||||||
setCities(c.items);
|
setCities(c.items);
|
||||||
|
setCategoryTree(Array.isArray(cats) ? cats : []);
|
||||||
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
|
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
|
||||||
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
|
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
|
||||||
|
if (!Array.isArray(cats) || !cats.length) {
|
||||||
|
message.warning('暂无门店分类,请先在「门店 → 门店分类」中配置');
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
message.error(e instanceof Error ? e.message : '加载合伙人/城市失败');
|
message.error(e instanceof Error ? e.message : '加载合伙人/城市/分类失败');
|
||||||
} finally {
|
} finally {
|
||||||
setOptionsLoading(false);
|
setOptionsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -379,7 +407,16 @@ export default function StoresPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await createForm.validateFields(['partnerAccountId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
|
await createForm.validateFields([
|
||||||
|
'partnerAccountId',
|
||||||
|
'regionCodes',
|
||||||
|
'cityId',
|
||||||
|
'categoryParentId',
|
||||||
|
'categoryId',
|
||||||
|
'name',
|
||||||
|
'phone',
|
||||||
|
'address',
|
||||||
|
]);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -403,6 +440,7 @@ export default function StoresPage() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
partnerAccountId: values.partnerAccountId,
|
partnerAccountId: values.partnerAccountId,
|
||||||
cityId: values.cityId,
|
cityId: values.cityId,
|
||||||
|
categoryId: values.categoryId,
|
||||||
province: values.province,
|
province: values.province,
|
||||||
city: values.city,
|
city: values.city,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
@@ -441,6 +479,11 @@ export default function StoresPage() {
|
|||||||
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
render: (url) => url ? <Image src={url} width={48} height={48} style={{ objectFit: 'cover', borderRadius: 4 }} /> : '—',
|
||||||
},
|
},
|
||||||
{ title: '门店名', dataIndex: 'name', width: 140 },
|
{ title: '门店名', dataIndex: 'name', width: 140 },
|
||||||
|
{
|
||||||
|
title: '分类',
|
||||||
|
width: 100,
|
||||||
|
render: (_, row) => row.category?.name || '—',
|
||||||
|
},
|
||||||
{ title: '城市', dataIndex: 'cityName', width: 80 },
|
{ title: '城市', dataIndex: 'cityName', width: 80 },
|
||||||
{ title: '电话', dataIndex: 'phone', width: 120 },
|
{ title: '电话', dataIndex: 'phone', width: 120 },
|
||||||
{
|
{
|
||||||
@@ -580,6 +623,11 @@ export default function StoresPage() {
|
|||||||
<StoreAuditMediaSection detail={detail} />
|
<StoreAuditMediaSection detail={detail} />
|
||||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="门店分类">
|
||||||
|
{detail.category && typeof detail.category === 'object' && 'name' in detail.category
|
||||||
|
? String((detail.category as { name?: string }).name || '—')
|
||||||
|
: '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="审核状态">
|
<Descriptions.Item label="审核状态">
|
||||||
<Tag color={
|
<Tag color={
|
||||||
String(detail.auditStatus) === 'PENDING' ? 'orange'
|
String(detail.auditStatus) === 'PENDING' ? 'orange'
|
||||||
@@ -685,6 +733,38 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="city" hidden><Input /></Form.Item>
|
<Form.Item name="city" hidden><Input /></Form.Item>
|
||||||
<Form.Item name="district" hidden><Input /></Form.Item>
|
<Form.Item name="district" hidden><Input /></Form.Item>
|
||||||
<Form.Item name="districtCode" hidden><Input /></Form.Item>
|
<Form.Item name="districtCode" hidden><Input /></Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="categoryParentId"
|
||||||
|
label="门店分类(大类)"
|
||||||
|
rules={[{ required: true, message: '请选择门店大类' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
loading={optionsLoading}
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder={optionsLoading ? '加载中…' : '选择大类'}
|
||||||
|
options={categoryParentOptions}
|
||||||
|
onChange={() => createForm.setFieldValue('categoryId', undefined)}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="categoryId"
|
||||||
|
label="门店分类(细类)"
|
||||||
|
rules={[{ required: true, message: '请选择门店细类' }]}
|
||||||
|
extra={
|
||||||
|
<Typography.Link onClick={() => navigate('/store-categories')}>
|
||||||
|
去配置门店分类
|
||||||
|
</Typography.Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder={selectedCategoryParentId ? '选择细类' : '请先选大类'}
|
||||||
|
disabled={!selectedCategoryParentId}
|
||||||
|
options={categoryChildOptions}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
||||||
<Input placeholder="请输入门店名称" />
|
<Input placeholder="请输入门店名称" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { MockSmsCodeItem, SystemConfigFieldMeta, SystemConfigFormResponse } from '@dukang/shared-types';
|
import type { MockSmsCodeItem, SystemConfigFieldMeta, SystemConfigFormResponse } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request, type HqProfile } from '../lib/api';
|
||||||
import { ConfigImageField, ConfigImageListField } from '../components/ConfigMediaFields';
|
import { ConfigImageField, ConfigImageListField } from '../components/ConfigMediaFields';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
@@ -161,6 +161,7 @@ export default function SystemSettingsPage() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [form] = Form.useForm<Record<string, string>>();
|
const [form] = Form.useForm<Record<string, string>>();
|
||||||
const [meta, setMeta] = useState<SystemConfigFormResponse | null>(null);
|
const [meta, setMeta] = useState<SystemConfigFormResponse | null>(null);
|
||||||
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
@@ -195,6 +196,7 @@ export default function SystemSettingsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -335,10 +337,14 @@ export default function SystemSettingsPage() {
|
|||||||
</Typography.Paragraph>
|
</Typography.Paragraph>
|
||||||
</div>
|
</div>
|
||||||
<Space>
|
<Space>
|
||||||
<Button onClick={() => void onImportEnv()}>从环境导入</Button>
|
{profile?.adminRole === 'SUPER_ADMIN' ? (
|
||||||
<Button loading={syncing} onClick={() => void onSyncEnv()}>
|
<>
|
||||||
同步到 env 文件
|
<Button onClick={() => void onImportEnv()}>从环境导入</Button>
|
||||||
</Button>
|
<Button loading={syncing} onClick={() => void onSyncEnv()}>
|
||||||
|
同步到 env 文件
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -363,7 +369,7 @@ export default function SystemSettingsPage() {
|
|||||||
|
|
||||||
<Card loading={loading}>
|
<Card loading={loading}>
|
||||||
<Form form={form} layout="vertical" onValuesChange={() => setDirty(true)}>
|
<Form form={form} layout="vertical" onValuesChange={() => setDirty(true)}>
|
||||||
<Collapse defaultActiveKey={meta?.groups.map((g) => g.key)} items={collapseItems} />
|
<Collapse defaultActiveKey={[]} items={collapseItems} />
|
||||||
</Form>
|
</Form>
|
||||||
{meta?.updatedAt ? (
|
{meta?.updatedAt ? (
|
||||||
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
|
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Descriptions, Drawer, Form, Image, Input, Select, Table, Typography, message } from 'antd';
|
import {
|
||||||
|
Button,
|
||||||
|
Descriptions,
|
||||||
|
Drawer,
|
||||||
|
Form,
|
||||||
|
Image,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Table,
|
||||||
|
Typography,
|
||||||
|
message,
|
||||||
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
|
import { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
@@ -32,6 +45,13 @@ export default function TicketsPage() {
|
|||||||
);
|
);
|
||||||
const [detail, setDetail] = useState<Row | null>(null);
|
const [detail, setDetail] = useState<Row | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createForm] = Form.useForm<{
|
||||||
|
ticketType: TicketTypeDto;
|
||||||
|
orderNo: string;
|
||||||
|
remark?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
async function approve(id: string) {
|
async function approve(id: string) {
|
||||||
await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) });
|
await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) });
|
||||||
@@ -50,6 +70,29 @@ export default function TicketsPage() {
|
|||||||
setDrawerOpen(false);
|
setDrawerOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
const values = await createForm.validateFields();
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await request('/admin/tickets', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
ticketType: values.ticketType,
|
||||||
|
orderNo: values.orderNo.trim(),
|
||||||
|
remark: values.remark?.trim() || undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
message.success('工单已创建');
|
||||||
|
setCreateOpen(false);
|
||||||
|
createForm.resetFields();
|
||||||
|
reload();
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '创建失败');
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||||
{
|
{
|
||||||
@@ -84,7 +127,21 @@ export default function TicketsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>工单中心</Typography.Title>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
工单中心
|
||||||
|
</Typography.Title>
|
||||||
|
<Button type="primary" onClick={() => setCreateOpen(true)}>
|
||||||
|
创建工单
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<Form
|
<Form
|
||||||
layout="inline"
|
layout="inline"
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
@@ -137,14 +194,14 @@ export default function TicketsPage() {
|
|||||||
onClose={() => setDrawerOpen(false)}
|
onClose={() => setDrawerOpen(false)}
|
||||||
extra={
|
extra={
|
||||||
detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
|
detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
|
||||||
<>
|
<Space>
|
||||||
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}>
|
<Button type="primary" onClick={() => approve(String(detail.id))}>
|
||||||
通过
|
通过
|
||||||
</Button>
|
</Button>
|
||||||
<Button danger onClick={() => reject(String(detail.id))}>
|
<Button danger onClick={() => reject(String(detail.id))}>
|
||||||
驳回
|
驳回
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</Space>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -173,6 +230,44 @@ export default function TicketsPage() {
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="创建工单"
|
||||||
|
open={createOpen}
|
||||||
|
onCancel={() => setCreateOpen(false)}
|
||||||
|
onOk={() => void submitCreate()}
|
||||||
|
confirmLoading={creating}
|
||||||
|
destroyOnClose
|
||||||
|
okText="提交"
|
||||||
|
>
|
||||||
|
<Form form={createForm} layout="vertical" initialValues={{ ticketType: 'REFUND' }}>
|
||||||
|
<Form.Item
|
||||||
|
name="ticketType"
|
||||||
|
label="工单类型"
|
||||||
|
rules={[{ required: true, message: '请选择类型' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
options={[
|
||||||
|
{ value: 'REFUND', label: '仅退款' },
|
||||||
|
{ value: 'RESHIPMENT', label: '破损补发' },
|
||||||
|
{ value: 'DAMAGE_RETURN', label: '破损退货' },
|
||||||
|
{ value: 'RETURN_REFUND', label: '退货退款' },
|
||||||
|
{ value: 'ALERT', label: '异常' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="orderNo"
|
||||||
|
label="订单号"
|
||||||
|
rules={[{ required: true, message: '请填写订单号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="关联订单号" allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="remark" label="备注">
|
||||||
|
<Input.TextArea rows={3} placeholder="可选" maxLength={512} showCount />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Descriptions,
|
Descriptions,
|
||||||
Drawer,
|
Drawer,
|
||||||
Form,
|
Form,
|
||||||
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
@@ -17,10 +18,13 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
|
import {
|
||||||
|
WINERY_SETTLEMENT_RATE,
|
||||||
|
type SystemConfigFormResponse,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { downloadExcelCsv } from '../lib/exportExcel';
|
import { downloadExcelCsv } from '../lib/exportExcel';
|
||||||
import { request } from '../lib/api';
|
import { request, type HqProfile } from '../lib/api';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
@@ -59,8 +63,16 @@ const DELIVERY_LABELS: Record<string, string> = {
|
|||||||
CROSS_CITY: '跨城',
|
CROSS_CITY: '跨城',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const WINERY_BANK_KEYS = [
|
||||||
|
'WINERY_BANK_ACCOUNT_NAME',
|
||||||
|
'WINERY_BANK_NAME',
|
||||||
|
'WINERY_BANK_BRANCH',
|
||||||
|
'WINERY_BANK_ACCOUNT_NO',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export default function WineryBillsPage() {
|
export default function WineryBillsPage() {
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
|
const [bankForm] = Form.useForm<Record<string, string>>();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/winery-bills',
|
'/admin/winery-bills',
|
||||||
@@ -79,6 +91,18 @@ export default function WineryBillsPage() {
|
|||||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||||
const [detail, setDetail] = useState<(Row & { items?: BillItem[] }) | null>(null);
|
const [detail, setDetail] = useState<(Row & { items?: BillItem[] }) | null>(null);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||||
|
const [bankOpen, setBankOpen] = useState(false);
|
||||||
|
const [bankLoading, setBankLoading] = useState(false);
|
||||||
|
const [bankSaving, setBankSaving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const canEditWineryBank =
|
||||||
|
profile?.adminRole === 'SUPER_ADMIN' ||
|
||||||
|
(profile?.permissionKeys ?? []).includes('system_settings_winery_bank');
|
||||||
|
|
||||||
function confirmPay(ids: string[], amountHint?: number) {
|
function confirmPay(ids: string[], amountHint?: number) {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
@@ -126,6 +150,41 @@ export default function WineryBillsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openBankModal() {
|
||||||
|
setBankOpen(true);
|
||||||
|
setBankLoading(true);
|
||||||
|
try {
|
||||||
|
const cfg = await request<SystemConfigFormResponse>('/admin/system-config');
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
for (const key of WINERY_BANK_KEYS) {
|
||||||
|
values[key] = cfg.values[key] ?? '';
|
||||||
|
}
|
||||||
|
bankForm.setFieldsValue(values);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '加载失败');
|
||||||
|
setBankOpen(false);
|
||||||
|
} finally {
|
||||||
|
setBankLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBank() {
|
||||||
|
const values = await bankForm.validateFields();
|
||||||
|
setBankSaving(true);
|
||||||
|
try {
|
||||||
|
await request('/admin/system-config', {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ values }),
|
||||||
|
});
|
||||||
|
message.success('酒厂银行账户已保存');
|
||||||
|
setBankOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setBankSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const summary = data?.summary;
|
const summary = data?.summary;
|
||||||
const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100);
|
const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100);
|
||||||
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
|
||||||
@@ -185,14 +244,29 @@ export default function WineryBillsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
<div
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
style={{
|
||||||
酒厂对账单
|
display: 'flex',
|
||||||
</Typography.Title>
|
justifyContent: 'space-between',
|
||||||
<Typography.Text type="secondary">
|
alignItems: 'flex-start',
|
||||||
每日 8:00 汇总昨日已付订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
marginBottom: 16,
|
||||||
</Typography.Text>
|
gap: 16,
|
||||||
</Space>
|
}}
|
||||||
|
>
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||||
|
酒厂对账单
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
每日 8:00 汇总昨日已付订单(实付 × {ratePct}%);未打款红色、已打款绿色,可展开订单明细
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
{canEditWineryBank ? (
|
||||||
|
<Button type="default" onClick={() => void openBankModal()}>
|
||||||
|
酒厂银行账户信息配置
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{summary && (
|
{summary && (
|
||||||
<Card size="small" style={{ marginBottom: 16 }}>
|
<Card size="small" style={{ marginBottom: 16 }}>
|
||||||
@@ -337,6 +411,43 @@ export default function WineryBillsPage() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="酒厂银行账户信息配置"
|
||||||
|
open={bankOpen}
|
||||||
|
onCancel={() => setBankOpen(false)}
|
||||||
|
onOk={() => void saveBank()}
|
||||||
|
confirmLoading={bankSaving}
|
||||||
|
destroyOnClose
|
||||||
|
okText="保存"
|
||||||
|
>
|
||||||
|
<Form form={bankForm} layout="vertical" disabled={bankLoading}>
|
||||||
|
<Form.Item
|
||||||
|
name="WINERY_BANK_ACCOUNT_NAME"
|
||||||
|
label="户名"
|
||||||
|
rules={[{ required: true, message: '请填写户名' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="收款账户户名" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="WINERY_BANK_NAME"
|
||||||
|
label="开户银行"
|
||||||
|
rules={[{ required: true, message: '请填写开户银行' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="如:中国工商银行" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="WINERY_BANK_BRANCH" label="开户支行">
|
||||||
|
<Input placeholder="可选" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="WINERY_BANK_ACCOUNT_NO"
|
||||||
|
label="银行账号"
|
||||||
|
rules={[{ required: true, message: '请填写银行账号' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="银行卡号" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ type CouponBadgeProps = {
|
|||||||
|
|
||||||
/** Taro 友好版权益角标(对齐 shared-ui CouponBadge) */
|
/** Taro 友好版权益角标(对齐 shared-ui CouponBadge) */
|
||||||
export default function CouponBadge({ amount, label = '好客权益' }: CouponBadgeProps) {
|
export default function CouponBadge({ amount, label = '好客权益' }: CouponBadgeProps) {
|
||||||
|
const n = Number(amount);
|
||||||
|
const display = Number.isFinite(n) ? (Number.isInteger(n) ? String(n) : n.toFixed(0)) : String(amount);
|
||||||
return (
|
return (
|
||||||
<Text className="coupon-badge">
|
<Text className="coupon-badge">
|
||||||
¥{amount}
|
享 ¥{display} {label}
|
||||||
{label}
|
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default function PageShell({
|
|||||||
'page-shell',
|
'page-shell',
|
||||||
`page-shell--${variant}`,
|
`page-shell--${variant}`,
|
||||||
hasFixedFooter ? 'page-shell--fixed-footer' : '',
|
hasFixedFooter ? 'page-shell--fixed-footer' : '',
|
||||||
|
variant === 'tab' && process.env.TARO_ENV === 'weapp' ? 'page-shell--native-tabbar' : '',
|
||||||
className,
|
className,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -4,19 +4,19 @@ import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
|||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import TabMainHeader from '../../components/TabMainHeader';
|
import TabMainHeader from '../../components/TabMainHeader';
|
||||||
import CouponBadge from '../../components/CouponBadge';
|
import CouponBadge from '../../components/CouponBadge';
|
||||||
import ProductCarousel from '../../components/ProductCarousel';
|
|
||||||
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
|
||||||
import { goLogin } from '../../lib/auth-nav';
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
import { isLoggedIn, request, toast } from '../../lib/api';
|
import { isLoggedIn, request, toast } from '../../lib/api';
|
||||||
import { ensurePayReady } from '../../lib/pay-ready';
|
import { ensurePayReady } from '../../lib/pay-ready';
|
||||||
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
|
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
|
||||||
import { getProductImages } from '../../lib/product-images';
|
import { getProductMainImage } from '../../lib/product-images';
|
||||||
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
|
||||||
|
|
||||||
type Product = {
|
type Product = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
|
spec?: string;
|
||||||
price: number;
|
price: number;
|
||||||
benefitDisplay?: number;
|
benefitDisplay?: number;
|
||||||
mainImageUrl?: string | null;
|
mainImageUrl?: string | null;
|
||||||
@@ -32,8 +32,8 @@ type MiniHomeConfig = {
|
|||||||
|
|
||||||
const AROMA_TABS = [
|
const AROMA_TABS = [
|
||||||
{ key: 'QINGXIANG', label: '清香型' },
|
{ key: 'QINGXIANG', label: '清香型' },
|
||||||
{ key: 'JIANGXIANG', label: '酱香型' },
|
|
||||||
{ key: 'NONGXIANG', label: '浓香型' },
|
{ key: 'NONGXIANG', label: '浓香型' },
|
||||||
|
{ key: 'JIANGXIANG', label: '酱香型' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
@@ -143,21 +143,6 @@ export default function HomePage() {
|
|||||||
<PageShell variant="tab" className="home-page no-tab-header">
|
<PageShell variant="tab" className="home-page no-tab-header">
|
||||||
<TabMainHeader title="杜康好客" />
|
<TabMainHeader title="杜康好客" />
|
||||||
|
|
||||||
<View className="home-aroma-nav">
|
|
||||||
<View className="home-aroma-tabs">
|
|
||||||
{availableAromas.map((t) => (
|
|
||||||
<Text
|
|
||||||
key={t.key}
|
|
||||||
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}`}
|
|
||||||
onClick={() => setTab(t.key)}
|
|
||||||
>
|
|
||||||
{t.label}
|
|
||||||
</Text>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
<Text className="home-aroma-city">{displayCity}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{banners.length > 0 ? (
|
{banners.length > 0 ? (
|
||||||
<View className="home-promo-banner">
|
<View className="home-promo-banner">
|
||||||
<Swiper
|
<Swiper
|
||||||
@@ -176,6 +161,21 @@ export default function HomePage() {
|
|||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<View className="home-aroma-nav">
|
||||||
|
<View className="home-aroma-tabs">
|
||||||
|
{availableAromas.map((t) => (
|
||||||
|
<Text
|
||||||
|
key={t.key}
|
||||||
|
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}`}
|
||||||
|
onClick={() => setTab(t.key)}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
<Text className="home-aroma-city">{displayCity}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="home-product-list">
|
<View className="home-product-list">
|
||||||
{loading ? <View className="home-empty">加载中…</View> : null}
|
{loading ? <View className="home-empty">加载中…</View> : null}
|
||||||
{!loading && products.length === 0 ? (
|
{!loading && products.length === 0 ? (
|
||||||
@@ -183,37 +183,54 @@ export default function HomePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{!loading &&
|
{!loading &&
|
||||||
filtered.map((p) => {
|
filtered.map((p) => {
|
||||||
const images = getProductImages(p);
|
const thumb = getProductMainImage(p);
|
||||||
|
const spec = p.subtitle || p.spec || '';
|
||||||
return (
|
return (
|
||||||
<View key={p.id} className="home-product-card">
|
<View key={p.id} className="home-product-card">
|
||||||
<View onClick={() => openProductDetail(p.id)}>
|
<View
|
||||||
<ProductCarousel images={images} alt={p.name} variant="home" />
|
className="home-product-card-inner"
|
||||||
<View className="home-product-body">
|
onClick={() => openProductDetail(p.id)}
|
||||||
|
>
|
||||||
|
<View className="home-product-thumb-wrap">
|
||||||
|
{thumb ? (
|
||||||
|
<Image className="home-product-thumb" src={thumb} mode="aspectFill" />
|
||||||
|
) : (
|
||||||
|
<View className="home-product-thumb home-product-thumb--empty" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View className="home-product-main">
|
||||||
<View className="home-product-row">
|
<View className="home-product-row">
|
||||||
<Text className="home-product-name">{p.name}</Text>
|
<Text className="home-product-name">{p.name}</Text>
|
||||||
<Text className="home-product-price">¥{Number(p.price).toFixed(2)}</Text>
|
<Text className="home-product-price">¥{Number(p.price).toFixed(0)}</Text>
|
||||||
</View>
|
</View>
|
||||||
{p.subtitle ? <Text className="home-product-sub">{p.subtitle}</Text> : null}
|
{spec ? <Text className="home-product-sub">{spec}</Text> : null}
|
||||||
<View className="home-product-footer">
|
<View className="home-product-footer">
|
||||||
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
|
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
|
||||||
</View>
|
</View>
|
||||||
|
<View className="home-product-actions">
|
||||||
|
{p.allowOnSitePickup ? (
|
||||||
|
<Text
|
||||||
|
className="home-pickup-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
void goOnSitePickup(p.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
现场取货
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
className="home-buy-btn"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation?.();
|
||||||
|
openProductDetail(p.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
立即购买
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className="home-product-actions">
|
|
||||||
{p.allowOnSitePickup ? (
|
|
||||||
<Text
|
|
||||||
className="home-pickup-btn"
|
|
||||||
onClick={() => {
|
|
||||||
void goOnSitePickup(p.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
现场取货
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
<Text className="home-buy-btn" onClick={() => openProductDetail(p.id)}>
|
|
||||||
立即购买
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -333,7 +333,11 @@ export default function OrderConfirmPage() {
|
|||||||
{!previewLoading && !preview && productId ? (
|
{!previewLoading && !preview && productId ? (
|
||||||
<View className="u-empty">无法加载商品信息</View>
|
<View className="u-empty">无法加载商品信息</View>
|
||||||
) : null}
|
) : null}
|
||||||
{msg ? <Text className="u-muted" style={{ display: 'block', marginTop: 8 }}>{msg}</Text> : null}
|
{msg ? (
|
||||||
|
<Text className="u-muted" style={{ display: 'block', marginTop: 8, textAlign: 'center' }}>
|
||||||
|
{msg}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className="order-confirm-bar">
|
<View className="order-confirm-bar">
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export default function PayPage() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{msg ? (
|
{msg ? (
|
||||||
<Text className="pay-wechat-auth-msg" style={{ display: 'block', marginTop: 12 }}>
|
<Text className="pay-wechat-auth-msg" style={{ marginTop: 12 }}>
|
||||||
{msg}
|
{msg}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
+185
-197
@@ -31,58 +31,8 @@
|
|||||||
background: var(--color-background);
|
background: var(--color-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-aroma-nav {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px var(--space-page);
|
|
||||||
background: rgba(250, 249, 247, 0.95);
|
|
||||||
border-bottom: 1px solid var(--color-surface-container);
|
|
||||||
position: sticky;
|
|
||||||
top: var(--nav-bar-height, 56px);
|
|
||||||
z-index: 40;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-aroma-tabs {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-aroma-city {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--color-subtle-gray);
|
|
||||||
pointer-events: none;
|
|
||||||
max-width: 72px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-aroma-tab {
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-family: var(--font-headline);
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 24px;
|
|
||||||
color: var(--color-subtle-gray);
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-aroma-tab--active {
|
|
||||||
color: var(--color-heritage-red);
|
|
||||||
border-bottom-color: var(--color-heritage-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-promo-banner {
|
.home-promo-banner {
|
||||||
margin: 16px var(--space-page) 0;
|
margin: 12px var(--space-page) 0;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
@@ -101,8 +51,184 @@
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.home-aroma-nav {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 6px var(--space-page) 4px;
|
||||||
|
background: transparent;
|
||||||
|
border-bottom: none;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 40;
|
||||||
|
background: rgba(250, 249, 247, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-aroma-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-aroma-city {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-subtle-gray);
|
||||||
|
pointer-events: none;
|
||||||
|
max-width: 72px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-aroma-tab {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-family: var(--font-headline);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 22px;
|
||||||
|
color: var(--color-subtle-gray);
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-aroma-tab--active {
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
border-bottom-color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-product-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px var(--space-page) 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-product-card {
|
||||||
|
background: var(--color-card);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-product-card-inner {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-product-thumb-wrap {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-surface-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-product-thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-product-thumb--empty {
|
||||||
|
background: var(--color-surface-container);
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-product-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-product-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-product-name {
|
||||||
|
font-family: var(--font-headline);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-product-price {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-product-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-subtle-gray);
|
||||||
|
line-height: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-product-footer {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-product-actions {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-pickup-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: #2e7d32;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 16px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-buy-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
background: var(--color-heritage-red);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 16px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
.home-promo-footer {
|
.home-promo-footer {
|
||||||
margin: 0 var(--space-page) 16px;
|
margin: 0 var(--space-page) 8px;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
@@ -117,162 +243,24 @@
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-product-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px var(--space-page);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-product-card {
|
|
||||||
background: var(--color-card);
|
|
||||||
border-radius: var(--radius-lg);
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: var(--shadow-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-carousel-wrap {
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
aspect-ratio: 1;
|
|
||||||
background: var(--color-surface-container);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-carousel {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-carousel-item,
|
|
||||||
.home-carousel-image,
|
|
||||||
.home-carousel-placeholder {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-carousel-placeholder {
|
|
||||||
background: var(--color-surface-container);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-carousel-dots {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 12px;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-carousel-dot {
|
|
||||||
height: 4px;
|
|
||||||
width: 6px;
|
|
||||||
margin: 0 3px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(153, 153, 153, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-carousel-dot--active {
|
|
||||||
width: 20px;
|
|
||||||
background: var(--color-heritage-red);
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-product-body {
|
|
||||||
padding: var(--space-gutter);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-product-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-product-name {
|
|
||||||
font-family: var(--font-headline);
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 24px;
|
|
||||||
color: var(--color-on-surface);
|
|
||||||
flex: 1;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-product-price {
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--color-heritage-red);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-product-sub {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--color-subtle-gray);
|
|
||||||
line-height: 18px;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-product-footer {
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-product-actions {
|
|
||||||
padding: 0 var(--space-gutter) var(--space-gutter);
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-pickup-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
background: #2e7d32;
|
|
||||||
color: #fff;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-buy-btn {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: var(--radius-full);
|
|
||||||
background: var(--color-heritage-red);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 600;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-empty {
|
.home-empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 48px 24px;
|
padding: 32px 24px;
|
||||||
color: var(--color-subtle-gray);
|
color: var(--color-subtle-gray);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.coupon-badge {
|
.home-page .coupon-badge {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--color-aged-amber);
|
background: var(--color-aged-amber);
|
||||||
color: var(--color-on-secondary-container);
|
color: var(--color-on-secondary-container);
|
||||||
padding: 4px 12px;
|
padding: 2px 8px;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
font-family: var(--font-label);
|
font-family: var(--font-label);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,11 @@
|
|||||||
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
|
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 小程序原生 tabBar 页面窗口已扣除底栏,避免滑到底多余空白 */
|
||||||
|
.page-shell--tab.page-shell--native-tabbar {
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.page-shell--scroll,
|
.page-shell--scroll,
|
||||||
.page-shell--sub,
|
.page-shell--sub,
|
||||||
.page-shell--plain {
|
.page-shell--plain {
|
||||||
|
|||||||
@@ -169,6 +169,7 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--color-heritage-red);
|
color: var(--color-heritage-red);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-row {
|
.order-row {
|
||||||
@@ -438,7 +439,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pay-wechat-auth-msg {
|
.pay-wechat-auth-msg {
|
||||||
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--color-heritage-red, #a02d30);
|
color: var(--color-heritage-red, #a02d30);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,69 @@
|
|||||||
|
/** HQ 权限目录(权限分配页勾选源) */
|
||||||
export const HQ_PERMISSION_CATALOG = [
|
export const HQ_PERMISSION_CATALOG = [
|
||||||
{ key: 'dashboard', label: '概览' },
|
{ key: 'dashboard', label: '概览', group: '业务' },
|
||||||
{ key: 'users', label: '用户管理' },
|
{ key: 'users', label: '用户管理', group: '业务' },
|
||||||
{ key: 'wechat_bindings', label: '微信绑定' },
|
{ key: 'wechat_bindings', label: '微信绑定', group: '业务' },
|
||||||
{ key: 'products', label: '商品管理' },
|
{ key: 'products', label: '商品管理', group: '业务' },
|
||||||
{ key: 'orders', label: '订单管理' },
|
{ key: 'orders', label: '订单管理', group: '业务' },
|
||||||
{ key: 'stores', label: '门店管理' },
|
{ key: 'promo_codes', label: '推广码', group: '业务' },
|
||||||
{ key: 'partners', label: '开城管理' },
|
{ key: 'stores', label: '门店管理', group: '业务' },
|
||||||
{ key: 'benefit', label: '好客权益' },
|
{ key: 'partners', label: '开城管理', group: '业务' },
|
||||||
{ key: 'deliveries', label: '配送单' },
|
{ key: 'finance', label: '财务', group: '业务' },
|
||||||
{ key: 'tickets', label: '工单中心' },
|
{ key: 'benefit', label: '好客权益', group: '业务' },
|
||||||
{ key: 'invoices', label: '发票管理' },
|
{ key: 'deliveries', label: '配送单', group: '业务' },
|
||||||
{ key: 'resources', label: 'OSS 资源库' },
|
{ key: 'tickets', label: '工单中心', group: '业务' },
|
||||||
{ key: 'logs', label: '日志' },
|
{ key: 'invoices', label: '发票管理', group: '业务' },
|
||||||
{ key: 'hq_permissions', label: '权限分配' },
|
{ key: 'resources', label: 'OSS 资源库', group: '业务' },
|
||||||
{ key: 'hq_accounts', label: 'HQ 账户' },
|
{ key: 'logs', label: '日志', group: '业务' },
|
||||||
{ key: 'system_settings', label: '系统设置' },
|
{ key: 'hq_permissions', label: '权限分配', group: '管理' },
|
||||||
|
{ key: 'hq_accounts', label: 'HQ 账户', group: '管理' },
|
||||||
|
{ key: 'system_settings_feature', label: '功能开关', group: '系统设置' },
|
||||||
|
{ key: 'system_settings_sms', label: '短信', group: '系统设置' },
|
||||||
|
{ key: 'system_settings_wechat', label: '微信', group: '系统设置' },
|
||||||
|
{ key: 'system_settings_wechat_mini', label: '微信小程序', group: '系统设置' },
|
||||||
|
{ key: 'system_settings_oss', label: '对象存储 OSS', group: '系统设置' },
|
||||||
|
{ key: 'system_settings_app', label: '应用链接', group: '系统设置' },
|
||||||
|
{ key: 'system_settings_deploy', label: '发布部署', group: '系统设置' },
|
||||||
|
{ key: 'system_settings_winery_bank', label: '酒厂银行账户', group: '系统设置' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
|
export type HqPermissionKey = (typeof HQ_PERMISSION_CATALOG)[number]['key'];
|
||||||
|
|
||||||
|
/** 系统配置 registry group → 权限 key */
|
||||||
|
export const SYSTEM_CONFIG_GROUP_PERMISSION: Record<string, HqPermissionKey> = {
|
||||||
|
feature: 'system_settings_feature',
|
||||||
|
sms: 'system_settings_sms',
|
||||||
|
wechat: 'system_settings_wechat',
|
||||||
|
wechat_mini: 'system_settings_wechat_mini',
|
||||||
|
oss: 'system_settings_oss',
|
||||||
|
app: 'system_settings_app',
|
||||||
|
deploy: 'system_settings_deploy',
|
||||||
|
winery_bank: 'system_settings_winery_bank',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SYSTEM_SETTINGS_PERMISSION_KEYS = Object.values(
|
||||||
|
SYSTEM_CONFIG_GROUP_PERMISSION,
|
||||||
|
) as HqPermissionKey[];
|
||||||
|
|
||||||
|
/** 旧版单一 system_settings 权限:视为拥有全部系统设置分组(兼容存量角色配置) */
|
||||||
|
export const LEGACY_SYSTEM_SETTINGS_KEY = 'system_settings';
|
||||||
|
|
||||||
|
export function expandHqPermissionKeys(keys: string[]): HqPermissionKey[] {
|
||||||
|
const set = new Set<string>(keys);
|
||||||
|
if (set.has(LEGACY_SYSTEM_SETTINGS_KEY)) {
|
||||||
|
for (const k of SYSTEM_SETTINGS_PERMISSION_KEYS) set.add(k);
|
||||||
|
set.delete(LEGACY_SYSTEM_SETTINGS_KEY);
|
||||||
|
}
|
||||||
|
return [...set].filter((k): k is HqPermissionKey =>
|
||||||
|
HQ_PERMISSION_CATALOG.some((p) => p.key === k),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAnySystemSettingsPermission(keys: string[]): boolean {
|
||||||
|
const expanded = expandHqPermissionKeys(keys);
|
||||||
|
return SYSTEM_SETTINGS_PERMISSION_KEYS.some((k) => expanded.includes(k));
|
||||||
|
}
|
||||||
|
|
||||||
export const HQ_ADMIN_ROLES = [
|
export const HQ_ADMIN_ROLES = [
|
||||||
{ value: 'SUPER_ADMIN', label: '超级管理员' },
|
{ value: 'SUPER_ADMIN', label: '超级管理员' },
|
||||||
{ value: 'OPS', label: '运营' },
|
{ value: 'OPS', label: '运营' },
|
||||||
@@ -34,6 +79,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
|||||||
'wechat_bindings',
|
'wechat_bindings',
|
||||||
'products',
|
'products',
|
||||||
'orders',
|
'orders',
|
||||||
|
'promo_codes',
|
||||||
'stores',
|
'stores',
|
||||||
'partners',
|
'partners',
|
||||||
'benefit',
|
'benefit',
|
||||||
@@ -42,7 +88,18 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
|
|||||||
'invoices',
|
'invoices',
|
||||||
'resources',
|
'resources',
|
||||||
'logs',
|
'logs',
|
||||||
|
'system_settings_wechat_mini',
|
||||||
|
],
|
||||||
|
FINANCE: [
|
||||||
|
'dashboard',
|
||||||
|
'orders',
|
||||||
|
'stores',
|
||||||
|
'partners',
|
||||||
|
'finance',
|
||||||
|
'benefit',
|
||||||
|
'invoices',
|
||||||
|
'logs',
|
||||||
|
'system_settings_winery_bank',
|
||||||
],
|
],
|
||||||
FINANCE: ['dashboard', 'orders', 'stores', 'partners', 'benefit', 'invoices', 'logs'],
|
|
||||||
CUSTOMER_SERVICE: ['dashboard', 'users', 'orders', 'tickets', 'invoices', 'logs'],
|
CUSTOMER_SERVICE: ['dashboard', 'users', 'orders', 'tickets', 'invoices', 'logs'],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
SetMetadata,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import {
|
||||||
|
HQ_PERMISSION_CATALOG,
|
||||||
|
HQ_ROLE_DEFAULT_PERMISSIONS,
|
||||||
|
expandHqPermissionKeys,
|
||||||
|
hasAnySystemSettingsPermission,
|
||||||
|
type HqPermissionKey,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
|
import { PrismaService } from '../prisma/prisma.module';
|
||||||
|
import type { AuthUser } from './jwt-auth.guard';
|
||||||
|
|
||||||
|
export const HQ_PERMISSIONS_KEY = 'hq:permissions';
|
||||||
|
export const RequireHqPermissions = (...keys: string[]) =>
|
||||||
|
SetMetadata(HQ_PERMISSIONS_KEY, keys);
|
||||||
|
/** 任意一项系统设置分组权限即可访问系统设置接口 */
|
||||||
|
export const RequireAnySystemSettings = () =>
|
||||||
|
SetMetadata(HQ_PERMISSIONS_KEY, ['__any_system_settings__']);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HqPermissionsResolver {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async resolveEffectiveKeys(actorId: bigint): Promise<HqPermissionKey[]> {
|
||||||
|
const account = await this.prisma.hqAccount.findUnique({
|
||||||
|
where: { id: actorId },
|
||||||
|
select: { adminRole: true, status: true },
|
||||||
|
});
|
||||||
|
if (!account || account.status !== 'ACTIVE') {
|
||||||
|
throw new ForbiddenException('HQ 账号不可用');
|
||||||
|
}
|
||||||
|
if (account.adminRole === 'SUPER_ADMIN') {
|
||||||
|
return HQ_PERMISSION_CATALOG.map((p) => p.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [roleRows, userRows] = await Promise.all([
|
||||||
|
this.prisma.hqRolePermission.findMany({
|
||||||
|
where: { adminRole: account.adminRole },
|
||||||
|
select: { permissionKey: true },
|
||||||
|
}),
|
||||||
|
this.prisma.hqAccountPermission.findMany({
|
||||||
|
where: { hqAccountId: actorId },
|
||||||
|
select: { permissionKey: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const roleKeys =
|
||||||
|
roleRows.length > 0
|
||||||
|
? roleRows.map((r) => r.permissionKey)
|
||||||
|
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[account.adminRole] ?? [])];
|
||||||
|
|
||||||
|
return expandHqPermissionKeys([
|
||||||
|
...roleKeys,
|
||||||
|
...userRows.map((r) => r.permissionKey),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HqPermissionGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly resolver: HqPermissionsResolver,
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||||
|
const req = context.switchToHttp().getRequest();
|
||||||
|
const user = req.user as AuthUser | undefined;
|
||||||
|
if (!user || user.actorType !== 'HQ') {
|
||||||
|
throw new ForbiddenException('需要 HQ 权限');
|
||||||
|
}
|
||||||
|
const keys = await this.resolver.resolveEffectiveKeys(user.actorId);
|
||||||
|
req.hqPermissionKeys = keys;
|
||||||
|
|
||||||
|
const required =
|
||||||
|
this.reflector.getAllAndOverride<string[]>(HQ_PERMISSIONS_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]) ?? [];
|
||||||
|
|
||||||
|
if (!required.length) return true;
|
||||||
|
if (required.includes('__any_system_settings__')) {
|
||||||
|
if (!hasAnySystemSettingsPermission(keys)) {
|
||||||
|
throw new ForbiddenException('无系统设置权限');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!required.some((k) => keys.includes(k as HqPermissionKey))) {
|
||||||
|
throw new ForbiddenException('权限不足');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,10 @@ export const HqOperationAction = {
|
|||||||
DELIVERY_UPDATE: 'DELIVERY_UPDATE',
|
DELIVERY_UPDATE: 'DELIVERY_UPDATE',
|
||||||
TICKET_APPROVE: 'TICKET_APPROVE',
|
TICKET_APPROVE: 'TICKET_APPROVE',
|
||||||
TICKET_REJECT: 'TICKET_REJECT',
|
TICKET_REJECT: 'TICKET_REJECT',
|
||||||
|
TICKET_CREATE: 'TICKET_CREATE',
|
||||||
|
INVOICE_CREATE: 'INVOICE_CREATE',
|
||||||
|
INVOICE_ISSUE: 'INVOICE_ISSUE',
|
||||||
|
INVOICE_REJECT: 'INVOICE_REJECT',
|
||||||
STORE_PAYOUT_CONFIRM: 'STORE_PAYOUT_CONFIRM',
|
STORE_PAYOUT_CONFIRM: 'STORE_PAYOUT_CONFIRM',
|
||||||
STORE_PAYOUT_BATCH_CONFIRM: 'STORE_PAYOUT_BATCH_CONFIRM',
|
STORE_PAYOUT_BATCH_CONFIRM: 'STORE_PAYOUT_BATCH_CONFIRM',
|
||||||
STORE_BILL_CONFIRM: 'STORE_BILL_CONFIRM',
|
STORE_BILL_CONFIRM: 'STORE_BILL_CONFIRM',
|
||||||
@@ -119,6 +123,10 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
|||||||
[HqOperationAction.DELIVERY_UPDATE]: '编辑配送单',
|
[HqOperationAction.DELIVERY_UPDATE]: '编辑配送单',
|
||||||
[HqOperationAction.TICKET_APPROVE]: '工单通过',
|
[HqOperationAction.TICKET_APPROVE]: '工单通过',
|
||||||
[HqOperationAction.TICKET_REJECT]: '工单驳回',
|
[HqOperationAction.TICKET_REJECT]: '工单驳回',
|
||||||
|
[HqOperationAction.TICKET_CREATE]: '创建工单',
|
||||||
|
[HqOperationAction.INVOICE_CREATE]: '创建发票申请',
|
||||||
|
[HqOperationAction.INVOICE_ISSUE]: '开具发票',
|
||||||
|
[HqOperationAction.INVOICE_REJECT]: '驳回发票',
|
||||||
[HqOperationAction.STORE_PAYOUT_CONFIRM]: '门店打款确认',
|
[HqOperationAction.STORE_PAYOUT_CONFIRM]: '门店打款确认',
|
||||||
[HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM]: '批量门店打款',
|
[HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM]: '批量门店打款',
|
||||||
[HqOperationAction.STORE_BILL_CONFIRM]: '门店对账单确认打款',
|
[HqOperationAction.STORE_BILL_CONFIRM]: '门店对账单确认打款',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
|
|||||||
{ key: 'oss', label: '对象存储 OSS' },
|
{ key: 'oss', label: '对象存储 OSS' },
|
||||||
{ key: 'app', label: '应用链接' },
|
{ key: 'app', label: '应用链接' },
|
||||||
{ key: 'deploy', label: '发布部署' },
|
{ key: 'deploy', label: '发布部署' },
|
||||||
|
{ key: 'winery_bank', label: '酒厂银行账户' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const G = {
|
const G = {
|
||||||
@@ -19,6 +20,7 @@ const G = {
|
|||||||
oss: 'oss',
|
oss: 'oss',
|
||||||
app: 'app',
|
app: 'app',
|
||||||
deploy: 'deploy',
|
deploy: 'deploy',
|
||||||
|
winery_bank: 'winery_bank',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** HQ 可维护字段(不含 NODE_ENV / DATABASE_URL / JWT 等基础设施项) */
|
/** HQ 可维护字段(不含 NODE_ENV / DATABASE_URL / JWT 等基础设施项) */
|
||||||
@@ -101,6 +103,37 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
|||||||
|
|
||||||
{ key: 'DEPLOY_WEBHOOK_URL', label: '发布 Webhook URL', group: G.deploy, type: 'string', requiresRestart: false },
|
{ key: 'DEPLOY_WEBHOOK_URL', label: '发布 Webhook URL', group: G.deploy, type: 'string', requiresRestart: false },
|
||||||
{ key: 'DEPLOY_WEBHOOK_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false },
|
{ key: 'DEPLOY_WEBHOOK_SECRET', label: '发布 Webhook Secret', group: G.deploy, type: 'password', secret: true, requiresRestart: false },
|
||||||
|
|
||||||
|
{
|
||||||
|
key: 'WINERY_BANK_ACCOUNT_NAME',
|
||||||
|
label: '户名',
|
||||||
|
group: G.winery_bank,
|
||||||
|
type: 'string',
|
||||||
|
requiresRestart: false,
|
||||||
|
description: '酒厂收款账户户名,打款时对照',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'WINERY_BANK_NAME',
|
||||||
|
label: '开户银行',
|
||||||
|
group: G.winery_bank,
|
||||||
|
type: 'string',
|
||||||
|
requiresRestart: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'WINERY_BANK_BRANCH',
|
||||||
|
label: '开户支行',
|
||||||
|
group: G.winery_bank,
|
||||||
|
type: 'string',
|
||||||
|
requiresRestart: false,
|
||||||
|
placeholder: '可选',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'WINERY_BANK_ACCOUNT_NO',
|
||||||
|
label: '银行账号',
|
||||||
|
group: G.winery_bank,
|
||||||
|
type: 'string',
|
||||||
|
requiresRestart: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 已从 HQ 配置移除、仅保留在 .env 的键(启动时从 DB 清理) */
|
/** 已从 HQ 配置移除、仅保留在 .env 的键(启动时从 DB 清理) */
|
||||||
|
|||||||
@@ -63,16 +63,23 @@ export class SystemConfigService implements OnModuleInit {
|
|||||||
return loadAppConfig(this.getMergedEnv());
|
return loadAppConfig(this.getMergedEnv());
|
||||||
}
|
}
|
||||||
|
|
||||||
async getForm(): Promise<SystemConfigFormResponse> {
|
async getForm(allowedGroups?: string[] | null): Promise<SystemConfigFormResponse> {
|
||||||
if (!this.tableReady) {
|
if (!this.tableReady) {
|
||||||
throw new Error('system_config 表未就绪,请在 server/dukang-api 执行 npx prisma db push');
|
throw new Error('system_config 表未就绪,请在 server/dukang-api 执行 npx prisma db push');
|
||||||
}
|
}
|
||||||
|
const groups =
|
||||||
|
allowedGroups == null
|
||||||
|
? SYSTEM_CONFIG_GROUPS
|
||||||
|
: SYSTEM_CONFIG_GROUPS.filter((g) => allowedGroups.includes(g.key));
|
||||||
|
const allowedGroupSet = new Set(groups.map((g) => g.key));
|
||||||
|
const fields = SYSTEM_CONFIG_FIELDS.filter((f) => allowedGroupSet.has(f.group));
|
||||||
|
|
||||||
const rows = await this.prisma.systemConfig.findMany();
|
const rows = await this.prisma.systemConfig.findMany();
|
||||||
const dbMap = new Map(rows.map((r) => [r.configKey, r.value]));
|
const dbMap = new Map(rows.map((r) => [r.configKey, r.value]));
|
||||||
const values: Record<string, string> = {};
|
const values: Record<string, string> = {};
|
||||||
const configuredSecrets: string[] = [];
|
const configuredSecrets: string[] = [];
|
||||||
|
|
||||||
for (const field of SYSTEM_CONFIG_FIELDS) {
|
for (const field of fields) {
|
||||||
const fromDb = dbMap.get(field.key);
|
const fromDb = dbMap.get(field.key);
|
||||||
const fromEnv = process.env[field.key];
|
const fromEnv = process.env[field.key];
|
||||||
const raw = fromDb ?? fromEnv ?? '';
|
const raw = fromDb ?? fromEnv ?? '';
|
||||||
@@ -85,8 +92,8 @@ export class SystemConfigService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
groups: SYSTEM_CONFIG_GROUPS,
|
groups,
|
||||||
fields: SYSTEM_CONFIG_FIELDS,
|
fields,
|
||||||
values,
|
values,
|
||||||
configuredSecrets,
|
configuredSecrets,
|
||||||
envFilePath: resolveEnvFilePath(),
|
envFilePath: resolveEnvFilePath(),
|
||||||
@@ -95,18 +102,24 @@ export class SystemConfigService implements OnModuleInit {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(dto: SystemConfigUpdateRequest): Promise<{
|
async update(
|
||||||
|
dto: SystemConfigUpdateRequest,
|
||||||
|
allowedGroups?: string[] | null,
|
||||||
|
): Promise<{
|
||||||
updatedKeys: string[];
|
updatedKeys: string[];
|
||||||
requiresRestartKeys: string[];
|
requiresRestartKeys: string[];
|
||||||
}> {
|
}> {
|
||||||
const updatedKeys: string[] = [];
|
const updatedKeys: string[] = [];
|
||||||
const requiresRestartKeys: string[] = [];
|
const requiresRestartKeys: string[] = [];
|
||||||
const overlay: Record<string, string> = {};
|
const overlay: Record<string, string> = {};
|
||||||
|
const allowedGroupSet =
|
||||||
|
allowedGroups == null ? null : new Set(allowedGroups);
|
||||||
|
|
||||||
for (const [key, rawValue] of Object.entries(dto.values ?? {})) {
|
for (const [key, rawValue] of Object.entries(dto.values ?? {})) {
|
||||||
if (!SYSTEM_CONFIG_KEY_SET.has(key)) continue;
|
if (!SYSTEM_CONFIG_KEY_SET.has(key)) continue;
|
||||||
const meta = getSystemConfigField(key);
|
const meta = getSystemConfigField(key);
|
||||||
if (!meta) continue;
|
if (!meta) continue;
|
||||||
|
if (allowedGroupSet && !allowedGroupSet.has(meta.group)) continue;
|
||||||
|
|
||||||
let value = String(rawValue ?? '').trim();
|
let value = String(rawValue ?? '').trim();
|
||||||
if (meta.secret && (!value || value === SECRET_PLACEHOLDER)) {
|
if (meta.secret && (!value || value === SECRET_PLACEHOLDER)) {
|
||||||
|
|||||||
@@ -170,6 +170,23 @@ export class CreateTicketDto {
|
|||||||
extraJson?: Record<string, unknown>;
|
extraJson?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AdminCreateTicketDto {
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['REFUND', 'RESHIPMENT', 'ALERT', 'DAMAGE_RETURN', 'RETURN_REFUND'])
|
||||||
|
ticketType: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
orderNo: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
remark?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
evidenceUrls?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateTicketStatusDto {
|
export class UpdateTicketStatusDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { verifyPassword } from '../../common/crypto/password.util';
|
|||||||
import { AnalyticsService } from '../analytics/analytics.service';
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
import { UserAddressService } from './user-address.service';
|
import { UserAddressService } from './user-address.service';
|
||||||
import { ResourceService } from '../common/resource.service';
|
import { ResourceService } from '../common/resource.service';
|
||||||
|
import { HqPermissionsResolver } from '../../common/guards/hq-permission.guard';
|
||||||
|
|
||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ export class AuthService {
|
|||||||
private readonly smsCodeStore: SmsCodeStore,
|
private readonly smsCodeStore: SmsCodeStore,
|
||||||
private readonly userAddressService: UserAddressService,
|
private readonly userAddressService: UserAddressService,
|
||||||
@Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService,
|
@Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService,
|
||||||
|
private readonly hqPermissions: HqPermissionsResolver,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private assertMobilePhone(phone: string) {
|
private assertMobilePhone(phone: string) {
|
||||||
@@ -1128,7 +1130,9 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
if (actorType === 'HQ') {
|
if (actorType === 'HQ') {
|
||||||
const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId } });
|
const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId } });
|
||||||
return serializeBigInt(account);
|
if (!account) return null;
|
||||||
|
const permissionKeys = await this.hqPermissions.resolveEffectiveKeys(actorId);
|
||||||
|
return serializeBigInt({ ...account, permissionKeys });
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
|||||||
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
|
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
|
||||||
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||||
import { StoreMembershipService } from '../../common/guards/store-membership.service';
|
import { StoreMembershipService } from '../../common/guards/store-membership.service';
|
||||||
|
import {
|
||||||
|
HqPermissionGuard,
|
||||||
|
HqPermissionsResolver,
|
||||||
|
} from '../../common/guards/hq-permission.guard';
|
||||||
import { CommonModule } from '../common/common.module';
|
import { CommonModule } from '../common/common.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -59,6 +63,8 @@ import { CommonModule } from '../common/common.module';
|
|||||||
PartnerPrimaryGuard,
|
PartnerPrimaryGuard,
|
||||||
PartnerPermissionGuard,
|
PartnerPermissionGuard,
|
||||||
ShopStoreGuard,
|
ShopStoreGuard,
|
||||||
|
HqPermissionsResolver,
|
||||||
|
HqPermissionGuard,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AuthService,
|
AuthService,
|
||||||
@@ -74,6 +80,8 @@ import { CommonModule } from '../common/common.module';
|
|||||||
PartnerPrimaryGuard,
|
PartnerPrimaryGuard,
|
||||||
PartnerPermissionGuard,
|
PartnerPermissionGuard,
|
||||||
ShopStoreGuard,
|
ShopStoreGuard,
|
||||||
|
HqPermissionsResolver,
|
||||||
|
HqPermissionGuard,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class IamModule {}
|
export class IamModule {}
|
||||||
|
|||||||
@@ -2,12 +2,17 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
|||||||
import {
|
import {
|
||||||
HQ_PERMISSION_CATALOG,
|
HQ_PERMISSION_CATALOG,
|
||||||
HQ_ROLE_DEFAULT_PERMISSIONS,
|
HQ_ROLE_DEFAULT_PERMISSIONS,
|
||||||
|
LEGACY_SYSTEM_SETTINGS_KEY,
|
||||||
|
expandHqPermissionKeys,
|
||||||
type HqPermissionKey,
|
type HqPermissionKey,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
|
|
||||||
const VALID_PERMISSION_KEYS = new Set<string>(HQ_PERMISSION_CATALOG.map((p) => p.key));
|
const VALID_PERMISSION_KEYS = new Set<string>([
|
||||||
|
...HQ_PERMISSION_CATALOG.map((p) => p.key),
|
||||||
|
LEGACY_SYSTEM_SETTINGS_KEY,
|
||||||
|
]);
|
||||||
|
|
||||||
function assertPermissionKeys(keys: string[]) {
|
function assertPermissionKeys(keys: string[]) {
|
||||||
const invalid = keys.filter((key) => !VALID_PERMISSION_KEYS.has(key));
|
const invalid = keys.filter((key) => !VALID_PERMISSION_KEYS.has(key));
|
||||||
@@ -37,7 +42,7 @@ export class AdminHqPermissionsService {
|
|||||||
});
|
});
|
||||||
const permissionKeys =
|
const permissionKeys =
|
||||||
rows.length > 0
|
rows.length > 0
|
||||||
? rows.map((r) => r.permissionKey)
|
? expandHqPermissionKeys(rows.map((r) => r.permissionKey))
|
||||||
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[role] ?? [])];
|
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[role] ?? [])];
|
||||||
return { role, permissionKeys };
|
return { role, permissionKeys };
|
||||||
}
|
}
|
||||||
@@ -47,13 +52,14 @@ export class AdminHqPermissionsService {
|
|||||||
throw new BadRequestException('超级管理员拥有全部权限,无需配置');
|
throw new BadRequestException('超级管理员拥有全部权限,无需配置');
|
||||||
}
|
}
|
||||||
assertPermissionKeys(permissionKeys);
|
assertPermissionKeys(permissionKeys);
|
||||||
|
const normalized = expandHqPermissionKeys(permissionKeys);
|
||||||
const adminRole = role as 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE';
|
const adminRole = role as 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE';
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction([
|
||||||
this.prisma.hqRolePermission.deleteMany({ where: { adminRole } }),
|
this.prisma.hqRolePermission.deleteMany({ where: { adminRole } }),
|
||||||
...(permissionKeys.length
|
...(normalized.length
|
||||||
? [
|
? [
|
||||||
this.prisma.hqRolePermission.createMany({
|
this.prisma.hqRolePermission.createMany({
|
||||||
data: permissionKeys.map((permissionKey) => ({ adminRole, permissionKey })),
|
data: normalized.map((permissionKey) => ({ adminRole, permissionKey })),
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
@@ -84,7 +90,7 @@ export class AdminHqPermissionsService {
|
|||||||
select: { permissionKey: true },
|
select: { permissionKey: true },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const userPermissionKeys = userPerms.map((p) => p.permissionKey);
|
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
|
||||||
const effectivePermissionKeys = [
|
const effectivePermissionKeys = [
|
||||||
...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]),
|
...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]),
|
||||||
] as HqPermissionKey[];
|
] as HqPermissionKey[];
|
||||||
@@ -105,12 +111,13 @@ export class AdminHqPermissionsService {
|
|||||||
throw new BadRequestException('超级管理员拥有全部权限,无需配置');
|
throw new BadRequestException('超级管理员拥有全部权限,无需配置');
|
||||||
}
|
}
|
||||||
assertPermissionKeys(permissionKeys);
|
assertPermissionKeys(permissionKeys);
|
||||||
|
const normalized = expandHqPermissionKeys(permissionKeys);
|
||||||
await this.prisma.$transaction([
|
await this.prisma.$transaction([
|
||||||
this.prisma.hqAccountPermission.deleteMany({ where: { hqAccountId: accountId } }),
|
this.prisma.hqAccountPermission.deleteMany({ where: { hqAccountId: accountId } }),
|
||||||
...(permissionKeys.length
|
...(normalized.length
|
||||||
? [
|
? [
|
||||||
this.prisma.hqAccountPermission.createMany({
|
this.prisma.hqAccountPermission.createMany({
|
||||||
data: permissionKeys.map((permissionKey) => ({ hqAccountId: accountId, permissionKey })),
|
data: normalized.map((permissionKey) => ({ hqAccountId: accountId, permissionKey })),
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/co
|
|||||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
import { AuthUser } from '../../common/guards/jwt-auth.guard';
|
import { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
|
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
import { TradeService } from '../trade/trade.service';
|
import { TradeService } from '../trade/trade.service';
|
||||||
import { IssueInvoiceDto, RejectInvoiceDto } from '../trade/dto/after-sale.dto';
|
import {
|
||||||
|
AdminCreateInvoiceDto,
|
||||||
|
IssueInvoiceDto,
|
||||||
|
RejectInvoiceDto,
|
||||||
|
} from '../trade/dto/after-sale.dto';
|
||||||
|
|
||||||
@Controller('admin/invoices')
|
@Controller('admin/invoices')
|
||||||
@UseGuards(HqAuthGuard)
|
@UseGuards(HqAuthGuard)
|
||||||
@@ -23,12 +29,29 @@ export class AdminInvoicesController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.INVOICE_CREATE,
|
||||||
|
refType: 'INVOICE',
|
||||||
|
batch: true,
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
create(@Body() body: AdminCreateInvoiceDto) {
|
||||||
|
return this.tradeService.adminCreateInvoice(body);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
detail(@Param('id') id: string) {
|
detail(@Param('id') id: string) {
|
||||||
return this.tradeService.adminGetInvoice(BigInt(id));
|
return this.tradeService.adminGetInvoice(BigInt(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/issue')
|
@Post(':id/issue')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.INVOICE_ISSUE,
|
||||||
|
refType: 'INVOICE',
|
||||||
|
refIdParam: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
issue(
|
issue(
|
||||||
@CurrentUser() user: AuthUser,
|
@CurrentUser() user: AuthUser,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@@ -38,6 +61,12 @@ export class AdminInvoicesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post(':id/reject')
|
@Post(':id/reject')
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.INVOICE_REJECT,
|
||||||
|
refType: 'INVOICE',
|
||||||
|
refIdParam: 'id',
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
reject(
|
reject(
|
||||||
@CurrentUser() user: AuthUser,
|
@CurrentUser() user: AuthUser,
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
|||||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||||
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
||||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||||
|
import { StoreCategoryService } from '../store/store-category.service';
|
||||||
import type {
|
import type {
|
||||||
CreateStoreAccountDto,
|
CreateStoreAccountDto,
|
||||||
CreateStoreDto,
|
CreateStoreDto,
|
||||||
@@ -20,6 +21,7 @@ export class AdminStoresService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly partnerCityService: PartnerCityService,
|
private readonly partnerCityService: PartnerCityService,
|
||||||
|
private readonly storeCategoryService: StoreCategoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listStores(query: AdminStoresQueryDto) {
|
async listStores(query: AdminStoresQueryDto) {
|
||||||
@@ -44,6 +46,7 @@ export class AdminStoresService {
|
|||||||
include: {
|
include: {
|
||||||
cityRef: { select: { id: true, name: true, code: true } },
|
cityRef: { select: { id: true, name: true, code: true } },
|
||||||
partnerAccount: { select: { id: true, companyName: true } },
|
partnerAccount: { select: { id: true, companyName: true } },
|
||||||
|
category: { select: { id: true, name: true, parentId: true } },
|
||||||
bindings: {
|
bindings: {
|
||||||
where: { storeAccount: { isPrimary: 1 } },
|
where: { storeAccount: { isPrimary: 1 } },
|
||||||
take: 1,
|
take: 1,
|
||||||
@@ -237,12 +240,18 @@ export class AdminStoresService {
|
|||||||
if (!city) throw new BadRequestException('开城城市不存在');
|
if (!city) throw new BadRequestException('开城城市不存在');
|
||||||
await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id);
|
await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id);
|
||||||
|
|
||||||
|
if (!dto.categoryId?.trim()) {
|
||||||
|
throw new BadRequestException('请选择门店分类');
|
||||||
|
}
|
||||||
|
const categoryId = BigInt(dto.categoryId);
|
||||||
|
await this.storeCategoryService.assertLeafCategoryId(categoryId);
|
||||||
|
|
||||||
const store = await this.prisma.store.create({
|
const store = await this.prisma.store.create({
|
||||||
data: {
|
data: {
|
||||||
cityId: city.id,
|
cityId: city.id,
|
||||||
partnerAccountId,
|
partnerAccountId,
|
||||||
settlementRate: dto.settlementRate ?? 0.6,
|
settlementRate: dto.settlementRate ?? 0.6,
|
||||||
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null,
|
categoryId,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
phone: normalizedPhone,
|
phone: normalizedPhone,
|
||||||
province: dto.province ?? city.province,
|
province: dto.province ?? city.province,
|
||||||
|
|||||||
@@ -1,22 +1,41 @@
|
|||||||
import { Body, Controller, Get, Post, Put, UseGuards } from '@nestjs/common';
|
import { Body, Controller, Get, Post, Put, UseGuards } from '@nestjs/common';
|
||||||
import type { SystemConfigUpdateRequest } from '@dukang/shared-types';
|
import type { SystemConfigUpdateRequest } from '@dukang/shared-types';
|
||||||
|
import {
|
||||||
|
SYSTEM_CONFIG_GROUP_PERMISSION,
|
||||||
|
SYSTEM_SETTINGS_PERMISSION_KEYS,
|
||||||
|
type HqPermissionKey,
|
||||||
|
} from '@dukang/shared-types';
|
||||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
import { SuperAdminGuard } from '../../common/guards/super-admin.guard';
|
||||||
|
import {
|
||||||
|
HqPermissionGuard,
|
||||||
|
HqPermissionsResolver,
|
||||||
|
RequireAnySystemSettings,
|
||||||
|
} from '../../common/guards/hq-permission.guard';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
import { SystemConfigService } from '../../common/system-config/system-config.service';
|
||||||
|
|
||||||
@Controller('admin/system-config')
|
@Controller('admin/system-config')
|
||||||
@UseGuards(HqAuthGuard, SuperAdminGuard)
|
@UseGuards(HqAuthGuard, HqPermissionGuard)
|
||||||
export class AdminSystemConfigController {
|
export class AdminSystemConfigController {
|
||||||
constructor(private readonly systemConfig: SystemConfigService) {}
|
constructor(
|
||||||
|
private readonly systemConfig: SystemConfigService,
|
||||||
|
private readonly permissions: HqPermissionsResolver,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
getForm() {
|
@RequireAnySystemSettings()
|
||||||
return this.systemConfig.getForm();
|
async getForm(@CurrentUser() user: AuthUser) {
|
||||||
|
const keys = await this.permissions.resolveEffectiveKeys(user.actorId);
|
||||||
|
const allowedGroups = allowedConfigGroups(keys);
|
||||||
|
return this.systemConfig.getForm(allowedGroups);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put()
|
@Put()
|
||||||
|
@RequireAnySystemSettings()
|
||||||
@HqOperation({
|
@HqOperation({
|
||||||
action: HqOperationAction.SYSTEM_CONFIG_UPDATE,
|
action: HqOperationAction.SYSTEM_CONFIG_UPDATE,
|
||||||
refType: 'SYSTEM_CONFIG',
|
refType: 'SYSTEM_CONFIG',
|
||||||
@@ -24,11 +43,14 @@ export class AdminSystemConfigController {
|
|||||||
batch: true,
|
batch: true,
|
||||||
includeBody: true,
|
includeBody: true,
|
||||||
})
|
})
|
||||||
update(@Body() dto: SystemConfigUpdateRequest) {
|
async update(@CurrentUser() user: AuthUser, @Body() dto: SystemConfigUpdateRequest) {
|
||||||
return this.systemConfig.update(dto);
|
const keys = await this.permissions.resolveEffectiveKeys(user.actorId);
|
||||||
|
const allowedGroups = allowedConfigGroups(keys);
|
||||||
|
return this.systemConfig.update(dto, allowedGroups);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('sync-env')
|
@Post('sync-env')
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
@HqOperation({
|
@HqOperation({
|
||||||
action: HqOperationAction.SYSTEM_CONFIG_SYNC_ENV,
|
action: HqOperationAction.SYSTEM_CONFIG_SYNC_ENV,
|
||||||
refType: 'SYSTEM_CONFIG',
|
refType: 'SYSTEM_CONFIG',
|
||||||
@@ -39,6 +61,7 @@ export class AdminSystemConfigController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('import-env')
|
@Post('import-env')
|
||||||
|
@UseGuards(SuperAdminGuard)
|
||||||
@HqOperation({
|
@HqOperation({
|
||||||
action: HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV,
|
action: HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV,
|
||||||
refType: 'SYSTEM_CONFIG',
|
refType: 'SYSTEM_CONFIG',
|
||||||
@@ -48,3 +71,12 @@ export class AdminSystemConfigController {
|
|||||||
return this.systemConfig.importFromProcessEnv();
|
return this.systemConfig.importFromProcessEnv();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function allowedConfigGroups(permissionKeys: HqPermissionKey[]): string[] | null {
|
||||||
|
if (SYSTEM_SETTINGS_PERMISSION_KEYS.every((k) => permissionKeys.includes(k))) {
|
||||||
|
return null; // 全部
|
||||||
|
}
|
||||||
|
return Object.entries(SYSTEM_CONFIG_GROUP_PERMISSION)
|
||||||
|
.filter(([, perm]) => permissionKeys.includes(perm))
|
||||||
|
.map(([group]) => group);
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
|||||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
import { AdminTicketsService } from './admin-tickets.service';
|
import { AdminTicketsService } from './admin-tickets.service';
|
||||||
import { TicketListQueryDto } from '../common/dto/common-query.dto';
|
import { TicketListQueryDto } from '../common/dto/common-query.dto';
|
||||||
|
import { AdminCreateTicketDto } from '../common/dto/common-mutate.dto';
|
||||||
|
|
||||||
@Controller('admin/tickets')
|
@Controller('admin/tickets')
|
||||||
@UseGuards(HqAuthGuard)
|
@UseGuards(HqAuthGuard)
|
||||||
@@ -19,6 +20,17 @@ export class AdminTicketsController {
|
|||||||
return this.service.list(query);
|
return this.service.list(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@HqOperation({
|
||||||
|
action: HqOperationAction.TICKET_CREATE,
|
||||||
|
refType: 'TICKET',
|
||||||
|
batch: true,
|
||||||
|
includeBody: true,
|
||||||
|
})
|
||||||
|
create(@Body() body: AdminCreateTicketDto) {
|
||||||
|
return this.service.createByHq(body);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
detail(@Param('id') id: string) {
|
detail(@Param('id') id: string) {
|
||||||
return this.service.detail(BigInt(id));
|
return this.service.detail(BigInt(id));
|
||||||
|
|||||||
@@ -40,6 +40,43 @@ export class AdminTicketsService {
|
|||||||
return this.ticketService.detail(id);
|
return this.ticketService.detail(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createByHq(body: {
|
||||||
|
ticketType: string;
|
||||||
|
orderNo: string;
|
||||||
|
remark?: string;
|
||||||
|
evidenceUrls?: string[];
|
||||||
|
}) {
|
||||||
|
const orderNo = body.orderNo?.trim();
|
||||||
|
if (!orderNo) throw new BadRequestException('请填写订单号');
|
||||||
|
const order = await this.prisma.order.findFirst({ where: { orderNo } });
|
||||||
|
if (!order) throw new NotFoundException('订单不存在');
|
||||||
|
if (order.status === 'PENDING_PAY' || order.status === 'CANCELLED') {
|
||||||
|
throw new BadRequestException('当前订单不可创建工单');
|
||||||
|
}
|
||||||
|
if (['REFUNDING', 'REFUNDED'].includes(order.status) && body.ticketType !== 'ALERT') {
|
||||||
|
throw new BadRequestException('订单已在退款流程中');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pending = await this.prisma.commonTicket.findFirst({
|
||||||
|
where: {
|
||||||
|
ticketType: body.ticketType as never,
|
||||||
|
refType: 'ORDER',
|
||||||
|
refId: order.id,
|
||||||
|
status: { in: ['PENDING', 'OPEN'] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (pending) throw new BadRequestException('该类型工单已在处理中');
|
||||||
|
|
||||||
|
const evidenceUrls = (body.evidenceUrls ?? []).filter((u) => typeof u === 'string' && u.trim());
|
||||||
|
return this.ticketService.create({
|
||||||
|
ticketType: body.ticketType,
|
||||||
|
refType: 'ORDER',
|
||||||
|
refId: order.id.toString(),
|
||||||
|
remark: body.remark ?? '',
|
||||||
|
extraJson: evidenceUrls.length ? { evidenceUrls } : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private parseExtra(raw: unknown): TicketCollabExtra {
|
private parseExtra(raw: unknown): TicketCollabExtra {
|
||||||
if (!raw || typeof raw !== 'object') return {};
|
if (!raw || typeof raw !== 'object') return {};
|
||||||
return raw as TicketCollabExtra;
|
return raw as TicketCollabExtra;
|
||||||
|
|||||||
@@ -37,9 +37,9 @@ export class CreateStoreDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@IsString()
|
||||||
categoryId?: string;
|
@IsNotEmpty()
|
||||||
|
categoryId: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -64,6 +64,13 @@ export class CreateInvoiceDto {
|
|||||||
remark?: string;
|
remark?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class AdminCreateInvoiceDto extends CreateInvoiceDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(32)
|
||||||
|
orderNo: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class IssueInvoiceDto {
|
export class IssueInvoiceDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -754,6 +754,27 @@ export class TradeService {
|
|||||||
return days;
|
return days;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async adminCreateInvoice(
|
||||||
|
body: {
|
||||||
|
orderNo: string;
|
||||||
|
titleType: string;
|
||||||
|
invoiceKind: string;
|
||||||
|
titleName: string;
|
||||||
|
taxNo?: string;
|
||||||
|
addressPhone?: string;
|
||||||
|
bankAccount?: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
remark?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const orderNo = body.orderNo?.trim();
|
||||||
|
if (!orderNo) throw new BadRequestException('请填写订单号');
|
||||||
|
const order = await this.prisma.order.findFirst({ where: { orderNo } });
|
||||||
|
if (!order) throw new NotFoundException('订单不存在');
|
||||||
|
return this.createInvoice(order.userId, order.id, body);
|
||||||
|
}
|
||||||
|
|
||||||
async adminListInvoices(query: { status?: string; page?: number; pageSize?: number }) {
|
async adminListInvoices(query: { status?: string; page?: number; pageSize?: number }) {
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const pageSize = query.pageSize ?? 20;
|
const pageSize = query.pageSize ?? 20;
|
||||||
|
|||||||
Reference in New Issue
Block a user