系统管理新增订单管理页面,支持一键锁定所有已完成订单(已收件/丢件/退件)

This commit is contained in:
2026-08-13 12:52:21 +08:00
parent b53195daf9
commit cddc1e6ea2
6 files changed
+365

No files matched your search

+33
View File
@@ -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 {
+5
View File
@@ -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 })
+5
View File
@@ -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",
+5
View File
@@ -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>