系统管理新增订单管理页面,支持一键锁定所有已完成订单(已收件/丢件/退件)
This commit is contained in:
6 files changed
+365
No files matched your search
@@ -812,6 +812,39 @@ func ApiPurchase(r *gin.RouterGroup) {
|
||||
ReturnJson(ctx, "apiOK", nil)
|
||||
})
|
||||
|
||||
// 一键锁定所有已完成订单(已收件/丢件/退件)
|
||||
r.POST("/lock_all_completed", func(ctx *gin.Context) {
|
||||
isAuth, user, _ := AuthenticationAuthority(ctx)
|
||||
if !isAuth {
|
||||
ReturnJson(ctx, "userCookieError", nil)
|
||||
return
|
||||
}
|
||||
|
||||
if !canUnlockPurchase(user.ID) {
|
||||
ReturnJson(ctx, "no_permission", nil)
|
||||
return
|
||||
}
|
||||
|
||||
result := models.DB.Model(&TabPurchaseOrder{}).
|
||||
Where("order_status IN ? AND locked = ? AND deleted_at IS NULL",
|
||||
[]string{"received", "lost", "returned"}, false).
|
||||
Update("locked", true)
|
||||
|
||||
lockedCount := result.RowsAffected
|
||||
|
||||
tosqllog := TabPurchaseLog{
|
||||
UserID: user.ID,
|
||||
ActionType: "lock_all",
|
||||
IP: ctx.ClientIP(),
|
||||
Remark: parsefmt.Sprintf("一键锁定所有已完成订单,共 %d 个", lockedCount),
|
||||
}
|
||||
models.DB.Create(&tosqllog)
|
||||
|
||||
ReturnJson(ctx, "apiOK", gin.H{
|
||||
"locked_count": lockedCount,
|
||||
})
|
||||
})
|
||||
|
||||
r.POST("/getorders", func(ctx *gin.Context) {
|
||||
isAuth, _, data := AuthenticationAuthority(ctx)
|
||||
if isAuth {
|
||||
|
||||
@@ -51,6 +51,11 @@ export const purchaseApi = {
|
||||
return api.post('/purchase/unlock', { id })
|
||||
},
|
||||
|
||||
/** 一键锁定所有已完成订单(管理员) */
|
||||
lockAllCompleted() {
|
||||
return api.post('/purchase/lock_all_completed', {})
|
||||
},
|
||||
|
||||
/** 搜索工单(用于采购订单关联) */
|
||||
searchWorkOrders(search = '', limit = 5) {
|
||||
return api.post('/purchase/search_work_orders', { search, limit })
|
||||
|
||||
@@ -607,6 +607,11 @@
|
||||
"tab_users": "User Management",
|
||||
"tab_groups": "User Groups",
|
||||
"tab_logs": "Login Logs",
|
||||
"tab_purchase_orders": "Purchase Orders",
|
||||
"lock_all_completed": "Lock All Completed Orders",
|
||||
"lock_all_confirm": "Lock all unlocked orders with received/lost/returned status?",
|
||||
"lock_all_success": "Locked {count} orders",
|
||||
"lock_all_none": "No completed orders to lock",
|
||||
"total_users": "Total {count} users",
|
||||
"search_placeholder": "Search username or email...",
|
||||
"search": "Search",
|
||||
|
||||
@@ -607,6 +607,11 @@
|
||||
"tab_users": "用户管理",
|
||||
"tab_groups": "用户组",
|
||||
"tab_logs": "登录日志",
|
||||
"tab_purchase_orders": "订单管理",
|
||||
"lock_all_completed": "一键锁定已完成订单",
|
||||
"lock_all_confirm": "确定要锁定所有已收件/丢件/退件状态的未锁定订单吗?",
|
||||
"lock_all_success": "已锁定 {count} 个订单",
|
||||
"lock_all_none": "没有需要锁定的已完成订单",
|
||||
"total_users": "共 {count} 位用户",
|
||||
"search_placeholder": "搜索用户名或邮箱...",
|
||||
"search": "搜索",
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
<script setup>
|
||||
import { ref, computed, onActivated } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { purchaseApi } from '@/api/purchase'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import {
|
||||
IconLock,
|
||||
IconLockOpen,
|
||||
IconRefresh,
|
||||
IconChevronLeftPipe,
|
||||
IconChevronRightPipe,
|
||||
IconChevronsLeft,
|
||||
IconChevronsRight,
|
||||
} from '@tabler/icons-vue'
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
const toast = useToastStore()
|
||||
|
||||
const orders = ref([])
|
||||
const totalCount = ref(0)
|
||||
const pageSize = ref(20)
|
||||
const currentPage = ref(1)
|
||||
const statusFilter = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const showLockAllConfirm = ref(false)
|
||||
const lockAllLoading = ref(false)
|
||||
|
||||
const totalPages = computed(() => Math.ceil(totalCount.value / pageSize.value) || 1)
|
||||
|
||||
const pageRange = computed(() => {
|
||||
const total = totalPages.value
|
||||
const cur = currentPage.value
|
||||
let start = Math.max(1, cur - 2)
|
||||
let end = Math.min(cur + 4, total)
|
||||
if (end - start < 4) start = Math.max(1, end - 4)
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ value: '', labelKey: 'purchase.filter_all' },
|
||||
{ value: 'pending', labelKey: 'purchase.status_pending' },
|
||||
{ value: 'ordered', labelKey: 'purchase.status_ordered' },
|
||||
{ value: 'arrived', labelKey: 'purchase.status_arrived' },
|
||||
{ value: 'received', labelKey: 'purchase.status_received' },
|
||||
{ value: 'lost', labelKey: 'purchase.status_lost' },
|
||||
{ value: 'returned', labelKey: 'purchase.status_returned' },
|
||||
]
|
||||
|
||||
async function fetchOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { errCode, data } = await purchaseApi.getOrders({
|
||||
status: statusFilter.value,
|
||||
entries: pageSize.value,
|
||||
page: currentPage.value,
|
||||
})
|
||||
if (errCode === 0) {
|
||||
orders.value = data.all_orders ?? []
|
||||
totalCount.value = data.all_count ?? 0
|
||||
}
|
||||
} catch {
|
||||
// handled by interceptor
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onStatusChange() {
|
||||
currentPage.value = 1
|
||||
fetchOrders()
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
if (p < 1 || p > totalPages.value) return
|
||||
currentPage.value = p
|
||||
fetchOrders()
|
||||
}
|
||||
|
||||
function handlePageSizeInput(e) {
|
||||
const val = parseInt(e.target.value)
|
||||
if (val > 0 && val <= 300) {
|
||||
pageSize.value = val
|
||||
currentPage.value = 1
|
||||
fetchOrders()
|
||||
}
|
||||
}
|
||||
|
||||
function handleJumpPageInput(e) {
|
||||
const val = parseInt(e.target.value)
|
||||
if (val >= 1 && val <= totalPages.value) {
|
||||
goToPage(val)
|
||||
}
|
||||
}
|
||||
|
||||
function jumpToOrder(id) {
|
||||
router.push(`/purchase/showorder/${id}`)
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
const d = new Date(dateStr)
|
||||
if (isNaN(d.getTime())) return '-'
|
||||
return new Intl.DateTimeFormat(locale.value, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}).format(d)
|
||||
}
|
||||
|
||||
async function confirmLockAll() {
|
||||
lockAllLoading.value = true
|
||||
try {
|
||||
const { errCode, data } = await purchaseApi.lockAllCompleted()
|
||||
if (errCode === 0) {
|
||||
const count = data?.locked_count ?? 0
|
||||
if (count > 0) {
|
||||
toast.success(t('sysadmin.lock_all_success', { count }))
|
||||
} else {
|
||||
toast.info(t('sysadmin.lock_all_none'))
|
||||
}
|
||||
await fetchOrders()
|
||||
} else {
|
||||
toast.error(t('message.server_error'))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('message.server_error'))
|
||||
} finally {
|
||||
lockAllLoading.value = false
|
||||
showLockAllConfirm.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ fetchOrders })
|
||||
|
||||
onActivated(() => fetchOrders())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<!-- 顶部操作栏 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-dk-text">
|
||||
{{ t('sysadmin.tab_purchase_orders') }}
|
||||
</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-500 dark:text-dk-subtle">
|
||||
{{ t('purchase.There_are_a_total_of') }} {{ totalCount }} {{ t('purchase.items') }}
|
||||
</span>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md border border-gray-300 px-3 py-1.5 text-sm text-gray-600 transition-colors hover:bg-gray-50 dark:border-dk-muted dark:text-dk-subtle dark:hover:bg-dk-card"
|
||||
:disabled="loading"
|
||||
@click="fetchOrders"
|
||||
>
|
||||
<IconRefresh :size="16" :class="{ 'animate-spin': loading }" />
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-1.5 rounded-md bg-orange-500 px-4 py-1.5 text-sm font-medium text-white transition-colors hover:bg-orange-600 disabled:opacity-50"
|
||||
:disabled="lockAllLoading"
|
||||
@click="showLockAllConfirm = true"
|
||||
>
|
||||
<IconLock :size="16" />
|
||||
{{ t('sysadmin.lock_all_completed') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态筛选 -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="opt in statusOptions"
|
||||
:key="opt.value"
|
||||
class="rounded-full border px-3 py-1 text-xs font-medium transition-all"
|
||||
:class="
|
||||
statusFilter === opt.value
|
||||
? 'border-blue-500 bg-blue-500 text-white'
|
||||
: 'border-gray-200 text-gray-500 hover:border-gray-300 hover:bg-gray-50 dark:border-dk-muted dark:text-dk-subtle dark:hover:bg-dk-card'
|
||||
"
|
||||
@click="statusFilter = opt.value; onStatusChange()"
|
||||
>
|
||||
{{ t('purchase.' + opt.labelKey) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 订单表格 -->
|
||||
<div class="overflow-hidden rounded-md border border-gray-200 dark:border-dk-muted">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-dk-muted">
|
||||
<thead class="bg-gray-50 dark:bg-dk-base">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">ID</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('purchase_addorder.part_name') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('purchase.status') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('purchase.created_at') }}</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dk-subtle">{{ t('purchase.updated_at') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dk-muted dark:bg-dk-card">
|
||||
<tr v-if="loading" class="text-center">
|
||||
<td colspan="5" class="py-8 text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.loading') }}</td>
|
||||
</tr>
|
||||
<tr v-else-if="orders.length === 0" class="text-center">
|
||||
<td colspan="5" class="py-8 text-gray-500 dark:text-dk-subtle">{{ t('sysadmin.no_data') }}</td>
|
||||
</tr>
|
||||
<tr
|
||||
v-for="order in orders"
|
||||
:key="order.ID"
|
||||
class="cursor-pointer transition-colors hover:bg-blue-50/50 dark:hover:bg-dk-base/50"
|
||||
@click="jumpToOrder(order.ID)"
|
||||
>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-400">{{ order.ID }}</td>
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900 dark:text-dk-text max-w-[200px] truncate">{{ order.Title || '-' }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold"
|
||||
:class="{
|
||||
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-400': order.OrderStatus === 'pending',
|
||||
'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400': order.OrderStatus === 'ordered',
|
||||
'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-400': order.OrderStatus === 'arrived',
|
||||
'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400': order.OrderStatus === 'received',
|
||||
'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400': order.OrderStatus === 'lost',
|
||||
'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300': order.OrderStatus === 'returned',
|
||||
}"
|
||||
>
|
||||
{{ t('purchase.status_' + order.OrderStatus) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="order.Locked"
|
||||
class="ml-1 inline-flex items-center text-red-500 dark:text-red-400"
|
||||
:title="t('purchase.locked')"
|
||||
>
|
||||
<IconLock :size="14" />
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-dk-subtle">{{ formatDate(order.CreatedAt) }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-dk-subtle">{{ formatDate(order.UpdatedAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="flex flex-col items-center justify-between gap-3 sm:flex-row">
|
||||
<div class="flex items-center gap-1.5 text-sm text-gray-500 dark:text-dk-subtle">
|
||||
<label>{{ t('purchase.show') }}</label>
|
||||
<input
|
||||
type="text"
|
||||
class="w-14 rounded border border-gray-300 px-2 py-1 text-center text-sm text-gray-900 dark:border-dk-muted dark:bg-dk-base dark:text-white"
|
||||
:value="pageSize"
|
||||
@change="handlePageSizeInput"
|
||||
/>
|
||||
<label>{{ t('purchase.entries') }}</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="goToPage(1)"
|
||||
>
|
||||
<IconChevronsLeft :size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card"
|
||||
:disabled="currentPage <= 1"
|
||||
@click="goToPage(currentPage - 1)"
|
||||
>
|
||||
<IconChevronLeftPipe :size="16" />
|
||||
</button>
|
||||
<template v-for="a in pageRange" :key="a">
|
||||
<button
|
||||
class="min-w-[32px] rounded px-2 py-1 text-sm font-medium transition-colors"
|
||||
:class="a === currentPage ? 'bg-blue-600 text-white' : 'text-gray-600 hover:bg-gray-100 dark:text-dk-subtle dark:hover:bg-dk-card'"
|
||||
@click="goToPage(a)"
|
||||
>
|
||||
{{ a }}
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="goToPage(currentPage + 1)"
|
||||
>
|
||||
<IconChevronRightPipe :size="16" />
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 disabled:opacity-40 dark:hover:bg-dk-card"
|
||||
:disabled="currentPage >= totalPages"
|
||||
@click="goToPage(totalPages)"
|
||||
>
|
||||
<IconChevronsRight :size="16" />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
class="ml-2 w-14 rounded border border-gray-300 px-2 py-1 text-center text-sm text-gray-900 dark:border-dk-muted dark:bg-dk-base dark:text-white"
|
||||
@change="handleJumpPageInput"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 一键锁定确认弹窗 -->
|
||||
<ConfirmDialog
|
||||
v-model="showLockAllConfirm"
|
||||
:message="t('sysadmin.lock_all_confirm')"
|
||||
danger
|
||||
@confirm="confirmLockAll"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -9,6 +9,7 @@ import UsersTab from '@/views/sysadmin/UsersTab.vue'
|
||||
import GroupsTab from '@/views/sysadmin/GroupsTab.vue'
|
||||
import LogsTab from '@/views/sysadmin/LogsTab.vue'
|
||||
import OperationLogsTab from '@/views/sysadmin/OperationLogsTab.vue'
|
||||
import PurchaseOrdersTab from '@/views/sysadmin/PurchaseOrdersTab.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
@@ -24,12 +25,14 @@ const usersTabRef = ref(null)
|
||||
const groupsTabRef = ref(null)
|
||||
const logsTabRef = ref(null)
|
||||
const operationLogsTabRef = ref(null)
|
||||
const purchaseOrdersTabRef = ref(null)
|
||||
|
||||
const tabs = [
|
||||
{ id: 'users', label: t('sysadmin.tab_users') },
|
||||
{ id: 'groups', label: t('sysadmin.tab_groups') },
|
||||
{ id: 'logs', label: t('sysadmin.tab_logs') },
|
||||
{ id: 'operation_logs', label: t('sysadmin.tab_operation_logs') },
|
||||
{ id: 'purchase_orders', label: t('sysadmin.tab_purchase_orders') },
|
||||
{ id: 'customer', label: t('customer.title'), to: '/customer' },
|
||||
{ id: 'calendar', label: t('calendar.admin_title'), to: '/calendars/admin' },
|
||||
{ id: 'aiconfig', label: t('aiconfig.title'), to: '/admin/aiconfig' },
|
||||
@@ -99,6 +102,7 @@ onMounted(() => {
|
||||
<GroupsTab v-else-if="activeTab === 'groups'" ref="groupsTabRef" />
|
||||
<LogsTab v-else-if="activeTab === 'logs'" ref="logsTabRef" />
|
||||
<OperationLogsTab v-else-if="activeTab === 'operation_logs'" ref="operationLogsTabRef" />
|
||||
<PurchaseOrdersTab v-else-if="activeTab === 'purchase_orders'" ref="purchaseOrdersTabRef" />
|
||||
</KeepAlive>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user