5 Commits

Author SHA1 Message Date
jacy ac1e0a9f13 feat(admin): require store category on HQ store create
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 07:13:56 +08:00
jacy 2a208d5dea 小程序端界面修改
CI / verify (pull_request) Has been cancelled
2026-07-23 15:23:23 +08:00
jacy 82c447b193 工单和发票
酒厂银行账户
2026-07-23 14:51:20 +08:00
jacy 75765bf9d4 后端修改权限和折叠功能
CI / verify (pull_request) Has been cancelled
2026-07-23 14:38:52 +08:00
jacy 6479365d6a 小程序页面优化
CI / verify (pull_request) Has been cancelled
2026-07-23 14:03:22 +08:00
35 changed files with 1318 additions and 322 deletions
+81 -3
View File
@@ -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 { Layout, Menu, Typography, Button, Space } from 'antd';
import type { MenuProps } from 'antd';
@@ -18,11 +18,14 @@ import {
SettingOutlined,
AccountBookOutlined,
} from '@ant-design/icons';
import { hasAnySystemSettingsPermission } from '@dukang/shared-types';
import { clearAuth, request, type HqProfile } from '../lib/api';
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
const { Header, Sider, Content } = Layout;
type MenuItem = NonNullable<MenuProps['items']>[number];
const MENU_ITEMS: MenuProps['items'] = [
{ key: '/', icon: <DashboardOutlined />, label: '概览' },
{ key: '/users', icon: <UserOutlined />, label: '用户' },
@@ -111,6 +114,75 @@ const MENU_ITEMS: MenuProps['items'] = [
{ 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() {
const navigate = useNavigate();
const location = useLocation();
@@ -138,6 +210,12 @@ export default function AdminLayout() {
? '/promo-codes'
: 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 (
<Layout style={{ height: '100vh', overflow: 'hidden' }}>
<Sider
@@ -156,8 +234,8 @@ export default function AdminLayout() {
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'finance-group', 'benefit-group', 'logs-group', 'deliveries-group']}
items={MENU_ITEMS}
defaultOpenKeys={[]}
items={menuItems}
onClick={({ key }) => {
if (key.startsWith('/')) navigate(key);
}}
+1
View File
@@ -7,6 +7,7 @@ export type HqProfile = {
name: string;
adminRole: string;
status: string;
permissionKeys?: string[];
};
export function getToken() {
+4
View File
@@ -38,6 +38,10 @@ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'DELIVERY_UPDATE', label: '编辑配送单' },
{ value: 'TICKET_APPROVE', 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_BATCH_CONFIRM', label: '批量门店打款' },
{ value: 'STORE_BILL_CONFIRM', label: '门店对账单确认打款' },
+14 -1
View File
@@ -6,6 +6,8 @@ export type StoreCreateForm = {
city?: string;
district: string;
districtCode?: string;
categoryParentId?: string;
categoryId: string;
name: string;
phone: string;
address: string;
@@ -23,11 +25,22 @@ const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/;
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 {
if (!form.partnerAccountId) return '请选择开城合伙人';
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
if (!form.categoryId?.trim()) return '请选择门店分类(细类)';
if (!form.name?.trim()) return '请填写门店名称';
if (!form.phone?.trim()) return '请填写门店手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
+28 -9
View File
@@ -5,6 +5,7 @@ import {
Card,
Checkbox,
Col,
Divider,
Form,
Row,
Select,
@@ -33,6 +34,8 @@ type AccountPermRes = {
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({
value,
onChange,
@@ -49,13 +52,24 @@ function PermissionChecklist({
disabled={disabled}
onChange={(checked) => onChange(checked as string[])}
>
<Row gutter={[8, 8]}>
{HQ_PERMISSION_CATALOG.map((item) => (
<Col key={item.key} span={8}>
<Checkbox value={item.key}>{item.label}</Checkbox>
</Col>
))}
</Row>
{CATALOG_GROUPS.map((group) => {
const items = HQ_PERMISSION_CATALOG.filter((p) => (p.group ?? '其他') === group);
return (
<div key={group} style={{ marginBottom: 16 }}>
<Typography.Text strong style={{ display: 'block', marginBottom: 8 }}>
{group}
</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>
);
}
@@ -159,6 +173,7 @@ export default function HqPermissionsPage() {
<Typography.Title level={4}></Typography.Title>
<Typography.Paragraph type="secondary">
=
//
</Typography.Paragraph>
<Tabs
@@ -231,7 +246,7 @@ export default function HqPermissionsPage() {
const item = HQ_PERMISSION_CATALOG.find((p) => p.key === key);
return (
<Tag key={key} color="blue">
{item?.label || key}
{item?.group ? `${item.group}·${item.label}` : item?.label || key}
</Tag>
);
})}
@@ -244,7 +259,11 @@ export default function HqPermissionsPage() {
<span></span>
{previewEffectiveKeys.map((key) => {
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>
<div style={{ marginTop: 16 }}>
+181 -4
View File
@@ -5,7 +5,9 @@ import {
Drawer,
Form,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
@@ -47,6 +49,19 @@ type Row = {
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() {
const [filters, setFilters] = useState<Record<string, string>>({});
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 [drawerOpen, setDrawerOpen] = 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) {
setDetail(await request(`/admin/invoices/${id}`));
@@ -98,6 +118,36 @@ export default function InvoicesPage() {
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> = [
{ title: '申请单号', dataIndex: 'invoiceNo', width: 180 },
{ title: '订单号', dataIndex: 'orderNo', width: 160 },
@@ -138,7 +188,30 @@ export default function InvoicesPage() {
return (
<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
layout="inline"
style={{ marginBottom: 16 }}
@@ -187,7 +260,7 @@ export default function InvoicesPage() {
onClose={() => setDrawerOpen(false)}
extra={
detail?.status === 'PENDING' ? (
<>
<Space>
<Upload
accept="image/*,.pdf"
showUploadList={false}
@@ -196,14 +269,14 @@ export default function InvoicesPage() {
return false;
}}
>
<Button type="primary" loading={uploading} style={{ marginRight: 8 }}>
<Button type="primary" loading={uploading}>
</Button>
</Upload>
<Button danger onClick={() => void reject()}>
</Button>
</>
</Space>
) : null
}
>
@@ -242,6 +315,110 @@ export default function InvoicesPage() {
</Descriptions>
)}
</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>
);
}
+83 -3
View File
@@ -263,6 +263,7 @@ type StoreRow = {
cityRef?: { name: string; code: string };
partner?: { companyName: string };
account?: { phone: string; name: string; status: string };
category?: { id: string; name: string; parentId?: string | null } | null;
};
type PartnerOption = { id: string; companyName: string };
@@ -272,6 +273,12 @@ type CityOption = {
code: string;
partnerBindings?: Array<{ partnerAccountId: string; partnerCompanyName?: string }>;
};
type CategoryNode = {
id: string;
name: string;
status?: string;
children?: CategoryNode[];
};
export default function StoresPage() {
const navigate = useNavigate();
@@ -301,11 +308,27 @@ export default function StoresPage() {
const [createError, setCreateError] = useState('');
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [cities, setCities] = useState<CityOption[]>([]);
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
const [optionsLoading, setOptionsLoading] = useState(false);
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
const selectedRegionCodes = Form.useWatch('regionCodes', 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) {
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
@@ -337,16 +360,21 @@ export default function StoresPage() {
setOptionsLoading(true);
try {
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<CityOption>>(`/admin/cities?${qs}`),
request<CategoryNode[]>('/admin/store-categories'),
]);
setPartners(p.items);
setCities(c.items);
setCategoryTree(Array.isArray(cats) ? cats : []);
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
if (!Array.isArray(cats) || !cats.length) {
message.warning('暂无门店分类,请先在「门店 → 门店分类」中配置');
}
} catch (e) {
message.error(e instanceof Error ? e.message : '加载合伙人/城市失败');
message.error(e instanceof Error ? e.message : '加载合伙人/城市/分类失败');
} finally {
setOptionsLoading(false);
}
@@ -379,7 +407,16 @@ export default function StoresPage() {
return;
}
try {
await createForm.validateFields(['partnerAccountId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
await createForm.validateFields([
'partnerAccountId',
'regionCodes',
'cityId',
'categoryParentId',
'categoryId',
'name',
'phone',
'address',
]);
} catch {
return;
}
@@ -403,6 +440,7 @@ export default function StoresPage() {
body: JSON.stringify({
partnerAccountId: values.partnerAccountId,
cityId: values.cityId,
categoryId: values.categoryId,
province: values.province,
city: values.city,
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 }} /> : '—',
},
{ title: '门店名', dataIndex: 'name', width: 140 },
{
title: '分类',
width: 100,
render: (_, row) => row.category?.name || '—',
},
{ title: '城市', dataIndex: 'cityName', width: 80 },
{ title: '电话', dataIndex: 'phone', width: 120 },
{
@@ -580,6 +623,11 @@ export default function StoresPage() {
<StoreAuditMediaSection detail={detail} />
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<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="审核状态">
<Tag color={
String(detail.auditStatus) === 'PENDING' ? 'orange'
@@ -685,6 +733,38 @@ export default function StoresPage() {
<Form.Item name="city" hidden><Input /></Form.Item>
<Form.Item name="district" 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: '请填写门店名称' }]}>
<Input placeholder="请输入门店名称" />
</Form.Item>
@@ -17,7 +17,7 @@ import {
message,
} from 'antd';
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';
const { TextArea } = Input;
@@ -161,6 +161,7 @@ export default function SystemSettingsPage() {
const navigate = useNavigate();
const [form] = Form.useForm<Record<string, string>>();
const [meta, setMeta] = useState<SystemConfigFormResponse | null>(null);
const [profile, setProfile] = useState<HqProfile | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [syncing, setSyncing] = useState(false);
@@ -195,6 +196,7 @@ export default function SystemSettingsPage() {
useEffect(() => {
void load();
request<HqProfile>('/admin/auth/me').then(setProfile).catch(() => {});
}, []);
useEffect(() => {
@@ -335,10 +337,14 @@ export default function SystemSettingsPage() {
</Typography.Paragraph>
</div>
<Space>
<Button onClick={() => void onImportEnv()}></Button>
<Button loading={syncing} onClick={() => void onSyncEnv()}>
env
</Button>
{profile?.adminRole === 'SUPER_ADMIN' ? (
<>
<Button onClick={() => void onImportEnv()}></Button>
<Button loading={syncing} onClick={() => void onSyncEnv()}>
env
</Button>
</>
) : null}
</Space>
</div>
@@ -363,7 +369,7 @@ export default function SystemSettingsPage() {
<Card loading={loading}>
<Form form={form} layout="vertical" onValuesChange={() => setDirty(true)}>
<Collapse defaultActiveKey={meta?.groups.map((g) => g.key)} items={collapseItems} />
<Collapse defaultActiveKey={[]} items={collapseItems} />
</Form>
{meta?.updatedAt ? (
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
+100 -5
View File
@@ -1,5 +1,18 @@
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 { TICKET_TYPE_LABELS, type TicketTypeDto } from '@dukang/shared-types';
import { request } from '../lib/api';
@@ -32,6 +45,13 @@ export default function TicketsPage() {
);
const [detail, setDetail] = useState<Row | null>(null);
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) {
await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) });
@@ -50,6 +70,29 @@ export default function TicketsPage() {
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> = [
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
{
@@ -84,7 +127,21 @@ export default function TicketsPage() {
return (
<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
layout="inline"
style={{ marginBottom: 16 }}
@@ -137,14 +194,14 @@ export default function TicketsPage() {
onClose={() => setDrawerOpen(false)}
extra={
detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
<>
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}>
<Space>
<Button type="primary" onClick={() => approve(String(detail.id))}>
</Button>
<Button danger onClick={() => reject(String(detail.id))}>
</Button>
</>
</Space>
) : null
}
>
@@ -173,6 +230,44 @@ export default function TicketsPage() {
</Descriptions>
)}
</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>
);
}
+122 -11
View File
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
Button,
Card,
@@ -6,6 +6,7 @@ import {
Descriptions,
Drawer,
Form,
Input,
Modal,
Select,
Space,
@@ -17,10 +18,13 @@ import {
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
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 { downloadExcelCsv } from '../lib/exportExcel';
import { request } from '../lib/api';
import { request, type HqProfile } from '../lib/api';
import { useAdminList } from '../lib/useAdminList';
type Row = {
@@ -59,8 +63,16 @@ const DELIVERY_LABELS: Record<string, string> = {
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() {
const [form] = Form.useForm();
const [bankForm] = Form.useForm<Record<string, string>>();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/winery-bills',
@@ -79,6 +91,18 @@ export default function WineryBillsPage() {
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const [detail, setDetail] = useState<(Row & { items?: BillItem[] }) | null>(null);
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) {
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 ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100);
const selectedRows = (data?.items ?? []).filter((r) => selectedKeys.includes(r.id));
@@ -185,14 +244,29 @@ export default function WineryBillsPage() {
return (
<div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
8:00 × {ratePct}%绿
</Typography.Text>
</Space>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 16,
gap: 16,
}}
>
<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 && (
<Card size="small" style={{ marginBottom: 16 }}>
@@ -337,6 +411,43 @@ export default function WineryBillsPage() {
</>
)}
</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>
);
}
@@ -7,10 +7,11 @@ type CouponBadgeProps = {
/** Taro 友好版权益角标(对齐 shared-ui CouponBadge */
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 (
<Text className="coupon-badge">
¥{amount}
{label}
¥{display} {label}
</Text>
);
}
@@ -26,6 +26,7 @@ export default function PageShell({
'page-shell',
`page-shell--${variant}`,
hasFixedFooter ? 'page-shell--fixed-footer' : '',
variant === 'tab' && process.env.TARO_ENV === 'weapp' ? 'page-shell--native-tabbar' : '',
className,
]
.filter(Boolean)
+56 -39
View File
@@ -4,19 +4,19 @@ import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
import TabMainHeader from '../../components/TabMainHeader';
import CouponBadge from '../../components/CouponBadge';
import ProductCarousel from '../../components/ProductCarousel';
import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../components/UserTabBar';
import { goLogin } from '../../lib/auth-nav';
import { isLoggedIn, request, toast } from '../../lib/api';
import { ensurePayReady } from '../../lib/pay-ready';
import { capturePromoSceneAndTouchScan } from '../../lib/promo';
import { getProductImages } from '../../lib/product-images';
import { getProductMainImage } from '../../lib/product-images';
import { getCityCodeForCatalog, resolveUserCity } from '../../lib/user-location';
type Product = {
id: string;
name: string;
subtitle?: string;
spec?: string;
price: number;
benefitDisplay?: number;
mainImageUrl?: string | null;
@@ -32,8 +32,8 @@ type MiniHomeConfig = {
const AROMA_TABS = [
{ key: 'QINGXIANG', label: '清香型' },
{ key: 'JIANGXIANG', label: '酱香型' },
{ key: 'NONGXIANG', label: '浓香型' },
{ key: 'JIANGXIANG', label: '酱香型' },
] as const;
export default function HomePage() {
@@ -143,21 +143,6 @@ export default function HomePage() {
<PageShell variant="tab" className="home-page no-tab-header">
<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 ? (
<View className="home-promo-banner">
<Swiper
@@ -176,6 +161,21 @@ export default function HomePage() {
</View>
) : 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">
{loading ? <View className="home-empty"></View> : null}
{!loading && products.length === 0 ? (
@@ -183,37 +183,54 @@ export default function HomePage() {
) : null}
{!loading &&
filtered.map((p) => {
const images = getProductImages(p);
const thumb = getProductMainImage(p);
const spec = p.subtitle || p.spec || '';
return (
<View key={p.id} className="home-product-card">
<View onClick={() => openProductDetail(p.id)}>
<ProductCarousel images={images} alt={p.name} variant="home" />
<View className="home-product-body">
<View
className="home-product-card-inner"
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">
<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>
{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">
<CouponBadge amount={p.benefitDisplay ?? p.price} label="好客权益" />
</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 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>
);
})}
@@ -333,7 +333,11 @@ export default function OrderConfirmPage() {
{!previewLoading && !preview && productId ? (
<View className="u-empty"></View>
) : 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 className="order-confirm-bar">
+1 -1
View File
@@ -210,7 +210,7 @@ export default function PayPage() {
</View>
</View>
{msg ? (
<Text className="pay-wechat-auth-msg" style={{ display: 'block', marginTop: 12 }}>
<Text className="pay-wechat-auth-msg" style={{ marginTop: 12 }}>
{msg}
</Text>
) : null}
+185 -197
View File
@@ -31,58 +31,8 @@
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 {
margin: 16px var(--space-page) 0;
margin: 12px var(--space-page) 0;
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-card);
@@ -101,8 +51,184 @@
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 {
margin: 0 var(--space-page) 16px;
margin: 0 var(--space-page) 8px;
border-radius: var(--radius-lg);
overflow: hidden;
background: var(--color-card);
@@ -117,162 +243,24 @@
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 {
text-align: center;
padding: 48px 24px;
padding: 32px 24px;
color: var(--color-subtle-gray);
font-size: 14px;
font-size: 13px;
}
.coupon-badge {
.home-page .coupon-badge {
position: relative;
display: inline-flex;
align-items: center;
background: var(--color-aged-amber);
color: var(--color-on-secondary-container);
padding: 4px 12px;
padding: 2px 8px;
border-radius: 2px;
font-family: var(--font-label);
font-size: 12px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
letter-spacing: 0.02em;
line-height: 16px;
}
+5
View File
@@ -10,6 +10,11 @@
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--sub,
.page-shell--plain {
+3
View File
@@ -169,6 +169,7 @@
font-size: 13px;
color: var(--color-heritage-red);
line-height: 1.4;
text-align: center;
}
.order-row {
@@ -438,7 +439,9 @@
}
.pay-wechat-auth-msg {
display: block;
font-size: 13px;
color: var(--color-heritage-red, #a02d30);
line-height: 1.5;
text-align: center;
}
+74 -17
View File
@@ -1,24 +1,69 @@
/** HQ 权限目录(权限分配页勾选源) */
export const HQ_PERMISSION_CATALOG = [
{ key: 'dashboard', label: '概览' },
{ key: 'users', label: '用户管理' },
{ key: 'wechat_bindings', label: '微信绑定' },
{ key: 'products', label: '商品管理' },
{ key: 'orders', label: '订单管理' },
{ key: 'stores', label: '门店管理' },
{ key: 'partners', label: '开城管理' },
{ key: 'benefit', label: '好客权益' },
{ key: 'deliveries', label: '配送单' },
{ key: 'tickets', label: '工单中心' },
{ key: 'invoices', label: '发票管理' },
{ key: 'resources', label: 'OSS 资源库' },
{ key: 'logs', label: '日志' },
{ key: 'hq_permissions', label: '权限分配' },
{ key: 'hq_accounts', label: 'HQ 账户' },
{ key: 'system_settings', label: '系统设置' },
{ key: 'dashboard', label: '概览', group: '业务' },
{ key: 'users', label: '用户管理', group: '业务' },
{ key: 'wechat_bindings', label: '微信绑定', group: '业务' },
{ key: 'products', label: '商品管理', group: '业务' },
{ key: 'orders', label: '订单管理', group: '业务' },
{ key: 'promo_codes', label: '推广码', group: '业务' },
{ key: 'stores', label: '门店管理', group: '业务' },
{ key: 'partners', label: '开城管理', group: '业务' },
{ key: 'finance', label: '财务', group: '业务' },
{ key: 'benefit', label: '好客权益', group: '业务' },
{ key: 'deliveries', label: '配送单', group: '业务' },
{ key: 'tickets', label: '工单中心', group: '业务' },
{ key: 'invoices', label: '发票管理', group: '业务' },
{ key: 'resources', label: 'OSS 资源库', group: '业务' },
{ key: 'logs', label: '日志', group: '业务' },
{ 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;
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 = [
{ value: 'SUPER_ADMIN', label: '超级管理员' },
{ value: 'OPS', label: '运营' },
@@ -34,6 +79,7 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
'wechat_bindings',
'products',
'orders',
'promo_codes',
'stores',
'partners',
'benefit',
@@ -42,7 +88,18 @@ export const HQ_ROLE_DEFAULT_PERMISSIONS: Record<string, HqPermissionKey[]> = {
'invoices',
'resources',
'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'],
};
@@ -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',
TICKET_APPROVE: 'TICKET_APPROVE',
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_BATCH_CONFIRM: 'STORE_PAYOUT_BATCH_CONFIRM',
STORE_BILL_CONFIRM: 'STORE_BILL_CONFIRM',
@@ -119,6 +123,10 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.DELIVERY_UPDATE]: '编辑配送单',
[HqOperationAction.TICKET_APPROVE]: '工单通过',
[HqOperationAction.TICKET_REJECT]: '工单驳回',
[HqOperationAction.TICKET_CREATE]: '创建工单',
[HqOperationAction.INVOICE_CREATE]: '创建发票申请',
[HqOperationAction.INVOICE_ISSUE]: '开具发票',
[HqOperationAction.INVOICE_REJECT]: '驳回发票',
[HqOperationAction.STORE_PAYOUT_CONFIRM]: '门店打款确认',
[HqOperationAction.STORE_PAYOUT_BATCH_CONFIRM]: '批量门店打款',
[HqOperationAction.STORE_BILL_CONFIRM]: '门店对账单确认打款',
@@ -9,6 +9,7 @@ export const SYSTEM_CONFIG_GROUPS: SystemConfigGroupMeta[] = [
{ key: 'oss', label: '对象存储 OSS' },
{ key: 'app', label: '应用链接' },
{ key: 'deploy', label: '发布部署' },
{ key: 'winery_bank', label: '酒厂银行账户' },
];
const G = {
@@ -19,6 +20,7 @@ const G = {
oss: 'oss',
app: 'app',
deploy: 'deploy',
winery_bank: 'winery_bank',
} as const;
/** 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_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 清理) */
@@ -63,16 +63,23 @@ export class SystemConfigService implements OnModuleInit {
return loadAppConfig(this.getMergedEnv());
}
async getForm(): Promise<SystemConfigFormResponse> {
async getForm(allowedGroups?: string[] | null): Promise<SystemConfigFormResponse> {
if (!this.tableReady) {
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 dbMap = new Map(rows.map((r) => [r.configKey, r.value]));
const values: Record<string, string> = {};
const configuredSecrets: string[] = [];
for (const field of SYSTEM_CONFIG_FIELDS) {
for (const field of fields) {
const fromDb = dbMap.get(field.key);
const fromEnv = process.env[field.key];
const raw = fromDb ?? fromEnv ?? '';
@@ -85,8 +92,8 @@ export class SystemConfigService implements OnModuleInit {
}
return {
groups: SYSTEM_CONFIG_GROUPS,
fields: SYSTEM_CONFIG_FIELDS,
groups,
fields,
values,
configuredSecrets,
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[];
requiresRestartKeys: string[];
}> {
const updatedKeys: string[] = [];
const requiresRestartKeys: string[] = [];
const overlay: Record<string, string> = {};
const allowedGroupSet =
allowedGroups == null ? null : new Set(allowedGroups);
for (const [key, rawValue] of Object.entries(dto.values ?? {})) {
if (!SYSTEM_CONFIG_KEY_SET.has(key)) continue;
const meta = getSystemConfigField(key);
if (!meta) continue;
if (allowedGroupSet && !allowedGroupSet.has(meta.group)) continue;
let value = String(rawValue ?? '').trim();
if (meta.secret && (!value || value === SECRET_PLACEHOLDER)) {
@@ -170,6 +170,23 @@ export class CreateTicketDto {
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 {
@IsString()
@IsNotEmpty()
@@ -24,6 +24,7 @@ import { verifyPassword } from '../../common/crypto/password.util';
import { AnalyticsService } from '../analytics/analytics.service';
import { UserAddressService } from './user-address.service';
import { ResourceService } from '../common/resource.service';
import { HqPermissionsResolver } from '../../common/guards/hq-permission.guard';
import type { User } from '@prisma/client';
@@ -65,6 +66,7 @@ export class AuthService {
private readonly smsCodeStore: SmsCodeStore,
private readonly userAddressService: UserAddressService,
@Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService,
private readonly hqPermissions: HqPermissionsResolver,
) {}
private assertMobilePhone(phone: string) {
@@ -1128,7 +1130,9 @@ export class AuthService {
}
if (actorType === 'HQ') {
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;
}
@@ -24,6 +24,10 @@ import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
import { StoreMembershipService } from '../../common/guards/store-membership.service';
import {
HqPermissionGuard,
HqPermissionsResolver,
} from '../../common/guards/hq-permission.guard';
import { CommonModule } from '../common/common.module';
@Module({
@@ -59,6 +63,8 @@ import { CommonModule } from '../common/common.module';
PartnerPrimaryGuard,
PartnerPermissionGuard,
ShopStoreGuard,
HqPermissionsResolver,
HqPermissionGuard,
],
exports: [
AuthService,
@@ -74,6 +80,8 @@ import { CommonModule } from '../common/common.module';
PartnerPrimaryGuard,
PartnerPermissionGuard,
ShopStoreGuard,
HqPermissionsResolver,
HqPermissionGuard,
],
})
export class IamModule {}
@@ -2,12 +2,17 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import {
HQ_PERMISSION_CATALOG,
HQ_ROLE_DEFAULT_PERMISSIONS,
LEGACY_SYSTEM_SETTINGS_KEY,
expandHqPermissionKeys,
type HqPermissionKey,
} from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
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[]) {
const invalid = keys.filter((key) => !VALID_PERMISSION_KEYS.has(key));
@@ -37,7 +42,7 @@ export class AdminHqPermissionsService {
});
const permissionKeys =
rows.length > 0
? rows.map((r) => r.permissionKey)
? expandHqPermissionKeys(rows.map((r) => r.permissionKey))
: [...(HQ_ROLE_DEFAULT_PERMISSIONS[role] ?? [])];
return { role, permissionKeys };
}
@@ -47,13 +52,14 @@ export class AdminHqPermissionsService {
throw new BadRequestException('超级管理员拥有全部权限,无需配置');
}
assertPermissionKeys(permissionKeys);
const normalized = expandHqPermissionKeys(permissionKeys);
const adminRole = role as 'OPS' | 'FINANCE' | 'CUSTOMER_SERVICE';
await this.prisma.$transaction([
this.prisma.hqRolePermission.deleteMany({ where: { adminRole } }),
...(permissionKeys.length
...(normalized.length
? [
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 },
}),
]);
const userPermissionKeys = userPerms.map((p) => p.permissionKey);
const userPermissionKeys = expandHqPermissionKeys(userPerms.map((p) => p.permissionKey));
const effectivePermissionKeys = [
...new Set([...rolePerms.permissionKeys, ...userPermissionKeys]),
] as HqPermissionKey[];
@@ -105,12 +111,13 @@ export class AdminHqPermissionsService {
throw new BadRequestException('超级管理员拥有全部权限,无需配置');
}
assertPermissionKeys(permissionKeys);
const normalized = expandHqPermissionKeys(permissionKeys);
await this.prisma.$transaction([
this.prisma.hqAccountPermission.deleteMany({ where: { hqAccountId: accountId } }),
...(permissionKeys.length
...(normalized.length
? [
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 { AuthUser } from '../../common/guards/jwt-auth.guard';
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 { IssueInvoiceDto, RejectInvoiceDto } from '../trade/dto/after-sale.dto';
import {
AdminCreateInvoiceDto,
IssueInvoiceDto,
RejectInvoiceDto,
} from '../trade/dto/after-sale.dto';
@Controller('admin/invoices')
@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')
detail(@Param('id') id: string) {
return this.tradeService.adminGetInvoice(BigInt(id));
}
@Post(':id/issue')
@HqOperation({
action: HqOperationAction.INVOICE_ISSUE,
refType: 'INVOICE',
refIdParam: 'id',
includeBody: true,
})
issue(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@@ -38,6 +61,12 @@ export class AdminInvoicesController {
}
@Post(':id/reject')
@HqOperation({
action: HqOperationAction.INVOICE_REJECT,
refType: 'INVOICE',
refIdParam: 'id',
includeBody: true,
})
reject(
@CurrentUser() user: AuthUser,
@Param('id') id: string,
@@ -5,6 +5,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { mapStoreCompat } from '../../common/compat/v31-compat';
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { StoreCategoryService } from '../store/store-category.service';
import type {
CreateStoreAccountDto,
CreateStoreDto,
@@ -20,6 +21,7 @@ export class AdminStoresService {
constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
private readonly storeCategoryService: StoreCategoryService,
) {}
async listStores(query: AdminStoresQueryDto) {
@@ -44,6 +46,7 @@ export class AdminStoresService {
include: {
cityRef: { select: { id: true, name: true, code: true } },
partnerAccount: { select: { id: true, companyName: true } },
category: { select: { id: true, name: true, parentId: true } },
bindings: {
where: { storeAccount: { isPrimary: 1 } },
take: 1,
@@ -237,12 +240,18 @@ export class AdminStoresService {
if (!city) throw new BadRequestException('开城城市不存在');
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({
data: {
cityId: city.id,
partnerAccountId,
settlementRate: dto.settlementRate ?? 0.6,
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null,
categoryId,
name: dto.name,
phone: normalizedPhone,
province: dto.province ?? city.province,
@@ -1,22 +1,41 @@
import { Body, Controller, Get, Post, Put, UseGuards } from '@nestjs/common';
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 { 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 { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { SystemConfigService } from '../../common/system-config/system-config.service';
@Controller('admin/system-config')
@UseGuards(HqAuthGuard, SuperAdminGuard)
@UseGuards(HqAuthGuard, HqPermissionGuard)
export class AdminSystemConfigController {
constructor(private readonly systemConfig: SystemConfigService) {}
constructor(
private readonly systemConfig: SystemConfigService,
private readonly permissions: HqPermissionsResolver,
) {}
@Get()
getForm() {
return this.systemConfig.getForm();
@RequireAnySystemSettings()
async getForm(@CurrentUser() user: AuthUser) {
const keys = await this.permissions.resolveEffectiveKeys(user.actorId);
const allowedGroups = allowedConfigGroups(keys);
return this.systemConfig.getForm(allowedGroups);
}
@Put()
@RequireAnySystemSettings()
@HqOperation({
action: HqOperationAction.SYSTEM_CONFIG_UPDATE,
refType: 'SYSTEM_CONFIG',
@@ -24,11 +43,14 @@ export class AdminSystemConfigController {
batch: true,
includeBody: true,
})
update(@Body() dto: SystemConfigUpdateRequest) {
return this.systemConfig.update(dto);
async update(@CurrentUser() user: AuthUser, @Body() dto: SystemConfigUpdateRequest) {
const keys = await this.permissions.resolveEffectiveKeys(user.actorId);
const allowedGroups = allowedConfigGroups(keys);
return this.systemConfig.update(dto, allowedGroups);
}
@Post('sync-env')
@UseGuards(SuperAdminGuard)
@HqOperation({
action: HqOperationAction.SYSTEM_CONFIG_SYNC_ENV,
refType: 'SYSTEM_CONFIG',
@@ -39,6 +61,7 @@ export class AdminSystemConfigController {
}
@Post('import-env')
@UseGuards(SuperAdminGuard)
@HqOperation({
action: HqOperationAction.SYSTEM_CONFIG_IMPORT_ENV,
refType: 'SYSTEM_CONFIG',
@@ -48,3 +71,12 @@ export class AdminSystemConfigController {
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 { AdminTicketsService } from './admin-tickets.service';
import { TicketListQueryDto } from '../common/dto/common-query.dto';
import { AdminCreateTicketDto } from '../common/dto/common-mutate.dto';
@Controller('admin/tickets')
@UseGuards(HqAuthGuard)
@@ -19,6 +20,17 @@ export class AdminTicketsController {
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')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
@@ -40,6 +40,43 @@ export class AdminTicketsService {
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 {
if (!raw || typeof raw !== 'object') return {};
return raw as TicketCollabExtra;
@@ -37,9 +37,9 @@ export class CreateStoreDto {
@IsNotEmpty()
phone: string;
@IsOptional()
@IsString()
categoryId?: string;
@IsNotEmpty()
categoryId: string;
@IsOptional()
@IsString()
@@ -64,6 +64,13 @@ export class CreateInvoiceDto {
remark?: string;
}
export class AdminCreateInvoiceDto extends CreateInvoiceDto {
@IsString()
@IsNotEmpty()
@MaxLength(32)
orderNo: string;
}
export class IssueInvoiceDto {
@IsString()
@IsNotEmpty()
@@ -754,6 +754,27 @@ export class TradeService {
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 }) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;