feat(health): 积分规则/商品 update/delete + 标签更新端点
- 积分规则: 添加 update/delete service + handler + 路由 - 兑换商品: 添加 update/delete service + handler + 路由 - 文章标签: 添加 update service + handler + 路由 - Web 管理端: 规则/商品列表页支持编辑/删除/启用切换 - Web 管理端: 标签管理页支持编辑、删除传 version
This commit is contained in:
@@ -252,11 +252,19 @@ export const articleTagApi = {
|
|||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
delete: async (id: string) => {
|
update: async (id: string, req: { name: string; version: number }) => {
|
||||||
|
const { data } = await client.put<{
|
||||||
|
success: boolean;
|
||||||
|
data: ArticleTagItem;
|
||||||
|
}>(`/health/article-tags/${id}`, req);
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: async (id: string, version: number) => {
|
||||||
const { data } = await client.delete<{
|
const { data } = await client.delete<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: null;
|
data: null;
|
||||||
}>(`/health/article-tags/${id}`);
|
}>(`/health/article-tags/${id}`, { data: { version } });
|
||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -168,6 +168,20 @@ export const pointsApi = {
|
|||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateRule: async (id: string, req: Partial<CreatePointsRuleReq> & { is_active?: boolean; version: number }) => {
|
||||||
|
const { data } = await client.put<{
|
||||||
|
success: boolean;
|
||||||
|
data: PointsRule;
|
||||||
|
}>(`/health/admin/points/rules/${id}`, { data: req, version: req.version });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteRule: async (id: string, version: number) => {
|
||||||
|
await client.delete(`/health/admin/points/rules/${id}`, {
|
||||||
|
data: { version },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Products
|
// Products
|
||||||
listProducts: async (params?: Record<string, unknown>) => {
|
listProducts: async (params?: Record<string, unknown>) => {
|
||||||
const { data } = await client.get<{
|
const { data } = await client.get<{
|
||||||
@@ -185,6 +199,20 @@ export const pointsApi = {
|
|||||||
return data.data;
|
return data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateProduct: async (id: string, req: Partial<CreatePointsProductReq> & { is_active?: boolean; version: number }) => {
|
||||||
|
const { data } = await client.put<{
|
||||||
|
success: boolean;
|
||||||
|
data: PointsProduct;
|
||||||
|
}>(`/health/admin/points/products/${id}`, { data: req, version: req.version });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteProduct: async (id: string, version: number) => {
|
||||||
|
await client.delete(`/health/admin/points/products/${id}`, {
|
||||||
|
data: { version },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
// Orders
|
// Orders
|
||||||
listOrders: async (params?: Record<string, unknown>) => {
|
listOrders: async (params?: Record<string, unknown>) => {
|
||||||
const { data } = await client.get<{
|
const { data } = await client.get<{
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
Tag,
|
Tag,
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
articleTagApi,
|
articleTagApi,
|
||||||
type ArticleTagItem,
|
type ArticleTagItem,
|
||||||
@@ -22,6 +22,7 @@ export default function ArticleTagManage() {
|
|||||||
const [tags, setTags] = useState<ArticleTagItem[]>([]);
|
const [tags, setTags] = useState<ArticleTagItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editing, setEditing] = useState<ArticleTagItem | null>(null);
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const isDark = useThemeMode();
|
const isDark = useThemeMode();
|
||||||
|
|
||||||
@@ -42,37 +43,50 @@ export default function ArticleTagManage() {
|
|||||||
}, [fetchTags]);
|
}, [fetchTags]);
|
||||||
|
|
||||||
const openCreateModal = () => {
|
const openCreateModal = () => {
|
||||||
|
setEditing(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEditModal = (record: ArticleTagItem) => {
|
||||||
|
setEditing(record);
|
||||||
|
form.setFieldsValue({ name: record.name });
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
|
setEditing(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async (values: { name: string; slug?: string; color?: string }) => {
|
const handleSubmit = async (values: { name: string; slug?: string; color?: string }) => {
|
||||||
try {
|
try {
|
||||||
const req: CreateTagReq = {
|
if (editing) {
|
||||||
name: values.name,
|
await articleTagApi.update(editing.id, { name: values.name, version: editing.version });
|
||||||
slug: values.slug,
|
message.success('标签更新成功');
|
||||||
color: values.color,
|
} else {
|
||||||
};
|
const req: CreateTagReq = {
|
||||||
await articleTagApi.create(req);
|
name: values.name,
|
||||||
message.success('标签创建成功');
|
slug: values.slug,
|
||||||
|
color: values.color,
|
||||||
|
};
|
||||||
|
await articleTagApi.create(req);
|
||||||
|
message.success('标签创建成功');
|
||||||
|
}
|
||||||
closeModal();
|
closeModal();
|
||||||
fetchTags();
|
fetchTags();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMsg =
|
const errorMsg =
|
||||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ||
|
||||||
'创建失败';
|
(editing ? '更新失败' : '创建失败');
|
||||||
message.error(errorMsg);
|
message.error(errorMsg);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (record: ArticleTagItem) => {
|
||||||
try {
|
try {
|
||||||
await articleTagApi.delete(id);
|
await articleTagApi.delete(record.id, record.version);
|
||||||
message.success('标签已删除');
|
message.success('标签已删除');
|
||||||
fetchTags();
|
fetchTags();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -138,13 +152,16 @@ export default function ArticleTagManage() {
|
|||||||
width: 80,
|
width: 80,
|
||||||
render: (_: unknown, record: ArticleTagItem) => (
|
render: (_: unknown, record: ArticleTagItem) => (
|
||||||
<AuthButton code="health.articles.manage">
|
<AuthButton code="health.articles.manage">
|
||||||
<Popconfirm
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
title="确定删除此标签?"
|
<Button size="small" type="text" icon={<EditOutlined />} onClick={() => openEditModal(record)} />
|
||||||
description="删除后关联的文章将移除该标签"
|
<Popconfirm
|
||||||
onConfirm={() => handleDelete(record.id)}
|
title="确定删除此标签?"
|
||||||
>
|
description="删除后关联的文章将移除该标签"
|
||||||
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
|
onConfirm={() => handleDelete(record)}
|
||||||
</Popconfirm>
|
>
|
||||||
|
<Button size="small" type="text" icon={<DeleteOutlined />} danger />
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
</AuthButton>
|
</AuthButton>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -183,9 +200,9 @@ export default function ArticleTagManage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 新建标签弹窗 */}
|
{/* 新建/编辑标签弹窗 */}
|
||||||
<Modal
|
<Modal
|
||||||
title="新建标签"
|
title={editing ? '编辑标签' : '新建标签'}
|
||||||
open={modalOpen}
|
open={modalOpen}
|
||||||
onCancel={closeModal}
|
onCancel={closeModal}
|
||||||
onOk={() => form.submit()}
|
onOk={() => form.submit()}
|
||||||
@@ -193,7 +210,7 @@ export default function ArticleTagManage() {
|
|||||||
>
|
>
|
||||||
<Form
|
<Form
|
||||||
form={form}
|
form={form}
|
||||||
onFinish={handleCreate}
|
onFinish={handleSubmit}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
style={{ marginTop: 16 }}
|
style={{ marginTop: 16 }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
@@ -113,16 +114,23 @@ export default function PointsProductList() {
|
|||||||
sort_order?: number;
|
sort_order?: number;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
const req: CreatePointsProductReq = {
|
if (editing) {
|
||||||
name: values.name,
|
await pointsApi.updateProduct(editing.id, {
|
||||||
product_type: values.product_type,
|
...values,
|
||||||
points_cost: values.points_cost,
|
version: editing.version,
|
||||||
stock: values.stock,
|
});
|
||||||
description: values.description,
|
} else {
|
||||||
image_url: values.image_url,
|
const req: CreatePointsProductReq = {
|
||||||
sort_order: values.sort_order,
|
name: values.name,
|
||||||
};
|
product_type: values.product_type,
|
||||||
await pointsApi.createProduct(req);
|
points_cost: values.points_cost,
|
||||||
|
stock: values.stock,
|
||||||
|
description: values.description,
|
||||||
|
image_url: values.image_url,
|
||||||
|
sort_order: values.sort_order,
|
||||||
|
};
|
||||||
|
await pointsApi.createProduct(req);
|
||||||
|
}
|
||||||
message.success(editing ? '更新成功' : '创建成功');
|
message.success(editing ? '更新成功' : '创建成功');
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
@@ -135,16 +143,10 @@ export default function PointsProductList() {
|
|||||||
// ---- 切换上下架 ----
|
// ---- 切换上下架 ----
|
||||||
const handleToggleActive = async (record: PointsProduct) => {
|
const handleToggleActive = async (record: PointsProduct) => {
|
||||||
try {
|
try {
|
||||||
const req: CreatePointsProductReq = {
|
await pointsApi.updateProduct(record.id, {
|
||||||
name: record.name,
|
is_active: !record.is_active,
|
||||||
product_type: record.product_type,
|
version: record.version,
|
||||||
points_cost: record.points_cost,
|
});
|
||||||
stock: record.stock,
|
|
||||||
description: record.description ?? undefined,
|
|
||||||
image_url: record.image_url ?? undefined,
|
|
||||||
sort_order: record.sort_order,
|
|
||||||
};
|
|
||||||
await pointsApi.createProduct(req);
|
|
||||||
message.success(record.is_active ? '已下架' : '已上架');
|
message.success(record.is_active ? '已下架' : '已上架');
|
||||||
fetchData(page, pageSize);
|
fetchData(page, pageSize);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -152,6 +154,24 @@ export default function PointsProductList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- 删除 ----
|
||||||
|
const handleDelete = (record: PointsProduct) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定要删除商品「${record.name}」吗?`,
|
||||||
|
okType: 'danger',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await pointsApi.deleteProduct(record.id, record.version);
|
||||||
|
message.success('删除成功');
|
||||||
|
fetchData(page, pageSize);
|
||||||
|
} catch {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// ---- 列定义 ----
|
// ---- 列定义 ----
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
@@ -230,6 +250,15 @@ export default function PointsProductList() {
|
|||||||
unCheckedChildren="下架"
|
unCheckedChildren="下架"
|
||||||
onChange={() => handleToggleActive(record)}
|
onChange={() => handleToggleActive(record)}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => handleDelete(record)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</AuthButton>
|
</AuthButton>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
@@ -101,17 +102,24 @@ export default function PointsRuleList() {
|
|||||||
streak_30d_bonus?: number;
|
streak_30d_bonus?: number;
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
const req: CreatePointsRuleReq = {
|
if (editing) {
|
||||||
event_type: values.event_type,
|
await pointsApi.updateRule(editing.id, {
|
||||||
name: values.name,
|
...values,
|
||||||
description: values.description,
|
version: editing.version,
|
||||||
points_value: values.points_value,
|
});
|
||||||
daily_cap: values.daily_cap,
|
} else {
|
||||||
streak_7d_bonus: values.streak_7d_bonus,
|
const req: CreatePointsRuleReq = {
|
||||||
streak_14d_bonus: values.streak_14d_bonus,
|
event_type: values.event_type,
|
||||||
streak_30d_bonus: values.streak_30d_bonus,
|
name: values.name,
|
||||||
};
|
description: values.description,
|
||||||
await pointsApi.createRule(req);
|
points_value: values.points_value,
|
||||||
|
daily_cap: values.daily_cap,
|
||||||
|
streak_7d_bonus: values.streak_7d_bonus,
|
||||||
|
streak_14d_bonus: values.streak_14d_bonus,
|
||||||
|
streak_30d_bonus: values.streak_30d_bonus,
|
||||||
|
};
|
||||||
|
await pointsApi.createRule(req);
|
||||||
|
}
|
||||||
message.success(editing ? '更新成功' : '创建成功');
|
message.success(editing ? '更新成功' : '创建成功');
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
@@ -124,19 +132,10 @@ export default function PointsRuleList() {
|
|||||||
// ---- 切换启用状态 ----
|
// ---- 切换启用状态 ----
|
||||||
const handleToggleActive = async (record: PointsRule) => {
|
const handleToggleActive = async (record: PointsRule) => {
|
||||||
try {
|
try {
|
||||||
// 目前后端没有 toggle 接口,重新创建等同于更新
|
await pointsApi.updateRule(record.id, {
|
||||||
// 使用 create 接口覆盖同 event_type 的规则
|
is_active: !record.is_active,
|
||||||
const req: CreatePointsRuleReq = {
|
version: record.version,
|
||||||
event_type: record.event_type,
|
});
|
||||||
name: record.name,
|
|
||||||
description: record.description ?? undefined,
|
|
||||||
points_value: record.points_value,
|
|
||||||
daily_cap: record.daily_cap,
|
|
||||||
streak_7d_bonus: record.streak_7d_bonus,
|
|
||||||
streak_14d_bonus: record.streak_14d_bonus,
|
|
||||||
streak_30d_bonus: record.streak_30d_bonus,
|
|
||||||
};
|
|
||||||
await pointsApi.createRule(req);
|
|
||||||
message.success(record.is_active ? '已停用' : '已启用');
|
message.success(record.is_active ? '已停用' : '已启用');
|
||||||
fetchData();
|
fetchData();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -144,6 +143,24 @@ export default function PointsRuleList() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---- 删除 ----
|
||||||
|
const handleDelete = (record: PointsRule) => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认删除',
|
||||||
|
content: `确定要删除规则「${record.name}」吗?`,
|
||||||
|
okType: 'danger',
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
await pointsApi.deleteRule(record.id, record.version);
|
||||||
|
message.success('删除成功');
|
||||||
|
fetchData();
|
||||||
|
} catch {
|
||||||
|
message.error('删除失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// ---- 列定义 ----
|
// ---- 列定义 ----
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
@@ -234,6 +251,15 @@ export default function PointsRuleList() {
|
|||||||
unCheckedChildren="停用"
|
unCheckedChildren="停用"
|
||||||
onChange={() => handleToggleActive(record)}
|
onChange={() => handleToggleActive(record)}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => handleDelete(record)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</AuthButton>
|
</AuthButton>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -218,3 +218,15 @@ impl CreateTagReq {
|
|||||||
self.name = sanitize_string(&self.name);
|
self.name = sanitize_string(&self.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateTagReq {
|
||||||
|
pub name: String,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UpdateTagReq {
|
||||||
|
pub fn sanitize(&mut self) {
|
||||||
|
self.name = sanitize_string(&self.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -264,6 +264,18 @@ pub struct OfflineEventResp {
|
|||||||
// 管理端:带版本号的更新/删除包装
|
// 管理端:带版本号的更新/删除包装
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateRuleWithVersion {
|
||||||
|
pub data: UpdatePointsRuleReq,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
|
pub struct UpdateProductWithVersion {
|
||||||
|
pub data: UpdatePointsProductReq,
|
||||||
|
pub version: i32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
||||||
pub struct UpdateOfflineEventWithVersion {
|
pub struct UpdateOfflineEventWithVersion {
|
||||||
pub data: UpdateOfflineEventReq,
|
pub data: UpdateOfflineEventReq,
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ pub enum HealthError {
|
|||||||
#[error("日常监测记录不存在")]
|
#[error("日常监测记录不存在")]
|
||||||
DailyMonitoringNotFound,
|
DailyMonitoringNotFound,
|
||||||
|
|
||||||
|
#[error("积分规则不存在")]
|
||||||
|
PointsRuleNotFound,
|
||||||
|
|
||||||
#[error("兑换商品不存在")]
|
#[error("兑换商品不存在")]
|
||||||
PointsProductNotFound,
|
PointsProductNotFound,
|
||||||
|
|
||||||
@@ -92,6 +95,7 @@ impl From<HealthError> for AppError {
|
|||||||
| HealthError::FollowUpTaskNotFound
|
| HealthError::FollowUpTaskNotFound
|
||||||
| HealthError::ConsultationNotFound
|
| HealthError::ConsultationNotFound
|
||||||
| HealthError::ArticleNotFound
|
| HealthError::ArticleNotFound
|
||||||
|
| HealthError::PointsRuleNotFound
|
||||||
| HealthError::PointsProductNotFound
|
| HealthError::PointsProductNotFound
|
||||||
| HealthError::PointsOrderNotFound
|
| HealthError::PointsOrderNotFound
|
||||||
| HealthError::OfflineEventNotFound
|
| HealthError::OfflineEventNotFound
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use erp_core::error::AppError;
|
|||||||
use erp_core::rbac::require_permission;
|
use erp_core::rbac::require_permission;
|
||||||
use erp_core::types::{ApiResponse, TenantContext};
|
use erp_core::types::{ApiResponse, TenantContext};
|
||||||
|
|
||||||
use crate::dto::article_dto::{CreateTagReq, TagResp};
|
use crate::dto::article_dto::{CreateTagReq, TagResp, UpdateTagReq};
|
||||||
use crate::service::article_tag_service;
|
use crate::service::article_tag_service;
|
||||||
use crate::state::HealthState;
|
use crate::state::HealthState;
|
||||||
|
|
||||||
@@ -40,6 +40,24 @@ where
|
|||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_tag<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(id): Path<uuid::Uuid>,
|
||||||
|
mut req: Json<UpdateTagReq>,
|
||||||
|
) -> Result<Json<ApiResponse<TagResp>>, AppError>
|
||||||
|
where
|
||||||
|
HealthState: FromRef<S>,
|
||||||
|
S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.articles.manage")?;
|
||||||
|
req.sanitize();
|
||||||
|
let result = article_tag_service::update_tag(
|
||||||
|
&state, ctx.tenant_id, id, Some(ctx.user_id), req.0,
|
||||||
|
).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
|
||||||
pub struct DeleteTagReq {
|
pub struct DeleteTagReq {
|
||||||
pub version: i32,
|
pub version: i32,
|
||||||
|
|||||||
@@ -227,6 +227,38 @@ where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
|||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_rule<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(rule_id): Path<Uuid>,
|
||||||
|
Json(wrapper): Json<crate::dto::points_dto::UpdateRuleWithVersion>,
|
||||||
|
) -> Result<Json<ApiResponse<PointsRuleResp>>, AppError>
|
||||||
|
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.points.manage")?;
|
||||||
|
let mut data = wrapper.data;
|
||||||
|
data.sanitize();
|
||||||
|
let result = points_service::update_rule(
|
||||||
|
&state, ctx.tenant_id, rule_id, Some(ctx.user_id), data, wrapper.version,
|
||||||
|
).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_rule<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(rule_id): Path<Uuid>,
|
||||||
|
Json(wrapper): Json<crate::dto::DeleteWithVersion>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.points.manage")?;
|
||||||
|
points_service::delete_rule(
|
||||||
|
&state, ctx.tenant_id, rule_id, Some(ctx.user_id), wrapper.version,
|
||||||
|
).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(())))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn admin_create_product<S>(
|
pub async fn admin_create_product<S>(
|
||||||
State(state): State<HealthState>,
|
State(state): State<HealthState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
@@ -243,6 +275,38 @@ where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
|||||||
Ok(Json(ApiResponse::ok(result)))
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn admin_update_product<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(product_id): Path<Uuid>,
|
||||||
|
Json(wrapper): Json<crate::dto::points_dto::UpdateProductWithVersion>,
|
||||||
|
) -> Result<Json<ApiResponse<PointsProductResp>>, AppError>
|
||||||
|
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.points.manage")?;
|
||||||
|
let mut data = wrapper.data;
|
||||||
|
data.sanitize();
|
||||||
|
let result = points_service::update_product(
|
||||||
|
&state, ctx.tenant_id, product_id, Some(ctx.user_id), data, wrapper.version,
|
||||||
|
).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(result)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn admin_delete_product<S>(
|
||||||
|
State(state): State<HealthState>,
|
||||||
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
Path(product_id): Path<Uuid>,
|
||||||
|
Json(wrapper): Json<crate::dto::DeleteWithVersion>,
|
||||||
|
) -> Result<Json<ApiResponse<()>>, AppError>
|
||||||
|
where HealthState: FromRef<S>, S: Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
require_permission(&ctx, "health.points.manage")?;
|
||||||
|
points_service::delete_product(
|
||||||
|
&state, ctx.tenant_id, product_id, Some(ctx.user_id), wrapper.version,
|
||||||
|
).await?;
|
||||||
|
Ok(Json(ApiResponse::ok(())))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn admin_list_orders<S>(
|
pub async fn admin_list_orders<S>(
|
||||||
State(state): State<HealthState>,
|
State(state): State<HealthState>,
|
||||||
Extension(ctx): Extension<TenantContext>,
|
Extension(ctx): Extension<TenantContext>,
|
||||||
|
|||||||
@@ -372,7 +372,8 @@ impl HealthModule {
|
|||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/health/article-tags/{id}",
|
"/health/article-tags/{id}",
|
||||||
axum::routing::delete(article_tag_handler::delete_tag),
|
axum::routing::put(article_tag_handler::update_tag)
|
||||||
|
.delete(article_tag_handler::delete_tag),
|
||||||
)
|
)
|
||||||
// 积分商城 — 患者端
|
// 积分商城 — 患者端
|
||||||
.route(
|
.route(
|
||||||
@@ -426,10 +427,20 @@ impl HealthModule {
|
|||||||
axum::routing::get(points_handler::list_rules)
|
axum::routing::get(points_handler::list_rules)
|
||||||
.post(points_handler::create_rule),
|
.post(points_handler::create_rule),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/health/admin/points/rules/{id}",
|
||||||
|
axum::routing::put(points_handler::update_rule)
|
||||||
|
.delete(points_handler::delete_rule),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/health/admin/points/products",
|
"/health/admin/points/products",
|
||||||
axum::routing::post(points_handler::admin_create_product),
|
axum::routing::post(points_handler::admin_create_product),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/health/admin/points/products/{id}",
|
||||||
|
axum::routing::put(points_handler::admin_update_product)
|
||||||
|
.delete(points_handler::admin_delete_product),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/health/admin/points/orders",
|
"/health/admin/points/orders",
|
||||||
axum::routing::get(points_handler::admin_list_orders),
|
axum::routing::get(points_handler::admin_list_orders),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use erp_core::audit::AuditLog;
|
|||||||
use erp_core::audit_service;
|
use erp_core::audit_service;
|
||||||
use erp_core::error::check_version;
|
use erp_core::error::check_version;
|
||||||
|
|
||||||
use crate::dto::article_dto::{CreateTagReq, TagResp};
|
use crate::dto::article_dto::{CreateTagReq, TagResp, UpdateTagReq};
|
||||||
use crate::entity::article_tag;
|
use crate::entity::article_tag;
|
||||||
use crate::error::{HealthError, HealthResult};
|
use crate::error::{HealthError, HealthResult};
|
||||||
use crate::state::HealthState;
|
use crate::state::HealthState;
|
||||||
@@ -66,6 +66,44 @@ pub async fn create_tag(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_tag(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
id: Uuid,
|
||||||
|
operator_id: Option<Uuid>,
|
||||||
|
req: UpdateTagReq,
|
||||||
|
) -> HealthResult<TagResp> {
|
||||||
|
let expected_version = req.version;
|
||||||
|
let model = article_tag::Entity::find()
|
||||||
|
.filter(article_tag::Column::Id.eq(id))
|
||||||
|
.filter(article_tag::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(article_tag::Column::DeletedAt.is_null())
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(HealthError::TagNotFound)?;
|
||||||
|
|
||||||
|
let next_ver = check_version(expected_version, model.version)?;
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut active: article_tag::ActiveModel = model.into();
|
||||||
|
active.name = Set(req.name);
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
let m = active.update(&state.db).await?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, operator_id, "article_tag.updated", "article_tag")
|
||||||
|
.with_resource_id(m.id),
|
||||||
|
&state.db,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok(TagResp {
|
||||||
|
id: m.id, name: m.name,
|
||||||
|
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete_tag(
|
pub async fn delete_tag(
|
||||||
state: &HealthState,
|
state: &HealthState,
|
||||||
tenant_id: Uuid,
|
tenant_id: Uuid,
|
||||||
|
|||||||
@@ -645,6 +645,89 @@ pub async fn create_product(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_product(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
product_id: Uuid,
|
||||||
|
operator_id: Option<Uuid>,
|
||||||
|
req: UpdatePointsProductReq,
|
||||||
|
expected_version: i32,
|
||||||
|
) -> HealthResult<PointsProductResp> {
|
||||||
|
let model = points_product::Entity::find()
|
||||||
|
.filter(points_product::Column::Id.eq(product_id))
|
||||||
|
.filter(points_product::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(points_product::Column::DeletedAt.is_null())
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(HealthError::PointsProductNotFound)?;
|
||||||
|
|
||||||
|
let next_ver = check_version(expected_version, model.version)?;
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut active: points_product::ActiveModel = model.into();
|
||||||
|
if let Some(name) = req.name { active.name = Set(name); }
|
||||||
|
if let Some(product_type) = req.product_type { active.product_type = Set(product_type); }
|
||||||
|
if let Some(points_cost) = req.points_cost { active.points_cost = Set(points_cost); }
|
||||||
|
if let Some(stock) = req.stock { active.stock = Set(stock); }
|
||||||
|
if let Some(image_url) = req.image_url { active.image_url = Set(Some(image_url)); }
|
||||||
|
if let Some(description) = req.description { active.description = Set(Some(description)); }
|
||||||
|
if let Some(service_config) = req.service_config { active.service_config = Set(Some(service_config)); }
|
||||||
|
if let Some(is_active) = req.is_active { active.is_active = Set(is_active); }
|
||||||
|
if let Some(sort_order) = req.sort_order { active.sort_order = Set(sort_order); }
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
let m = active.update(&state.db).await?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, operator_id, "points_product.updated", "points_product")
|
||||||
|
.with_resource_id(m.id),
|
||||||
|
&state.db,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok(PointsProductResp {
|
||||||
|
id: m.id, name: m.name, product_type: m.product_type,
|
||||||
|
points_cost: m.points_cost, stock: m.stock,
|
||||||
|
image_url: m.image_url, description: m.description,
|
||||||
|
is_active: m.is_active, sort_order: m.sort_order,
|
||||||
|
created_at: m.created_at, updated_at: m.updated_at, version: m.version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_product(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
product_id: Uuid,
|
||||||
|
operator_id: Option<Uuid>,
|
||||||
|
expected_version: i32,
|
||||||
|
) -> HealthResult<()> {
|
||||||
|
let model = points_product::Entity::find()
|
||||||
|
.filter(points_product::Column::Id.eq(product_id))
|
||||||
|
.filter(points_product::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(points_product::Column::DeletedAt.is_null())
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(HealthError::PointsProductNotFound)?;
|
||||||
|
|
||||||
|
let _next_ver = check_version(expected_version, model.version)?;
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut active: points_product::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(now));
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(active.version.unwrap() + 1);
|
||||||
|
let m = active.update(&state.db).await?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, operator_id, "points_product.deleted", "points_product")
|
||||||
|
.with_resource_id(m.id),
|
||||||
|
&state.db,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 兑换(FIFO 消费积分)
|
// 兑换(FIFO 消费积分)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -1055,6 +1138,89 @@ pub async fn create_rule(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn update_rule(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
rule_id: Uuid,
|
||||||
|
operator_id: Option<Uuid>,
|
||||||
|
req: UpdatePointsRuleReq,
|
||||||
|
expected_version: i32,
|
||||||
|
) -> HealthResult<PointsRuleResp> {
|
||||||
|
let model = points_rule::Entity::find()
|
||||||
|
.filter(points_rule::Column::Id.eq(rule_id))
|
||||||
|
.filter(points_rule::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(points_rule::Column::DeletedAt.is_null())
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(HealthError::PointsRuleNotFound)?;
|
||||||
|
|
||||||
|
let next_ver = check_version(expected_version, model.version)?;
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut active: points_rule::ActiveModel = model.into();
|
||||||
|
if let Some(name) = req.name { active.name = Set(name); }
|
||||||
|
if let Some(description) = req.description { active.description = Set(Some(description)); }
|
||||||
|
if let Some(points_value) = req.points_value { active.points_value = Set(points_value); }
|
||||||
|
if let Some(daily_cap) = req.daily_cap { active.daily_cap = Set(daily_cap); }
|
||||||
|
if let Some(streak_7d_bonus) = req.streak_7d_bonus { active.streak_7d_bonus = Set(streak_7d_bonus); }
|
||||||
|
if let Some(streak_14d_bonus) = req.streak_14d_bonus { active.streak_14d_bonus = Set(streak_14d_bonus); }
|
||||||
|
if let Some(streak_30d_bonus) = req.streak_30d_bonus { active.streak_30d_bonus = Set(streak_30d_bonus); }
|
||||||
|
if let Some(is_active) = req.is_active { active.is_active = Set(is_active); }
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(next_ver);
|
||||||
|
let m = active.update(&state.db).await?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, operator_id, "points_rule.updated", "points_rule")
|
||||||
|
.with_resource_id(m.id),
|
||||||
|
&state.db,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok(PointsRuleResp {
|
||||||
|
id: m.id, event_type: m.event_type, name: m.name,
|
||||||
|
description: m.description, points_value: m.points_value,
|
||||||
|
daily_cap: m.daily_cap, streak_7d_bonus: m.streak_7d_bonus,
|
||||||
|
streak_14d_bonus: m.streak_14d_bonus, streak_30d_bonus: m.streak_30d_bonus,
|
||||||
|
is_active: m.is_active, created_at: m.created_at,
|
||||||
|
updated_at: m.updated_at, version: m.version,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_rule(
|
||||||
|
state: &HealthState,
|
||||||
|
tenant_id: Uuid,
|
||||||
|
rule_id: Uuid,
|
||||||
|
operator_id: Option<Uuid>,
|
||||||
|
expected_version: i32,
|
||||||
|
) -> HealthResult<()> {
|
||||||
|
let model = points_rule::Entity::find()
|
||||||
|
.filter(points_rule::Column::Id.eq(rule_id))
|
||||||
|
.filter(points_rule::Column::TenantId.eq(tenant_id))
|
||||||
|
.filter(points_rule::Column::DeletedAt.is_null())
|
||||||
|
.one(&state.db)
|
||||||
|
.await?
|
||||||
|
.ok_or(HealthError::PointsRuleNotFound)?;
|
||||||
|
|
||||||
|
let _next_ver = check_version(expected_version, model.version)?;
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
let mut active: points_rule::ActiveModel = model.into();
|
||||||
|
active.deleted_at = Set(Some(now));
|
||||||
|
active.updated_at = Set(now);
|
||||||
|
active.updated_by = Set(operator_id);
|
||||||
|
active.version = Set(active.version.unwrap() + 1);
|
||||||
|
let m = active.update(&state.db).await?;
|
||||||
|
|
||||||
|
audit_service::record(
|
||||||
|
AuditLog::new(tenant_id, operator_id, "points_rule.deleted", "points_rule")
|
||||||
|
.with_resource_id(m.id),
|
||||||
|
&state.db,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 线下活动
|
// 线下活动
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user