feat(admin): merge stash changes into comclib-analytics (order/finance/product + rpc sql)

This commit is contained in:
comlibmb
2026-02-10 18:49:21 +08:00
parent bf394eb65d
commit 80e5a1ddeb
23 changed files with 1599 additions and 143 deletions

View File

@@ -0,0 +1,50 @@
# 操作文档Admin 商品模块标准化实施
- **日期**2026-02-06
- **作用域**`admin` / `product`
- **实施人**Cascade (AI Assistant)
## 1. 摘要
按照 `AGENT_PROJECT_SPEC.md` 规范,完成了 Admin 商品模块从数据库 RPC 到 Service 层,再到前端页面的全链路标准化改造。
## 2. 动机
- 统一商品模块数据访问口径,消除页面 Mock 数据。
- 增强数据库安全性,所有特权操作均通过 `SECURITY DEFINER` RPC 并包含角色校验。
- 修复分类层级变动时 `path``level` 字段不同步的潜在风险。
## 3. 影响范围
- **数据库**:新增/更新了 `rpc_admin_product_*``rpc_admin_category_*` 系列函数。
- **服务层**:新增 `services/admin/productService.uts``services/admin/productCategoryService.uts`
- **前端页面**:重构了 `product-management/index.uvue``classification/index.uvue`
## 4. 变更清单
### 4.1 数据库 RPC (docs/sql/30_rpc/product/)
- `rpc_admin_product_list_v1.sql`: 标准化分页查询,对齐 `JSONB` 返回结构。
- `rpc_admin_product_update_status_v1.sql`: 统一处理上下架与回收站逻辑。
- `rpc_admin_category_list_v1.sql`: 适配 `ml_categories` 权威字段。
- `rpc_admin_category_create_v1.sql`: 自动维护层级路径。
- `rpc_admin_category_update_v1.sql`: **核心增强**,支持子树 `path``level` 的级联更新,并具备递归防循环引用校验。
- `rpc_admin_category_delete_v1.sql`: 实现“有子项禁止删除”的安全策略。
### 4.2 服务层 (services/admin/)
- `productService.uts`: 封装商品列表与状态变更接口。
- `productCategoryService.uts`: 封装分类列表与 CRUD 接口。
### 4.3 前端重构
- **商品管理**:接入真实数据流,支持按名称、状态搜索,支持实时上下架切换。
- **商品分类**:接入真实树形数据,支持完整的 CRUD 操作与状态开关。
## 5. 安全与权限验证
- **RPC 安全**:所有函数均声明为 `SECURITY DEFINER`,并固定 `search_path = public`
- **角色守卫**:函数入口显式校验 `role IN ('admin', 'analytics')`
- **数据隔离**:仅返回 UI 渲染必要的最小字段集。
## 6. 回滚方案
- **SQL**:执行 `DROP FUNCTION IF EXISTS public.rpc_admin_...`
- **代码**:通过 Git 回退 `pages/mall/admin/product/` 相关目录的变更。
## 7. 验证方式
1. 登录 Admin 账号,进入“商品管理”,验证列表分页与搜索是否正常。
2. 切换商品“上架/下架”开关,刷新页面确认状态持久化。
3. 进入“商品分类”,尝试添加子分类并移动其父级,通过数据库查询确认其 `path` 已级联修正。

View File

@@ -41,7 +41,7 @@
<view class="icon-box blue"><text class="icon">🕒</text></view>
<view class="item-info">
<text class="item-label">营业额</text>
<text class="item-value">442753.70</text>
<text class="item-value">{{ stats.revenue }}</text>
<view class="trend-row">
<text class="trend-label">环比增长:</text>
<text class="trend-value up">44275370% ▲</text>
@@ -52,7 +52,7 @@
<view class="icon-box green"><text class="icon">¥</text></view>
<view class="item-info">
<text class="item-label">商品支付金额</text>
<text class="item-value">434693.52</text>
<text class="item-value">{{ stats.payAmount }}</text>
<view class="trend-row">
<text class="trend-label">环比增长:</text>
<text class="trend-value up">43469352% ▲</text>
@@ -63,7 +63,7 @@
<view class="icon-box orange"><text class="icon">🔒</text></view>
<view class="item-info">
<text class="item-label">购买会员金额</text>
<text class="item-value">8059.18</text>
<text class="item-value">{{ stats.memberAmount }}</text>
<view class="trend-row">
<text class="trend-label">环比增长:</text>
<text class="trend-value up">805918% ▲</text>
@@ -74,7 +74,7 @@
<view class="icon-box purple"><text class="icon">💰</text></view>
<view class="item-info">
<text class="item-label">充值金额</text>
<text class="item-value">0.00</text>
<text class="item-value">{{ stats.rechargeAmount }}</text>
<view class="trend-row">
<text class="trend-label">环比增长:</text>
<text class="trend-value">0% -</text>
@@ -85,7 +85,7 @@
<view class="icon-box cyan"><text class="icon">🛒</text></view>
<view class="item-info">
<text class="item-label">线下收银金额</text>
<text class="item-value">1</text>
<text class="item-value">{{ stats.offlineAmount }}</text>
<view class="trend-row">
<text class="trend-label">环比增长:</text>
<text class="trend-value up">100% ▲</text>
@@ -100,7 +100,7 @@
<view class="icon-box light-green"><text class="icon">↘</text></view>
<view class="item-info">
<text class="item-label">支出金额</text>
<text class="item-value">442752.69</text>
<text class="item-value">{{ stats.expenditure }}</text>
<view class="trend-row">
<text class="trend-label">环比增长:</text>
<text class="trend-value up">44275269% ▲</text>
@@ -111,7 +111,7 @@
<view class="icon-box gold"><text class="icon">💳</text></view>
<view class="item-info">
<text class="item-label">余额支付金额</text>
<text class="item-value">442752.69</text>
<text class="item-value">{{ stats.balancePay }}</text>
<view class="trend-row">
<text class="trend-label">环比增长:</text>
<text class="trend-value up">5293.00% ▲</text>
@@ -122,7 +122,7 @@
<view class="icon-box red-purple"><text class="icon"></text></view>
<view class="item-info">
<text class="item-label">支付佣金金额</text>
<text class="item-value">0.00</text>
<text class="item-value">{{ stats.commissionPay }}</text>
<view class="trend-row">
<text class="trend-label">环比增长:</text>
<text class="trend-value">0% -</text>
@@ -133,7 +133,7 @@
<view class="icon-box blue-gray"><text class="icon">📦</text></view>
<view class="item-info">
<text class="item-label">商品退款金额</text>
<text class="item-value">0.00</text>
<text class="item-value">{{ stats.refundAmount }}</text>
<view class="trend-row">
<text class="trend-label">环比增长:</text>
<text class="trend-value">0% -</text>
@@ -281,42 +281,69 @@
</template>
<script setup lang="uts">
import { ref, onMounted } from 'vue'
import { ref, onMounted, reactive } from 'vue'
import EChartsView from '@/uni_modules/charts/EChartsView.vue'
import { fetchFinanceOverview } from '@/services/admin/financeService.uts'
import { rpcOrNull } from '@/services/analytics/rpc.uts'
const dateOptions = ['今天', '昨天', '最近7天', '最近30天', '本月', '本年']
const activeDateTab = ref(0)
// 响应式统计数据
const stats = reactive({
revenue: '0.00', // 营业额
payAmount: '0.00', // 商品支付金额
memberAmount: '0.00', // 购买会员金额
rechargeAmount: '0.00', // 充值金额
offlineAmount: '0.00', // 线下收银金额
expenditure: '0.00', // 支出金额
balancePay: '0.00', // 余额支付金额
commissionPay: '0.00', // 支付佣金金额
refundAmount: '0.00', // 商品退款金额
// 环比数据 (示例暂留)
revenueTrend: '0%',
rechargeTrend: '0%'
})
const orderAmountOption = ref<any>(null)
const overviewTrendOption = ref<any>(null)
/**
* 工具函数:将 UTS 对象转换为纯 JavaScript 对象
* 确保 ECharts 在 renderjs 中能正确接收数据
* 加载统计数据
*/
function toPlainObject(obj : any) : any {
if (obj == null) return null
if (typeof obj !== 'object') return obj
if (Array.isArray(obj)) {
return (obj as Array<any>).map((item : any) : any => toPlainObject(item))
}
const plain : Record<string, any> = {}
const keys = Object.keys(obj as Record<string, any>)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
if (key.startsWith('_') || key == 'toJSON') continue
const value = (obj as Record<string, any>)[key]
if (typeof value == 'function') continue
if (value != null && typeof value == 'object' && !Array.isArray(value)) {
plain[key] = toPlainObject(value)
} else {
plain[key] = value
async function loadData() {
// TODO: 根据 activeDateTab 计算具体的 startTime 和 endTime
const startTime = '2026-01-01T00:00:00Z'
const endTime = '2026-12-31T23:59:59Z'
try {
// 1. 获取财务概况 (充值、提现等)
const financeRes = await fetchFinanceOverview(startTime, endTime)
if (financeRes != null) {
stats.rechargeAmount = financeRes.recharge_amount.toFixed(2)
// 支出金额暂以提现成功金额为例
stats.expenditure = financeRes.extract_amount.toFixed(2)
}
// 2. 获取订单统计 (营业额、退款等)
const orderRes = await rpcOrNull('rpc_admin_order_stats', {
p_start_time: startTime,
p_end_time: endTime
} as UTSJSONObject)
if (orderRes != null) {
stats.revenue = ((orderRes as any).total_amount ?? 0).toFixed(2)
stats.payAmount = stats.revenue // 简单处理
stats.refundAmount = ((orderRes as any).refund_amount ?? 0).toFixed(2)
}
} catch (e) {
console.error('Failed to load transaction stats:', e)
}
return plain
}
onMounted(() => {
loadData()
// 延迟初始化图表确保容器就位
setTimeout(() => {
initCharts()

View File

@@ -20,7 +20,7 @@
<view class="custom-icon icon-order"></view>
</view>
<view class="stat-info">
<text class="stat-value">209</text>
<text class="stat-value">{{ orderStats?.order_count ?? 0 }}</text>
<text class="stat-desc">订单量</text>
</view>
</view>
@@ -31,7 +31,7 @@
<view class="custom-icon icon-money"></view>
</view>
<view class="stat-info">
<text class="stat-value">443254.62</text>
<text class="stat-value">{{ orderStats?.total_amount?.toFixed(2) ?? '0.00' }}</text>
<text class="stat-desc">订单销售额</text>
</view>
</view>
@@ -42,7 +42,7 @@
<view class="custom-icon icon-refund"></view>
</view>
<view class="stat-info">
<text class="stat-value">0</text>
<text class="stat-value">{{ orderStats?.refund_count ?? 0 }}</text>
<text class="stat-desc">退款订单数</text>
</view>
</view>
@@ -53,7 +53,7 @@
<view class="custom-icon icon-refund-money"></view>
</view>
<view class="stat-info">
<text class="stat-value">0</text>
<text class="stat-value">{{ orderStats?.refund_amount?.toFixed(2) ?? '0.00' }}</text>
<text class="stat-desc">退款金额</text>
</view>
</view>

View File

@@ -149,59 +149,99 @@
</template>
<script setup lang="uts">
import { ref, reactive } from 'vue'
import { ref, reactive, onMounted } from 'vue'
import {
fetchAdminCategoryList,
createAdminCategory,
updateAdminCategory,
deleteAdminCategory,
type AdminCategory
} from '@/services/admin/productCategoryService.uts'
interface CateItem {
id: number;
id: string;
name: string;
icon: string;
sort: number;
status: boolean;
expanded?: boolean;
children?: CateItem[];
parentId?: number;
parentId?: string | null;
}
const list = reactive<CateItem[]>([
{
id: 100, name: '家用电器', icon: '/static/logo.png', sort: 1, status: true, expanded: true,
children: [
{ id: 101, name: '电视机', icon: '/static/logo.png', sort: 1, status: true, parentId: 100 },
{ id: 102, name: '电冰箱', icon: '/static/logo.png', sort: 2, status: true, parentId: 100 }
]
},
{
id: 200, name: '手机数码', icon: '/static/logo.png', sort: 2, status: true, expanded: false,
children: [
{ id: 201, name: '手机', icon: '/static/logo.png', sort: 1, status: true, parentId: 200 },
{ id: 202, name: '耳机', icon: '/static/logo.png', sort: 2, status: true, parentId: 200 }
]
}
])
const list = ref<Array<CateItem>>([])
const showDrawerMask = ref(false)
const showDrawer = ref(false)
const isEdit = ref(false)
const editingId = ref<string | null>(null)
const form = reactive({
name: '',
parentId: null as string | null,
parentName: '',
sort: 0,
status: true
})
onMounted(() => {
loadList()
})
function buildTree(items: Array<AdminCategory>): Array<CateItem> {
const map: Record<string, CateItem> = {}
const roots: Array<CateItem> = []
for (let i = 0; i < items.length; i++) {
const c = items[i]
map[c.id] = {
id: c.id,
name: c.name,
icon: c.icon ?? '',
sort: c.sort ?? 0,
status: c.is_active === true,
expanded: false,
children: [],
parentId: c.parent_id ?? null
}
}
const ids = Object.keys(map)
for (let i = 0; i < ids.length; i++) {
const id = ids[i]
const node = map[id]
if (node.parentId != null && map[node.parentId] != null) {
map[node.parentId].children = map[node.parentId].children ?? []
map[node.parentId].children!.push(node)
} else {
roots.push(node)
}
}
return roots
}
async function loadList() {
const items = await fetchAdminCategoryList({})
list.value = buildTree(items)
}
function openDrawer(item: CateItem | null = null) {
if (item != null) {
isEdit.value = true
editingId.value = item.id
form.name = item.name
form.sort = item.sort
form.status = item.status
form.parentId = item.parentId ?? null
form.parentName = item.parentId != null ? '子分类' : '顶级分类'
} else {
isEdit.value = false
editingId.value = null
form.name = ''
form.sort = 0
form.status = true
form.parentId = null
form.parentName = '顶级分类'
}
showDrawerMask.value = true
@@ -217,22 +257,49 @@ showDrawerMask.value = false
}, 300)
}
function saveCate() {
async function saveCate() {
if (isEdit.value && editingId.value != null) {
await updateAdminCategory({
id: editingId.value,
parentId: form.parentId,
name: form.name,
sortOrder: form.sort,
isActive: form.status
})
} else {
await createAdminCategory({
parentId: form.parentId,
name: form.name,
sortOrder: form.sort,
isActive: form.status
})
}
uni.showToast({ title: '保存成功', icon: 'success' })
closeDrawer()
loadList()
}
function toggleStatus(item: CateItem) {
async function toggleStatus(item: CateItem) {
await updateAdminCategory({
id: item.id,
parentId: item.parentId,
name: item.name,
sortOrder: item.sort,
isActive: !item.status
})
item.status = !item.status
}
function deleteItem(item: CateItem) {
async function deleteItem(item: CateItem) {
uni.showModal({
title: '提示',
content: '确定删除该分类吗?',
success: (res) => {
success: async (res) => {
if (res.confirm) {
uni.showToast({ title: '已模拟删除', icon: 'none' })
await deleteAdminCategory(item.id)
uni.showToast({ title: '删除成功', icon: 'success' })
loadList()
}
}
})

View File

@@ -160,91 +160,97 @@
</template>
<script setup lang="uts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { openRoute } from '@/layouts/admin/store/adminNavStore.uts'
import { fetchAdminProductPage, updateAdminProductStatus, type AdminProduct } from '@/services/admin/productService.uts'
const total = ref(49)
const activeStatus = ref('selling')
const activeDropdownId = ref<number | null>(null)
const total = ref(0)
const page = ref(1)
const pageSize = ref(10)
const activeStatus = ref<number | null>(1) // 1:出售中
const activeDropdownId = ref<string | null>(null)
const productList = ref<Array<AdminProduct>>([])
const searchName = ref('')
const statusTabs = ref([
{ key: 'selling', label: '出售中的商品', count: 49 },
{ key: 'warehouse', label: '仓库中的商品', count: 4 },
{ key: 'soldout', label: '已经售罄商品', count: 11 },
{ key: 'alarm', label: '警戒库存商品', count: 27 },
{ key: 'recycle', label: '回收站的商品', count: 176 },
{ key: 1, label: '出售中的商品', count: 0 },
{ key: 2, label: '仓库中的商品', count: 0 },
{ key: 3, label: '草稿箱', count: 0 },
{ key: 4, label: '回收站', count: 0 },
])
const productList = ref([
{
id: 963,
image: 'https://img1.baidu.com/it/u=254065646,3100346083&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
name: 'UR2024夏季新款女装复古纯欲氛围感一字肩短款T恤衫UWG440060',
activities: ['kj', 'pt'],
typeName: '普通商品',
price: '0.01',
sales: 639,
stock: 1602,
sort: 9999,
status: 1
},
{
id: 108,
image: 'https://img2.baidu.com/it/u=3033501986,2204481084&fm=253&fmt=auto&app=138&f=JPEG?w=569&h=500',
name: 'FOMIX 蛋壳椅 进口头层牛皮橙色单人沙发椅Egg chair设计师师单椅单沙头层牛皮/单椅',
activities: ['pt', 'ms'],
typeName: '普通商品',
price: '7580.00',
sales: 14,
stock: 16638,
sort: 9999,
status: 1
},
{
id: 48,
image: 'https://img0.baidu.com/it/u=1762118431,3101886131&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=500',
name: '阿迪达斯官网 adidas BBALL CAP COT 男女训练运动帽子FQ5270 传奇墨水蓝/传奇墨水蓝/白 XL',
activities: ['kj', 'pt', 'ms'],
typeName: '普通商品',
price: '100.00',
sales: 841,
stock: 2318,
sort: 9998,
status: 1
}
])
onMounted(() => {
loadData()
})
function getActivityName(tag: string): string {
if (tag === 'kj') return '砍价'
if (tag === 'pt') return '拼团'
if (tag === 'ms') return '秒杀'
return tag
async function loadData() {
const res = await fetchAdminProductPage(page.value, pageSize.value, {
name: searchName.value,
status: activeStatus.value ?? undefined
})
productList.value = res.items
total.value = res.total
}
function goEdit(id: number | null) {
openRoute('product_edit')
function handleSearch() {
page.value = 1
loadData()
}
function goReviews(id: number) {
openRoute('product_productReply')
function handleReset() {
searchName.value = ''
page.value = 1
loadData()
}
function goMemberPrice(id: number) {
openRoute('product_member_price')
async function toggleStatus(item: AdminProduct) {
const newStatus = item.status === 1 ? 2 : 1
const ok = await updateAdminProductStatus(item.id, newStatus)
if (ok) {
uni.showToast({ title: '操作成功', icon: 'success' })
loadData()
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
function moveToRecycle(id: number) {
const action = activeStatus.value === 'recycle' ? '恢复' : '移到回收站';
async function moveToRecycle(item: AdminProduct) {
const isDelete = activeStatus.value !== 4
const targetStatus = isDelete ? 4 : 2 // 移到回收站或恢复到下架
const action = isDelete ? '移到回收站' : '恢复'
uni.showModal({
title: '提示',
content: `确认要将该商品${action}吗?`,
success: (res) => {
success: async (res) => {
if (res.confirm) {
uni.showToast({ title: '操作成功', icon: 'success' });
const ok = await updateAdminProductStatus(item.id, targetStatus)
if (ok) {
uni.showToast({ title: '操作成功', icon: 'success' })
loadData()
}
}
}
})
}
function changeStatus(key: number) {
activeStatus.value = key
page.value = 1
loadData()
}
function goEdit(id: string | null) {
openRoute('product_edit')
}
function goReviews(id: string) {
openRoute('product_productReply')
}
function goMemberPrice(id: string) {
openRoute('product_member_price')
}
</script>
<style scoped lang="scss">