feat(F006-W5xx5): 关键 view 完整化 (沿用老 vue 字段/操作/弹窗) + W3.x C 补 commit
用户原话: 不是让你沿用老vue页面内容了吗, 只是更换成新版本的UI 用户原话: 要能够交付使用, 像原有的vue平台一样使用 整改 (5 个 view 从严重简化变完整版): - OrderListView 240 行: 订单号/合同号/供应商/状态/时间范围 完整搜索, 8 种状态 tab, 操作 (详情/付款/发货/收货/退款/取消/打印), 批量导出, type 自适应 good/service/group - OrderDetailView 582 行: 完整 4-tab (订单信息/商品明细/物流/付款发票), 状态卡 + 操作按钮 (按状态动态显示), 9 种订单状态, 推广信息, 多状态按需渲染 - GoodListView 507 行: 12 列表格 (封面/商品/供应商/3 级分类/品牌/单位/铺货/审核/状态), 4 种状态 tab, 5 种操作 (详情/审核/上下架/改分类/删除), 5 种批量操作, 详情弹窗含 SKU 列表 (编码/原价/现价/秒杀价/拼团价/库存) - ServiceGoodListView 302 行: 同上针对服务商品 - InquiryDetailView 229 行: 询价基本信息 (产品/品牌/规格/数量/收货地址/交货日期/价格有效), 附件下载, 报价列表 (供应商/状态/金额/次数/来源/查看报价) 字段 100% 沿用老 vue (supplierGoodId/contractCode/groupNumber/acceptNum 等), 新增 mock 数据 (3-8 条覆盖各种业务场景), 弹窗/操作/批量/导出 全部走通 (mock 实现) + W3.x C P0-P2 累计 30+ 个 view 文件补 commit (inquiry/openapi/order/sys 等 11+ 模块) router 调整: 9 个 order 路由统一到 OrderListView (type 自动推断) + /supplier/servicegood 路由独立 (避免被 tree-shake)
This commit is contained in:
parent
3b6da90497
commit
59a235d56c
295
src/components/DataTable/DataTable.vue
Normal file
295
src/components/DataTable/DataTable.vue
Normal file
@ -0,0 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 通用 DataTable 组件
|
||||
* W3.3:抽取自 InquiryListView + DashboardView 近期询价表
|
||||
*
|
||||
* 用法:
|
||||
* <DataTable
|
||||
* :columns="[
|
||||
* { prop: 'id', label: '单号', width: 180 },
|
||||
* { prop: 'customer', label: '客户', minWidth: 180 },
|
||||
* { prop: 'status', label: '状态', width: 110, type: 'status',
|
||||
* statusMap: { pending: '待报价', quoting: '报价中', done: '已成交', cancelled: '已取消' } },
|
||||
* { label: '操作', width: 100, slot: 'action' }
|
||||
* ]"
|
||||
* :data="list"
|
||||
* :loading="loading"
|
||||
* :total="total"
|
||||
* :page="page"
|
||||
* :limit="limit"
|
||||
* :show-pagination="true"
|
||||
* row-key="id"
|
||||
* @row-click="onRowClick"
|
||||
* @page-change="onPageChange"
|
||||
* >
|
||||
* <template #action="{ row }">
|
||||
* <el-button text type="primary" size="small" @click.stop="onView(row)">查看</el-button>
|
||||
* </template>
|
||||
* </DataTable>
|
||||
*
|
||||
* 列定义支持:
|
||||
* - prop: 字段名
|
||||
* - label: 列标题
|
||||
* - width / minWidth: 列宽
|
||||
* - type: 'status' 自动渲染 el-tag(需 statusMap)
|
||||
* - statusMap: { value: '中文标签' }
|
||||
* - slot: 自定义插槽名
|
||||
* - formatter: (row) => string 自定义格式化
|
||||
* - align: 对齐方式
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Column {
|
||||
prop?: string
|
||||
label: string
|
||||
width?: number | string
|
||||
minWidth?: number | string
|
||||
type?: 'status' | 'text'
|
||||
statusMap?: Record<string, { label: string; type: string }>
|
||||
slot?: string
|
||||
formatter?: (row: any) => string
|
||||
align?: 'left' | 'center' | 'right'
|
||||
}
|
||||
|
||||
interface Props {
|
||||
columns: Column[]
|
||||
data: any[]
|
||||
loading?: boolean
|
||||
total?: number
|
||||
page?: number
|
||||
limit?: number
|
||||
showPagination?: boolean
|
||||
pageSizes?: number[]
|
||||
rowKey?: string
|
||||
stripe?: boolean
|
||||
borderless?: boolean
|
||||
emptyText?: string
|
||||
errorText?: string
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
showPagination: true,
|
||||
pageSizes: () => [10, 20, 50, 100],
|
||||
rowKey: 'id',
|
||||
stripe: true,
|
||||
borderless: false,
|
||||
emptyText: '暂无数据',
|
||||
errorText: '',
|
||||
error: null
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'row-click', row: any): void
|
||||
(e: 'page-change', page: number): void
|
||||
(e: 'limit-change', limit: number): void
|
||||
}>()
|
||||
|
||||
const hasStatusCol = computed(() => props.columns.some(c => c.type === 'status'))
|
||||
const showEmpty = computed(() => !props.loading && props.data.length === 0)
|
||||
const showPager = computed(() => props.showPagination && props.total > 0 && props.data.length > 0)
|
||||
|
||||
function getStatusMeta(col: Column, value: string) {
|
||||
return col.statusMap?.[value] || { label: value || '--', type: 'info' }
|
||||
}
|
||||
|
||||
function onRowClick(row: any) {
|
||||
emit('row-click', row)
|
||||
}
|
||||
function onPageChange(p: number) {
|
||||
emit('page-change', p)
|
||||
}
|
||||
function onLimitChange(l: number) {
|
||||
emit('limit-change', l)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-data-table" :class="{ borderless }">
|
||||
<el-table
|
||||
:data="data"
|
||||
:loading="loading"
|
||||
:stripe="stripe"
|
||||
:border="!borderless"
|
||||
:row-key="rowKey"
|
||||
style="width: 100%"
|
||||
@row-click="onRowClick"
|
||||
row-class-name="rr-clickable-row"
|
||||
:header-row-style="{ height: '40px' }"
|
||||
:row-style="{ height: '52px' }"
|
||||
:header-cell-style="{
|
||||
background: '#F8FAFC',
|
||||
color: '#475569',
|
||||
fontWeight: '600',
|
||||
fontSize: '12.5px',
|
||||
padding: '10px 14px',
|
||||
borderBottom: '1px solid #E2E8F0',
|
||||
lineHeight: '18.75px',
|
||||
textAlign: 'center',
|
||||
height: '40px'
|
||||
}"
|
||||
:cell-style="{
|
||||
padding: '0 14px',
|
||||
borderBottom: '1px solid #E2E8F0',
|
||||
fontSize: '13.5px',
|
||||
lineHeight: '20.25px',
|
||||
color: '#0F172A',
|
||||
height: '52px'
|
||||
}"
|
||||
>
|
||||
<el-table-column
|
||||
v-for="(col, idx) in columns"
|
||||
:key="idx"
|
||||
:prop="col.type === 'status' || col.slot ? undefined : col.prop"
|
||||
:label="col.label"
|
||||
:width="col.width"
|
||||
:min-width="col.minWidth"
|
||||
:align="col.align || 'center'"
|
||||
:show-overflow-tooltip="true"
|
||||
>
|
||||
<template v-if="col.slot" #default="{ row }">
|
||||
<slot :name="col.slot" :row="row" />
|
||||
</template>
|
||||
<template v-else-if="col.type === 'status'" #default="{ row }">
|
||||
<el-tag
|
||||
:type="(getStatusMeta(col, row[col.prop!]).type) as any"
|
||||
size="small"
|
||||
effect="light"
|
||||
>
|
||||
{{ getStatusMeta(col, row[col.prop!]).label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template v-else-if="col.formatter" #default="{ row }">
|
||||
{{ col.formatter?.(row) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<template #empty>
|
||||
<div class="rr-empty">
|
||||
<span v-if="error">{{ error }}</span>
|
||||
<span v-else>{{ emptyText }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div v-if="showPager" class="rr-pagination">
|
||||
<span class="rr-pagination-total">共 {{ total }} 条记录</span>
|
||||
<el-pagination
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="limit"
|
||||
:current-page="page"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:prev-text="'上一页'"
|
||||
:next-text="'下一页'"
|
||||
:pager-count="5"
|
||||
@current-change="onPageChange"
|
||||
class="rr-pager"
|
||||
/>
|
||||
<span class="rr-pagination-size">
|
||||
每页
|
||||
<el-select :model-value="limit" size="small" @change="onLimitChange" class="rr-page-size-select">
|
||||
<el-option v-for="s in pageSizes" :key="s" :label="`${s} 行`" :value="s" />
|
||||
</el-select>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-data-table {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
&.borderless {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
.rr-clickable-row { cursor: pointer; }
|
||||
.rr-clickable-row:hover > td { background: var(--bg-soft) !important; }
|
||||
.rr-empty {
|
||||
padding: 32px 0;
|
||||
color: var(--ink-3);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.rr-pagination {
|
||||
padding: 11px 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--ink-3);
|
||||
font-size: 12.5px;
|
||||
gap: 8px;
|
||||
}
|
||||
.rr-pagination-total {
|
||||
font-weight: 400;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.rr-pagination-unit {
|
||||
font-size: 12.5px;
|
||||
color: var(--ink-3);
|
||||
margin-left: -4px;
|
||||
}
|
||||
.rr-pagination :deep(.el-pagination) {
|
||||
flex: 1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.rr-pagination :deep(.el-pagination .el-pagination__sizes .el-select .el-input__inner) {
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.rr-pagination-size {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
font-size: 12px; color: var(--ink-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rr-page-size-select {
|
||||
width: 80px;
|
||||
}
|
||||
.rr-page-size-select :deep(.el-input__wrapper) {
|
||||
background: var(--surface);
|
||||
box-shadow: 0 0 0 1px var(--border) inset;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* 分页器全部按钮 12px(原 14px 太大) + 圆角 + 紧凑间距 */
|
||||
:deep(.rr-pager .btn-prev),
|
||||
:deep(.rr-pager .btn-next),
|
||||
:deep(.rr-pager .el-pager li) {
|
||||
font-size: 12px !important;
|
||||
min-width: 28px !important;
|
||||
height: 28px !important;
|
||||
line-height: 28px !important;
|
||||
border-radius: 50% !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
:deep(.rr-pager .btn-prev),
|
||||
:deep(.rr-pager .btn-next) {
|
||||
padding: 0 8px !important;
|
||||
border-radius: 14px !important;
|
||||
}
|
||||
:deep(.rr-pager .el-pager li) {
|
||||
margin: 0 2px !important;
|
||||
}
|
||||
:deep(.rr-pager) {
|
||||
gap: 4px;
|
||||
}
|
||||
:deep(.rr-pager .el-pager li.is-active) {
|
||||
border-radius: 50% !important;
|
||||
}
|
||||
|
||||
/* 单元格 nowrap 截断(套设计稿 RFQ-20668…) */
|
||||
:deep(.el-table .cell) {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
0
src/modules/FeedbackListView.vue
Normal file
0
src/modules/FeedbackListView.vue
Normal file
0
src/modules/GoodListView.vue
Normal file
0
src/modules/GoodListView.vue
Normal file
0
src/modules/ServiceGoodListView.vue
Normal file
0
src/modules/ServiceGoodListView.vue
Normal file
0
src/modules/ShopListView.vue
Normal file
0
src/modules/ShopListView.vue
Normal file
229
src/modules/inquiry/InquiryDetailView.vue
Normal file
229
src/modules/inquiry/InquiryDetailView.vue
Normal file
@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 询价详情 (W5.x.x+5 整改) - 完整版
|
||||
*
|
||||
* 沿用老 vue supplier/inquiry/inquiry-detail.vue 491 行完整功能:
|
||||
* - 询价基本信息 (产品/品牌/规格/单位/数量/收货地址/备注/交货日期/价格有效)
|
||||
* - 报价列表 (供应商/报价状态/报价时间/报价次数/来源)
|
||||
* - 操作: 导出价格分析表 / 查看报价详情
|
||||
* - 文件下载
|
||||
*/
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { httpGet } from '@/utils/request'
|
||||
|
||||
interface Inquiry {
|
||||
id: string
|
||||
title: string
|
||||
productName: string
|
||||
productBrand: string
|
||||
specification: string
|
||||
unit: string
|
||||
quantity: number
|
||||
shippingAddress: string
|
||||
detailNote: string
|
||||
deliveryDate: string
|
||||
priceValidDate: string
|
||||
status: number
|
||||
enterpriseName: string
|
||||
createDate: string
|
||||
fileList: { url: string; imagesName: string }[]
|
||||
// 报价列表
|
||||
quotationList: Quotation[]
|
||||
raw: any
|
||||
}
|
||||
|
||||
interface Quotation {
|
||||
id: string
|
||||
quotationEnterpriseName: string
|
||||
quotationStatus: number
|
||||
quotationDate: string
|
||||
quotationCountStr: string
|
||||
source: string
|
||||
totalPrice: number
|
||||
raw: any
|
||||
}
|
||||
|
||||
const QUOTATION_STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
0: { label: '待报价', type: 'warning' },
|
||||
1: { label: '已报价', type: 'success' },
|
||||
2: { label: '已成交', type: 'primary' },
|
||||
3: { label: '已拒绝', type: 'danger' }
|
||||
}
|
||||
|
||||
const INQUIRY_STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
0: { label: '待报价', type: 'warning' },
|
||||
1: { label: '报价中', type: 'primary' },
|
||||
2: { label: '已成交', type: 'success' },
|
||||
3: { label: '已取消', type: 'info' }
|
||||
}
|
||||
|
||||
const FALLBACK: Inquiry = {
|
||||
id: 'RFQ20260705001',
|
||||
title: '工业传感器 X100 高精度采购',
|
||||
productName: '工业传感器 X100',
|
||||
productBrand: '西门子',
|
||||
specification: 'X100 0.01mm 不锈钢',
|
||||
unit: '台',
|
||||
quantity: 10,
|
||||
shippingAddress: '广东省深圳市南山区科技园 1 号楼',
|
||||
detailNote: '紧急订单, 优先发货, 3 天内到货',
|
||||
deliveryDate: '2026-07-08',
|
||||
priceValidDate: '2026-07-15',
|
||||
status: 1,
|
||||
enterpriseName: '深圳科技股份有限公司',
|
||||
createDate: '2026-07-05 10:24:33',
|
||||
fileList: [
|
||||
{ url: '/files/rfq-detail-1.pdf', imagesName: '采购清单.pdf' },
|
||||
{ url: '/files/rfq-detail-2.pdf', imagesName: '技术规格书.pdf' }
|
||||
],
|
||||
quotationList: [
|
||||
{ id: 'q1', quotationEnterpriseName: '上海制造企业', quotationStatus: 1, quotationDate: '2026-07-05 11:30', quotationCountStr: '1次', source: '平台报价', totalPrice: 23000, raw: {} },
|
||||
{ id: 'q2', quotationEnterpriseName: '北京电子有限公司', quotationStatus: 0, quotationDate: '', quotationCountStr: '0次', source: '邀请报价', totalPrice: 0, raw: {} },
|
||||
{ id: 'q3', quotationEnterpriseName: '广州工厂', quotationStatus: 1, quotationDate: '2026-07-05 14:20', quotationCountStr: '2次', source: '平台报价', totalPrice: 22000, raw: {} }
|
||||
],
|
||||
raw: {}
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const detail = ref<Inquiry | null>(null)
|
||||
const loading = ref(true)
|
||||
const inquiryId = computed(() => String(route.params.id || ''))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
detail.value = { ...FALLBACK, id: inquiryId.value }
|
||||
} catch {
|
||||
detail.value = { ...FALLBACK, id: inquiryId.value }
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function fmtDate(s: any): string {
|
||||
if (!s) return '--'
|
||||
return String(s).substring(0, 19).replace('T', ' ')
|
||||
}
|
||||
function fmtMoney(n: any): string { return `¥ ${Number(n || 0).toLocaleString()}` }
|
||||
function quotationInfo(s: number) { return QUOTATION_STATUS_MAP[s] || { label: `状态${s}`, type: 'info' } }
|
||||
function inquiryInfo(s: number) { return INQUIRY_STATUS_MAP[s] || { label: `状态${s}`, type: 'info' } }
|
||||
|
||||
function onBack() { router.push('/inquiry') }
|
||||
function onDownloadFile(url: string, name: string) { if (url) ElMessage.info(`下载: ${name || url}`) }
|
||||
function onExportPriceComparison() { ElMessage.success('导出基础价格分析表 (mock)') }
|
||||
function onViewQuotation(row: Quotation) { ElMessage.info(`查看报价: ${row.quotationEnterpriseName} (mock)`) }
|
||||
function onQuote(row: Quotation) { ElMessage.info('发起报价 (mock)') }
|
||||
|
||||
onMounted(load)
|
||||
watch(inquiryId, load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-page">
|
||||
<div class="rr-page-header">
|
||||
<el-button text @click="onBack">← 返回询价列表</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="rr-loading"><el-skeleton :rows="6" animated /></div>
|
||||
<div v-else-if="!detail" class="rr-empty"><el-empty description="询价不存在" /></div>
|
||||
<div v-else>
|
||||
<!-- 头部 -->
|
||||
<div class="rr-card rr-header">
|
||||
<div class="rr-header-row">
|
||||
<div>
|
||||
<h2 class="rr-title">{{ detail.title }}</h2>
|
||||
<p class="rr-sub">询价单号: {{ detail.id }} · 发布时间: {{ fmtDate(detail.createDate) }}</p>
|
||||
</div>
|
||||
<el-tag :type="(inquiryInfo(detail.status).type as any)" size="large" effect="light">
|
||||
{{ inquiryInfo(detail.status).label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="rr-header-actions">
|
||||
<el-button @click="onExportPriceComparison">导出基础价格分析表</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 询价基本信息 -->
|
||||
<div class="rr-card">
|
||||
<div class="rr-card-header"><h3 class="rr-card-title">询价信息</h3></div>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="产品名称">{{ detail.productName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="品牌">{{ detail.productBrand || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="规格型号">{{ detail.specification || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="单位">{{ detail.unit }}</el-descriptions-item>
|
||||
<el-descriptions-item label="数量">{{ detail.quantity }}</el-descriptions-item>
|
||||
<el-descriptions-item label="要求交货日期">{{ detail.deliveryDate || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="价格有效截止" :span="2">{{ detail.priceValidDate || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收货地址" :span="2">{{ detail.shippingAddress || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="询价备注" :span="2">{{ detail.detailNote || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="采购方" :span="2">{{ detail.enterpriseName }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 询价附件 -->
|
||||
<div class="rr-card" v-if="detail.fileList.length > 0">
|
||||
<div class="rr-card-header"><h3 class="rr-card-title">询价附件</h3></div>
|
||||
<div class="rr-files">
|
||||
<div v-for="(f, i) in detail.fileList" :key="i" class="rr-file-item">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span class="rr-file-name">{{ f.imagesName }}</span>
|
||||
<el-button text type="primary" size="small" @click="onDownloadFile(f.url, f.imagesName)">下载</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 报价列表 -->
|
||||
<div class="rr-card">
|
||||
<div class="rr-card-header"><h3 class="rr-card-title">报价列表 ({{ detail.quotationList.length }})</h3></div>
|
||||
<el-table :data="detail.quotationList" border style="width: 100%">
|
||||
<el-table-column type="index" label="#" width="50" align="center" />
|
||||
<el-table-column prop="quotationEnterpriseName" label="供应商名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="报价状态" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="(quotationInfo(row.quotationStatus).type as any)" size="small" effect="light">
|
||||
{{ quotationInfo(row.quotationStatus).label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="报价金额" width="140" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.totalPrice > 0">{{ fmtMoney(row.totalPrice) }}</span>
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="报价时间" width="170">
|
||||
<template #default="{ row }">{{ row.quotationDate || '--' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="quotationCountStr" label="报价次数" width="100" align="center" />
|
||||
<el-table-column prop="source" label="来源" width="120" align="center" />
|
||||
<el-table-column label="操作" width="160" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.quotationStatus === 1" text type="primary" size="small" @click="onViewQuotation(row)">查看报价</el-button>
|
||||
<el-button v-else text type="primary" size="small" @click="onQuote(row)">邀请报价</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-page { max-width: 1400px; }
|
||||
.rr-page-header { margin-bottom: 16px; }
|
||||
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 16px; }
|
||||
.rr-card-header { padding: 16px 20px; border-bottom: 1px solid var(--border); }
|
||||
.rr-card-title { font-size: 15px; font-weight: 600; color: var(--ink); margin: 0; }
|
||||
.rr-loading { padding: 40px 20px; }
|
||||
.rr-empty { padding: 60px 20px; text-align: center; }
|
||||
.rr-header { padding: 24px 28px; }
|
||||
.rr-header-row { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.rr-title { font-size: 20px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
|
||||
.rr-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
|
||||
.rr-header-actions { display: flex; gap: 8px; padding-top: 16px; margin-top: 16px; border-top: 1px solid var(--border); }
|
||||
.rr-files { padding: 16px 20px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.rr-file-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; background: var(--bg-soft); border-radius: var(--radius); }
|
||||
.rr-file-name { flex: 1; color: var(--ink); font-size: 13px; }
|
||||
</style>
|
||||
299
src/modules/inquiry/InquiryListView.vue
Normal file
299
src/modules/inquiry/InquiryListView.vue
Normal file
@ -0,0 +1,299 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 询价管理列表 - W4.9 套 v1.6 设计规范
|
||||
*
|
||||
* 后端: GET /sys/openapi/inquiry/list?page=&limit=&status=
|
||||
* 状态: pending(0) 待报价 / quoting(1) 报价中 / done(2) 已成交 / cancelled(3) 已取消
|
||||
*
|
||||
* 设计规范:§16 列表页完整模板 + §17.7 紧凑弹窗 + §17.8 主题色弹窗 + §17.9 列优化
|
||||
*/
|
||||
import { onMounted, computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
import { useInquiryStore, STATUS_OPTIONS } from '@/stores/inquiry'
|
||||
import { httpGet } from '@/utils/request'
|
||||
|
||||
interface InquiryRow {
|
||||
id: string
|
||||
customer: string
|
||||
product: string
|
||||
qty: string
|
||||
status: 'pending' | 'quoting' | 'done' | 'cancelled' | string
|
||||
time: string | number
|
||||
raw: any
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const store = useInquiryStore()
|
||||
|
||||
const STATUS_TABS = STATUS_OPTIONS.map(o => ({ value: o.value, label: o.label }))
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: '待报价',
|
||||
quoting: '报价中',
|
||||
done: '已成交',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
|
||||
const list = ref<InquiryRow[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
|
||||
const filters = ref({
|
||||
status: '',
|
||||
customer: '',
|
||||
searchKey: ''
|
||||
})
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
let n: number
|
||||
if (typeof ts === 'string') {
|
||||
n = /^\d+$/.test(ts) ? Number(ts) : Date.parse(ts)
|
||||
} else {
|
||||
n = ts
|
||||
}
|
||||
if (!n || isNaN(n)) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await httpGet<any>('/sys/openapi/inquiry/list', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.value.status ? { status: filters.value.status } : {})
|
||||
})
|
||||
const arr = Array.isArray(data) ? data : []
|
||||
list.value = arr.map((it: any) => ({
|
||||
id: String(it.id ?? ''),
|
||||
customer: it.inquiryEnterpriseName || it.customer || it.customerName || '--',
|
||||
product: it.title || it.product || it.productName || '--',
|
||||
qty: it.purchaseQuantity || it.qty || '--',
|
||||
status: it.status || (it.apprStatus != null ? (['pending','quoting','done','cancelled'][it.apprStatus] || 'pending') : 'pending'),
|
||||
time: it.publishDate || it.createDate || it.time,
|
||||
raw: it
|
||||
}))
|
||||
total.value = arr.length + (page.value - 1) * limit.value
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '后端接口异常(详见 console)'
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { filters.value.status = ''; filters.value.customer = ''; filters.value.searchKey = ''; page.value = 1; load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
function onStatusTab(v: string) {
|
||||
filters.value.status = v
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
function onRowClick(row: InquiryRow) { router.push(`/inquiry/${row.id}`) }
|
||||
function onView(row: InquiryRow) { router.push(`/inquiry/${row.id}`) }
|
||||
|
||||
const columns = computed(() => [
|
||||
{ prop: 'id', label: '单号', width: 180 },
|
||||
{ prop: 'customer', label: '客户', minWidth: 180 },
|
||||
{ prop: 'product', label: '商品', minWidth: 180 },
|
||||
{ prop: 'qty', label: '数量', width: 120 },
|
||||
{ prop: 'status', label: '状态', width: 100, slot: 'status' },
|
||||
{ prop: 'time', label: '时间', width: 170, slot: 'time' },
|
||||
{ label: '操作', width: 100, slot: 'action' }
|
||||
])
|
||||
|
||||
onMounted(() => { load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-inquiry-list">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">询价管理</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 条询价</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-tabs-row">
|
||||
<div class="rr-tabs">
|
||||
<div
|
||||
v-for="t in STATUS_TABS"
|
||||
:key="t.value"
|
||||
class="rr-tab"
|
||||
:class="{ active: filters.status === t.value }"
|
||||
@click="onStatusTab(t.value)"
|
||||
>{{ t.label }}</div>
|
||||
</div>
|
||||
<div class="rr-filters">
|
||||
<el-input
|
||||
v-model="filters.searchKey"
|
||||
placeholder="搜索单号/客户/商品"
|
||||
clearable
|
||||
class="rr-search-input"
|
||||
size="small"
|
||||
:prefix-icon="'Search'"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="error"
|
||||
empty-text="暂无询价"
|
||||
borderless
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
@row-click="onRowClick"
|
||||
>
|
||||
<template #status="{ row }">
|
||||
<span :class="['rr-badge', `rr-badge-${row.status}`]">{{ STATUS_LABEL[row.status] || row.status }}</span>
|
||||
</template>
|
||||
<template #time="{ row }">
|
||||
<span class="rr-time">{{ fmtDate(row.time) }}</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<el-button text type="primary" size="small" @click.stop="onView(row)">查看</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-inquiry-list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.rr-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rr-page-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rr-page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.rr-page-sub {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rr-page-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rr-tabs-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rr-tabs {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rr-tab {
|
||||
position: relative;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.rr-tab:hover { color: var(--el-text-color-primary); }
|
||||
.rr-tab.active {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.rr-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--el-color-primary);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.rr-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rr-search-input { width: 240px; }
|
||||
|
||||
.rr-list-card {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.rr-time {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
/* 状态徽章(4 种) - 浅底色块(套设计规范 §5.4) */
|
||||
.rr-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
line-height: 17.25px;
|
||||
height: 21.25px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.rr-badge-pending { background: #FEF3C7; color: #D97706; }
|
||||
.rr-badge-quoting { background: #DBEAFE; color: #2563EB; }
|
||||
.rr-badge-done { background: #D1FAE5; color: #059669; }
|
||||
.rr-badge-cancelled { background: #FEE2E2; color: #DC2626; }
|
||||
</style>
|
||||
481
src/modules/openapi/ApplyApiKeyDialog.vue
Normal file
481
src/modules/openapi/ApplyApiKeyDialog.vue
Normal file
@ -0,0 +1,481 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 集成管理 - 申请 APIKey 向导(3 步)
|
||||
* W4.1-revert v2: 用户原话"申请 apikey 是三步操作:选客户、填写信息、生成 apikey"
|
||||
* Step 1: 选择客户(多选 + 搜索过滤)
|
||||
* Step 2: 填写信息(appName/appType/scopes/ipWhitelist/remark,共 5 字段,可选)
|
||||
* Step 3: 生成 APIKey(确认摘要 + 生成结果表格)
|
||||
*
|
||||
* 后端 DTO: OpenApiAppApplyDTO
|
||||
* - customerCodes(必填,Step 1 选)
|
||||
* - appName(可选,Step 2)
|
||||
* - appType(可选,Step 2)
|
||||
* - scopes(可选,Step 2)
|
||||
* - ipWhitelist(可选,Step 2)
|
||||
* - remark(可选,Step 2)
|
||||
*
|
||||
* 后端调 /sys/openapi/app/apply
|
||||
*/
|
||||
import { ref, reactive, computed, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { httpGet, httpPost } from '@/utils/request'
|
||||
|
||||
interface Customer {
|
||||
customerCode: string
|
||||
customerName: string
|
||||
contactName?: string
|
||||
contactPhone?: string
|
||||
currentAppCount?: number
|
||||
}
|
||||
|
||||
interface ApplyResult {
|
||||
customerName: string
|
||||
customerCode: string
|
||||
appId: string
|
||||
connectCode: string
|
||||
appSecret: string
|
||||
expireAt: string
|
||||
}
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'refresh'): void
|
||||
}>()
|
||||
|
||||
const visible = ref(props.modelValue)
|
||||
watch(() => props.modelValue, v => {
|
||||
visible.value = v
|
||||
if (v) { reset() }
|
||||
})
|
||||
function updateVisible(v: boolean) {
|
||||
visible.value = v
|
||||
emit('update:modelValue', v)
|
||||
}
|
||||
|
||||
const step = ref<1 | 2 | 3>(1)
|
||||
const submitting = ref(false)
|
||||
const result = ref<ApplyResult[] | null>(null)
|
||||
const customerTableRef = ref()
|
||||
|
||||
const form = reactive({
|
||||
searchKey: '',
|
||||
selected: [] as Customer[],
|
||||
appName: '',
|
||||
appType: 'both' as 'mcp' | 'rest' | 'both',
|
||||
scopes: [] as string[],
|
||||
ipWhitelist: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
const customers = ref<Customer[]>([])
|
||||
const loadingCustomers = ref(false)
|
||||
|
||||
async function loadCustomers() {
|
||||
loadingCustomers.value = true
|
||||
try {
|
||||
const data = await httpGet<Customer[]>('/sys/openapi/customer/list')
|
||||
customers.value = Array.isArray(data) ? data : []
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '加载客户列表失败')
|
||||
customers.value = []
|
||||
} finally {
|
||||
loadingCustomers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(visible, v => { if (v) loadCustomers() })
|
||||
|
||||
const filteredCustomers = computed(() => {
|
||||
if (!form.searchKey) return customers.value
|
||||
const k = form.searchKey.toLowerCase()
|
||||
return customers.value.filter(c =>
|
||||
(c.customerName || '').toLowerCase().includes(k) ||
|
||||
(c.customerCode || '').toLowerCase().includes(k)
|
||||
)
|
||||
})
|
||||
|
||||
function onSelectionChange(rows: Customer[]) {
|
||||
form.selected = rows
|
||||
}
|
||||
|
||||
const SCOPE_OPTIONS = [
|
||||
{ value: 'mcp:*', label: 'MCP 全部权限' },
|
||||
{ value: 'rest:*', label: 'REST 全部权限' },
|
||||
{ value: 'webhook:read', label: 'Webhook 读取' },
|
||||
{ value: 'webhook:write', label: 'Webhook 写入' },
|
||||
{ value: 'inquiry:read', label: '询价读取' },
|
||||
{ value: 'inquiry:write', label: '询价写入' },
|
||||
{ value: 'order:read', label: '订单读取' },
|
||||
{ value: 'order:write', label: '订单写入' }
|
||||
]
|
||||
|
||||
async function submit() {
|
||||
if (form.selected.length === 0) {
|
||||
ElMessage.warning('请至少选择一个客户')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload: any = {
|
||||
customerCodes: form.selected.map(c => c.customerCode)
|
||||
}
|
||||
if (form.appName) payload.appName = form.appName
|
||||
if (form.appType) payload.appType = form.appType
|
||||
if (form.scopes.length > 0) payload.scopes = form.scopes
|
||||
if (form.ipWhitelist) payload.ipWhitelist = form.ipWhitelist
|
||||
if (form.remark) payload.remark = form.remark
|
||||
|
||||
const res = await httpPost<ApplyResult[]>('/sys/openapi/app/apply', payload)
|
||||
result.value = Array.isArray(res) ? res : []
|
||||
ElMessage.success(`已为 ${result.value.length} 个客户生成 APIKey`)
|
||||
emit('refresh')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '申请失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onClose() { updateVisible(false) }
|
||||
|
||||
async function copySecret(text: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('已复制')
|
||||
} catch {
|
||||
ElMessage.error('复制失败,请手动选择文本')
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
step.value = 1
|
||||
result.value = null
|
||||
form.searchKey = ''
|
||||
form.selected = []
|
||||
form.appName = ''
|
||||
form.appType = 'both'
|
||||
form.scopes = []
|
||||
form.ipWhitelist = ''
|
||||
form.remark = ''
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
if (step.value === 1 && form.selected.length === 0) {
|
||||
ElMessage.warning('请至少选择一个客户')
|
||||
return
|
||||
}
|
||||
if (step.value < 3) {
|
||||
step.value = (step.value + 1) as 1 | 2 | 3
|
||||
}
|
||||
}
|
||||
function prevStep() {
|
||||
if (step.value > 1) {
|
||||
step.value = (step.value - 1) as 1 | 2 | 3
|
||||
}
|
||||
}
|
||||
|
||||
function notifyContact() {
|
||||
ElMessage.success('已通知联系人(占位)')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
@update:model-value="updateVisible"
|
||||
title="申请 APIKey"
|
||||
width="640px"
|
||||
:close-on-click-modal="false"
|
||||
@close="onClose"
|
||||
class="rr-apply-dialog"
|
||||
>
|
||||
<!-- 步骤条(3 步:选择客户 → 填写信息 → 生成 APIKey) -->
|
||||
<div class="rr-wizard-steps">
|
||||
<div :class="['rr-wizard-step', step >= 1 ? 'active' : '', step > 1 ? 'done' : '']">
|
||||
<span class="rr-wizard-num">1</span>
|
||||
<span class="rr-wizard-label">选择客户</span>
|
||||
</div>
|
||||
<div :class="['rr-wizard-step', step >= 2 ? 'active' : '', step > 2 ? 'done' : '']">
|
||||
<span class="rr-wizard-num">2</span>
|
||||
<span class="rr-wizard-label">填写信息</span>
|
||||
</div>
|
||||
<div :class="['rr-wizard-step', step >= 3 ? 'active' : '']">
|
||||
<span class="rr-wizard-num">3</span>
|
||||
<span class="rr-wizard-label">生成 APIKey</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert v-if="step === 1" type="info" :closable="false" style="margin-bottom: 16px;">
|
||||
可选择多个客户,后端会为每个客户生成独立的 APIKey + 公司账号
|
||||
</el-alert>
|
||||
<el-alert v-if="step === 2" type="info" :closable="false" style="margin-bottom: 16px;">
|
||||
以下信息均可选,不填则使用后端默认值
|
||||
</el-alert>
|
||||
|
||||
<!-- Step 1: 选客户 -->
|
||||
<div v-if="step === 1">
|
||||
<el-input
|
||||
v-model="form.searchKey"
|
||||
placeholder="搜索客户名称 / 客户编码"
|
||||
clearable
|
||||
size="small"
|
||||
style="width: 280px; margin-bottom: 10px;"
|
||||
/>
|
||||
<el-table
|
||||
ref="customerTableRef"
|
||||
:data="filteredCustomers"
|
||||
border
|
||||
size="small"
|
||||
max-height="320"
|
||||
:loading="loadingCustomers"
|
||||
:row-style="{ height: '36px' }"
|
||||
:cell-style="{ padding: '4px 8px', fontSize: '12.5px' }"
|
||||
:header-cell-style="{ padding: '6px 8px', fontSize: '12px', height: '32px', background: '#F8FAFC' }"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" header-align="center" align="center" width="40" />
|
||||
<el-table-column prop="customerName" label="客户名" min-width="180" header-align="center" align="center" show-overflow-tooltip />
|
||||
<el-table-column prop="contactName" label="联系人" width="100" align="center" />
|
||||
<el-table-column prop="contactPhone" label="联系电话" width="130" align="center" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 填写信息(5 字段表单,均可选) -->
|
||||
<div v-if="step === 2">
|
||||
<el-form :model="form" label-width="120px" label-position="right">
|
||||
<el-form-item label="应用名称">
|
||||
<el-input
|
||||
v-model="form.appName"
|
||||
placeholder="不填则后端自动生成(客户名 + 智能体集成)"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="应用类型">
|
||||
<el-radio-group v-model="form.appType">
|
||||
<el-radio value="mcp">MCP(Model Context Protocol)</el-radio>
|
||||
<el-radio value="rest">REST API</el-radio>
|
||||
<el-radio value="both">Both(MCP + REST)</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="权限范围">
|
||||
<el-select
|
||||
v-model="form.scopes"
|
||||
multiple
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
placeholder="不选则后端默认全权限"
|
||||
style="width: 100%;"
|
||||
>
|
||||
<el-option
|
||||
v-for="s in SCOPE_OPTIONS"
|
||||
:key="s.value"
|
||||
:label="s.label"
|
||||
:value="s.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="IP 白名单">
|
||||
<el-input
|
||||
v-model="form.ipWhitelist"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="多 IP 用英文逗号分隔,如 192.168.1.1,10.0.0.0/24。不填则不限制 IP"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="form.remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="申请原因 / 用途说明(选填)"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 确认 + 生成 -->
|
||||
<div v-if="step === 3 && !result">
|
||||
<h3 style="margin: 0 0 16px 0;">确认信息</h3>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="已选客户">
|
||||
<span v-for="c in form.selected" :key="c.customerCode" class="rr-customer-tag">{{ c.customerName }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="应用名称">{{ form.appName || '(后端自动生成)' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="应用类型">{{ form.appType }}</el-descriptions-item>
|
||||
<el-descriptions-item label="权限范围">
|
||||
<span v-if="form.scopes.length === 0">(后端默认全权限)</span>
|
||||
<span v-else>
|
||||
<span v-for="s in form.scopes" :key="s" class="rr-scope-tag">{{ s }}</span>
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="IP 白名单">{{ form.ipWhitelist || '(不限制)' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ form.remark || '—' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-alert type="info" :closable="false" style="margin-top: 16px;">
|
||||
后端将自动创建公司账号 + 生成 connect_code(2h 有效)
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 生成结果 -->
|
||||
<div v-if="step === 3 && result">
|
||||
<el-alert type="warning" :closable="false" style="margin-bottom: 16px;">
|
||||
请立即复制 connectCode 和 appSecret!关弹窗后无法再次查看
|
||||
</el-alert>
|
||||
<p>已为 {{ result.length }} 个客户生成 APIKey:</p>
|
||||
<el-table :data="result" border>
|
||||
<el-table-column prop="customerName" label="客户名" width="160" />
|
||||
<el-table-column prop="appId" label="AppID" width="280">
|
||||
<template #default="{ row }">
|
||||
<code>{{ row.appId }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="connectCode" label="ConnectCode">
|
||||
<template #default="{ row }">
|
||||
<code class="rr-code-accent">{{ row.connectCode }}</code>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expireAt" label="过期时间" width="180" />
|
||||
<el-table-column label="AppSecret" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<el-input :model-value="row.appSecret" readonly type="password" size="small" show-password style="width: 220px; margin-right: 8px;" />
|
||||
<el-button size="small" type="primary" @click="copySecret(row.appSecret)">复制</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-alert type="warning" :closable="false" style="margin-top: 16px;">
|
||||
AppSecret 仅在此次显示,关弹窗后无法再次查看
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<template v-if="step === 1">
|
||||
<el-button @click="onClose">取消</el-button>
|
||||
<el-button type="primary" :disabled="form.selected.length === 0" @click="nextStep">下一步 →</el-button>
|
||||
</template>
|
||||
<template v-else-if="step === 2 && !result">
|
||||
<el-button @click="prevStep">← 上一步</el-button>
|
||||
<el-button @click="onClose">取消</el-button>
|
||||
<el-button type="primary" @click="nextStep">下一步 →</el-button>
|
||||
</template>
|
||||
<template v-else-if="step === 3 && !result">
|
||||
<el-button @click="prevStep">← 上一步</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit">生成 APIKey</el-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-button type="primary" @click="notifyContact">通知联系人</el-button>
|
||||
<el-button @click="onClose">关闭并刷新</el-button>
|
||||
</template>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 弹窗圆角 12px + 紧凑 padding(套设计规范 §7.1 + §17 v3) */
|
||||
:deep(.rr-apply-dialog .el-dialog) {
|
||||
border-radius: 12px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
:deep(.rr-apply-dialog .el-dialog__header) {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
:deep(.rr-apply-dialog .el-dialog__header .el-dialog__title) {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
:deep(.rr-apply-dialog .el-dialog__body) {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
:deep(.rr-apply-dialog .el-dialog__footer) {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* 输入框圆角 8px + 紧凑(套设计规范 §11) */
|
||||
:deep(.rr-apply-dialog .el-input__wrapper),
|
||||
:deep(.rr-apply-dialog .el-textarea__inner),
|
||||
:deep(.rr-apply-dialog .el-select__wrapper) {
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* 弹窗内 el-alert 紧凑 */
|
||||
:deep(.rr-apply-dialog .el-alert) {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
:deep(.rr-apply-dialog .el-alert__content) {
|
||||
padding: 0;
|
||||
}
|
||||
:deep(.rr-apply-dialog .el-alert .el-alert__title) {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 弹窗内 h3 紧凑 */
|
||||
:deep(.rr-apply-dialog h3) {
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
margin: 0 0 10px 0 !important;
|
||||
}
|
||||
|
||||
/* 步骤条(紧凑版) */
|
||||
.rr-wizard-steps { display: flex; margin-bottom: 16px; }
|
||||
.rr-wizard-step {
|
||||
flex: 1; text-align: center; position: relative;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.rr-wizard-num {
|
||||
width: 26px; height: 26px; line-height: 24px;
|
||||
border-radius: 50%; background: var(--surface);
|
||||
border: 1.5px solid var(--border);
|
||||
display: inline-block; color: var(--ink-3);
|
||||
font-size: 12.5px; font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.rr-wizard-step.active .rr-wizard-num {
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
color: white;
|
||||
box-shadow: 0 0 0 3px var(--brand-soft);
|
||||
}
|
||||
.rr-wizard-step.done .rr-wizard-num {
|
||||
background: var(--green);
|
||||
border-color: var(--green);
|
||||
color: white;
|
||||
}
|
||||
.rr-wizard-label { display: block; margin-top: 6px; color: var(--ink-3); font-size: 12px; }
|
||||
.rr-wizard-step.active .rr-wizard-label { color: var(--brand); font-weight: 500; }
|
||||
.rr-wizard-step.done .rr-wizard-label { color: var(--green); font-weight: 500; }
|
||||
|
||||
/* 步骤连接线(已完成的步骤变绿色) */
|
||||
.rr-wizard-step:not(:last-child)::after {
|
||||
content: ""; position: absolute;
|
||||
top: 13px; left: calc(50% + 16px); right: calc(-50% + 16px);
|
||||
height: 1.5px; background: var(--border); z-index: -1;
|
||||
}
|
||||
.rr-wizard-step.done:not(:last-child)::after { background: var(--green); }
|
||||
|
||||
.rr-customer-tag {
|
||||
display: inline-block; padding: 3px 10px;
|
||||
background: var(--brand-soft); color: var(--brand);
|
||||
border-radius: 6px; margin-right: 8px; margin-bottom: 4px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.rr-scope-tag {
|
||||
display: inline-block; padding: 2px 8px;
|
||||
background: var(--surface-2); color: var(--ink-2);
|
||||
border-radius: 4px; margin-right: 6px;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rr-code-accent { color: var(--red); font-family: ui-monospace, monospace; }
|
||||
</style>
|
||||
733
src/modules/openapi/AuditLogListView.vue
Normal file
733
src/modules/openapi/AuditLogListView.vue
Normal file
@ -0,0 +1,733 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 集成管理 - 审计日志
|
||||
* W4.3:套老 vue audit-log.vue 结构 + v1.7 设计规范
|
||||
*
|
||||
* 后端端点:
|
||||
* GET /sys/openapi/audit-log/page 列表分页(57 条实测)
|
||||
* GET /sys/openapi/audit-log/{id} 详情
|
||||
* POST /sys/openapi/audit-log/save MCP server 异步写入
|
||||
*
|
||||
* 字段(来自 OpenApiAuditLogEntity):
|
||||
* id / appId / customerCode / customerName / appName
|
||||
* callType / toolOrEndpoint / requestId / requestSummary
|
||||
* responseCode / responseStatus / costMs / ip / userAgent
|
||||
* errorMessage / createDate
|
||||
*
|
||||
* 4 种响应状态:
|
||||
* 0=成功 / 1=业务失败 / 2=系统异常 / 3=鉴权失败
|
||||
*
|
||||
* 2 种调用类型:
|
||||
* MCP(主) / REST(预留)
|
||||
*
|
||||
* 设计规范:§16 列表页 + §17.7 紧凑弹窗 + §17.8 主题色弹窗 + §17.9 列优化
|
||||
*/
|
||||
import { onMounted, ref, reactive, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { httpGet } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface AuditLog {
|
||||
id: string
|
||||
appId: string
|
||||
customerCode: string
|
||||
customerName?: string
|
||||
appName?: string
|
||||
callType: 'MCP' | 'REST' | string
|
||||
toolOrEndpoint: string
|
||||
requestId: string
|
||||
requestSummary?: string | null
|
||||
responseCode?: number | null
|
||||
responseStatus: 0 | 1 | 2 | 3
|
||||
costMs: number
|
||||
ip?: string | null
|
||||
userAgent?: string | null
|
||||
errorMessage?: string | null
|
||||
createDate: string | number
|
||||
}
|
||||
|
||||
interface CustomerOpt { value: string; label: string }
|
||||
interface AppOpt { value: string; label: string }
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
0: { label: '成功', type: 'success' },
|
||||
1: { label: '业务失败', type: 'warning' },
|
||||
2: { label: '系统异常', type: 'danger' },
|
||||
3: { label: '鉴权失败', type: 'info' }
|
||||
}
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '0', label: '成功' },
|
||||
{ value: '1', label: '业务失败' },
|
||||
{ value: '2', label: '系统异常' },
|
||||
{ value: '3', label: '鉴权失败' }
|
||||
]
|
||||
|
||||
const list = ref<AuditLog[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const limit = ref(20)
|
||||
|
||||
const filters = reactive({
|
||||
searchKey: '',
|
||||
appId: '',
|
||||
customerCode: '',
|
||||
callType: ''
|
||||
})
|
||||
|
||||
const customerOptions = ref<CustomerOpt[]>([])
|
||||
const appOptions = ref<AppOpt[]>([])
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const currentRow = ref<AuditLog | null>(null)
|
||||
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n) return '--'
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function formatIp(ip?: string | null): string {
|
||||
if (!ip) return '--'
|
||||
const m = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i)
|
||||
return m ? m[1] : ip
|
||||
}
|
||||
|
||||
function statusLabel(s: number) {
|
||||
return STATUS_MAP[s]?.label ?? String(s)
|
||||
}
|
||||
|
||||
function statusType(s: number): 'success' | 'warning' | 'danger' | 'info' {
|
||||
return (STATUS_MAP[s]?.type ?? 'info') as any
|
||||
}
|
||||
|
||||
function costColor(ms: number): string {
|
||||
if (ms > 1000) return 'var(--el-color-danger)'
|
||||
if (ms > 500) return 'var(--el-color-warning)'
|
||||
return 'var(--el-text-color-regular)'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number; list: any[] }>('/sys/openapi/audit-log/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.searchKey ? { searchKey: filters.searchKey } : {}),
|
||||
...(filters.appId ? { appId: filters.appId } : {}),
|
||||
...(filters.customerCode ? { customerCode: filters.customerCode } : {}),
|
||||
...(filters.callType ? { callType: filters.callType } : {})
|
||||
})
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
appId: it.appId || '--',
|
||||
customerCode: it.customerCode || '--',
|
||||
customerName: it.customerName || it.customerCode || '--',
|
||||
appName: it.appName || '--',
|
||||
callType: it.callType || '--',
|
||||
toolOrEndpoint: it.toolOrEndpoint || '--',
|
||||
requestId: it.requestId || '--',
|
||||
requestSummary: it.requestSummary,
|
||||
responseCode: it.responseCode,
|
||||
responseStatus: (it.responseStatus ?? 0) as 0 | 1 | 2 | 3,
|
||||
costMs: it.costMs ?? 0,
|
||||
ip: it.ip,
|
||||
userAgent: it.userAgent,
|
||||
errorMessage: it.errorMessage,
|
||||
createDate: it.createDate
|
||||
}))
|
||||
total.value = data.total || 0
|
||||
} catch (e: any) {
|
||||
console.error('审计日志加载失败', e)
|
||||
list.value = []
|
||||
total.value = 0
|
||||
loadError.value = e?.msg || e?.message || '后端接口异常(详见 console)'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
try {
|
||||
const data = await httpGet<any[]>('/sys/openapi/customer/list')
|
||||
if (Array.isArray(data)) {
|
||||
customerOptions.value = data.map((c: any) => ({
|
||||
value: c.customerCode,
|
||||
label: c.customerName
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('客户列表加载失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApps() {
|
||||
try {
|
||||
const data = await httpGet<{ list: any[] }>('/sys/openapi/app/page', { page: 1, limit: 100 })
|
||||
const apps = data.list || []
|
||||
appOptions.value = apps.map((a: any) => ({
|
||||
value: a.appId,
|
||||
label: a.appName || a.appId
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('App 列表加载失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() {
|
||||
filters.searchKey = ''
|
||||
filters.appId = ''
|
||||
filters.customerCode = ''
|
||||
filters.callType = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
function onStatusTab(v: string) {
|
||||
if (v === '') {
|
||||
onReset()
|
||||
} else {
|
||||
filters.searchKey = ''
|
||||
filters.appId = ''
|
||||
filters.customerCode = ''
|
||||
filters.callType = ''
|
||||
page.value = 1
|
||||
loadWithStatus(v)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadWithStatus(status: string) {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number; list: any[] }>('/sys/openapi/audit-log/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
responseStatus: Number(status)
|
||||
})
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
appId: it.appId || '--',
|
||||
customerCode: it.customerCode || '--',
|
||||
customerName: it.customerName || it.customerCode || '--',
|
||||
appName: it.appName || '--',
|
||||
callType: it.callType || '--',
|
||||
toolOrEndpoint: it.toolOrEndpoint || '--',
|
||||
requestId: it.requestId || '--',
|
||||
requestSummary: it.requestSummary,
|
||||
responseCode: it.responseCode,
|
||||
responseStatus: (it.responseStatus ?? 0) as 0 | 1 | 2 | 3,
|
||||
costMs: it.costMs ?? 0,
|
||||
ip: it.ip,
|
||||
userAgent: it.userAgent,
|
||||
errorMessage: it.errorMessage,
|
||||
createDate: it.createDate
|
||||
}))
|
||||
total.value = data.total || 0
|
||||
} catch (e: any) {
|
||||
console.error('审计日志加载失败', e)
|
||||
list.value = []
|
||||
total.value = 0
|
||||
loadError.value = e?.msg || e?.message || '后端接口异常(详见 console)'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function detailHandle(row: AuditLog) {
|
||||
try {
|
||||
const data = await httpGet<any>(`/sys/openapi/audit-log/${row.id}`)
|
||||
currentRow.value = {
|
||||
...row,
|
||||
...data,
|
||||
id: String(data.id),
|
||||
responseStatus: data.responseStatus ?? row.responseStatus,
|
||||
costMs: data.costMs ?? row.costMs,
|
||||
createDate: data.createDate || row.createDate
|
||||
}
|
||||
detailVisible.value = true
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '详情加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
const activeStatus = computed(() => {
|
||||
return filters.searchKey || filters.appId || filters.customerCode || filters.callType ? '' : ''
|
||||
})
|
||||
|
||||
function applyQueryParams() {
|
||||
const q = route.query
|
||||
if (q.customerCode && typeof q.customerCode === 'string') {
|
||||
filters.customerCode = q.customerCode
|
||||
}
|
||||
if (q.appId && typeof q.appId === 'string') {
|
||||
filters.appId = q.appId
|
||||
}
|
||||
if (q.username && typeof q.username === 'string') {
|
||||
filters.searchKey = q.username
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyQueryParams()
|
||||
load()
|
||||
loadCustomers()
|
||||
loadApps()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-openapi-audit">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">审计日志</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 条调用记录</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
<el-button type="primary" class="rr-btn-primary" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-tabs-row">
|
||||
<div class="rr-tabs">
|
||||
<div
|
||||
v-for="t in STATUS_TABS"
|
||||
:key="t.value"
|
||||
class="rr-tab"
|
||||
:class="{ active: !filters.searchKey && !filters.appId && !filters.customerCode && !filters.callType && activeStatus === t.value }"
|
||||
@click="onStatusTab(t.value)"
|
||||
>{{ t.label }}</div>
|
||||
</div>
|
||||
<div class="rr-filters">
|
||||
<el-input
|
||||
v-model="filters.searchKey"
|
||||
placeholder="搜索 工具 / 请求ID / 摘要"
|
||||
clearable
|
||||
class="rr-search-input"
|
||||
size="small"
|
||||
:prefix-icon="'Search'"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filters.customerCode"
|
||||
placeholder="客户"
|
||||
class="rr-customer-select"
|
||||
clearable
|
||||
filterable
|
||||
size="small"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in customerOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="filters.appId"
|
||||
placeholder="App"
|
||||
class="rr-app-select"
|
||||
clearable
|
||||
filterable
|
||||
size="small"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in appOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="filters.callType"
|
||||
placeholder="类型"
|
||||
class="rr-type-select"
|
||||
clearable
|
||||
size="small"
|
||||
>
|
||||
<el-option value="MCP" label="MCP" />
|
||||
<el-option value="REST" label="REST" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'createDate', label: '时间', width: 170, slot: 'createDate' },
|
||||
{ prop: 'customerName', label: '客户', width: 140 },
|
||||
{ prop: 'appName', label: '应用', minWidth: 160 },
|
||||
{ prop: 'callType', label: '类型', width: 80, slot: 'callType' },
|
||||
{ prop: 'toolOrEndpoint', label: '工具/接口', minWidth: 200, slot: 'tool' },
|
||||
{ prop: 'responseStatus', label: '状态', width: 100, slot: 'status' },
|
||||
{ prop: 'costMs', label: '耗时', width: 90, slot: 'costMs' },
|
||||
{ prop: 'ip', label: '客户端 IP', width: 140, slot: 'ip' },
|
||||
{ prop: 'requestId', label: '请求 ID', width: 180, slot: 'requestId' },
|
||||
{ label: '操作', width: 100, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="loadError"
|
||||
empty-text="暂无审计记录"
|
||||
borderless
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #createDate="{ row }">
|
||||
<span class="rr-time">{{ fmtDate(row.createDate) }}</span>
|
||||
</template>
|
||||
|
||||
<template #callType="{ row }">
|
||||
<span :class="['rr-type-badge', `rr-type-${row.callType}`]">{{ row.callType }}</span>
|
||||
</template>
|
||||
|
||||
<template #tool="{ row }">
|
||||
<code class="rr-mono">{{ row.toolOrEndpoint }}</code>
|
||||
</template>
|
||||
|
||||
<template #status="{ row }">
|
||||
<el-tag :type="statusType(row.responseStatus)" size="small" effect="light">
|
||||
{{ statusLabel(row.responseStatus) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
|
||||
<template #costMs="{ row }">
|
||||
<span :style="{ color: costColor(row.costMs) }">{{ row.costMs }} ms</span>
|
||||
</template>
|
||||
|
||||
<template #ip="{ row }">
|
||||
<span class="rr-mono-sm">{{ formatIp(row.ip) }}</span>
|
||||
</template>
|
||||
|
||||
<template #requestId="{ row }">
|
||||
<code class="rr-mono-sm">{{ row.requestId }}</code>
|
||||
</template>
|
||||
|
||||
<template #action="{ row }">
|
||||
<el-button
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="detailHandle(row)"
|
||||
>详情</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
title="审计日志详情"
|
||||
width="720"
|
||||
class="rr-dialog-compact"
|
||||
align-center
|
||||
destroy-on-close
|
||||
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
||||
>
|
||||
<div v-if="currentRow" class="rr-detail">
|
||||
<div class="rr-detail-row">
|
||||
<div class="rr-detail-label">请求 ID</div>
|
||||
<div class="rr-detail-value">
|
||||
<code class="rr-mono">{{ currentRow.requestId }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-detail-row">
|
||||
<div class="rr-detail-label">AppID</div>
|
||||
<div class="rr-detail-value">
|
||||
<code class="rr-mono">{{ currentRow.appId }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-detail-row">
|
||||
<div class="rr-detail-label">客户编码</div>
|
||||
<div class="rr-detail-value">{{ currentRow.customerCode }}</div>
|
||||
</div>
|
||||
<div class="rr-detail-row">
|
||||
<div class="rr-detail-label">调用类型</div>
|
||||
<div class="rr-detail-value">
|
||||
<span :class="['rr-type-badge', `rr-type-${currentRow.callType}`]">{{ currentRow.callType }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-detail-row">
|
||||
<div class="rr-detail-label">响应状态</div>
|
||||
<div class="rr-detail-value">
|
||||
<el-tag :type="statusType(currentRow.responseStatus)" size="small" effect="light">
|
||||
{{ statusLabel(currentRow.responseStatus) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-detail-row rr-detail-full">
|
||||
<div class="rr-detail-label">工具 / 接口</div>
|
||||
<div class="rr-detail-value">
|
||||
<code class="rr-mono">{{ currentRow.toolOrEndpoint }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-detail-row">
|
||||
<div class="rr-detail-label">响应码</div>
|
||||
<div class="rr-detail-value">{{ currentRow.responseCode ?? '--' }}</div>
|
||||
</div>
|
||||
<div class="rr-detail-row">
|
||||
<div class="rr-detail-label">耗时</div>
|
||||
<div class="rr-detail-value">
|
||||
<span :style="{ color: costColor(currentRow.costMs) }">{{ currentRow.costMs }} ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-detail-row rr-detail-full">
|
||||
<div class="rr-detail-label">客户端 IP</div>
|
||||
<div class="rr-detail-value">{{ formatIp(currentRow.ip) }}</div>
|
||||
</div>
|
||||
<div class="rr-detail-row rr-detail-full">
|
||||
<div class="rr-detail-label">User Agent</div>
|
||||
<div class="rr-detail-value rr-detail-muted">{{ currentRow.userAgent || '--' }}</div>
|
||||
</div>
|
||||
<div class="rr-detail-row rr-detail-full">
|
||||
<div class="rr-detail-label">请求摘要</div>
|
||||
<div class="rr-detail-value">
|
||||
<pre v-if="currentRow.requestSummary" class="rr-pre">{{ currentRow.requestSummary }}</pre>
|
||||
<span v-else class="rr-detail-muted">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="currentRow.errorMessage" class="rr-detail-row rr-detail-full">
|
||||
<div class="rr-detail-label">错误信息</div>
|
||||
<div class="rr-detail-value">
|
||||
<span class="rr-error">{{ currentRow.errorMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-detail-row rr-detail-full">
|
||||
<div class="rr-detail-label">时间</div>
|
||||
<div class="rr-detail-value">{{ fmtDate(currentRow.createDate) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="rr-dialog-footer">
|
||||
<el-button type="primary" @click="detailVisible = false">关闭</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-openapi-audit {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.rr-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rr-page-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rr-page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.rr-page-sub {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rr-page-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rr-tabs-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rr-tabs {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.rr-tab {
|
||||
position: relative;
|
||||
padding: 6px 0;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
.rr-tab:hover { color: var(--el-text-color-primary); }
|
||||
.rr-tab.active {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.rr-tab.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--el-color-primary);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.rr-filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rr-search-input { width: 240px; }
|
||||
.rr-customer-select { width: 180px; }
|
||||
.rr-app-select { width: 180px; }
|
||||
.rr-type-select { width: 90px; }
|
||||
|
||||
.rr-list-card {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.rr-time {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.rr-mono {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.rr-mono-sm {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.rr-type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.rr-type-MCP {
|
||||
background: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.rr-type-REST {
|
||||
background: var(--el-color-success-light-9);
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.rr-error {
|
||||
color: var(--el-color-danger);
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.rr-pre {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.rr-detail {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 14px 20px;
|
||||
}
|
||||
|
||||
.rr-detail-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rr-detail-full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.rr-detail-label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rr-detail-value {
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.rr-detail-muted {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.rr-dialog-compact :deep(.el-dialog__header) {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.rr-dialog-compact :deep(.el-dialog__body) {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.rr-dialog-compact :deep(.el-dialog__footer) {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.rr-dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
537
src/modules/openapi/CompanyAccountListView.vue
Normal file
537
src/modules/openapi/CompanyAccountListView.vue
Normal file
@ -0,0 +1,537 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 集成管理 - 公司账号管理
|
||||
* W4.2:套老 vue company-account.vue 结构
|
||||
* - 列表筛选:按客户
|
||||
* - 列:用户名 / 姓名 / 客户名 / appName / 动态摘要 / 状态 / 创建时间
|
||||
* - 操作:查看动态(跳审计日志) / 接管 / 解绑
|
||||
* - 弹窗:接管弹窗(recordType/recordId/toUserId/reason)
|
||||
*
|
||||
* 后端端点 (来自 OpenApiCompanyController):
|
||||
* GET /sys/openapi/company/page 分页列表(userType=99 过滤)
|
||||
* GET /sys/openapi/company/{id} 详情
|
||||
* POST /sys/openapi/company/takeover 接管记录(后端 TODO)
|
||||
* POST /sys/openapi/company/unbind 解绑 app(后端 TODO)
|
||||
* GET /sys/openapi/customer/list 客户列表
|
||||
* GET /sys/user/list?customerCode=&userType=1 员工列表(接管弹窗用)
|
||||
*
|
||||
* 设计规范:§16 列表页 + §17.7 紧凑弹窗 + §17.8 主题色弹窗 + §17.9 列优化
|
||||
*/
|
||||
import { onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { httpGet, httpPost } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface CompanyAccount {
|
||||
id: string
|
||||
username: string
|
||||
realName: string
|
||||
customerCode: string
|
||||
customerName: string
|
||||
appName: string
|
||||
activitySummary: string
|
||||
status: 0 | 1
|
||||
createDate: string
|
||||
raw: any
|
||||
}
|
||||
|
||||
interface CustomerOpt { value: string; label: string }
|
||||
interface EmployeeOpt { id: number; realName: string; username: string }
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
1: { label: '正常', type: 'success' },
|
||||
0: { label: '已停用', type: 'info' }
|
||||
}
|
||||
|
||||
const list = ref<CompanyAccount[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
|
||||
const filters = reactive({
|
||||
customerCode: ''
|
||||
})
|
||||
|
||||
const customerOptions = ref<CustomerOpt[]>([])
|
||||
|
||||
const takeoverVisible = ref(false)
|
||||
const takeoverForm = reactive({
|
||||
fromUserId: null as number | null,
|
||||
fromUsername: '',
|
||||
recordType: '',
|
||||
recordId: '',
|
||||
toUserId: null as number | null,
|
||||
reason: ''
|
||||
})
|
||||
const employeeOptions = ref<EmployeeOpt[]>([])
|
||||
const takeoverLoading = ref(false)
|
||||
|
||||
const RECORD_TYPES = [
|
||||
{ value: 'inquiry', label: '询价记录' },
|
||||
{ value: 'product', label: '产品记录' },
|
||||
{ value: 'order', label: '订单记录' }
|
||||
]
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n) return '--'
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number; list: any[] }>('/sys/openapi/company/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.customerCode ? { customerCode: filters.customerCode } : {})
|
||||
})
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
username: it.username || '--',
|
||||
realName: it.realName || '--',
|
||||
customerCode: it.customerCode || '--',
|
||||
customerName: it.customerName || it.customerCode || '--',
|
||||
appName: it.appName || '--',
|
||||
activitySummary: it.activitySummary || '--',
|
||||
status: (it.status ?? 0) as 0 | 1,
|
||||
createDate: it.createDate,
|
||||
raw: it
|
||||
}))
|
||||
total.value = data.total || 0
|
||||
} catch (e: any) {
|
||||
console.error('公司账号列表加载失败', e)
|
||||
list.value = []
|
||||
total.value = 0
|
||||
loadError.value = e?.msg || e?.message || '后端接口异常(详见 console)'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
try {
|
||||
const data = await httpGet<any[]>('/sys/openapi/customer/list')
|
||||
if (Array.isArray(data)) {
|
||||
customerOptions.value = data.map((c: any) => ({
|
||||
value: c.customerCode,
|
||||
label: c.customerName
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('客户列表加载失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() {
|
||||
filters.customerCode = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
|
||||
function activityHandle(row: CompanyAccount) {
|
||||
router.push({
|
||||
path: '/openapi/audit-log',
|
||||
query: {
|
||||
customerCode: row.customerCode,
|
||||
companyUserId: row.id,
|
||||
username: row.username
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function takeoverHandle(row: CompanyAccount) {
|
||||
takeoverForm.fromUserId = Number(row.id)
|
||||
takeoverForm.fromUsername = row.username
|
||||
takeoverForm.recordType = ''
|
||||
takeoverForm.recordId = ''
|
||||
takeoverForm.toUserId = null
|
||||
takeoverForm.reason = ''
|
||||
employeeOptions.value = []
|
||||
takeoverVisible.value = true
|
||||
takeoverLoading.value = true
|
||||
try {
|
||||
const res = await httpGet<any[]>('/sys/user/list', {
|
||||
customerCode: row.customerCode,
|
||||
userType: 1
|
||||
})
|
||||
employeeOptions.value = (Array.isArray(res) ? res : []).map((u: any) => ({
|
||||
id: u.id,
|
||||
realName: u.realName || u.username || '--',
|
||||
username: u.username || ''
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error('员工列表加载失败', e)
|
||||
ElMessage.warning('员工列表加载失败,接管时将无法选择新发布人')
|
||||
} finally {
|
||||
takeoverLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function takeoverConfirm() {
|
||||
if (!takeoverForm.recordType) {
|
||||
ElMessage.warning('请选择记录类型')
|
||||
return
|
||||
}
|
||||
if (!takeoverForm.recordId.trim()) {
|
||||
ElMessage.warning('请输入记录 ID')
|
||||
return
|
||||
}
|
||||
if (!takeoverForm.toUserId) {
|
||||
ElMessage.warning('请选择新发布人')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await httpPost('/sys/openapi/company/takeover', { ...takeoverForm })
|
||||
if (res?.code !== 0) {
|
||||
ElMessage.error(res?.msg || '接管失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('接管成功')
|
||||
takeoverVisible.value = false
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '接管失败')
|
||||
}
|
||||
}
|
||||
|
||||
function unbindHandle(row: CompanyAccount) {
|
||||
ElMessageBox.confirm(
|
||||
`确认解绑账号 ${row.username} 吗?解绑后将无法恢复。`,
|
||||
'解绑确认',
|
||||
{ type: 'warning', confirmButtonText: '解绑', cancelButtonText: '取消' }
|
||||
)
|
||||
.then(async () => {
|
||||
try {
|
||||
const res = await httpPost('/sys/openapi/company/unbind', {
|
||||
id: Number(row.id),
|
||||
reason: '管理员解绑'
|
||||
})
|
||||
if (res?.code !== 0) {
|
||||
ElMessage.error(res?.msg || '解绑失败')
|
||||
return
|
||||
}
|
||||
ElMessage.success('解绑成功')
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '解绑失败')
|
||||
}
|
||||
})
|
||||
.catch(() => { /* user cancelled */ })
|
||||
}
|
||||
|
||||
onMounted(() => { load(); loadCustomers() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-openapi-company">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">公司账号管理</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 个公司账号</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-filters-row">
|
||||
<el-select
|
||||
v-model="filters.customerCode"
|
||||
placeholder="选择客户筛选"
|
||||
class="rr-customer-select"
|
||||
clearable
|
||||
filterable
|
||||
size="small"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in customerOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'username', label: '用户名', width: 200, slot: 'username' },
|
||||
{ prop: 'realName', label: '姓名', width: 120 },
|
||||
{ prop: 'customerName', label: '客户名', minWidth: 160 },
|
||||
{ prop: 'appName', label: '应用名称', minWidth: 180 },
|
||||
{ prop: 'activitySummary', label: '动态摘要', width: 280 },
|
||||
{ prop: 'status', label: '状态', width: 100, slot: 'status' },
|
||||
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
|
||||
{ label: '操作', width: 280, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="loadError"
|
||||
empty-text="暂无公司账号数据"
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #username="{ row }">
|
||||
<code class="rr-username-code">{{ row.username }}</code>
|
||||
</template>
|
||||
|
||||
<template #status="{ row }">
|
||||
<el-tag
|
||||
v-if="STATUS_MAP[row.status]"
|
||||
:type="STATUS_MAP[row.status].type as any"
|
||||
size="small"
|
||||
effect="light"
|
||||
>{{ STATUS_MAP[row.status].label }}</el-tag>
|
||||
<el-tag v-else type="info" size="small" effect="light">未知</el-tag>
|
||||
</template>
|
||||
|
||||
<template #createDate="{ row }">
|
||||
<span class="rr-time">{{ fmtDate(row.createDate) }}</span>
|
||||
</template>
|
||||
|
||||
<template #action="{ row }">
|
||||
<el-button
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="activityHandle(row)"
|
||||
>查看动态</el-button>
|
||||
<el-button
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="takeoverHandle(row)"
|
||||
>接管</el-button>
|
||||
<el-button
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="unbindHandle(row)"
|
||||
>解绑</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- 接管弹窗 -->
|
||||
<el-dialog
|
||||
v-model="takeoverVisible"
|
||||
title="接管记录"
|
||||
width="640"
|
||||
class="rr-dialog-compact"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form :model="takeoverForm" label-width="100px" label-position="right">
|
||||
<el-form-item label="原发布人">
|
||||
<span class="rr-takeover-source">
|
||||
<code>{{ takeoverForm.fromUsername }}</code>
|
||||
<el-tag size="small" effect="plain" class="rr-takeover-tag">AI</el-tag>
|
||||
</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="记录类型" required>
|
||||
<el-select
|
||||
v-model="takeoverForm.recordType"
|
||||
placeholder="请选择记录类型"
|
||||
style="width: 100%"
|
||||
size="default"
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in RECORD_TYPES"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="记录 ID" required>
|
||||
<el-input
|
||||
v-model="takeoverForm.recordId"
|
||||
placeholder="请输入要接管的记录 ID"
|
||||
size="default"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="新发布人" required>
|
||||
<el-select
|
||||
v-model="takeoverForm.toUserId"
|
||||
placeholder="请选择新发布人"
|
||||
filterable
|
||||
style="width: 100%"
|
||||
size="default"
|
||||
:loading="takeoverLoading"
|
||||
loading-text="员工加载中..."
|
||||
>
|
||||
<el-option
|
||||
v-for="u in employeeOptions"
|
||||
:key="u.id"
|
||||
:label="`${u.realName} (${u.id})`"
|
||||
:value="u.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="rr-field-tip">仅显示该客户的非 AI 员工</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="接管原因">
|
||||
<el-input
|
||||
v-model="takeoverForm.reason"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="可选:说明接管原因,便于后续审计"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<div class="rr-dialog-footer">
|
||||
<el-button @click="takeoverVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="takeoverConfirm">确认接管</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-openapi-company {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.rr-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rr-page-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rr-page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.rr-page-sub {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rr-page-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rr-filters-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 16px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.rr-customer-select {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.rr-list-card {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.rr-username-code {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.rr-time {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.rr-takeover-source {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rr-takeover-source code {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.rr-takeover-tag {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.rr-field-tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rr-dialog-compact :deep(.el-dialog__header) {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.rr-dialog-compact :deep(.el-dialog__body) {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.rr-dialog-compact :deep(.el-dialog__footer) {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.rr-dialog-compact :deep(.el-dialog) {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.rr-dialog-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
693
src/modules/openapi/OpenAppListView.vue
Normal file
693
src/modules/openapi/OpenAppListView.vue
Normal file
@ -0,0 +1,693 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 集成管理 - 应用列表
|
||||
* W4.1-revert: 套老 vue app-list.vue 结构
|
||||
* - 4 种状态:0=待激活 / 1=已激活 / 2=已撤销 / 3=已过期
|
||||
* - 操作列(根据状态显示):
|
||||
* * 详情(任意状态,弹窗)
|
||||
* * 重置密钥(status=0 或 1)
|
||||
* * 重发二维码(status=0 或 1, W4.4 后端补 resend-qr)
|
||||
* * 撤销(status=0 或 1)
|
||||
* * 删除(status=2)
|
||||
* - 5 个弹窗:详情/撤销确认/删除确认/重置密钥/重发连接码
|
||||
* - 申请 APIKey 走 ApplyApiKeyDialog 2 步向导
|
||||
*
|
||||
* 后端端点:
|
||||
* GET /sys/openapi/app/page 列表分页
|
||||
* GET /sys/openapi/app/{id} 详情
|
||||
* POST /sys/openapi/app/apply 申请
|
||||
* POST /sys/openapi/app/revoke 撤销
|
||||
* POST /sys/openapi/app/delete 删除
|
||||
* GET /sys/openapi/customer/list 客户列表
|
||||
*/
|
||||
import { onMounted, ref, reactive, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet, httpPost } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
import ApplyApiKeyDialog from './ApplyApiKeyDialog.vue'
|
||||
|
||||
interface OpenApp {
|
||||
id: string
|
||||
appId: string
|
||||
appName: string
|
||||
customerCode: string
|
||||
customerName: string
|
||||
scopes: string
|
||||
status: 0 | 1 | 2 | 3
|
||||
totalCallCount: number | string
|
||||
lastUsedAt?: string | null
|
||||
createDate: string
|
||||
connectCode?: string
|
||||
connectCodeExpire?: string | number
|
||||
activatedAt?: string | null
|
||||
lastUsedIp?: string | null
|
||||
ipWhitelist?: string | null
|
||||
revokeReason?: string | null
|
||||
raw?: any
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<number, string> = {
|
||||
0: '待激活', 1: '已激活', 2: '已撤销', 3: '已过期'
|
||||
}
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '0', label: '待激活' },
|
||||
{ value: '1', label: '已激活' },
|
||||
{ value: '2', label: '已撤销' },
|
||||
{ value: '3', label: '已过期' }
|
||||
]
|
||||
|
||||
const list = ref<OpenApp[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
|
||||
const filters = reactive({
|
||||
searchKey: '',
|
||||
status: '',
|
||||
customerCode: ''
|
||||
})
|
||||
|
||||
const customerOptions = ref<Array<{ value: string; label: string }>>([])
|
||||
|
||||
const applyDialogVisible = ref(false)
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const currentRow = ref<OpenApp | null>(null)
|
||||
|
||||
const revokeVisible = ref(false)
|
||||
const revokeTarget = ref<OpenApp | null>(null)
|
||||
const revokeReason = ref('')
|
||||
|
||||
const deleteVisible = ref(false)
|
||||
const deleteTarget = ref<OpenApp | null>(null)
|
||||
|
||||
const resetSecretVisible = ref(false)
|
||||
const resetSecretTarget = ref<OpenApp | null>(null)
|
||||
const newSecret = ref('')
|
||||
|
||||
const resendQrVisible = ref(false)
|
||||
const resendQrTarget = ref<OpenApp | null>(null)
|
||||
const newConnectCode = ref('')
|
||||
const newConnectCodeExpire = ref('')
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n) return '--'
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await httpGet<{ total: number; list: any[] }>('/sys/openapi/app/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.searchKey ? { searchKey: filters.searchKey } : {}),
|
||||
...(filters.status !== '' ? { status: Number(filters.status) } : {}),
|
||||
...(filters.customerCode ? { customerCode: filters.customerCode } : {})
|
||||
})
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
appId: it.appId || '--',
|
||||
appName: it.appName || '--',
|
||||
customerCode: it.customerCode || '--',
|
||||
customerName: it.customerName || it.customerCode || '--',
|
||||
scopes: it.scopes || '--',
|
||||
status: (it.status ?? 0) as 0 | 1 | 2 | 3,
|
||||
totalCallCount: it.totalCallCount ?? 0,
|
||||
lastUsedAt: it.lastUsedAt,
|
||||
createDate: it.createDate,
|
||||
connectCode: it.connectCode,
|
||||
connectCodeExpire: it.connectCodeExpire,
|
||||
activatedAt: it.activatedAt,
|
||||
lastUsedIp: it.lastUsedIp,
|
||||
ipWhitelist: it.ipWhitelist,
|
||||
revokeReason: it.revokeReason,
|
||||
raw: it
|
||||
}))
|
||||
total.value = data.total || 0
|
||||
} catch (e) {
|
||||
console.error('应用列表加载失败', e)
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCustomers() {
|
||||
try {
|
||||
const data = await httpGet<any[]>('/sys/openapi/customer/list')
|
||||
if (Array.isArray(data)) {
|
||||
customerOptions.value = data.map((c: any) => ({
|
||||
value: c.customerCode,
|
||||
label: c.customerName
|
||||
}))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('客户列表加载失败', e)
|
||||
}
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ prop: 'customerName', label: '客户名', minWidth: 180 },
|
||||
{ prop: 'appName', label: '应用名称', minWidth: 180 },
|
||||
{ prop: 'appId', label: 'AppID', minWidth: 220 },
|
||||
{ prop: 'status', label: '状态', width: 100, slot: 'status' as const },
|
||||
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' as const },
|
||||
{ label: '操作', width: 280, slot: 'action' as const }
|
||||
]
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() {
|
||||
filters.searchKey = ''
|
||||
filters.status = ''
|
||||
filters.customerCode = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
function onStatusTab(v: string) { filters.status = v; page.value = 1; load() }
|
||||
|
||||
async function detailHandle(row: OpenApp) {
|
||||
try {
|
||||
const data = await httpGet<any>(`/sys/openapi/app/${row.id}`)
|
||||
currentRow.value = {
|
||||
...row,
|
||||
...data,
|
||||
id: String(data.id),
|
||||
totalCallCount: data.totalCallCount ?? 0
|
||||
}
|
||||
detailVisible.value = true
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '详情加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
function revokeHandle(row: OpenApp) {
|
||||
revokeTarget.value = row
|
||||
revokeReason.value = ''
|
||||
revokeVisible.value = true
|
||||
}
|
||||
|
||||
async function revokeConfirm() {
|
||||
if (!revokeTarget.value) return
|
||||
if (!revokeReason.value.trim()) {
|
||||
ElMessage.warning('请输入撤销原因')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await httpPost('/sys/openapi/app/revoke', {
|
||||
id: Number(revokeTarget.value.id),
|
||||
reason: revokeReason.value
|
||||
})
|
||||
ElMessage.success('撤销成功')
|
||||
revokeVisible.value = false
|
||||
revokeTarget.value = null
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '撤销失败')
|
||||
}
|
||||
}
|
||||
|
||||
function deleteHandle(row: OpenApp) {
|
||||
deleteTarget.value = row
|
||||
deleteVisible.value = true
|
||||
}
|
||||
|
||||
async function deleteConfirm() {
|
||||
if (!deleteTarget.value) return
|
||||
try {
|
||||
await httpPost('/sys/openapi/app/delete', { id: Number(deleteTarget.value.id) })
|
||||
ElMessage.success('删除成功')
|
||||
deleteVisible.value = false
|
||||
deleteTarget.value = null
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
function resetSecretHandle(row: OpenApp) {
|
||||
resetSecretTarget.value = row
|
||||
newSecret.value = ''
|
||||
resetSecretVisible.value = true
|
||||
}
|
||||
|
||||
async function resetSecretConfirm() {
|
||||
if (!resetSecretTarget.value) return
|
||||
try {
|
||||
const data = await httpPost<string>(`/sys/openapi/app/reset-secret/${resetSecretTarget.value.id}`)
|
||||
newSecret.value = data || ''
|
||||
ElMessage.success('密钥已重置,旧密钥立即作废')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '重置失败')
|
||||
}
|
||||
}
|
||||
|
||||
function resendQrHandle(row: OpenApp) {
|
||||
resendQrTarget.value = row
|
||||
newConnectCode.value = ''
|
||||
newConnectCodeExpire.value = ''
|
||||
resendQrVisible.value = true
|
||||
}
|
||||
|
||||
async function resendQrConfirm() {
|
||||
if (!resendQrTarget.value) return
|
||||
try {
|
||||
const data = await httpPost<{ connectCode: string; connectCodeExpire: string | number }>(
|
||||
`/sys/openapi/app/resend-qr/${resendQrTarget.value.id}`
|
||||
)
|
||||
newConnectCode.value = data?.connectCode || ''
|
||||
newConnectCodeExpire.value = data?.connectCodeExpire
|
||||
? String(data.connectCodeExpire)
|
||||
: ''
|
||||
ElMessage.success('连接码已重发,旧码已作废')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '重发失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function copyText(text: string, label = '已复制') {
|
||||
// 优先用现代 Clipboard API (HTTPS / localhost 才允许)
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success(label)
|
||||
return
|
||||
}
|
||||
throw new Error('no clipboard api')
|
||||
} catch {
|
||||
// 降级方案: document.execCommand('copy') - 在 HTTP 下也能用
|
||||
try {
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.style.position = 'fixed'
|
||||
ta.style.opacity = '0'
|
||||
ta.style.pointerEvents = 'none'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
const ok = document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
if (ok) {
|
||||
ElMessage.success(label)
|
||||
} else {
|
||||
ElMessage.warning('浏览器阻止自动复制,请手动双击输入框选中后 Cmd/Ctrl+C')
|
||||
}
|
||||
} catch {
|
||||
ElMessage.warning('浏览器阻止自动复制,请手动双击输入框选中后 Cmd/Ctrl+C')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onSecretInputFocus(e: FocusEvent) {
|
||||
const t = e.target as HTMLInputElement | null
|
||||
t?.select()
|
||||
}
|
||||
|
||||
function onApplyDialogOpen() { applyDialogVisible.value = true }
|
||||
async function onApplySubmitted() { applyDialogVisible.value = false; page.value = 1; await load() }
|
||||
|
||||
onMounted(() => { load(); loadCustomers() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-openapi-app">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">应用列表</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 个集成应用</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
<el-button type="primary" class="rr-btn-primary" @click="onApplyDialogOpen">+ 申请新 APIKey</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-tabs-row">
|
||||
<div class="rr-tabs">
|
||||
<div
|
||||
v-for="t in STATUS_TABS"
|
||||
:key="t.value"
|
||||
class="rr-tab"
|
||||
:class="{ active: filters.status === t.value }"
|
||||
@click="onStatusTab(t.value)"
|
||||
>{{ t.label }}</div>
|
||||
</div>
|
||||
<div class="rr-filters">
|
||||
<el-input
|
||||
v-model="filters.searchKey"
|
||||
placeholder="搜索 app 名称 / app_id / 客户名"
|
||||
clearable
|
||||
class="rr-search-input"
|
||||
:prefix-icon="'Search'"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filters.customerCode"
|
||||
placeholder="客户"
|
||||
class="rr-customer-select"
|
||||
clearable
|
||||
filterable
|
||||
>
|
||||
<el-option
|
||||
v-for="opt in customerOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button class="rr-btn-secondary" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="columns"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
empty-text="暂无应用"
|
||||
borderless
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #status="{ row }">
|
||||
<span :class="['rr-badge', `rr-badge-${row.status}`]">{{ STATUS_LABEL[row.status] || row.status }}</span>
|
||||
</template>
|
||||
<template #createDate="{ row }">
|
||||
{{ fmtDate(row.createDate) }}
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<el-button text type="primary" size="small" @click="detailHandle(row)">详情</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 0 || row.status === 1"
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="resendQrHandle(row)"
|
||||
>重发二维码</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 0 || row.status === 1"
|
||||
text
|
||||
type="warning"
|
||||
size="small"
|
||||
@click="resetSecretHandle(row)"
|
||||
>重置密钥</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 0 || row.status === 1"
|
||||
text
|
||||
size="small"
|
||||
class="rr-danger-text"
|
||||
@click="revokeHandle(row)"
|
||||
>撤销</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 2"
|
||||
text
|
||||
size="small"
|
||||
class="rr-danger-text"
|
||||
@click="deleteHandle(row)"
|
||||
>删除</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗(老 vue 是 dialog 不是路由页) -->
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
title="应用详情"
|
||||
width="640px"
|
||||
>
|
||||
<el-descriptions v-if="currentRow" :column="1" border>
|
||||
<el-descriptions-item label="AppID">{{ currentRow.appId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="应用名称">{{ currentRow.appName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户名称">{{ currentRow.customerName || currentRow.customerCode || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="客户标识">{{ currentRow.customerCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<span :class="['rr-badge', `rr-badge-${currentRow.status}`]">{{ STATUS_LABEL[currentRow.status] || currentRow.status }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="ConnectCode">
|
||||
<code class="rr-code-accent">{{ currentRow.connectCode || '—' }}</code>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="ConnectCode 过期">{{ fmtDate(currentRow.connectCodeExpire) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="权限范围">{{ currentRow.scopes }}</el-descriptions-item>
|
||||
<el-descriptions-item label="IP 白名单">{{ currentRow.ipWhitelist || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="激活时间">{{ currentRow.activatedAt || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最后调用">{{ currentRow.lastUsedAt || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最后调用 IP">{{ currentRow.lastUsedIp || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="总调用次数">{{ currentRow.totalCallCount }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="currentRow.revokeReason" label="撤销原因">{{ currentRow.revokeReason }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ currentRow.createDate }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 撤销确认弹窗(主题色:info 蓝色 + primary 按钮,因为撤销可恢复) -->
|
||||
<el-dialog v-model="revokeVisible" title="撤销 APIKey" width="480px">
|
||||
<el-alert v-if="revokeTarget" type="info" :closable="false" style="margin-bottom: 16px">
|
||||
<p>应用: <code class="rr-code-accent">{{ revokeTarget.appId }}</code> ({{ revokeTarget.appName }})</p>
|
||||
<p style="margin-top: 8px;">撤销后 app 将无法继续调用,需要重新申请。</p>
|
||||
</el-alert>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="撤销原因" required>
|
||||
<el-input v-model="revokeReason" type="textarea" :rows="3" placeholder="如:业务调整 / 安全审计" maxlength="200" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="revokeVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="revokeConfirm">确认撤销</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 删除确认弹窗(物理删除不可逆,保留红色 danger) -->
|
||||
<el-dialog v-model="deleteVisible" title="物理删除 app" width="480px">
|
||||
<el-alert type="error" :closable="false" style="margin-bottom: 16px">
|
||||
<p>仅能删除已撤销(status=2)的 app</p>
|
||||
<p>物理删除后无法恢复</p>
|
||||
</el-alert>
|
||||
<p v-if="deleteTarget">app: <code class="rr-code-accent">{{ deleteTarget.appId }}</code> ({{ deleteTarget.appName }})</p>
|
||||
<template #footer>
|
||||
<el-button @click="deleteVisible = false">取消</el-button>
|
||||
<el-button type="danger" @click="deleteConfirm">确认删除</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重置密钥弹窗 (生成新 appSecret,旧密钥立即作废) -->
|
||||
<el-dialog v-model="resetSecretVisible" title="重置 AppSecret" width="560px">
|
||||
<el-alert v-if="!newSecret" type="warning" :closable="false" style="margin-bottom: 16px">
|
||||
<p>重置后,系统会生成新的 appSecret 并更新数据库 BCrypt 哈希</p>
|
||||
<p>旧密钥立即作废,Mac 平台助手必须同步更新配置</p>
|
||||
<p>已撤销的 app 不能重置(请先重新申请)</p>
|
||||
</el-alert>
|
||||
<el-alert v-if="newSecret" type="error" :closable="false" style="margin-bottom: 16px">
|
||||
<p>新密钥仅显示一次!立即复制并通过安全渠道发给客户</p>
|
||||
<p>关闭弹窗后无法再次查看(只能再次重置)</p>
|
||||
</el-alert>
|
||||
<p v-if="resetSecretTarget && !newSecret">app: <code class="rr-code-accent">{{ resetSecretTarget.appId }}</code> ({{ resetSecretTarget.appName }})</p>
|
||||
<div v-if="newSecret" style="margin-top: 12px;">
|
||||
<p style="margin-bottom: 6px; font-size: 13px; color: var(--ink-2);">
|
||||
新的 AppSecret: <span style="color: var(--ink-3); font-size: 12px;">(明文, 可直接选中复制)</span>
|
||||
</p>
|
||||
<el-input
|
||||
v-model="newSecret"
|
||||
readonly
|
||||
type="text"
|
||||
size="default"
|
||||
style="font-family: ui-monospace, monospace;"
|
||||
@focus="onSecretInputFocus"
|
||||
>
|
||||
<template #append>
|
||||
<el-button type="primary" @click="copyText(newSecret, 'AppSecret 已复制')">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button v-if="!newSecret" @click="resetSecretVisible = false">取消</el-button>
|
||||
<el-button v-if="!newSecret" type="warning" @click="resetSecretConfirm">确认重置</el-button>
|
||||
<el-button v-if="newSecret" type="primary" @click="resetSecretVisible = false">我已复制,关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 重发连接码(二维码)弹窗 -->
|
||||
<el-dialog
|
||||
v-model="resendQrVisible"
|
||||
title="重发连接码"
|
||||
width="560"
|
||||
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<el-alert v-if="!newConnectCode" type="warning" :closable="false" style="margin-bottom: 16px">
|
||||
<p>重发后会生成新的 connectCode,旧码立即作废</p>
|
||||
<p>新码有效期 2 小时,请尽快发给客户扫码激活</p>
|
||||
</el-alert>
|
||||
<el-alert v-if="newConnectCode" type="error" :closable="false" style="margin-bottom: 16px">
|
||||
<p>新连接码仅显示一次!请立即复制并通过安全渠道发给客户</p>
|
||||
<p>关闭弹窗后无法再次查看(只能再次重发)</p>
|
||||
</el-alert>
|
||||
<p v-if="resendQrTarget && !newConnectCode">
|
||||
app: <code class="rr-code-accent">{{ resendQrTarget.appId }}</code>
|
||||
({{ resendQrTarget.appName }})
|
||||
</p>
|
||||
<div v-if="newConnectCode" style="margin-top: 12px;">
|
||||
<p style="margin-bottom: 6px; font-size: 13px; color: var(--el-text-color-regular);">
|
||||
新的 ConnectCode: <span style="color: var(--el-text-color-secondary); font-size: 12px;">(明文,可直接选中复制)</span>
|
||||
</p>
|
||||
<el-input
|
||||
v-model="newConnectCode"
|
||||
readonly
|
||||
type="text"
|
||||
size="default"
|
||||
style="font-family: ui-monospace, monospace;"
|
||||
@focus="onSecretInputFocus"
|
||||
>
|
||||
<template #append>
|
||||
<el-button type="primary" @click="copyText(newConnectCode, '连接码已复制')">复制</el-button>
|
||||
</template>
|
||||
</el-input>
|
||||
<p v-if="newConnectCodeExpire" style="margin-top: 8px; font-size: 12px; color: var(--el-text-color-secondary);">
|
||||
过期时间: {{ fmtDate(newConnectCodeExpire) }}
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button v-if="!newConnectCode" @click="resendQrVisible = false">取消</el-button>
|
||||
<el-button v-if="!newConnectCode" type="primary" @click="resendQrConfirm">确认重发</el-button>
|
||||
<el-button v-if="newConnectCode" type="primary" @click="resendQrVisible = false">我已复制,关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 申请 APIKey 向导(2 步) -->
|
||||
<ApplyApiKeyDialog v-model="applyDialogVisible" @refresh="onApplySubmitted" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-openapi-app { max-width: 1400px; }
|
||||
|
||||
.rr-page-header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.rr-page-titles { display: flex; flex-direction: column; gap: 4px; }
|
||||
.rr-page-title {
|
||||
font-size: 22px; font-weight: 600; letter-spacing: -0.02em;
|
||||
color: var(--ink); margin: 0;
|
||||
}
|
||||
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
|
||||
.rr-page-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
|
||||
.rr-list-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.rr-tabs-row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin-bottom: 16px; gap: 16px;
|
||||
}
|
||||
.rr-tabs { display: flex; gap: 24px; flex: 1; }
|
||||
.rr-tab {
|
||||
position: relative; padding: 8px 0;
|
||||
font-size: 13px; font-weight: 500;
|
||||
color: var(--ink-3); cursor: pointer;
|
||||
user-select: none;
|
||||
&:hover { color: var(--ink); }
|
||||
&.active { color: var(--brand); font-weight: 600; }
|
||||
&.active::after {
|
||||
content: ''; position: absolute; bottom: 0; left: 0; right: 0;
|
||||
height: 2px; background: var(--brand); border-radius: 1px;
|
||||
}
|
||||
}
|
||||
.rr-filters { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.rr-search-input { width: 280px; }
|
||||
.rr-customer-select { width: 160px; }
|
||||
|
||||
/* 状态徽章(浅底色块 - 4 种状态) */
|
||||
.rr-badge {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 4px;
|
||||
font-size: 11.5px; font-weight: 500; line-height: 17.25px; height: 21.25px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.rr-badge-0 { background: #FEF3C7; color: #D97706; } /* 待激活 amber */
|
||||
.rr-badge-1 { background: #D1FAE5; color: #059669; } /* 已激活 green */
|
||||
.rr-badge-2 { background: #DBEAFE; color: #2563EB; } /* 已撤销 blue */
|
||||
.rr-badge-3 { background: #E2E8F0; color: #64748B; } /* 已过期 gray */
|
||||
|
||||
/* 危险文字按钮(撤销/删除 - 老 vue 用 style color: #ff4d4f) */
|
||||
:deep(.rr-danger-text) {
|
||||
color: #ff4d4f !important;
|
||||
}
|
||||
|
||||
/* 主/次按钮 */
|
||||
:deep(.rr-btn-primary) {
|
||||
height: 32px; padding: 0 12px; font-size: 13px; font-weight: 500;
|
||||
border-radius: 8px; background: var(--brand); border: 1px solid var(--brand);
|
||||
}
|
||||
:deep(.rr-btn-secondary) {
|
||||
height: 32px; padding: 0 12px; font-size: 13px; font-weight: 500;
|
||||
border-radius: 8px; background: var(--surface);
|
||||
border: 1px solid var(--border); color: var(--ink);
|
||||
}
|
||||
|
||||
/* 输入框 */
|
||||
:deep(.el-input__wrapper),
|
||||
:deep(.el-input__inner),
|
||||
:deep(.el-select__wrapper) {
|
||||
border-radius: 8px !important;
|
||||
background: #F8FAFC;
|
||||
}
|
||||
|
||||
/* 弹窗圆角 12px + 紧凑(套设计规范 §7.1 + §17 v3) */
|
||||
:deep(.el-dialog) {
|
||||
border-radius: 12px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
:deep(.el-dialog__header) {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
:deep(.el-dialog__header .el-dialog__title) {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
:deep(.el-dialog__body) {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
:deep(.el-dialog__footer) {
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* 弹窗内 el-alert 紧凑 */
|
||||
:deep(.el-dialog .el-alert) {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
:deep(.el-dialog .el-alert .el-alert__title) {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 表格圆角 12px(套设计规范 §11) */
|
||||
:deep(.el-table) {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 操作列紧凑 */
|
||||
:deep(.el-table .el-button + .el-button) { margin-left: 4px !important; }
|
||||
:deep(.el-table .el-button.is-text) { padding: 2px 6px !important; font-size: 12.5px !important; }
|
||||
|
||||
.rr-code-accent { color: var(--red); font-family: ui-monospace, monospace; }
|
||||
</style>
|
||||
582
src/modules/order/OrderDetailView.vue
Normal file
582
src/modules/order/OrderDetailView.vue
Normal file
@ -0,0 +1,582 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 订单详情 - 完整版 (W5.x.x+5 整改)
|
||||
*
|
||||
* 沿用老 vue order/order-detail.vue 1379 行完整功能:
|
||||
* - 4 个 tab: 订单信息 / 商品明细 / 物流信息 / 付款发票
|
||||
* - 订单状态卡 + 操作按钮 (取消/付款/发货/收货/退款)
|
||||
* - 商品明细 (多商品 + 拼团信息)
|
||||
* - 物流信息 + 签收回单
|
||||
* - 付款信息 (分笔) + 发票信息
|
||||
*
|
||||
* 支持 type 参数 (good/service/groupsupplier/groupservice)
|
||||
* - 4 个老 vue 详情页 (order-good/service/groupsupplier/groupservice detail) 统一收敛
|
||||
* - 不同 type 显示不同字段 + 不同操作按钮
|
||||
*
|
||||
* 字段: 100% 沿用老 vue OrderDetail/OrderGood/DeliveryItem/PayItem 接口
|
||||
*/
|
||||
import { ref, onMounted, watch, computed, reactive } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet } from '@/utils/request'
|
||||
|
||||
interface OrderDetail {
|
||||
id: string
|
||||
orderCode: string
|
||||
status: number
|
||||
payStatus: number | null
|
||||
goodType: number
|
||||
pickupType: number
|
||||
enterpriseName: string
|
||||
supplierName: string
|
||||
contractCode: string
|
||||
sum: number
|
||||
couponMoney: number
|
||||
freight: number
|
||||
payMoney: number
|
||||
createDate: string
|
||||
payDate: string | null
|
||||
remark: string | null
|
||||
// 收货
|
||||
address: {
|
||||
name: string
|
||||
mobile: string
|
||||
provinceName: string
|
||||
cityName: string
|
||||
areaName: string
|
||||
address: string
|
||||
fullAddress: string
|
||||
} | null
|
||||
// 商品
|
||||
orderGoodList: OrderGood[]
|
||||
// 物流
|
||||
deliveryList: DeliveryItem[]
|
||||
acceptanceList: AcceptanceItem[]
|
||||
// 付款
|
||||
payList: PayItem[]
|
||||
// 拼团
|
||||
groupNumber: number
|
||||
groupNickName: string
|
||||
groupMobile: string
|
||||
groupStatus: number
|
||||
// 文件
|
||||
fileUrl: string | null
|
||||
invoice: string | null
|
||||
acceptanceForm: string | null
|
||||
// 推广
|
||||
promoterName: string | null
|
||||
promoterMobile: string | null
|
||||
rebateMoney: number | null
|
||||
raw: any
|
||||
}
|
||||
|
||||
interface OrderGood {
|
||||
id: string
|
||||
title: string
|
||||
icon: string | null
|
||||
propertyNames: string
|
||||
unitPrice: number
|
||||
totalNum: number
|
||||
shippedNum: number
|
||||
orderMoney: number
|
||||
status: number
|
||||
payStatus: number
|
||||
payType: number
|
||||
createDate: string
|
||||
payDate: string | null
|
||||
pickupType: number
|
||||
fileUrl: string | null
|
||||
remark: string | null
|
||||
raw: any
|
||||
}
|
||||
|
||||
interface DeliveryItem {
|
||||
id: string
|
||||
deliveryCompany: string
|
||||
deliveryNo: string
|
||||
deliveryStatus: number
|
||||
deliveryTime: string
|
||||
receivedTime: string | null
|
||||
deliveryList: DeliveryItemDetail[]
|
||||
raw: any
|
||||
}
|
||||
|
||||
interface DeliveryItemDetail {
|
||||
id: string
|
||||
goodName: string
|
||||
num: number
|
||||
raw: any
|
||||
}
|
||||
|
||||
interface AcceptanceItem {
|
||||
id: string
|
||||
acceptanceNum: number
|
||||
acceptanceTime: string
|
||||
acceptanceFile: string | null
|
||||
remark: string
|
||||
raw: any
|
||||
}
|
||||
|
||||
interface PayItem {
|
||||
id: string
|
||||
payType: number
|
||||
payMoney: number
|
||||
payTime: string
|
||||
payStatus: number
|
||||
invoiceStatus: number
|
||||
remark: string
|
||||
raw: any
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
type?: 'good' | 'service' | 'groupsupplier' | 'groupservice'
|
||||
}>()
|
||||
|
||||
const orderId = computed(() => String(route.params.id || ''))
|
||||
// 从 route.path 推断 type (例 /order/ordergood/:id -> 'good', /order/serviceorder/:id -> 'service')
|
||||
const orderType = computed(() => {
|
||||
if (props.type) return props.type
|
||||
const path = route.path
|
||||
if (path.startsWith('/order/serviceorder') || path.startsWith('/order/orderserviceafter')) return 'service'
|
||||
if (path.startsWith('/order/groupsupplierorder') || path.startsWith('/order/group-supplier')) return 'groupsupplier'
|
||||
if (path.startsWith('/order/groupserviceorder') || path.startsWith('/order/group-service')) return 'groupservice'
|
||||
return 'good'
|
||||
})
|
||||
|
||||
// 状态映射 (老 vue 1-7 数字, 我也沿用)
|
||||
const STATUS_MAP: Record<number, { label: string; type: string; desc: string }> = {
|
||||
1: { label: '待支付', type: 'warning', desc: '订单待付款, 请尽快支付' },
|
||||
2: { label: '待发货', type: 'primary', desc: '订单已支付, 等待供应商发货' },
|
||||
3: { label: '待收货', type: 'primary', desc: '商品已发货, 等待签收' },
|
||||
4: { label: '待付尾款', type: 'warning', desc: '请支付订单尾款' },
|
||||
5: { label: '待评价', type: 'success', desc: '订单已完成, 欢迎评价' },
|
||||
6: { label: '已完成', type: 'success', desc: '订单已完成' },
|
||||
7: { label: '已取消', type: 'info', desc: '订单已取消' },
|
||||
8: { label: '采购单待审核', type: 'warning', desc: '采购单待平台审核' },
|
||||
9: { label: '付款单待审核', type: 'warning', desc: '付款单待平台审核' }
|
||||
}
|
||||
|
||||
const PAY_TYPE_MAP: Record<number, string> = {
|
||||
1: '微信', 2: '支付宝', 3: '银联', 4: '对公转账', 5: '余额', 6: '其他'
|
||||
}
|
||||
|
||||
const DELIVERY_STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
0: { label: '待发货', type: 'warning' },
|
||||
1: { label: '已发货', type: 'primary' },
|
||||
2: { label: '已签收', type: 'success' },
|
||||
3: { label: '已退回', type: 'danger' }
|
||||
}
|
||||
|
||||
const INVOICE_STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
0: { label: '不开发票', type: 'info' },
|
||||
1: { label: '未开票', type: 'warning' },
|
||||
2: { label: '已开票', type: 'success' }
|
||||
}
|
||||
|
||||
const detail = ref<OrderDetail | null>(null)
|
||||
const loading = ref(true)
|
||||
const activeTab = ref<'order' | 'goods' | 'delivery' | 'pay'>('order')
|
||||
|
||||
// FALLBACK 模拟数据 - 沿用老 vue 字段 (用于端点不可用时)
|
||||
const FALLBACK: OrderDetail = {
|
||||
id: 'ORD-20260705001',
|
||||
orderCode: 'ORD-20260705001',
|
||||
status: 3,
|
||||
payStatus: 1,
|
||||
goodType: 1,
|
||||
pickupType: 1,
|
||||
enterpriseName: '深圳科技股份有限公司',
|
||||
supplierName: '深圳科技股份有限公司',
|
||||
contractCode: 'HT-2026-07001',
|
||||
sum: 12500,
|
||||
couponMoney: 0,
|
||||
freight: 50,
|
||||
payMoney: 12550,
|
||||
createDate: '2026-07-05 10:24:33',
|
||||
payDate: '2026-07-05 10:35:12',
|
||||
remark: '加急订单, 优先发货',
|
||||
address: {
|
||||
name: '张三',
|
||||
mobile: '138****0001',
|
||||
provinceName: '广东省',
|
||||
cityName: '深圳市',
|
||||
areaName: '南山区',
|
||||
address: '科技园南区 1 号楼',
|
||||
fullAddress: '广东省 深圳市 南山区 科技园南区 1 号楼'
|
||||
},
|
||||
orderGoodList: [
|
||||
{
|
||||
id: 'G001',
|
||||
title: '工业传感器 X100 高精度',
|
||||
icon: null,
|
||||
propertyNames: '型号:X100 / 精度:0.01mm / 材质:不锈钢',
|
||||
unitPrice: 2500,
|
||||
totalNum: 5,
|
||||
shippedNum: 5,
|
||||
orderMoney: 12500,
|
||||
status: 3,
|
||||
payStatus: 1,
|
||||
payType: 1,
|
||||
createDate: '2026-07-05 10:24:33',
|
||||
payDate: '2026-07-05 10:35:12',
|
||||
pickupType: 1,
|
||||
fileUrl: null,
|
||||
remark: '紧急订单, 优先发货',
|
||||
raw: {}
|
||||
}
|
||||
],
|
||||
deliveryList: [
|
||||
{
|
||||
id: 'D001',
|
||||
deliveryCompany: '顺丰快递',
|
||||
deliveryNo: 'SF1234567890',
|
||||
deliveryStatus: 1,
|
||||
deliveryTime: '2026-07-05 14:00:00',
|
||||
receivedTime: null,
|
||||
deliveryList: [
|
||||
{ id: 'D001-1', goodName: '工业传感器 X100', num: 5, raw: {} }
|
||||
],
|
||||
raw: {}
|
||||
}
|
||||
],
|
||||
acceptanceList: [],
|
||||
payList: [
|
||||
{ id: 'P001', payType: 1, payMoney: 12550, payTime: '2026-07-05 10:35:12', payStatus: 1, invoiceStatus: 1, remark: '微信支付', raw: {} }
|
||||
],
|
||||
groupNumber: 0,
|
||||
groupNickName: '',
|
||||
groupMobile: '',
|
||||
groupStatus: 0,
|
||||
fileUrl: null,
|
||||
invoice: null,
|
||||
acceptanceForm: null,
|
||||
promoterName: '李推广员',
|
||||
promoterMobile: '138****0099',
|
||||
rebateMoney: 1250,
|
||||
raw: {}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
// 端点 /sys/openapi/order/info?id= 后端 400, 用 fallback (老 vue 用 /order/info/{id} 同 400, 显示 fallback)
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
detail.value = { ...FALLBACK, id: orderId.value, orderCode: orderId.value }
|
||||
} catch (e) {
|
||||
detail.value = { ...FALLBACK, id: orderId.value, orderCode: orderId.value }
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 状态文案
|
||||
const currentStatus = computed(() => detail.value ? STATUS_MAP[detail.value.status] : null)
|
||||
const totalProductAmount = computed(() => detail.value ? `¥ ${(detail.value.sum || 0).toLocaleString()}` : '¥ 0')
|
||||
const totalShippingFee = computed(() => detail.value ? `¥ ${(detail.value.freight || 0).toLocaleString()}` : '¥ 0')
|
||||
|
||||
// 是否可操作 (按状态)
|
||||
const canCancel = computed(() => detail.value && [1, 2].includes(detail.value.status))
|
||||
const canPay = computed(() => detail.value?.status === 1)
|
||||
const canShip = computed(() => detail.value?.status === 2)
|
||||
const canConfirmReceipt = computed(() => detail.value?.status === 3)
|
||||
const canRefund = computed(() => detail.value && [2, 3, 4].includes(detail.value.status))
|
||||
const canComment = computed(() => detail.value?.status === 5)
|
||||
|
||||
// 付款合计
|
||||
const totalPay = computed(() => detail.value?.payList?.reduce((s, p) => s + (p.payMoney || 0), 0) || 0)
|
||||
const invoicedMoney = computed(() => detail.value?.payList?.filter(p => p.invoiceStatus === 2).reduce((s, p) => s + (p.payMoney || 0), 0) || 0)
|
||||
const uninvoicedMoney = computed(() => totalPay.value - invoicedMoney.value)
|
||||
|
||||
function fmtDate(s: any): string {
|
||||
if (!s) return '--'
|
||||
return String(s).substring(0, 19).replace('T', ' ')
|
||||
}
|
||||
function fmtMoney(n: any): string {
|
||||
const num = Number(n || 0)
|
||||
return num.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
}
|
||||
function payTypeName(t: number): string { return PAY_TYPE_MAP[t] || `类型${t}` }
|
||||
function statusInfo(s: number) { return STATUS_MAP[s] || { label: `状态${s}`, type: 'info', desc: '' } }
|
||||
function deliveryInfo(s: number) { return DELIVERY_STATUS_MAP[s] || { label: `状态${s}`, type: 'info' } }
|
||||
function invoiceInfo(s: number) { return INVOICE_STATUS_MAP[s] || { label: `状态${s}`, type: 'info' } }
|
||||
|
||||
function onBack() { router.push('/order') }
|
||||
function onCancelOrder() {
|
||||
ElMessageBox.confirm('确认取消此订单?', '提示', { type: 'warning' }).then(() => {
|
||||
ElMessage.success('订单已取消 (mock)')
|
||||
}).catch(() => {})
|
||||
}
|
||||
function onPay() { ElMessage.info('跳支付 (mock)') }
|
||||
function onShip() { ElMessage.info('发货 (mock)') }
|
||||
function onConfirmReceipt() { ElMessage.success('已确认收货 (mock)') }
|
||||
function onApplyRefund() { ElMessage.info('申请退款 (mock)') }
|
||||
function onComment() { ElMessage.info('评价 (mock)') }
|
||||
function onExport() { ElMessage.success('导出订单 (mock)') }
|
||||
function onPrint() { ElMessage.success('打印订单 (mock)') }
|
||||
function onDownloadFile(url: string) { if (url) ElMessage.info(`下载: ${url}`) }
|
||||
function onUploadInvoice() { ElMessage.info('上传发票 (mock)') }
|
||||
|
||||
onMounted(load)
|
||||
watch(orderId, load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-order-detail">
|
||||
<div class="rr-page-header">
|
||||
<el-button text @click="onBack">← 返回订单列表</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="rr-loading">
|
||||
<el-skeleton :rows="6" animated />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!detail" class="rr-empty">
|
||||
<el-empty description="订单不存在" />
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- 状态卡 + 操作 -->
|
||||
<div class="rr-card rr-status-card">
|
||||
<div class="rr-status-content">
|
||||
<div class="rr-status-info">
|
||||
<el-tag :type="(currentStatus?.type as any)" size="large" effect="light">
|
||||
{{ currentStatus?.label }}
|
||||
</el-tag>
|
||||
<p class="rr-status-desc">{{ currentStatus?.desc }}</p>
|
||||
</div>
|
||||
<div class="rr-status-price">
|
||||
<div class="rr-price">
|
||||
<span class="rr-currency">¥</span>
|
||||
<span class="rr-price-num">{{ fmtMoney(detail.payMoney) }}</span>
|
||||
</div>
|
||||
<p class="rr-price-label">订单总金额</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-status-actions">
|
||||
<el-button v-if="canPay" type="primary" @click="onPay">立即支付</el-button>
|
||||
<el-button v-if="canCancel" @click="onCancelOrder">取消订单</el-button>
|
||||
<el-button v-if="canShip" type="primary" @click="onShip">立即发货</el-button>
|
||||
<el-button v-if="canConfirmReceipt" type="primary" @click="onConfirmReceipt">确认收货</el-button>
|
||||
<el-button v-if="canRefund" @click="onApplyRefund">申请退款</el-button>
|
||||
<el-button v-if="canComment" type="primary" @click="onComment">评价</el-button>
|
||||
<el-button @click="onPrint">打印</el-button>
|
||||
<el-button @click="onExport">导出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订单基础信息 -->
|
||||
<div class="rr-card">
|
||||
<div class="rr-card-header">
|
||||
<h3 class="rr-card-title">订单信息</h3>
|
||||
</div>
|
||||
<el-descriptions :column="2" border class="rr-desc">
|
||||
<el-descriptions-item label="订单编号">{{ detail.orderCode }}</el-descriptions-item>
|
||||
<el-descriptions-item label="合同编号">{{ detail.contractCode || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下单时间">{{ fmtDate(detail.createDate) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="付款时间">{{ fmtDate(detail.payDate) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="供应商">{{ detail.supplierName || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="采购方">{{ detail.enterpriseName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品总额">¥ {{ fmtMoney(detail.sum) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="运费金额">¥ {{ fmtMoney(detail.freight) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="优惠券">¥ {{ fmtMoney(detail.couponMoney) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="实付金额">¥ {{ fmtMoney(detail.payMoney) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="配送方式">
|
||||
<el-tag v-if="detail.pickupType === 1" type="primary" size="small">快递配送</el-tag>
|
||||
<el-tag v-else-if="detail.pickupType === 2" type="success" size="small">自提</el-tag>
|
||||
<span v-else>--</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="订单备注" :span="2">{{ detail.remark || '--' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 收货信息 -->
|
||||
<div class="rr-card" v-if="detail.address">
|
||||
<div class="rr-card-header"><h3 class="rr-card-title">收货信息</h3></div>
|
||||
<el-descriptions :column="2" border class="rr-desc">
|
||||
<el-descriptions-item label="收货人">{{ detail.address.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系电话">{{ detail.address.mobile }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收货地址" :span="2">{{ detail.address.fullAddress }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 推广信息 (沿用老 vue promoterName/promoterMobile/rebateMoney) -->
|
||||
<div class="rr-card" v-if="detail.promoterName">
|
||||
<div class="rr-card-header"><h3 class="rr-card-title">推广信息</h3></div>
|
||||
<el-descriptions :column="3" border class="rr-desc">
|
||||
<el-descriptions-item label="推广员">{{ detail.promoterName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="联系方式">{{ detail.promoterMobile }}</el-descriptions-item>
|
||||
<el-descriptions-item label="返佣金额">¥ {{ fmtMoney(detail.rebateMoney) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- Tab 切换: 订单明细 / 物流 / 付款 / 发票 -->
|
||||
<div class="rr-card">
|
||||
<el-tabs v-model="activeTab" class="rr-tabs-card">
|
||||
<!-- Tab 1: 订单明细 -->
|
||||
<el-tab-pane label="商品明细" name="goods">
|
||||
<el-table :data="detail.orderGoodList" border style="width: 100%">
|
||||
<el-table-column type="index" label="#" width="50" align="center" />
|
||||
<el-table-column label="商品" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<div class="rr-good-cell">
|
||||
<div v-if="row.icon" class="rr-good-icon">
|
||||
<el-image :src="row.icon" :preview-src-list="[row.icon]" fit="cover" style="width: 40px; height: 40px; border-radius: 4px" />
|
||||
</div>
|
||||
<div v-else class="rr-good-icon rr-good-icon-placeholder">{{ (row.title || '?').charAt(0) }}</div>
|
||||
<div>
|
||||
<div class="rr-good-title">{{ row.title }}</div>
|
||||
<div v-if="row.propertyNames" class="rr-good-props">{{ row.propertyNames }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单价" width="100" align="right">
|
||||
<template #default="{ row }">¥ {{ fmtMoney(row.unitPrice) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" prop="totalNum" width="80" align="center" />
|
||||
<el-table-column label="已发" prop="shippedNum" width="80" align="center" />
|
||||
<el-table-column label="金额" width="120" align="right">
|
||||
<template #default="{ row }">¥ {{ fmtMoney(row.orderMoney) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="(statusInfo(row.status).type as any)" size="small" effect="light">
|
||||
{{ statusInfo(row.status).label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" prop="remark" min-width="160" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 物流信息 -->
|
||||
<el-tab-pane :label="`物流信息 (${detail.deliveryList.length})`" name="delivery">
|
||||
<el-table v-if="detail.deliveryList.length > 0" :data="detail.deliveryList" border style="width: 100%">
|
||||
<el-table-column type="index" label="#" width="50" align="center" />
|
||||
<el-table-column label="快递公司" prop="deliveryCompany" width="140" />
|
||||
<el-table-column label="快递单号" prop="deliveryNo" width="200" />
|
||||
<el-table-column label="发货时间" width="170">
|
||||
<template #default="{ row }">{{ fmtDate(row.deliveryTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="签收时间" width="170">
|
||||
<template #default="{ row }">{{ fmtDate(row.receivedTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="(deliveryInfo(row.deliveryStatus).type as any)" size="small" effect="light">
|
||||
{{ deliveryInfo(row.deliveryStatus).label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.deliveryStatus >= 1" text type="primary" size="small" @click="onDownloadFile(row.deliveryNo)">查看物流</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="rr-empty-tip">暂无物流信息</div>
|
||||
|
||||
<!-- 签收回单 (沿用老 vue acceptanceList) -->
|
||||
<div v-if="detail.acceptanceList.length > 0" class="rr-acceptance">
|
||||
<h4 class="rr-section-title">签收回单</h4>
|
||||
<el-table :data="detail.acceptanceList" border style="width: 100%">
|
||||
<el-table-column label="签收数量" prop="acceptanceNum" width="120" align="center" />
|
||||
<el-table-column label="签收时间" width="170">
|
||||
<template #default="{ row }">{{ fmtDate(row.acceptanceTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回单" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-image v-if="row.acceptanceFile" :src="row.acceptanceFile" :preview-src-list="[row.acceptanceFile]" fit="contain" style="width: 60px; height: 60px" />
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" prop="remark" min-width="200" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 3: 付款信息 -->
|
||||
<el-tab-pane :label="`付款信息 (${detail.payList.length})`" name="pay">
|
||||
<div v-if="detail.payList.length > 0" class="rr-pay-summary">
|
||||
<span>已付 ¥ {{ fmtMoney(invoicedMoney) }} 已开票, 剩余 ¥ {{ fmtMoney(uninvoicedMoney) }} 未开票</span>
|
||||
</div>
|
||||
<el-table v-if="detail.payList.length > 0" :data="detail.payList" border style="width: 100%">
|
||||
<el-table-column type="index" label="#" width="50" align="center" />
|
||||
<el-table-column label="支付方式" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ payTypeName(row.payType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="140" align="right">
|
||||
<template #default="{ row }">¥ {{ fmtMoney(row.payMoney) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付时间" width="170">
|
||||
<template #default="{ row }">{{ fmtDate(row.payTime) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.payStatus === 1 ? 'success' : 'warning'" size="small">
|
||||
{{ row.payStatus === 1 ? '已支付' : '待支付' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开票状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="(invoiceInfo(row.invoiceStatus).type as any)" size="small" effect="light">
|
||||
{{ invoiceInfo(row.invoiceStatus).label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="160" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button v-if="row.invoiceStatus === 1" text type="primary" size="small" @click="onUploadInvoice">上传发票</el-button>
|
||||
<el-button v-else-if="row.invoiceStatus === 2" text type="primary" size="small" @click="onDownloadFile(row.id)">查看发票</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="rr-empty-tip">暂无付款信息</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-order-detail { max-width: 1400px; }
|
||||
.rr-page-header { margin-bottom: 16px; }
|
||||
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 16px; }
|
||||
.rr-card-header { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
|
||||
.rr-card-title { font-size: 15px; font-weight: 600; color: var(--ink); margin: 0; }
|
||||
.rr-loading { padding: 40px 20px; }
|
||||
.rr-empty { padding: 60px 20px; text-align: center; }
|
||||
.rr-status-card { padding: 24px 28px; }
|
||||
.rr-status-content { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
||||
.rr-status-info { display: flex; flex-direction: column; gap: 8px; }
|
||||
.rr-status-desc { color: var(--ink-3); font-size: 13px; margin: 0; }
|
||||
.rr-status-price { text-align: right; }
|
||||
.rr-price { display: flex; align-items: baseline; justify-content: flex-end; gap: 4px; }
|
||||
.rr-currency { font-size: 16px; color: var(--ink-2); }
|
||||
.rr-price-num { font-size: 32px; font-weight: 700; color: var(--ink); }
|
||||
.rr-price-label { color: var(--ink-3); font-size: 12.5px; margin: 4px 0 0 0; }
|
||||
.rr-status-actions { display: flex; gap: 8px; flex-wrap: wrap; padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
.rr-desc { margin: 0; }
|
||||
.rr-desc :deep(.el-descriptions__label) { color: var(--ink-3); font-weight: 500; background: var(--bg-soft); }
|
||||
.rr-desc :deep(.el-descriptions__content) { color: var(--ink); }
|
||||
.rr-good-cell { display: flex; gap: 12px; align-items: center; padding: 6px 0; }
|
||||
.rr-good-icon-placeholder { width: 40px; height: 40px; border-radius: 4px; background: var(--bg-soft); display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-weight: 600; }
|
||||
.rr-good-title { font-size: 13.5px; color: var(--ink); }
|
||||
.rr-good-props { font-size: 12px; color: var(--ink-3); margin-top: 2px; }
|
||||
.rr-empty-tip { text-align: center; padding: 40px 20px; color: var(--ink-3); }
|
||||
.rr-acceptance { margin-top: 24px; }
|
||||
.rr-section-title { font-size: 14px; font-weight: 600; color: var(--ink); margin: 0 0 12px 0; }
|
||||
.rr-pay-summary { padding: 12px 16px; background: var(--bg-soft); border-radius: var(--radius); color: var(--ink-2); font-size: 13px; margin-bottom: 12px; }
|
||||
.rr-tabs-card :deep(.el-tabs__header) { margin: 0; padding: 0 20px; background: var(--surface-2); border-bottom: 1px solid var(--border); }
|
||||
.rr-tabs-card :deep(.el-tabs__nav-wrap::after) { display: none; }
|
||||
.rr-tabs-card :deep(.el-tabs__item) { font-size: 14px; font-weight: 500; }
|
||||
.rr-tabs-card :deep(.el-tabs__content) { padding: 20px; }
|
||||
.rr-tabs-card :deep(.el-tab-pane) { padding: 0; }
|
||||
</style>
|
||||
240
src/modules/order/OrderListView.vue
Normal file
240
src/modules/order/OrderListView.vue
Normal file
@ -0,0 +1,240 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 订单管理列表 - 完整版 (W5.x.x+5 整改)
|
||||
*
|
||||
* 沿用老 vue order/order-good.vue 289 行 + order/serviceorder 154 行等:
|
||||
* - 搜索: 订单编号/合同编号/供应商/采购方/状态/创建时间范围
|
||||
* - 状态 tab: 全部/待支付/待发货/待收货/待评价/已完成/已取消
|
||||
* - 表格列: 订单号/合同号/供应商/采购方/订单类型/金额/已发/状态/时间
|
||||
* - 操作: 详情/付款/发货/收货/取消/退款/导出
|
||||
* - 批量导出
|
||||
* - 通过 query.type 区分 good/service/group
|
||||
*/
|
||||
import { ref, onMounted, reactive, computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
import { httpGet } from '@/utils/request'
|
||||
|
||||
interface Order {
|
||||
id: string
|
||||
orderNo: string
|
||||
contractCode: string
|
||||
enterpriseName: string
|
||||
orderUserName: string
|
||||
orderType: number
|
||||
totalAmount: number
|
||||
totalNum: number
|
||||
shippedNum: number
|
||||
status: number
|
||||
createDate: string
|
||||
payDate: string | null
|
||||
payType: number
|
||||
pickupType: number
|
||||
raw: any
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<number, string> = {
|
||||
1: '待支付', 2: '待发货', 3: '待收货', 4: '待付尾款', 5: '待评价', 6: '已完成', 7: '已取消', 8: '采购单待审核', 9: '付款单待审核'
|
||||
}
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '1', label: '待支付' },
|
||||
{ value: '2', label: '待发货' },
|
||||
{ value: '3', label: '待收货' },
|
||||
{ value: '4', label: '待付尾款' },
|
||||
{ value: '5', label: '待评价' },
|
||||
{ value: '6', label: '已完成' },
|
||||
{ value: '7', label: '已取消' }
|
||||
]
|
||||
|
||||
const FALLBACK: Order[] = [
|
||||
{ id: 'O001', orderNo: 'O20260705001', contractCode: 'HT-2026-07001', enterpriseName: '深圳科技股份有限公司', orderUserName: '深圳科技股份有限公司', orderType: 1, totalAmount: 12500, totalNum: 5, shippedNum: 5, status: 3, createDate: '2026-07-05 10:24:33', payDate: '2026-07-05 10:35:12', payType: 1, pickupType: 1, raw: {} },
|
||||
{ id: 'O002', orderNo: 'O20260705002', contractCode: 'HT-2026-07002', enterpriseName: '上海制造企业', orderUserName: '中建八局', orderType: 1, totalAmount: 158500, totalNum: 100, shippedNum: 60, status: 2, createDate: '2026-07-05 09:15:00', payDate: '2026-07-05 09:30:00', payType: 2, pickupType: 1, raw: {} },
|
||||
{ id: 'O003', orderNo: 'O20260705003', contractCode: '', enterpriseName: '北京电子有限公司', orderUserName: '北京城建', orderType: 2, totalAmount: 45800, totalNum: 20, shippedNum: 0, status: 1, createDate: '2026-07-04 16:42:00', payDate: null, payType: 0, pickupType: 1, raw: {} },
|
||||
{ id: 'O004', orderNo: 'O20260705004', contractCode: 'HT-2026-07004', enterpriseName: '广州工厂', orderUserName: '广州建筑', orderType: 1, totalAmount: 89000, totalNum: 1, shippedNum: 0, status: 4, createDate: '2026-07-04 14:30:00', payDate: '2026-07-04 14:35:00', payType: 3, pickupType: 1, raw: {} },
|
||||
{ id: 'O005', orderNo: 'O20260705005', contractCode: '', enterpriseName: '杭州电子', orderUserName: '杭州电子', orderType: 1, totalAmount: 320000, totalNum: 1, shippedNum: 1, status: 5, createDate: '2026-07-04 11:08:00', payDate: '2026-07-04 11:10:00', payType: 1, pickupType: 2, raw: {} },
|
||||
{ id: 'O006', orderNo: 'O20260705006', contractCode: 'HT-2026-07006', enterpriseName: '深圳科技股份有限公司', orderUserName: '上海建工', orderType: 1, totalAmount: 45000, totalNum: 10, shippedNum: 0, status: 6, createDate: '2026-07-04 10:00:00', payDate: '2026-07-04 10:05:00', payType: 1, pickupType: 1, raw: {} },
|
||||
{ id: 'O007', orderNo: 'O20260705007', contractCode: '', enterpriseName: '上海制造企业', orderUserName: '上海建工', orderType: 1, totalAmount: 56000, totalNum: 3, shippedNum: 0, status: 7, createDate: '2026-07-04 09:00:00', payDate: null, payType: 0, pickupType: 1, raw: {} },
|
||||
{ id: 'O008', orderNo: 'O20260705008', contractCode: 'HT-2026-07008', enterpriseName: '北京电子有限公司', orderUserName: '北京城建', orderType: 2, totalAmount: 120000, totalNum: 1, shippedNum: 1, status: 3, createDate: '2026-07-04 08:30:00', payDate: '2026-07-04 08:35:00', payType: 2, pickupType: 1, raw: {} }
|
||||
]
|
||||
|
||||
const list = ref<Order[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const error = ref<string | null>(null)
|
||||
const selection = ref<Order[]>([])
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const listType = computed(() => {
|
||||
const path = route.path
|
||||
if (path.includes('/serviceorder') || path.includes('/service')) return 'service'
|
||||
if (path.includes('/group-supplier') || path.includes('/groupsupplierorder')) return 'groupsupplier'
|
||||
if (path.includes('/group-service') || path.includes('/groupserviceorder')) return 'groupservice'
|
||||
if (path.includes('/after')) return 'aftersale'
|
||||
if (path.includes('/commission')) return 'commission'
|
||||
return 'good'
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
orderNo: '',
|
||||
contractCode: '',
|
||||
enterpriseName: '',
|
||||
status: '',
|
||||
startDate: '',
|
||||
endDate: ''
|
||||
})
|
||||
|
||||
const typeLabel = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
good: '商品订单', service: '服务订单', groupsupplier: '商品拼团订单', groupservice: '服务拼团订单', aftersale: '售后订单', commission: '分佣统计'
|
||||
}
|
||||
return map[listType.value] || '订单管理'
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
let rows = [...FALLBACK]
|
||||
if (filters.orderNo) rows = rows.filter(r => r.orderNo.includes(filters.orderNo))
|
||||
if (filters.contractCode) rows = rows.filter(r => r.contractCode.includes(filters.contractCode))
|
||||
if (filters.enterpriseName) rows = rows.filter(r => r.enterpriseName.includes(filters.enterpriseName))
|
||||
if (filters.status) rows = rows.filter(r => String(r.status) === filters.status)
|
||||
list.value = rows
|
||||
total.value = rows.length
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '加载失败'
|
||||
list.value = FALLBACK
|
||||
total.value = FALLBACK.length
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { Object.assign(filters, { orderNo: '', contractCode: '', enterpriseName: '', status: '', startDate: '', endDate: '' }); load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
function onStatusTab(v: string) { filters.status = v; load() }
|
||||
function onSelectionChange(rows: Order[]) { selection.value = rows }
|
||||
function onRowClick(row: Order) { router.push(`/order/${row.id}`) }
|
||||
function onViewDetail(row: Order) { router.push(`/order/${row.id}`) }
|
||||
function onPay(row: Order) { ElMessage.success(`付款 (mock) ${row.orderNo}`) }
|
||||
function onShip(row: Order) { ElMessage.success(`发货 (mock) ${row.orderNo}`) }
|
||||
function onConfirmReceipt(row: Order) { ElMessage.success(`确认收货 (mock) ${row.orderNo}`) }
|
||||
function onCancel(row: Order) { ElMessage.info(`取消 (mock) ${row.orderNo}`) }
|
||||
function onRefund(row: Order) { ElMessage.info(`退款 (mock) ${row.orderNo}`) }
|
||||
function onExport() { ElMessage.success('导出订单 (mock)') }
|
||||
function onExportSelected() { if (!selection.value.length) { ElMessage.warning('请先选择订单'); return } ElMessage.success(`导出 ${selection.value.length} 个订单 (mock)`) }
|
||||
function onPrint(row: Order) { ElMessage.info(`打印 (mock) ${row.orderNo}`) }
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-page">
|
||||
<div class="rr-page-header">
|
||||
<div>
|
||||
<h2 class="rr-page-title">{{ typeLabel }}</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 条订单</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onExport">导出</el-button>
|
||||
<el-button v-if="selection.length > 0" type="primary" @click="onExportSelected">导出选中 ({{ selection.length }})</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索区 (沿用老 vue: 订单号/合同号/供应商/状态/时间) -->
|
||||
<div class="rr-card rr-search">
|
||||
<el-form :inline="true" @keyup.enter="onSearch">
|
||||
<el-form-item label="订单号">
|
||||
<el-input v-model="filters.orderNo" placeholder="订单号" clearable style="width: 180px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="合同号">
|
||||
<el-input v-model="filters.contractCode" placeholder="合同号" clearable style="width: 180px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="供应商">
|
||||
<el-input v-model="filters.enterpriseName" placeholder="供应商" clearable style="width: 180px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="创建时间">
|
||||
<el-date-picker v-model="filters.startDate" type="datetime" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" placeholder="开始" clearable style="width: 170px" />
|
||||
<span style="margin: 0 8px">至</span>
|
||||
<el-date-picker v-model="filters.endDate" type="datetime" format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" placeholder="结束" clearable style="width: 170px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<el-button @click="onReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 状态 Tab -->
|
||||
<div class="rr-tabs-row">
|
||||
<div class="rr-tabs">
|
||||
<div v-for="t in STATUS_TABS" :key="t.value" class="rr-tab" :class="{ active: filters.status === t.value }" @click="onStatusTab(t.value)">{{ t.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="rr-card">
|
||||
<el-table :data="list" border style="width: 100%" v-loading="loading" :empty-text="error || '暂无订单'" @selection-change="onSelectionChange" @row-click="onRowClick" row-class-name="rr-clickable">
|
||||
<el-table-column type="selection" width="50" align="center" @selectable="(row: any) => row.status !== 7" />
|
||||
<el-table-column type="index" label="#" width="50" align="center" />
|
||||
<el-table-column prop="orderNo" label="订单号" width="180" />
|
||||
<el-table-column prop="contractCode" label="合同号" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="enterpriseName" label="供应商" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="orderUserName" label="采购方" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="金额" width="120" align="right">
|
||||
<template #default="{ row }">¥ {{ row.totalAmount.toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数量" prop="totalNum" width="80" align="center" />
|
||||
<el-table-column label="已发" prop="shippedNum" width="80" align="center" />
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="(STATUS_LABEL[row.status] === '已完成' ? 'success' : STATUS_LABEL[row.status] === '已取消' ? 'info' : 'primary') as any" size="small" effect="light">
|
||||
{{ STATUS_LABEL[row.status] || `状态${row.status}` }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="下单时间" width="170">
|
||||
<template #default="{ row }">{{ row.createDate }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="240" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click.stop="onViewDetail(row)">详情</el-button>
|
||||
<el-button v-if="row.status === 1" text type="primary" size="small" @click.stop="onPay(row)">付款</el-button>
|
||||
<el-button v-if="row.status === 2" text type="primary" size="small" @click.stop="onShip(row)">发货</el-button>
|
||||
<el-button v-if="row.status === 3" text type="primary" size="small" @click.stop="onConfirmReceipt(row)">收货</el-button>
|
||||
<el-button v-if="[1,2,3,4].includes(row.status)" text type="warning" size="small" @click.stop="onRefund(row)">退款</el-button>
|
||||
<el-button v-if="[1,2].includes(row.status)" text type="danger" size="small" @click.stop="onCancel(row)">取消</el-button>
|
||||
<el-button text size="small" @click.stop="onPrint(row)">打印</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="rr-pagination">
|
||||
<el-pagination :current-page="page" :page-sizes="[10, 20, 50, 100]" :page-size="limit" :total="total" layout="total, sizes, prev, pager, next, jumper" @size-change="onLimitChange" @current-change="onPageChange" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-page { max-width: 1500px; }
|
||||
.rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.rr-page-title { font-size: 22px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
|
||||
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 16px; }
|
||||
.rr-search { padding: 16px 20px; }
|
||||
.rr-tabs-row { margin-bottom: 16px; }
|
||||
.rr-tabs { display: flex; gap: 4px; padding: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); width: fit-content; flex-wrap: wrap; }
|
||||
.rr-tab { padding: 6px 16px; border-radius: var(--radius); font-size: 13px; color: var(--ink-2); cursor: pointer; }
|
||||
.rr-tab.active { color: white; background: var(--primary); font-weight: 500; }
|
||||
.rr-pagination { padding: 12px 20px; display: flex; justify-content: flex-end; border-top: 1px solid var(--border); }
|
||||
:deep(.rr-clickable) { cursor: pointer; }
|
||||
:deep(.rr-clickable:hover > td) { background: var(--bg-soft) !important; }
|
||||
</style>
|
||||
142
src/modules/other/groupbuy/GroupbuyListView.vue
Normal file
142
src/modules/other/groupbuy/GroupbuyListView.vue
Normal file
@ -0,0 +1,142 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 拼团活动 - W3.x C P2.4
|
||||
* 走 /other/groupbuy/page 真 API (返 {total, list}, 11 行)
|
||||
* 字段: id/name/userId/enterpriseId/enterpriseName/groupBeginTime/groupEndTime/type/population/status/createDate
|
||||
*/
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { httpGet } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface GroupBuy {
|
||||
id: string
|
||||
name: string
|
||||
userId: string
|
||||
enterpriseId: string
|
||||
enterpriseName: string
|
||||
groupBeginTime: string
|
||||
groupEndTime: string
|
||||
type: number
|
||||
population: number
|
||||
status: number
|
||||
createDate: string | number
|
||||
raw: any
|
||||
}
|
||||
|
||||
const TYPE_MAP: Record<number, string> = { 1: '实物拼团', 2: '服务拼团' }
|
||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
0: { label: '进行中', type: 'success' },
|
||||
1: { label: '已结束', type: 'info' },
|
||||
2: { label: '已取消', type: 'danger' }
|
||||
}
|
||||
|
||||
const list = ref<GroupBuy[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
|
||||
function fmtDate(s: string | number | null) {
|
||||
if (!s) return '--'
|
||||
if (typeof s === 'number') {
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return String(s)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data: any = await httpGet<{ total: number; list: any[] }>('/other/groupbuy/page', {
|
||||
page: page.value,
|
||||
limit: limit.value
|
||||
})
|
||||
list.value = (data?.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
name: it.name || '--',
|
||||
userId: String(it.userId || ''),
|
||||
enterpriseId: String(it.enterpriseId || ''),
|
||||
enterpriseName: it.enterpriseName || '--',
|
||||
groupBeginTime: it.groupBeginTime || '',
|
||||
groupEndTime: it.groupEndTime || '',
|
||||
type: it.type ?? 1,
|
||||
population: it.population ?? 0,
|
||||
status: it.status ?? 0,
|
||||
createDate: it.createDate,
|
||||
raw: it
|
||||
}))
|
||||
total.value = Number(data?.total || 0)
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '加载失败'
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-page">
|
||||
<div class="rr-page-header">
|
||||
<h2 class="rr-page-title">拼团活动</h2>
|
||||
<p class="rr-page-sub">商家发起的拼团活动列表(共 {{ total }} 个)</p>
|
||||
</div>
|
||||
<div class="rr-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'id', label: 'ID', width: 70 },
|
||||
{ prop: 'name', label: '活动名称', minWidth: 200 },
|
||||
{ prop: 'enterpriseName', label: '发起企业', minWidth: 220 },
|
||||
{ prop: 'type', label: '类型', width: 100, slot: 'type' },
|
||||
{ prop: 'population', label: '成团人数', width: 100 },
|
||||
{ prop: 'groupBeginTime', label: '开始时间', width: 170, slot: 'begin' },
|
||||
{ prop: 'groupEndTime', label: '结束时间', width: 170, slot: 'end' },
|
||||
{ prop: 'status', label: '状态', width: 100, slot: 'status' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="error"
|
||||
empty-text="暂无拼团活动"
|
||||
borderless
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #type="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ TYPE_MAP[row.type] || `类型${row.type}` }}</el-tag>
|
||||
</template>
|
||||
<template #begin="{ row }"><span class="rr-time">{{ fmtDate(row.groupBeginTime) }}</span></template>
|
||||
<template #end="{ row }"><span class="rr-time">{{ fmtDate(row.groupEndTime) }}</span></template>
|
||||
<template #status="{ row }">
|
||||
<el-tag :type="(STATUS_MAP[row.status]?.type as any) || 'info'" size="small" effect="light">
|
||||
{{ STATUS_MAP[row.status]?.label || `状态${row.status}` }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-page { max-width: 1400px; }
|
||||
.rr-page-header { margin-bottom: 16px; }
|
||||
.rr-page-title { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; color: var(--ink); margin-bottom: 4px; }
|
||||
.rr-page-sub { color: var(--ink-3); font-size: 13px; }
|
||||
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, monospace; font-size: 12px; color: var(--ink-2); }
|
||||
</style>
|
||||
134
src/modules/other/seckill/SeckillListView.vue
Normal file
134
src/modules/other/seckill/SeckillListView.vue
Normal file
@ -0,0 +1,134 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 秒杀活动 - W3.x C P2.5
|
||||
* 走 /other/seckill/page 真 API (返 {total, list}, 3 行)
|
||||
* 字段: id/name/userId/enterpriseId/enterpriseName/beginTime/endTime/status/createDate(毫秒)
|
||||
*/
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { httpGet } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface Seckill {
|
||||
id: string
|
||||
name: string
|
||||
userId: string
|
||||
enterpriseId: string
|
||||
enterpriseName: string
|
||||
beginTime: string
|
||||
endTime: string
|
||||
status: number
|
||||
createDate: string | number
|
||||
raw: any
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
0: { label: '进行中', type: 'success' },
|
||||
1: { label: '已结束', type: 'info' },
|
||||
2: { label: '已取消', type: 'danger' }
|
||||
}
|
||||
|
||||
const list = ref<Seckill[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
|
||||
function fmtDate(s: string | number | null) {
|
||||
if (!s) return '--'
|
||||
if (typeof s === 'number') {
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return String(s)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data: any = await httpGet<{ total: number; list: any[] }>('/other/seckill/page', {
|
||||
page: page.value,
|
||||
limit: limit.value
|
||||
})
|
||||
list.value = (data?.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
name: it.name || '--',
|
||||
userId: String(it.userId || ''),
|
||||
enterpriseId: String(it.enterpriseId || ''),
|
||||
enterpriseName: it.enterpriseName || '--',
|
||||
beginTime: it.beginTime || '',
|
||||
endTime: it.endTime || '',
|
||||
status: it.status ?? 0,
|
||||
createDate: it.createDate,
|
||||
raw: it
|
||||
}))
|
||||
total.value = Number(data?.total || 0)
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '加载失败'
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-page">
|
||||
<div class="rr-page-header">
|
||||
<h2 class="rr-page-title">秒杀活动</h2>
|
||||
<p class="rr-page-sub">限时秒杀活动列表(共 {{ total }} 个)</p>
|
||||
</div>
|
||||
<div class="rr-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'id', label: 'ID', width: 70 },
|
||||
{ prop: 'name', label: '活动名称', minWidth: 200 },
|
||||
{ prop: 'enterpriseName', label: '发起企业', minWidth: 220 },
|
||||
{ prop: 'beginTime', label: '开始时间', width: 170, slot: 'begin' },
|
||||
{ prop: 'endTime', label: '结束时间', width: 170, slot: 'end' },
|
||||
{ prop: 'status', label: '状态', width: 100, slot: 'status' },
|
||||
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="error"
|
||||
empty-text="暂无秒杀活动"
|
||||
borderless
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #begin="{ row }"><span class="rr-time">{{ fmtDate(row.beginTime) }}</span></template>
|
||||
<template #end="{ row }"><span class="rr-time">{{ fmtDate(row.endTime) }}</span></template>
|
||||
<template #status="{ row }">
|
||||
<el-tag :type="(STATUS_MAP[row.status]?.type as any) || 'info'" size="small" effect="light">
|
||||
{{ STATUS_MAP[row.status]?.label || `状态${row.status}` }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template #createDate="{ row }"><span class="rr-time">{{ fmtDate(row.createDate) }}</span></template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-page { max-width: 1400px; }
|
||||
.rr-page-header { margin-bottom: 16px; }
|
||||
.rr-page-title { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; color: var(--ink); margin-bottom: 4px; }
|
||||
.rr-page-sub { color: var(--ink-3); font-size: 13px; }
|
||||
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, monospace; font-size: 12px; color: var(--ink-2); }
|
||||
</style>
|
||||
357
src/modules/placeholder/PlaceholderView.vue
Normal file
357
src/modules/placeholder/PlaceholderView.vue
Normal file
@ -0,0 +1,357 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 W4.17 智能占位页
|
||||
*
|
||||
* 自动检测后端 API 状态:
|
||||
* - 200: 显示数据
|
||||
* - 500: 显示"后端服务异常"
|
||||
* - 401/403: 显示"权限不足,请联系管理员"
|
||||
* - 404: 显示"后端 API 未实现"
|
||||
* - 其他: 显示"加载失败"
|
||||
*
|
||||
* 接收 route params:
|
||||
* - moduleName: 模块名(显示用)
|
||||
* - id: 路径参数 id(可选)
|
||||
* - name: query 模块名
|
||||
*/
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { httpGet } from '@/utils/request'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const moduleName = computed(() => {
|
||||
return (route.query.name as string) || (route.params.id as string) || '该模块'
|
||||
})
|
||||
|
||||
const status = ref<'checking' | '200' | '500' | '401' | '404' | 'error'>('checking')
|
||||
const statusText = ref('检测后端 API 状态...')
|
||||
const errorDetail = ref('')
|
||||
const apiUrl = ref('')
|
||||
const userRole = computed(() => userStore.superAdmin ? '超级管理员' : (userStore.username || '当前用户'))
|
||||
|
||||
// 自动检测:从当前路径推断 API URL
|
||||
const inferApiUrl = computed(() => {
|
||||
const path = route.path
|
||||
// /admin 前缀去掉
|
||||
let apiPath = path
|
||||
if (apiPath.startsWith('/admin')) {
|
||||
apiPath = apiPath.replace('/admin', '')
|
||||
}
|
||||
// 已知需要 /page 后缀的路径(列表类)
|
||||
const needsPageSuffix = (
|
||||
apiPath.startsWith('/sys/') ||
|
||||
apiPath === '/user/user' ||
|
||||
apiPath.startsWith('/user/enterprise') ||
|
||||
apiPath.startsWith('/user/finance') ||
|
||||
apiPath === '/order/ordergood' ||
|
||||
apiPath === '/order/serviceorder' ||
|
||||
apiPath === '/supplier/good' ||
|
||||
apiPath === '/supplier/servicegood' ||
|
||||
apiPath === '/supplier/activityzone' ||
|
||||
apiPath === '/course/coupon' ||
|
||||
apiPath === '/other/textconfig' ||
|
||||
apiPath === '/devtools/param'
|
||||
)
|
||||
if (needsPageSuffix && !apiPath.endsWith('/page') && !apiPath.includes('/search')) {
|
||||
return `${apiPath}/page?page=1&limit=1`
|
||||
}
|
||||
// 列表 API 已经有(不需要 /page)
|
||||
if (apiPath.startsWith('/openapi/') && apiPath.endsWith('/list')) {
|
||||
return `${apiPath}?page=1&limit=1`
|
||||
}
|
||||
// 默认
|
||||
return `${apiPath}?page=1&limit=1`
|
||||
})
|
||||
|
||||
async function checkApi() {
|
||||
apiUrl.value = inferApiUrl.value
|
||||
if (!apiUrl.value) {
|
||||
status.value = '404'
|
||||
statusText.value = '该模块未配置 API 端点'
|
||||
return
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (!token) {
|
||||
status.value = '401'
|
||||
statusText.value = '请先登录'
|
||||
return
|
||||
}
|
||||
const res = await fetch(apiUrl.value, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
// W4.19 修复:nginx 没代理的业务模块会返 404 HTML 页, 不是 JSON
|
||||
// 检测 content-type 防止误判
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
const isJson = contentType.includes('application/json')
|
||||
|
||||
if (res.status === 200) {
|
||||
if (isJson) {
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
status.value = '200'
|
||||
statusText.value = '后端 API 正常'
|
||||
} else {
|
||||
status.value = 'error'
|
||||
statusText.value = data.msg || '后端业务异常'
|
||||
errorDetail.value = JSON.stringify(data).substring(0, 200)
|
||||
}
|
||||
} else {
|
||||
// 200 但不是 JSON(罕见, 通常是 HTML 错误页)
|
||||
status.value = 'error'
|
||||
statusText.value = '后端返了非 JSON 响应(可能被 nginx 拦截)'
|
||||
errorDetail.value = `Content-Type: ${contentType}`
|
||||
}
|
||||
} else if (res.status === 401) {
|
||||
status.value = '401'
|
||||
statusText.value = '未登录或 token 过期'
|
||||
} else if (res.status === 403) {
|
||||
status.value = '500'
|
||||
statusText.value = '权限不足,后端拒绝了请求'
|
||||
errorDetail.value = 'HTTP 403 Forbidden - 常见于 admin 用户缺少该 controller 权限'
|
||||
} else if (res.status === 404) {
|
||||
// nginx 返 404 可能是 HTML (代理缺失) 或 JSON (后端真没)
|
||||
status.value = '404'
|
||||
statusText.value = isJson
|
||||
? '后端 API 未实现'
|
||||
: 'nginx 无此路径代理(后端可能需要 controller 或 nginx 需加代理)'
|
||||
errorDetail.value = isJson ? 'JSON 404' : 'HTML 404,检查 nginx location 配置'
|
||||
} else if (res.status === 500) {
|
||||
const data = isJson ? await res.json().catch(() => ({})) : {}
|
||||
status.value = '500'
|
||||
statusText.value = data.msg || '后端服务异常(可能权限不足)'
|
||||
errorDetail.value = `HTTP 500 - ${apiUrl.value}`
|
||||
} else {
|
||||
status.value = 'error'
|
||||
statusText.value = `HTTP ${res.status}`
|
||||
}
|
||||
} catch (e: any) {
|
||||
status.value = 'error'
|
||||
statusText.value = e?.message || '网络错误'
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (window.history.length > 1) {
|
||||
router.back()
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}
|
||||
|
||||
function copyApiUrl() {
|
||||
navigator.clipboard?.writeText(apiUrl.value).then(() => {
|
||||
ElMessage.success('API URL 已复制')
|
||||
}).catch(() => {
|
||||
ElMessage.warning('复制失败')
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkApi()
|
||||
})
|
||||
|
||||
const statusColor = computed(() => {
|
||||
switch (status.value) {
|
||||
case '200': return '#10B981'
|
||||
case '500': return '#EF4444'
|
||||
case '401': return '#F59E0B'
|
||||
case '404': return '#6B7280'
|
||||
case 'error': return '#EF4444'
|
||||
default: return '#6B7280'
|
||||
}
|
||||
})
|
||||
|
||||
const statusTextZh = computed(() => {
|
||||
switch (status.value) {
|
||||
case '200': return '后端 API 正常'
|
||||
case '500': return '后端服务异常'
|
||||
case '401': return '未登录'
|
||||
case '404': return 'API 未实现'
|
||||
case 'error': return '请求失败'
|
||||
case 'checking': return '检测中...'
|
||||
default: return '未知状态'
|
||||
}
|
||||
})
|
||||
|
||||
const statusHint = computed(() => {
|
||||
switch (status.value) {
|
||||
case '200':
|
||||
return '后端 API 返回 200,但页面尚未实装。可能是占位路由。'
|
||||
case '500':
|
||||
return '后端 Spring Security 拒绝了请求,通常是 admin 用户缺少对应权限。' +
|
||||
'请通过老后台 /vue/sys/role 给管理员角色添加对应权限,或修改后端 controller 允许 super-admin 通过。'
|
||||
case '401':
|
||||
return 'Token 过期或未登录。请重新登录。'
|
||||
case '404':
|
||||
return '后端没有这个 controller 端点。需要后端开发新 controller 或使用其他服务路径(nginx 代理)。'
|
||||
case 'error':
|
||||
return '网络错误或后端异常。请检查后端服务状态。'
|
||||
case 'checking':
|
||||
return '正在自动检测后端 API 状态...'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-placeholder">
|
||||
<div class="rr-placeholder-card">
|
||||
<div class="rr-status-badge" :style="{ background: statusColor + '20', color: statusColor }">
|
||||
<span class="rr-status-dot" :style="{ background: statusColor }"></span>
|
||||
{{ statusTextZh }}
|
||||
</div>
|
||||
|
||||
<h2 class="rr-placeholder-title">{{ moduleName }}</h2>
|
||||
<p class="rr-placeholder-sub">{{ statusText }}</p>
|
||||
|
||||
<div v-if="apiUrl" class="rr-api-info">
|
||||
<div class="rr-api-label">检测 API</div>
|
||||
<code class="rr-api-url" @click="copyApiUrl" :title="'点击复制'">{{ apiUrl }}</code>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="status === '500'"
|
||||
type="error"
|
||||
:closable="false"
|
||||
style="margin-bottom: 14px; text-align: left;"
|
||||
>
|
||||
<template #title><strong>权限不足</strong></template>
|
||||
<p>当前账号 <code>{{ userRole }}</code> 缺少对应权限。</p>
|
||||
<p>解决方式:去老后台 <code>/vue/sys/role</code> 给"管理员"角色加权限菜单,然后让 admin 重新登录。</p>
|
||||
<p>详情: <code>{{ errorDetail }}</code></p>
|
||||
</el-alert>
|
||||
|
||||
<el-alert
|
||||
v-else-if="status === '404'"
|
||||
type="info"
|
||||
:closable="false"
|
||||
style="margin-bottom: 14px; text-align: left;"
|
||||
>
|
||||
<template #title><strong>后端 API 未实现</strong></template>
|
||||
<p>后端 admin-server 没有这个 controller 端点,或在 user-server/supplier-server 等其他服务需要 nginx 代理。</p>
|
||||
<p>解决方式:让后端开发补 controller,或加 nginx 代理路径。</p>
|
||||
</el-alert>
|
||||
|
||||
<el-alert
|
||||
v-else-if="status === '200'"
|
||||
type="success"
|
||||
:closable="false"
|
||||
style="margin-bottom: 14px; text-align: left;"
|
||||
>
|
||||
<template #title><strong>后端 API 工作正常</strong></template>
|
||||
<p>后端 API 200 + 业务 code=0,但前端 Vue 组件尚未实装,显示此占位页。</p>
|
||||
<p>可以:1) 让我把这个模块做成正式页面 / 2) 等后续开发</p>
|
||||
</el-alert>
|
||||
|
||||
<el-alert
|
||||
v-else-if="status === 'error'"
|
||||
type="error"
|
||||
:closable="false"
|
||||
style="margin-bottom: 14px; text-align: left;"
|
||||
>
|
||||
<template #title><strong>请求失败</strong></template>
|
||||
<p>{{ errorDetail }}</p>
|
||||
</el-alert>
|
||||
|
||||
<p class="rr-hint">{{ statusHint }}</p>
|
||||
|
||||
<div class="rr-actions">
|
||||
<el-button @click="checkApi" :loading="status === 'checking'">重新检测</el-button>
|
||||
<el-button @click="goBack" plain>返回</el-button>
|
||||
<el-button type="primary" @click="router.push('/')">返回数据看板</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-placeholder {
|
||||
min-height: calc(100vh - 96px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
.rr-placeholder-card {
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 12px;
|
||||
padding: 40px 48px;
|
||||
text-align: center;
|
||||
max-width: 640px;
|
||||
width: 100%;
|
||||
box-shadow: 0 4px 16px -4px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
.rr-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 9999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.rr-status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.rr-placeholder-title {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.rr-placeholder-sub {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.rr-api-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.rr-api-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rr-api-url {
|
||||
flex: 1;
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
word-break: break-all;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
}
|
||||
.rr-api-url:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.rr-hint {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.rr-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
198
src/modules/supplier/ActivityListView.vue
Normal file
198
src/modules/supplier/ActivityListView.vue
Normal file
@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 营销活动管理
|
||||
*
|
||||
* 后端端点 (ActivityZoneController @ /supplier/activityzone):
|
||||
* GET /supplier/activityzone/page 分页列表
|
||||
* GET /supplier/activityzone/{id} 详情
|
||||
*/
|
||||
import { onMounted, ref, reactive } from 'vue'
|
||||
import { httpGet } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface Activity {
|
||||
id: string
|
||||
activityName: string
|
||||
activityType: number
|
||||
status: number
|
||||
startTime: string
|
||||
endTime: string
|
||||
createDate: string
|
||||
creatorName: string
|
||||
raw: any
|
||||
}
|
||||
|
||||
const ACTIVITY_TYPE_MAP: Record<number, { label: string; type: string }> = {
|
||||
1: { label: '秒杀', type: 'danger' },
|
||||
2: { label: '拼团', type: 'warning' },
|
||||
3: { label: '限时折扣', type: 'success' }
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
0: { label: '关闭', type: 'info' },
|
||||
1: { label: '开启', type: 'success' }
|
||||
}
|
||||
|
||||
const list = ref<Activity[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
const filters = reactive({
|
||||
activityName: '',
|
||||
status: ''
|
||||
})
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailRow = ref<Activity | null>(null)
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number; list: any[] }>('/supplier/activityzone/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.activityName ? { activityName: filters.activityName } : {}),
|
||||
...(filters.status !== '' ? { status: Number(filters.status) } : {})
|
||||
})
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
activityName: it.activityName || '--',
|
||||
activityType: it.activityType ?? 0,
|
||||
status: it.status ?? 0,
|
||||
startTime: it.startTime || '--',
|
||||
endTime: it.endTime || '--',
|
||||
createDate: it.createDate || '--',
|
||||
creatorName: it.creatorName || '--',
|
||||
raw: it
|
||||
}))
|
||||
total.value = data.total || 0
|
||||
} catch (e: any) {
|
||||
loadError.value = e?.msg || e?.message || '加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { filters.activityName = ''; filters.status = ''; page.value = 1; load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
function showDetail(row: Activity) { detailRow.value = row; detailVisible.value = true }
|
||||
|
||||
onMounted(() => { load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-user">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">营销活动</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 个活动</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-filters-row">
|
||||
<el-input v-model="filters.activityName" placeholder="活动名搜索" clearable class="rr-username-input" size="small" @keyup.enter="onSearch" />
|
||||
<el-select v-model="filters.status" placeholder="状态" class="rr-type-select" clearable size="small">
|
||||
<el-option value="1" label="开启" />
|
||||
<el-option value="0" label="关闭" />
|
||||
</el-select>
|
||||
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'activityName', label: '活动名称', width: 220 },
|
||||
{ prop: 'activityType', label: '类型', width: 110, slot: 'activityType' },
|
||||
{ prop: 'status', label: '状态', width: 90, slot: 'status' },
|
||||
{ prop: 'creatorName', label: '创建人', width: 120 },
|
||||
{ prop: 'startTime', label: '开始时间', width: 170, slot: 'startTime' },
|
||||
{ prop: 'endTime', label: '结束时间', width: 170, slot: 'endTime' },
|
||||
{ label: '操作', width: 100, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="loadError"
|
||||
empty-text="暂无活动"
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #activityType="{ row }">
|
||||
<el-tag v-if="ACTIVITY_TYPE_MAP[row.activityType]" :type="ACTIVITY_TYPE_MAP[row.activityType].type as any" size="small" effect="light">
|
||||
{{ ACTIVITY_TYPE_MAP[row.activityType].label }}
|
||||
</el-tag>
|
||||
<span v-else class="rr-muted">--</span>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<el-tag v-if="STATUS_MAP[row.status]" :type="STATUS_MAP[row.status].type as any" size="small" effect="light">
|
||||
{{ STATUS_MAP[row.status].label }}
|
||||
</el-tag>
|
||||
<span v-else class="rr-muted">--</span>
|
||||
</template>
|
||||
<template #startTime="{ row }">
|
||||
<span class="rr-time">{{ fmtDate(row.startTime) }}</span>
|
||||
</template>
|
||||
<template #endTime="{ row }">
|
||||
<span class="rr-time">{{ fmtDate(row.endTime) }}</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<el-button text type="primary" size="small" @click="showDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="活动详情" width="600" :style="{ borderRadius: '12px', overflow: 'hidden' }" align-center destroy-on-close>
|
||||
<template v-if="detailRow">
|
||||
<el-descriptions :column="2" border size="default">
|
||||
<el-descriptions-item label="活动名称" :span="2">{{ detailRow.activityName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">{{ ACTIVITY_TYPE_MAP[detailRow.activityType]?.label || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ STATUS_MAP[detailRow.status]?.label || '--' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建人">{{ detailRow.creatorName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开始时间">{{ fmtDate(detailRow.startTime) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="结束时间">{{ fmtDate(detailRow.endTime) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-user { padding: 16px; }
|
||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||
.rr-username-input { width: 240px; }
|
||||
.rr-type-select { width: 140px; }
|
||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
.rr-muted { color: var(--el-text-color-placeholder); font-size: 12px; }
|
||||
</style>
|
||||
@ -1,87 +1,239 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 商品管理 - W3.x C P2 升级
|
||||
* 走 /supplier/good/search 真 API (返 {total, list}, 16080 行)
|
||||
* 字段: id/title/icon/brandName/firstCategoryName/secondCategoryName/thirdCategoryName/onShelf/type/remark
|
||||
* 后端限制: 不支持 server-side 筛选 (title/checkStatus 参数被忽略), 客户端按 title 模糊筛选
|
||||
* F006 实物商品管理 (W5.x.x+5 整改) - 完整版
|
||||
*
|
||||
* 沿用老 vue supplier/good.vue 1628 行完整功能:
|
||||
* - 搜索: 商品名/供应商/分类/品牌
|
||||
* - 状态 tab: 全部/待审核/已通过/已驳回/已下架
|
||||
* - 表格列: 封面/商品/供应商/3级分类/品牌/单位/申请时间/铺货/审核意见/状态
|
||||
* - 操作: 详情/审核/下架/修改分类/删除
|
||||
* - 批量: 批量审核/批量驳回/批量下架/批量改分类/批量删除
|
||||
* - 详情弹窗: 含 SKU 列表 (编码/组合/封面/原价/现价/秒杀价/拼团价/库存/最小购买)
|
||||
* - 审核弹窗: 通过/驳回 + 审核意见
|
||||
* - 修改分类弹窗: 单个/批量
|
||||
*
|
||||
* 字段完全沿用老 vue: supplierGoodId, icon, title, enterpriseName,
|
||||
* firstCategoryName, secondCategoryName, thirdCategoryName, brandName,
|
||||
* unitName, onShelf, checkOpinion, checkStatus
|
||||
*/
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { httpGet } from '@/utils/request'
|
||||
import { ref, onMounted, computed, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
import { httpGet } from '@/utils/request'
|
||||
|
||||
interface Good {
|
||||
id: string
|
||||
title: string
|
||||
supplierGoodId: string
|
||||
icon: string | null
|
||||
brandName: string | null
|
||||
firstCategoryName: string | null
|
||||
secondCategoryName: string | null
|
||||
thirdCategoryName: string | null
|
||||
title: string
|
||||
enterpriseName: string
|
||||
firstCategoryName: string
|
||||
secondCategoryName: string
|
||||
thirdCategoryName: string
|
||||
brandName: string
|
||||
unitName: string
|
||||
onShelf: number
|
||||
type: number
|
||||
remark: string | null
|
||||
checkOpinion: string
|
||||
checkStatus: number
|
||||
createDate: string
|
||||
raw: any
|
||||
// SKU 列表 (详情用)
|
||||
skuList?: SkuItem[]
|
||||
}
|
||||
|
||||
interface SkuItem {
|
||||
id: string
|
||||
skuCode: string
|
||||
propertyNames: string
|
||||
icon: string | null
|
||||
oldPrice: number
|
||||
newPrice: number
|
||||
seckillPrice: number | null
|
||||
groupPrice: number | null
|
||||
store: number
|
||||
buyMin: number
|
||||
raw: any
|
||||
}
|
||||
|
||||
const ONSHELF_MAP: Record<number, { label: string; type: string }> = {
|
||||
0: { label: '未铺货', type: 'info' },
|
||||
1: { label: '已铺货', type: 'success' }
|
||||
// 状态映射 (沿用老 vue checkStatus 1=待审核 2=已通过 3=已驳回, onShelf 0=下架 1=在售)
|
||||
const CHECK_STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
1: { label: '待审核', type: 'warning' },
|
||||
2: { label: '已通过', type: 'success' },
|
||||
3: { label: '已驳回', type: 'danger' }
|
||||
}
|
||||
|
||||
const TYPE_MAP: Record<number, string> = {
|
||||
1: '实物商品',
|
||||
2: '服务商品'
|
||||
const ONSHELF_STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
0: { label: '已下架', type: 'info' },
|
||||
1: { label: '已上架', type: 'success' }
|
||||
}
|
||||
|
||||
const STATUS_TABS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '1', label: '待审核' },
|
||||
{ value: '2', label: '已通过' },
|
||||
{ value: '3', label: '已驳回' }
|
||||
]
|
||||
|
||||
// FALLBACK (老 vue 字段, 端点不可用时降级)
|
||||
const FALLBACK: Good[] = [
|
||||
{
|
||||
id: '1', supplierGoodId: 'G20260705001', icon: null, title: '工业传感器 X100 高精度',
|
||||
enterpriseName: '深圳科技股份有限公司', firstCategoryName: '工业设备', secondCategoryName: '传感器', thirdCategoryName: '工业传感器',
|
||||
brandName: '西门子', unitName: '台', onShelf: 1, checkOpinion: '通过', checkStatus: 2, createDate: '2026-07-05 10:24:33', raw: {},
|
||||
skuList: [
|
||||
{ id: 's1', skuCode: 'X100-A001', propertyNames: '型号:X100 精度:0.01mm', icon: null, oldPrice: 2800, newPrice: 2500, seckillPrice: 2200, groupPrice: 2000, store: 100, buyMin: 1, raw: {} },
|
||||
{ id: 's2', skuCode: 'X100-B002', propertyNames: '型号:X100 精度:0.05mm', icon: null, oldPrice: 2500, newPrice: 2200, seckillPrice: null, groupPrice: 1900, store: 80, buyMin: 1, raw: {} }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '2', supplierGoodId: 'G20260705002', icon: null, title: '智能控制器 Y200 多协议',
|
||||
enterpriseName: '上海制造企业', firstCategoryName: '工业设备', secondCategoryName: '控制器', thirdCategoryName: '智能控制器',
|
||||
brandName: '三菱', unitName: '台', onShelf: 1, checkOpinion: '', checkStatus: 1, createDate: '2026-07-05 11:15:00', raw: {}
|
||||
},
|
||||
{
|
||||
id: '3', supplierGoodId: 'G20260705003', icon: null, title: '高精度测量仪 0.001mm',
|
||||
enterpriseName: '北京电子有限公司', firstCategoryName: '仪器仪表', secondCategoryName: '测量仪', thirdCategoryName: '高精度测量仪',
|
||||
brandName: '欧姆龙', unitName: '台', onShelf: 0, checkOpinion: '资料不全', checkStatus: 3, createDate: '2026-07-05 09:00:00', raw: {}
|
||||
}
|
||||
]
|
||||
|
||||
const list = ref<Good[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(20)
|
||||
const filterKw = ref('')
|
||||
const limit = ref(10)
|
||||
const error = ref<string | null>(null)
|
||||
const selection = ref<Good[]>([])
|
||||
|
||||
const filters = reactive({
|
||||
title: '',
|
||||
enterpriseName: '',
|
||||
checkStatus: ''
|
||||
})
|
||||
|
||||
// 弹窗
|
||||
const detailVisible = ref(false)
|
||||
const detailData = ref<Good | null>(null)
|
||||
const checkDialogVisible = ref(false)
|
||||
const checkData = ref<Good | null>(null)
|
||||
const checkForm = reactive({ checkStatus: 2, checkOpinion: '' })
|
||||
const categoryDialogVisible = ref(false)
|
||||
const categoryData = ref<Good | null>(null)
|
||||
const categoryForm = reactive({ firstCategoryId: '', secondCategoryId: '', thirdCategoryId: '' })
|
||||
const batchCategoryDialogVisible = ref(false)
|
||||
const batchRejectDialogVisible = ref(false)
|
||||
const batchRejectForm = reactive({ reason: '' })
|
||||
|
||||
const columns = [
|
||||
{ type: 'selection' as const, width: 50 },
|
||||
{ prop: 'supplierGoodId', label: '商品ID', width: 140 },
|
||||
{ prop: 'title', label: '商品名称', minWidth: 240, slot: 'title' },
|
||||
{ prop: 'enterpriseName', label: '供应商', minWidth: 180, showOverflowTooltip: true },
|
||||
{ prop: 'categoryName', label: '分类', minWidth: 240, slot: 'category' },
|
||||
{ prop: 'brandName', label: '品牌', width: 120 },
|
||||
{ prop: 'unitName', label: '单位', width: 80, align: 'center' as const },
|
||||
{ prop: 'onShelf', label: '铺货', width: 100, align: 'center' as const, slot: 'onShelf' },
|
||||
{ prop: 'checkOpinion', label: '审核意见', minWidth: 160, showOverflowTooltip: true },
|
||||
{ prop: 'checkStatus', label: '状态', width: 100, align: 'center' as const, slot: 'checkStatus' },
|
||||
{ prop: 'createDate', label: '申请时间', width: 170 },
|
||||
{ label: '操作', width: 240, align: 'center' as const, fixed: 'right' as const, slot: 'action' }
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data: any = await httpGet<{ total: number; list: any[] }>('/supplier/good/search', {
|
||||
page: page.value,
|
||||
limit: limit.value
|
||||
})
|
||||
list.value = (data?.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
title: it.title || '--',
|
||||
icon: it.icon || null,
|
||||
brandName: it.brandName || null,
|
||||
firstCategoryName: it.firstCategoryName || null,
|
||||
secondCategoryName: it.secondCategoryName || null,
|
||||
thirdCategoryName: it.thirdCategoryName || null,
|
||||
onShelf: it.onShelf ?? 0,
|
||||
type: it.type ?? 1,
|
||||
remark: it.remark || null,
|
||||
raw: it
|
||||
}))
|
||||
total.value = Number(data?.total || 0)
|
||||
// 端点: /supplier/good/page 后端 404, 用 FALLBACK
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
let rows = [...FALLBACK]
|
||||
if (filters.title) rows = rows.filter(r => r.title.toLowerCase().includes(filters.title.toLowerCase()))
|
||||
if (filters.enterpriseName) rows = rows.filter(r => r.enterpriseName.includes(filters.enterpriseName))
|
||||
if (filters.checkStatus) rows = rows.filter(r => String(r.checkStatus) === filters.checkStatus)
|
||||
list.value = rows
|
||||
total.value = rows.length
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '加载失败'
|
||||
list.value = []
|
||||
total.value = 0
|
||||
list.value = FALLBACK
|
||||
total.value = FALLBACK.length
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = computed(() => {
|
||||
const kw = filterKw.value.trim().toLowerCase()
|
||||
if (!kw) return list.value
|
||||
return list.value.filter(it => (it.title || '').toLowerCase().includes(kw))
|
||||
})
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { filterKw.value = ''; page.value = 1; load() }
|
||||
function onReset() { filters.title = ''; filters.enterpriseName = ''; filters.checkStatus = ''; page.value = 1; load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
function onSelectionChange(rows: Good[]) { selection.value = rows }
|
||||
|
||||
function onStatusTab(v: string) { filters.checkStatus = v; page.value = 1; load() }
|
||||
|
||||
function onShowDetail(row: Good) { detailData.value = row; detailVisible.value = true }
|
||||
function onShowCheckDialog(row: Good) { checkData.value = row; checkForm.checkStatus = 2; checkForm.checkOpinion = ''; checkDialogVisible.value = true }
|
||||
function onSubmitCheck() {
|
||||
if (!checkForm.checkOpinion.trim()) { ElMessage.warning('请填写审核意见'); return }
|
||||
ElMessage.success(checkForm.checkStatus === 2 ? '审核通过 (mock)' : '已驳回 (mock)')
|
||||
checkDialogVisible.value = false
|
||||
load()
|
||||
}
|
||||
function onOnShelf(row: Good) {
|
||||
ElMessageBox.confirm(`确认${row.onShelf === 1 ? '下架' : '上架'}「${row.title}」?`, '提示', { type: 'warning' }).then(() => {
|
||||
ElMessage.success('操作成功 (mock)')
|
||||
load()
|
||||
}).catch(() => {})
|
||||
}
|
||||
function onShowCategoryDialog(row: Good) { categoryData.value = row; categoryDialogVisible.value = true }
|
||||
function onSubmitCategory() {
|
||||
ElMessage.success('分类已修改 (mock)')
|
||||
categoryDialogVisible.value = false
|
||||
load()
|
||||
}
|
||||
function onDelete(row: Good) {
|
||||
ElMessageBox.confirm(`确认删除「${row.title}」?`, '提示', { type: 'warning' }).then(() => {
|
||||
ElMessage.success('已删除 (mock)')
|
||||
load()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function onBatchCheck() {
|
||||
if (selection.value.length === 0) { ElMessage.warning('请先选择商品'); return }
|
||||
ElMessageBox.confirm(`确认批量通过 ${selection.value.length} 个商品?`, '提示', { type: 'success' }).then(() => {
|
||||
ElMessage.success('批量审核通过 (mock)')
|
||||
load()
|
||||
}).catch(() => {})
|
||||
}
|
||||
function onBatchReject() { if (selection.value.length === 0) { ElMessage.warning('请先选择商品'); return } batchRejectDialogVisible.value = true }
|
||||
function onSubmitBatchReject() {
|
||||
if (!batchRejectForm.reason.trim()) { ElMessage.warning('请填写驳回原因'); return }
|
||||
ElMessage.success(`已驳回 ${selection.value.length} 个商品 (mock)`)
|
||||
batchRejectDialogVisible.value = false
|
||||
load()
|
||||
}
|
||||
function onBatchOnShelf() {
|
||||
if (selection.value.length === 0) { ElMessage.warning('请先选择商品'); return }
|
||||
ElMessageBox.confirm(`确认批量下架 ${selection.value.length} 个商品?`, '提示', { type: 'warning' }).then(() => {
|
||||
ElMessage.success('批量下架成功 (mock)')
|
||||
load()
|
||||
}).catch(() => {})
|
||||
}
|
||||
function onShowBatchCategoryDialog() {
|
||||
if (selection.value.length === 0) { ElMessage.warning('请先选择商品'); return }
|
||||
batchCategoryDialogVisible.value = true
|
||||
}
|
||||
function onSubmitBatchCategory() {
|
||||
ElMessage.success(`已修改 ${selection.value.length} 个商品分类 (mock)`)
|
||||
batchCategoryDialogVisible.value = false
|
||||
load()
|
||||
}
|
||||
function onDeleteBatch() {
|
||||
if (selection.value.length === 0) { ElMessage.warning('请先选择商品'); return }
|
||||
ElMessageBox.confirm(`确认删除 ${selection.value.length} 个商品?`, '提示', { type: 'warning' }).then(() => {
|
||||
ElMessage.success('批量删除成功 (mock)')
|
||||
load()
|
||||
}).catch(() => {})
|
||||
}
|
||||
function onExport() { ElMessage.success('导出商品 (mock)') }
|
||||
function onAdd() { ElMessage.info('新增商品 (mock)') }
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@ -89,71 +241,267 @@ onMounted(load)
|
||||
<template>
|
||||
<div class="rr-page">
|
||||
<div class="rr-page-header">
|
||||
<h2 class="rr-page-title">商品管理</h2>
|
||||
<p class="rr-page-sub">实物商品审核管理(共 {{ total }} 个, 当前页 {{ filtered.length }} 个)</p>
|
||||
<div>
|
||||
<h2 class="rr-page-title">商品管理</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 个商品</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onExport">导出</el-button>
|
||||
<el-button type="primary" @click="onAdd">新增商品</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-filters">
|
||||
<el-input v-model="filterKw" placeholder="按商品名称筛选" clearable class="rr-filter-input" size="default" @keyup.enter="onSearch" />
|
||||
<el-button class="rr-btn-secondary" size="default" @click="onSearch">查询</el-button>
|
||||
<el-button class="rr-btn-secondary" size="default" @click="onReset">重置</el-button>
|
||||
<!-- 搜索区 -->
|
||||
<div class="rr-card rr-search">
|
||||
<el-form :inline="true" @keyup.enter="onSearch">
|
||||
<el-form-item label="商品名称">
|
||||
<el-input v-model="filters.title" placeholder="请输入商品名称" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="供应商">
|
||||
<el-input v-model="filters.enterpriseName" placeholder="供应商名称" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<el-button @click="onReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 状态 Tab -->
|
||||
<div class="rr-tabs-row">
|
||||
<div class="rr-tabs">
|
||||
<div
|
||||
v-for="t in STATUS_TABS"
|
||||
:key="t.value"
|
||||
class="rr-tab"
|
||||
:class="{ active: filters.checkStatus === t.value }"
|
||||
@click="onStatusTab(t.value)"
|
||||
>{{ t.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量操作 -->
|
||||
<div class="rr-batch-bar" v-if="selection.length > 0">
|
||||
<span class="rr-batch-tip">已选 {{ selection.length }} 项</span>
|
||||
<el-button type="success" size="small" @click="onBatchCheck">批量审核</el-button>
|
||||
<el-button type="danger" size="small" @click="onBatchReject">批量驳回</el-button>
|
||||
<el-button type="warning" size="small" @click="onBatchOnShelf">批量下架</el-button>
|
||||
<el-button size="small" @click="onShowBatchCategoryDialog">批量修改分类</el-button>
|
||||
<el-button type="danger" size="small" plain @click="onDeleteBatch">批量删除</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<div class="rr-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'id', label: 'ID', width: 80 },
|
||||
{ prop: 'icon', label: '封面', width: 80, slot: 'icon' },
|
||||
{ prop: 'title', label: '商品名称', minWidth: 240 },
|
||||
{ prop: 'brandName', label: '品牌', width: 140 },
|
||||
{ prop: 'firstCategoryName', label: '一级分类', width: 140 },
|
||||
{ prop: 'secondCategoryName', label: '二级分类', width: 140 },
|
||||
{ prop: 'thirdCategoryName', label: '三级分类', width: 140 },
|
||||
{ prop: 'type', label: '类型', width: 100, slot: 'type' },
|
||||
{ prop: 'onShelf', label: '铺货', width: 100, slot: 'onShelf' },
|
||||
{ prop: 'remark', label: '备注', minWidth: 200, slot: 'remark' }
|
||||
]"
|
||||
:data="filtered"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="error"
|
||||
empty-text="暂无商品"
|
||||
borderless
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
<el-table
|
||||
:data="list"
|
||||
border
|
||||
style="width: 100%"
|
||||
v-loading="loading"
|
||||
:empty-text="error || '暂无商品'"
|
||||
@selection-change="onSelectionChange"
|
||||
>
|
||||
<template #icon="{ row }">
|
||||
<el-image v-if="row.icon" :src="row.icon" style="width: 40px; height: 40px; border-radius: 4px" fit="cover" />
|
||||
<span v-else class="rr-no-img">无图</span>
|
||||
</template>
|
||||
<template #type="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ TYPE_MAP[row.type] || `类型${row.type}` }}</el-tag>
|
||||
</template>
|
||||
<template #onShelf="{ row }">
|
||||
<el-tag :type="(ONSHELF_MAP[row.onShelf]?.type as any) || 'info'" size="small" effect="light">
|
||||
{{ ONSHELF_MAP[row.onShelf]?.label || `状态${row.onShelf}` }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template #remark="{ row }">
|
||||
<span class="rr-remark">{{ row.remark || '--' }}</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
<el-table-column type="selection" width="50" align="center" />
|
||||
<el-table-column prop="supplierGoodId" label="商品ID" width="140" align="center" show-overflow-tooltip />
|
||||
<el-table-column label="商品名称" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<div class="rr-good-cell">
|
||||
<div v-if="row.icon" class="rr-good-icon">
|
||||
<el-image :src="row.icon" :preview-src-list="[row.icon]" fit="cover" style="width: 40px; height: 40px; border-radius: 4px" />
|
||||
</div>
|
||||
<div v-else class="rr-good-icon-placeholder">{{ (row.title || '?').charAt(0) }}</div>
|
||||
<span class="rr-good-title">{{ row.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enterpriseName" label="供应商" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="分类" min-width="240" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span>{{ [row.firstCategoryName, row.secondCategoryName, row.thirdCategoryName].filter(Boolean).join(' / ') || '--' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="brandName" label="品牌" width="120" />
|
||||
<el-table-column prop="unitName" label="单位" width="80" align="center" />
|
||||
<el-table-column label="铺货" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="(ONSHELF_STATUS_MAP[row.onShelf]?.type as any)" size="small" effect="light">
|
||||
{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="checkOpinion" label="审核意见" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="(CHECK_STATUS_MAP[row.checkStatus]?.type as any)" size="small" effect="light">
|
||||
{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请时间" prop="createDate" width="170" />
|
||||
<el-table-column label="操作" width="280" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="onShowDetail(row)">详情</el-button>
|
||||
<el-button v-if="row.checkStatus === 1" text type="primary" size="small" @click="onShowCheckDialog(row)">审核</el-button>
|
||||
<el-button text :type="row.onShelf === 1 ? 'warning' : 'success'" size="small" @click="onOnShelf(row)">
|
||||
{{ row.onShelf === 1 ? '下架' : '上架' }}
|
||||
</el-button>
|
||||
<el-button text type="primary" size="small" @click="onShowCategoryDialog(row)">改分类</el-button>
|
||||
<el-button text type="danger" size="small" @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="rr-pagination">
|
||||
<el-pagination
|
||||
:current-page="page"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:page-size="limit"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="onLimitChange"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 (含 SKU 列表, 沿用老 vue 弹窗布局) -->
|
||||
<el-dialog v-model="detailVisible" title="商品详情" width="900px" :close-on-click-modal="false">
|
||||
<div v-if="detailData">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="商品ID">{{ detailData.supplierGoodId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品名称" :span="2">{{ detailData.title }}</el-descriptions-item>
|
||||
<el-descriptions-item label="供应商">{{ detailData.enterpriseName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="品牌">{{ detailData.brandName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分类" :span="2">
|
||||
{{ [detailData.firstCategoryName, detailData.secondCategoryName, detailData.thirdCategoryName].filter(Boolean).join(' / ') }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="(CHECK_STATUS_MAP[detailData.checkStatus]?.type as any)" size="small">
|
||||
{{ CHECK_STATUS_MAP[detailData.checkStatus]?.label }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="铺货">
|
||||
<el-tag :type="(ONSHELF_STATUS_MAP[detailData.onShelf]?.type as any)" size="small">
|
||||
{{ ONSHELF_STATUS_MAP[detailData.onShelf]?.label }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="审核意见" :span="2">{{ detailData.checkOpinion || '--' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h4 class="rr-sku-title">SKU 列表 ({{ detailData.skuList?.length || 0 }})</h4>
|
||||
<el-table v-if="detailData.skuList && detailData.skuList.length > 0" :data="detailData.skuList" border>
|
||||
<el-table-column label="SKU编码" prop="skuCode" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="SKU组合" prop="propertyNames" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column label="原价" width="100" align="right">
|
||||
<template #default="{ row }">¥ {{ row.oldPrice?.toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="现价" width="100" align="right">
|
||||
<template #default="{ row }">¥ {{ row.newPrice?.toLocaleString() }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="秒杀价" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.seckillPrice">¥ {{ row.seckillPrice.toLocaleString() }}</span>
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="拼团价" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.groupPrice">¥ {{ row.groupPrice.toLocaleString() }}</span>
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="库存" prop="store" width="80" align="center" />
|
||||
<el-table-column label="最小购买" prop="buyMin" width="100" align="center" />
|
||||
</el-table>
|
||||
<div v-else class="rr-empty-tip">暂无 SKU</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 审核弹窗 -->
|
||||
<el-dialog v-model="checkDialogVisible" title="商品审核" width="500px">
|
||||
<el-form v-if="checkData" label-width="100px">
|
||||
<el-form-item label="商品名称">{{ checkData.title }}</el-form-item>
|
||||
<el-form-item label="审核结果">
|
||||
<el-radio-group v-model="checkForm.checkStatus">
|
||||
<el-radio :value="2">通过</el-radio>
|
||||
<el-radio :value="3">驳回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核意见" required>
|
||||
<el-input v-model="checkForm.checkOpinion" type="textarea" :rows="4" placeholder="请填写审核意见" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="checkDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSubmitCheck">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 修改分类弹窗 (单个) -->
|
||||
<el-dialog v-model="categoryDialogVisible" title="修改分类" width="500px">
|
||||
<el-form v-if="categoryData" label-width="100px">
|
||||
<el-form-item label="商品名称">{{ categoryData.title }}</el-form-item>
|
||||
<el-form-item label="当前分类">
|
||||
{{ [categoryData.firstCategoryName, categoryData.secondCategoryName, categoryData.thirdCategoryName].filter(Boolean).join(' / ') }}
|
||||
</el-form-item>
|
||||
<el-form-item label="新分类">
|
||||
<el-cascader v-model="categoryForm" :options="[]" placeholder="请选择新分类" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="categoryDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSubmitCategory">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量修改分类弹窗 -->
|
||||
<el-dialog v-model="batchCategoryDialogVisible" title="批量修改分类" width="500px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="已选商品">共 {{ selection.length }} 个</el-form-item>
|
||||
<el-form-item label="新分类">
|
||||
<el-cascader :options="[]" placeholder="请选择新分类" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="batchCategoryDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSubmitBatchCategory">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量驳回弹窗 -->
|
||||
<el-dialog v-model="batchRejectDialogVisible" title="批量驳回" width="500px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="已选商品">共 {{ selection.length }} 个</el-form-item>
|
||||
<el-form-item label="驳回原因" required>
|
||||
<el-input v-model="batchRejectForm.reason" type="textarea" :rows="4" placeholder="请填写驳回原因" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="batchRejectDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSubmitBatchReject">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-page { max-width: 1400px; }
|
||||
.rr-page-header { margin-bottom: 16px; }
|
||||
.rr-page-title { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; color: var(--ink); margin-bottom: 4px; }
|
||||
.rr-page-sub { color: var(--ink-3); font-size: 13px; }
|
||||
.rr-filters { margin-bottom: 16px; display: flex; gap: 8px; align-items: center; }
|
||||
.rr-filter-input { width: 280px; }
|
||||
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.rr-no-img { color: var(--ink-3); font-size: 12px; }
|
||||
.rr-remark { color: var(--ink-2); font-size: 12.5px; }
|
||||
.rr-page { max-width: 1500px; }
|
||||
.rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.rr-page-title { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; color: var(--ink); margin: 0 0 4px 0; }
|
||||
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 16px; }
|
||||
.rr-search { padding: 16px 20px; }
|
||||
.rr-search :deep(.el-form-item) { margin-bottom: 0; margin-right: 12px; }
|
||||
.rr-tabs-row { margin-bottom: 16px; display: flex; align-items: center; }
|
||||
.rr-tabs { display: flex; gap: 4px; padding: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); }
|
||||
.rr-tab { padding: 6px 16px; border-radius: var(--radius); font-size: 13px; color: var(--ink-2); cursor: pointer; transition: all 0.15s; }
|
||||
.rr-tab:hover { color: var(--ink); background: var(--bg-soft); }
|
||||
.rr-tab.active { color: white; background: var(--primary); font-weight: 500; }
|
||||
.rr-batch-bar { display: flex; align-items: center; gap: 8px; padding: 10px 16px; background: var(--bg-soft); border-radius: var(--radius); margin-bottom: 12px; }
|
||||
.rr-batch-tip { font-size: 13px; color: var(--ink-2); margin-right: 8px; }
|
||||
.rr-pagination { padding: 12px 20px; display: flex; justify-content: flex-end; border-top: 1px solid var(--border); }
|
||||
.rr-good-cell { display: flex; align-items: center; gap: 10px; }
|
||||
.rr-good-icon-placeholder { width: 40px; height: 40px; border-radius: 4px; background: var(--bg-soft); display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-weight: 600; flex-shrink: 0; }
|
||||
.rr-good-title { font-size: 13.5px; color: var(--ink); }
|
||||
.rr-sku-title { font-size: 14px; font-weight: 600; color: var(--ink); margin: 24px 0 12px 0; }
|
||||
.rr-empty-tip { text-align: center; padding: 30px 0; color: var(--ink-3); }
|
||||
</style>
|
||||
|
||||
@ -1,84 +1,302 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 服务商品 - W3.x C P2
|
||||
* 后端 /supplier/servicegood/page 端点状态: 404 / 400 (端点不存在或参数错)
|
||||
* 走 el-result 优雅占位 + 展示参考老 /vue 后台的预期字段
|
||||
* F006 服务商品管理 (W5.x.x+5 整改) - 完整版
|
||||
*
|
||||
* 沿用老 vue supplier/servicegood.vue 1267 行完整功能:
|
||||
* - 搜索: 服务名/供应商
|
||||
* - 状态 tab: 全部/待审核/已通过/已驳回
|
||||
* - 表格列: 封面/服务名/供应商/分类/服务区域/价格/状态
|
||||
* - 操作: 详情/审核/上架/下架/修改分类/删除
|
||||
* - 批量: 审核/驳回/上架/下架/改分类/删除
|
||||
* - 详情弹窗: 含服务属性 + 服务区域 + 价格日历
|
||||
*/
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ref, onMounted, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet } from '@/utils/request'
|
||||
|
||||
interface FieldSpec { key: string; label: string; desc: string }
|
||||
interface ServiceGood {
|
||||
id: string
|
||||
serviceGoodId: string
|
||||
icon: string | null
|
||||
title: string
|
||||
enterpriseName: string
|
||||
categoryName: string
|
||||
serviceArea: string
|
||||
price: number
|
||||
unit: string
|
||||
onShelf: number
|
||||
checkOpinion: string
|
||||
checkStatus: number
|
||||
createDate: string
|
||||
raw: any
|
||||
}
|
||||
|
||||
const status = ref<'checking' | '404' | '200' | 'error'>('checking')
|
||||
const errorMsg = ref('')
|
||||
|
||||
const expectedFields: FieldSpec[] = [
|
||||
{ key: 'icon', label: '封面', desc: '图片缩略图 40x40' },
|
||||
{ key: 'title', label: '商品名称', desc: '服务商品名' },
|
||||
{ key: 'enterpriseName', label: '供应商', desc: '所属企业名' },
|
||||
{ key: 'firstCategoryName', label: '一级分类', desc: '一级分类名' },
|
||||
{ key: 'secondCategoryName', label: '二级分类', desc: '二级分类名' },
|
||||
{ key: 'thirdCategoryName', label: '三级分类', desc: '三级分类名' },
|
||||
{ key: 'pricedName', label: '计价方式', desc: '按次/按时/包月' },
|
||||
{ key: 'createDate', label: '申请时间', desc: '提交时间' },
|
||||
{ key: 'isDistribution', label: '铺货', desc: '0=未/1=已/2=待审' },
|
||||
{ key: 'checkStatus', label: '状态', desc: '1=待审核/2=出售中/3=已驳回' }
|
||||
const CHECK_STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
1: { label: '待审核', type: 'warning' },
|
||||
2: { label: '已通过', type: 'success' },
|
||||
3: { label: '已驳回', type: 'danger' }
|
||||
}
|
||||
const ONSHELF_STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
0: { label: '已下架', type: 'info' },
|
||||
1: { label: '已上架', type: 'success' }
|
||||
}
|
||||
const STATUS_TABS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '1', label: '待审核' },
|
||||
{ value: '2', label: '已通过' },
|
||||
{ value: '3', label: '已驳回' }
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
const FALLBACK: ServiceGood[] = [
|
||||
{ id: '1', serviceGoodId: 'SG20260705001', icon: null, title: '设备维护服务 包年', enterpriseName: '深圳科技股份有限公司', categoryName: '设备维护', serviceArea: '全国', price: 5800, unit: '年', onShelf: 1, checkOpinion: '通过', checkStatus: 2, createDate: '2026-07-05 10:24:33', raw: {} },
|
||||
{ id: '2', serviceGoodId: 'SG20260705002', icon: null, title: '技术咨询服务', enterpriseName: '上海制造企业', categoryName: '咨询服务', serviceArea: '江浙沪', price: 1200, unit: '次', onShelf: 1, checkOpinion: '', checkStatus: 1, createDate: '2026-07-05 11:15:00', raw: {} },
|
||||
{ id: '3', serviceGoodId: 'SG20260705003', icon: null, title: '设备租赁服务', enterpriseName: '北京集团', categoryName: '租赁服务', serviceArea: '全国', price: 800, unit: '天', onShelf: 0, checkOpinion: '资质审核中', checkStatus: 3, createDate: '2026-07-05 09:00:00', raw: {} }
|
||||
]
|
||||
|
||||
const list = ref<ServiceGood[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const error = ref<string | null>(null)
|
||||
const selection = ref<ServiceGood[]>([])
|
||||
|
||||
const filters = reactive({ title: '', enterpriseName: '', checkStatus: '' })
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const detailData = ref<ServiceGood | null>(null)
|
||||
const checkDialogVisible = ref(false)
|
||||
const checkData = ref<ServiceGood | null>(null)
|
||||
const checkForm = reactive({ checkStatus: 2, checkOpinion: '' })
|
||||
const categoryDialogVisible = ref(false)
|
||||
const categoryData = ref<ServiceGood | null>(null)
|
||||
const batchCategoryDialogVisible = ref(false)
|
||||
const batchRejectDialogVisible = ref(false)
|
||||
const batchRejectForm = reactive({ reason: '' })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const r: any = await httpGet('/supplier/servicegood/page', { page: 1, limit: 1 })
|
||||
if (r?.code === 0) status.value = '200'
|
||||
else { status.value = 'error'; errorMsg.value = r?.msg || '未知错误' }
|
||||
await new Promise(r => setTimeout(r, 200))
|
||||
let rows = [...FALLBACK]
|
||||
if (filters.title) rows = rows.filter(r => r.title.includes(filters.title))
|
||||
if (filters.enterpriseName) rows = rows.filter(r => r.enterpriseName.includes(filters.enterpriseName))
|
||||
if (filters.checkStatus) rows = rows.filter(r => String(r.checkStatus) === filters.checkStatus)
|
||||
list.value = rows
|
||||
total.value = rows.length
|
||||
} catch (e: any) {
|
||||
if (e?.response?.status === 404 || e?.message?.includes('404')) {
|
||||
status.value = '404'
|
||||
errorMsg.value = '后端 API 未实现 (/supplier/servicegood/page 返回 404)'
|
||||
} else {
|
||||
status.value = 'error'
|
||||
errorMsg.value = e?.msg || e?.message || '请求失败'
|
||||
}
|
||||
}
|
||||
})
|
||||
error.value = e?.msg || '加载失败'
|
||||
list.value = FALLBACK
|
||||
total.value = FALLBACK.length
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { filters.title = ''; filters.enterpriseName = ''; filters.checkStatus = ''; load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
function onSelectionChange(rows: ServiceGood[]) { selection.value = rows }
|
||||
function onStatusTab(v: string) { filters.checkStatus = v; load() }
|
||||
|
||||
function onShowDetail(row: ServiceGood) { detailData.value = row; detailVisible.value = true }
|
||||
function onShowCheck(row: ServiceGood) { checkData.value = row; checkDialogVisible.value = true }
|
||||
function onSubmitCheck() { if (!checkForm.checkOpinion.trim()) { ElMessage.warning('请填写审核意见'); return } ElMessage.success('审核成功 (mock)'); checkDialogVisible.value = false; load() }
|
||||
function onOnShelf(row: ServiceGood) { ElMessageBox.confirm(`确认${row.onShelf === 1 ? '下架' : '上架'}?`, '提示', { type: 'warning' }).then(() => { ElMessage.success('操作成功 (mock)'); load() }).catch(() => {}) }
|
||||
function onShowCategory(row: ServiceGood) { categoryData.value = row; categoryDialogVisible.value = true }
|
||||
function onSubmitCategory() { ElMessage.success('分类已修改 (mock)'); categoryDialogVisible.value = false; load() }
|
||||
function onDelete(row: ServiceGood) { ElMessageBox.confirm(`确认删除「${row.title}」?`, '提示', { type: 'warning' }).then(() => { ElMessage.success('已删除 (mock)'); load() }).catch(() => {}) }
|
||||
|
||||
function onBatchCheck() { if (!selection.value.length) return ElMessage.warning('请先选择'); ElMessage.success(`批量审核通过 ${selection.value.length} 个 (mock)`); load() }
|
||||
function onBatchReject() { if (!selection.value.length) return ElMessage.warning('请先选择'); batchRejectDialogVisible.value = true }
|
||||
function onSubmitBatchReject() { ElMessage.success(`批量驳回 ${selection.value.length} 个 (mock)`); batchRejectDialogVisible.value = false; load() }
|
||||
function onBatchOnShelf() { if (!selection.value.length) return ElMessage.warning('请先选择'); ElMessage.success(`批量上下架 ${selection.value.length} 个 (mock)`); load() }
|
||||
function onShowBatchCategory() { if (!selection.value.length) return ElMessage.warning('请先选择'); batchCategoryDialogVisible.value = true }
|
||||
function onSubmitBatchCategory() { ElMessage.success(`批量改分类 ${selection.value.length} 个 (mock)`); batchCategoryDialogVisible.value = false; load() }
|
||||
function onDeleteBatch() { if (!selection.value.length) return ElMessage.warning('请先选择'); ElMessageBox.confirm(`确认批量删除 ${selection.value.length} 个?`, '提示', { type: 'warning' }).then(() => { ElMessage.success('批量删除成功 (mock)'); load() }).catch(() => {}) }
|
||||
function onExport() { ElMessage.success('导出服务商品 (mock)') }
|
||||
function onAdd() { ElMessage.info('新增服务商品 (mock)') }
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-page">
|
||||
<div class="rr-page-header">
|
||||
<h2 class="rr-page-title">服务商品</h2>
|
||||
<p class="rr-page-sub">服务商品审核管理 - 数据接口未实现, 占位页展示预期字段</p>
|
||||
<div>
|
||||
<h2 class="rr-page-title">服务商品管理</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 个服务商品</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onExport">导出</el-button>
|
||||
<el-button type="primary" @click="onAdd">新增服务</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-card rr-status-card">
|
||||
<el-result
|
||||
:icon="status === '200' ? 'success' : 'warning'"
|
||||
:title="status === '200' ? 'API 已就绪' : '后端 API 未实现'"
|
||||
:sub-title="status === '200' ? '服务商品 数据接口已通, 可正常加载' : (errorMsg || '请联系后端实现 /supplier/servicegood/page 接口')"
|
||||
>
|
||||
<template #extra>
|
||||
<el-tag v-if="status === 'checking'" type="info">检测中...</el-tag>
|
||||
<el-tag v-else-if="status === '404'" type="warning">404 Not Found</el-tag>
|
||||
<el-tag v-else-if="status === '200'" type="success">200 OK</el-tag>
|
||||
<el-tag v-else type="danger">Error</el-tag>
|
||||
</template>
|
||||
</el-result>
|
||||
|
||||
<div class="rr-card rr-search">
|
||||
<el-form :inline="true" @keyup.enter="onSearch">
|
||||
<el-form-item label="服务名称">
|
||||
<el-input v-model="filters.title" placeholder="服务名称" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item label="供应商">
|
||||
<el-input v-model="filters.enterpriseName" placeholder="供应商" clearable style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="onSearch">查询</el-button>
|
||||
<el-button @click="onReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="rr-card rr-fields-card">
|
||||
<h3 class="rr-section-title">预期字段 (参考老 /vue 后台)</h3>
|
||||
<el-table :data="expectedFields" border size="small" :show-header="true" empty-text="无字段">
|
||||
<el-table-column prop="key" label="字段名 (key)" width="220" />
|
||||
<el-table-column prop="label" label="表头 (label)" width="180" />
|
||||
<el-table-column prop="desc" label="说明" />
|
||||
|
||||
<div class="rr-tabs-row">
|
||||
<div class="rr-tabs">
|
||||
<div v-for="t in STATUS_TABS" :key="t.value" class="rr-tab" :class="{ active: filters.checkStatus === t.value }" @click="onStatusTab(t.value)">{{ t.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-batch-bar" v-if="selection.length > 0">
|
||||
<span class="rr-batch-tip">已选 {{ selection.length }} 项</span>
|
||||
<el-button type="success" size="small" @click="onBatchCheck">批量审核</el-button>
|
||||
<el-button type="danger" size="small" @click="onBatchReject">批量驳回</el-button>
|
||||
<el-button type="warning" size="small" @click="onBatchOnShelf">批量上下架</el-button>
|
||||
<el-button size="small" @click="onShowBatchCategory">批量改分类</el-button>
|
||||
<el-button type="danger" size="small" plain @click="onDeleteBatch">批量删除</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rr-card">
|
||||
<el-table :data="list" border style="width: 100%" v-loading="loading" :empty-text="error || '暂无数据'" @selection-change="onSelectionChange">
|
||||
<el-table-column type="selection" width="50" align="center" />
|
||||
<el-table-column prop="serviceGoodId" label="服务ID" width="140" align="center" />
|
||||
<el-table-column label="服务名称" min-width="240">
|
||||
<template #default="{ row }">
|
||||
<div class="rr-good-cell">
|
||||
<div v-if="row.icon" class="rr-good-icon"><el-image :src="row.icon" :preview-src-list="[row.icon]" fit="cover" style="width: 40px; height: 40px; border-radius: 4px" /></div>
|
||||
<div v-else class="rr-good-icon-placeholder">{{ (row.title || '?').charAt(0) }}</div>
|
||||
<span>{{ row.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="enterpriseName" label="供应商" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="categoryName" label="分类" width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="serviceArea" label="服务区域" width="120" align="center" />
|
||||
<el-table-column label="价格" width="140" align="right">
|
||||
<template #default="{ row }">¥ {{ row.price.toLocaleString() }} / {{ row.unit }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="铺货" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="(ONSHELF_STATUS_MAP[row.onShelf]?.type as any)" size="small" effect="light">{{ ONSHELF_STATUS_MAP[row.onShelf]?.label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="checkOpinion" label="审核意见" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="(CHECK_STATUS_MAP[row.checkStatus]?.type as any)" size="small" effect="light">{{ CHECK_STATUS_MAP[row.checkStatus]?.label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="申请时间" prop="createDate" width="170" />
|
||||
<el-table-column label="操作" width="280" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button text type="primary" size="small" @click="onShowDetail(row)">详情</el-button>
|
||||
<el-button v-if="row.checkStatus === 1" text type="primary" size="small" @click="onShowCheck(row)">审核</el-button>
|
||||
<el-button text :type="row.onShelf === 1 ? 'warning' : 'success'" size="small" @click="onOnShelf(row)">{{ row.onShelf === 1 ? '下架' : '上架' }}</el-button>
|
||||
<el-button text type="primary" size="small" @click="onShowCategory(row)">改分类</el-button>
|
||||
<el-button text type="danger" size="small" @click="onDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="rr-pagination">
|
||||
<el-pagination :current-page="page" :page-sizes="[10, 20, 50, 100]" :page-size="limit" :total="total" layout="total, sizes, prev, pager, next, jumper" @size-change="onLimitChange" @current-change="onPageChange" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="detailVisible" title="服务详情" width="700px">
|
||||
<el-descriptions v-if="detailData" :column="2" border>
|
||||
<el-descriptions-item label="服务ID">{{ detailData.serviceGoodId }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务名称" :span="2">{{ detailData.title }}</el-descriptions-item>
|
||||
<el-descriptions-item label="供应商">{{ detailData.enterpriseName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分类">{{ detailData.categoryName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务区域">{{ detailData.serviceArea }}</el-descriptions-item>
|
||||
<el-descriptions-item label="价格">¥ {{ detailData.price.toLocaleString() }} / {{ detailData.unit }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="(CHECK_STATUS_MAP[detailData.checkStatus]?.type as any)" size="small">{{ CHECK_STATUS_MAP[detailData.checkStatus]?.label }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="铺货">
|
||||
<el-tag :type="(ONSHELF_STATUS_MAP[detailData.onShelf]?.type as any)" size="small">{{ ONSHELF_STATUS_MAP[detailData.onShelf]?.label }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="审核意见" :span="2">{{ detailData.checkOpinion || '--' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="checkDialogVisible" title="服务审核" width="500px">
|
||||
<el-form v-if="checkData" label-width="100px">
|
||||
<el-form-item label="服务名称">{{ checkData.title }}</el-form-item>
|
||||
<el-form-item label="审核结果">
|
||||
<el-radio-group v-model="checkForm.checkStatus">
|
||||
<el-radio :value="2">通过</el-radio>
|
||||
<el-radio :value="3">驳回</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核意见" required>
|
||||
<el-input v-model="checkForm.checkOpinion" type="textarea" :rows="4" placeholder="请填写审核意见" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="checkDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSubmitCheck">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="categoryDialogVisible" title="修改分类" width="500px">
|
||||
<el-form v-if="categoryData" label-width="100px">
|
||||
<el-form-item label="服务名称">{{ categoryData.title }}</el-form-item>
|
||||
<el-form-item label="当前分类">{{ categoryData.categoryName }}</el-form-item>
|
||||
<el-form-item label="新分类"><el-cascader :options="[]" placeholder="请选择新分类" style="width: 100%" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="categoryDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSubmitCategory">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="batchCategoryDialogVisible" title="批量修改分类" width="500px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="已选服务">共 {{ selection.length }} 个</el-form-item>
|
||||
<el-form-item label="新分类"><el-cascader :options="[]" placeholder="请选择新分类" style="width: 100%" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="batchCategoryDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSubmitBatchCategory">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="batchRejectDialogVisible" title="批量驳回" width="500px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="已选服务">共 {{ selection.length }} 个</el-form-item>
|
||||
<el-form-item label="驳回原因" required><el-input v-model="batchRejectForm.reason" type="textarea" :rows="4" placeholder="请填写驳回原因" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="batchRejectDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="onSubmitBatchReject">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.rr-page { max-width: 1400px; }
|
||||
.rr-page-header { margin-bottom: 20px; }
|
||||
.rr-page-title { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; color: var(--ink); margin-bottom: 4px; }
|
||||
.rr-page-sub { color: var(--ink-3); font-size: 13px; }
|
||||
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 24px; margin-bottom: 16px; }
|
||||
.rr-status-card { padding: 20px 20px 8px; }
|
||||
.rr-section-title { margin: 0 0 12px; font-size: 14px; font-weight: 600; color: var(--ink); }
|
||||
.rr-page { max-width: 1500px; }
|
||||
.rr-page-header { margin-bottom: 16px; display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.rr-page-title { font-size: 22px; font-weight: 600; color: var(--ink); margin: 0 0 4px 0; }
|
||||
.rr-page-sub { color: var(--ink-3); font-size: 13px; margin: 0; }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; margin-bottom: 16px; }
|
||||
.rr-search { padding: 16px 20px; }
|
||||
.rr-tabs-row { margin-bottom: 16px; }
|
||||
.rr-tabs { display: flex; gap: 4px; padding: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); width: fit-content; }
|
||||
.rr-tab { padding: 6px 16px; border-radius: var(--radius); font-size: 13px; color: var(--ink-2); cursor: pointer; }
|
||||
.rr-tab.active { color: white; background: var(--primary); font-weight: 500; }
|
||||
.rr-batch-bar { display: flex; align-items: center; gap: 8px; padding: 10px 16px; background: var(--bg-soft); border-radius: var(--radius); margin-bottom: 12px; }
|
||||
.rr-batch-tip { font-size: 13px; color: var(--ink-2); margin-right: 8px; }
|
||||
.rr-pagination { padding: 12px 20px; display: flex; justify-content: flex-end; border-top: 1px solid var(--border); }
|
||||
.rr-good-cell { display: flex; align-items: center; gap: 10px; }
|
||||
.rr-good-icon-placeholder { width: 40px; height: 40px; border-radius: 4px; background: var(--bg-soft); display: flex; align-items: center; justify-content: center; color: var(--ink-3); font-weight: 600; flex-shrink: 0; }
|
||||
</style>
|
||||
|
||||
232
src/modules/sys/DeptListView.vue
Normal file
232
src/modules/sys/DeptListView.vue
Normal file
@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 部门管理 - W4.14
|
||||
*
|
||||
* 后端: GET /sys/dept/list (返树结构)
|
||||
* 字段: id/pid/name/sort/createDate/parentName
|
||||
*
|
||||
* 设计规范:v1.6
|
||||
*/
|
||||
import { onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface Dept {
|
||||
id: string
|
||||
pid: string
|
||||
name: string
|
||||
sort: number
|
||||
createDate: string
|
||||
parentName?: string
|
||||
children?: Dept[]
|
||||
raw: any
|
||||
}
|
||||
|
||||
const list = ref<Dept[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editMode = ref<'create' | 'update'>('create')
|
||||
const editForm = reactive({
|
||||
id: null as number | null,
|
||||
pid: '0',
|
||||
name: '',
|
||||
sort: 0
|
||||
})
|
||||
|
||||
const parentOptions = ref<Array<{ value: string; label: string }>>([])
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n || isNaN(n)) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function flattenTree(depts: Dept[], level = 0): Dept[] {
|
||||
const out: Dept[] = []
|
||||
for (const d of depts) {
|
||||
out.push({ ...d, raw: d })
|
||||
if (d.children?.length) {
|
||||
out.push(...flattenTree(d.children, level + 1))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await httpGet<any>('/sys/dept/list')
|
||||
const tree = Array.isArray(data) ? data : []
|
||||
list.value = flattenTree(tree)
|
||||
parentOptions.value = [
|
||||
{ value: '0', label: '顶级部门' },
|
||||
...list.value.map(d => ({ value: d.id, label: d.name }))
|
||||
]
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '后端接口异常'
|
||||
list.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function createHandle() {
|
||||
editMode.value = 'create'
|
||||
editForm.id = null
|
||||
editForm.pid = '0'
|
||||
editForm.name = ''
|
||||
editForm.sort = 0
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editHandle(row: Dept) {
|
||||
editMode.value = 'update'
|
||||
editForm.id = Number(row.id)
|
||||
editForm.pid = row.pid
|
||||
editForm.name = row.name
|
||||
editForm.sort = row.sort
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editConfirm() {
|
||||
if (!editForm.name.trim()) {
|
||||
ElMessage.warning('请输入部门名称')
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
id: editForm.id,
|
||||
pid: editForm.pid,
|
||||
name: editForm.name.trim(),
|
||||
sort: editForm.sort
|
||||
}
|
||||
try {
|
||||
if (editMode.value === 'create') {
|
||||
await httpPost('/sys/dept', payload)
|
||||
ElMessage.success('新增成功')
|
||||
} else {
|
||||
await httpPut('/sys/dept', payload)
|
||||
ElMessage.success('修改成功')
|
||||
}
|
||||
editVisible.value = false
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHandle(row: Dept) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认删除部门 ${row.name} 吗?`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch { return }
|
||||
try {
|
||||
await httpDelete(`/sys/dept/${row.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-dept">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">部门管理</h2>
|
||||
<p class="rr-page-sub">共 {{ list.length }} 个部门(树形)</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增部门</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'name', label: '部门名称', minWidth: 240 },
|
||||
{ prop: 'parentName', label: '上级部门', minWidth: 200 },
|
||||
{ prop: 'sort', label: '排序', width: 100 },
|
||||
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
|
||||
{ label: '操作', width: 180, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="list.length"
|
||||
:page="1"
|
||||
:limit="999"
|
||||
:show-pagination="false"
|
||||
:error="error"
|
||||
empty-text="暂无部门"
|
||||
borderless
|
||||
row-key="id"
|
||||
>
|
||||
<template #createDate="{ row }">
|
||||
<span class="rr-time">{{ fmtDate(row.createDate) }}</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<el-button text type="primary" size="small" @click="editHandle(row)">编辑</el-button>
|
||||
<el-button text type="primary" size="small" @click="deleteHandle(row)">删除</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
:title="editMode === 'create' ? '新增部门' : '编辑部门'"
|
||||
width="540"
|
||||
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-width="100px" label-position="right">
|
||||
<el-form-item label="部门名称" required>
|
||||
<el-input v-model="editForm.name" placeholder="请输入部门名称" size="default" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="上级部门">
|
||||
<el-select v-model="editForm.pid" placeholder="选择上级部门" style="width: 100%" size="default">
|
||||
<el-option
|
||||
v-for="opt in parentOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="editForm.sort" :min="0" controls-position="right" size="default" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="editConfirm">
|
||||
{{ editMode === 'create' ? '确认新增' : '确认修改' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-dept { padding: 16px; }
|
||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
</style>
|
||||
235
src/modules/sys/DictDataListView.vue
Normal file
235
src/modules/sys/DictDataListView.vue
Normal file
@ -0,0 +1,235 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 字典数据 - W4.18
|
||||
*
|
||||
* 后端: GET /sys/dict/data/page
|
||||
* 字段: id/dictTypeId/dictLabel/dictValue/dictSort/remark/createDate
|
||||
*
|
||||
* 设计规范:v1.6
|
||||
*/
|
||||
import { onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface DictData {
|
||||
id: string
|
||||
dictTypeId: number
|
||||
dictLabel: string
|
||||
dictValue: string
|
||||
dictSort: number
|
||||
remark: string | null
|
||||
createDate: string | number
|
||||
raw: any
|
||||
}
|
||||
|
||||
const list = ref<DictData[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const filters = reactive({ dictLabel: '', dictType: '' })
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editMode = ref<'create' | 'update'>('create')
|
||||
const editForm = reactive({
|
||||
id: null as number | null,
|
||||
dictTypeId: 0,
|
||||
dictLabel: '',
|
||||
dictValue: '',
|
||||
dictSort: 0,
|
||||
remark: ''
|
||||
})
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n || isNaN(n)) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number | string; list: any[] }>('/sys/dict/data/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.dictLabel ? { dictLabel: filters.dictLabel } : {})
|
||||
})
|
||||
if (data && data.list) {
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
dictTypeId: it.dictTypeId ?? 0,
|
||||
dictLabel: it.dictLabel || '--',
|
||||
dictValue: it.dictValue || '--',
|
||||
dictSort: it.dictSort ?? 0,
|
||||
remark: it.remark,
|
||||
createDate: it.createDate,
|
||||
raw: it
|
||||
}))
|
||||
total.value = Number(data.total) || 0
|
||||
} else {
|
||||
list.value = []
|
||||
total.value = 0
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '后端接口异常'
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { filters.dictLabel = ''; filters.dictType = ''; page.value = 1; load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
|
||||
function createHandle() {
|
||||
editMode.value = 'create'
|
||||
editForm.id = null
|
||||
editForm.dictTypeId = 0
|
||||
editForm.dictLabel = ''
|
||||
editForm.dictValue = ''
|
||||
editForm.dictSort = 0
|
||||
editForm.remark = ''
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editHandle(row: DictData) {
|
||||
editMode.value = 'update'
|
||||
editForm.id = Number(row.id)
|
||||
editForm.dictTypeId = row.dictTypeId
|
||||
editForm.dictLabel = row.dictLabel
|
||||
editForm.dictValue = row.dictValue
|
||||
editForm.dictSort = row.dictSort
|
||||
editForm.remark = row.remark || ''
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editConfirm() {
|
||||
if (!editForm.dictLabel.trim()) { ElMessage.warning('请输入字典标签'); return }
|
||||
if (!editForm.dictValue.trim()) { ElMessage.warning('请输入字典值'); return }
|
||||
const payload = { id: editForm.id, dictTypeId: editForm.dictTypeId, dictLabel: editForm.dictLabel.trim(), dictValue: editForm.dictValue.trim(), dictSort: editForm.dictSort, remark: editForm.remark.trim() }
|
||||
try {
|
||||
if (editMode.value === 'create') { await httpPost('/sys/dict/data', payload); ElMessage.success('新增成功') }
|
||||
else { await httpPut('/sys/dict/data', payload); ElMessage.success('修改成功') }
|
||||
editVisible.value = false
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHandle(row: DictData) {
|
||||
try { await ElMessageBox.confirm(`确认删除字典项 ${row.dictLabel} 吗?`, '删除确认', { type: 'warning' }) } catch { return }
|
||||
try { await httpDelete(`/sys/dict/data/${row.id}`); ElMessage.success('删除成功'); load() }
|
||||
catch (e: any) { ElMessage.error(e?.msg || e?.message || '删除失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-dict-data">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">字典数据</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 条字典项</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增字典项</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-filters-row">
|
||||
<el-input v-model="filters.dictLabel" placeholder="字典标签搜索" clearable class="rr-label-input" size="small" @keyup.enter="onSearch" />
|
||||
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'dictLabel', label: '字典标签', width: 200 },
|
||||
{ prop: 'dictValue', label: '字典值', width: 160, slot: 'dictValue' },
|
||||
{ prop: 'dictTypeId', label: '类型 ID', width: 100 },
|
||||
{ prop: 'dictSort', label: '排序', width: 80 },
|
||||
{ prop: 'remark', label: '备注', minWidth: 200 },
|
||||
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
|
||||
{ label: '操作', width: 180, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="error"
|
||||
empty-text="暂无字典数据"
|
||||
borderless
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #dictValue="{ row }"><code class="rr-mono">{{ row.dictValue }}</code></template>
|
||||
<template #createDate="{ row }"><span class="rr-time">{{ fmtDate(row.createDate) }}</span></template>
|
||||
<template #action="{ row }">
|
||||
<el-button text type="primary" size="small" @click="editHandle(row)">编辑</el-button>
|
||||
<el-button text type="primary" size="small" @click="deleteHandle(row)">删除</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
:title="editMode === 'create' ? '新增字典项' : '编辑字典项'"
|
||||
width="560"
|
||||
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-width="100px" label-position="right">
|
||||
<el-form-item label="类型 ID" required>
|
||||
<el-input-number v-model="editForm.dictTypeId" :min="0" controls-position="right" size="default" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="字典标签" required>
|
||||
<el-input v-model="editForm.dictLabel" placeholder="例:男 / 是 / 启用" size="default" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="字典值" required>
|
||||
<el-input v-model="editForm.dictValue" placeholder="例:1 / true / active" size="default" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="editForm.dictSort" :min="0" controls-position="right" size="default" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="editForm.remark" type="textarea" :rows="2" placeholder="可选" maxlength="200" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="editConfirm">{{ editMode === 'create' ? '确认新增' : '确认修改' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-dict-data { padding: 16px; }
|
||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||
.rr-label-input { width: 240px; }
|
||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
.rr-mono { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; padding: 2px 6px; background: var(--el-fill-color-light); border-radius: 4px; color: var(--el-color-primary); }
|
||||
</style>
|
||||
217
src/modules/sys/DictTypeListView.vue
Normal file
217
src/modules/sys/DictTypeListView.vue
Normal file
@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 字典类型 - W4.18
|
||||
*
|
||||
* 后端: GET /sys/dict/type/page
|
||||
* 字段: id/dictType/dictName/remark/createDate
|
||||
*
|
||||
* 设计规范:v1.6
|
||||
*/
|
||||
import { onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface DictType {
|
||||
id: string
|
||||
dictType: string
|
||||
dictName: string
|
||||
remark: string | null
|
||||
createDate: string | number
|
||||
raw: any
|
||||
}
|
||||
|
||||
const list = ref<DictType[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const filters = reactive({ dictType: '', dictName: '' })
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editMode = ref<'create' | 'update'>('create')
|
||||
const editForm = reactive({
|
||||
id: null as number | null,
|
||||
dictType: '',
|
||||
dictName: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n || isNaN(n)) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number | string; list: any[] }>('/sys/dict/type/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.dictType ? { dictType: filters.dictType } : {})
|
||||
})
|
||||
if (data && data.list) {
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
dictType: it.dictType || '--',
|
||||
dictName: it.dictName || '--',
|
||||
remark: it.remark,
|
||||
createDate: it.createDate,
|
||||
raw: it
|
||||
}))
|
||||
total.value = Number(data.total) || 0
|
||||
} else {
|
||||
list.value = []
|
||||
total.value = 0
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '后端接口异常'
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { filters.dictType = ''; filters.dictName = ''; page.value = 1; load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
|
||||
function createHandle() {
|
||||
editMode.value = 'create'
|
||||
editForm.id = null
|
||||
editForm.dictType = ''
|
||||
editForm.dictName = ''
|
||||
editForm.remark = ''
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editHandle(row: DictType) {
|
||||
editMode.value = 'update'
|
||||
editForm.id = Number(row.id)
|
||||
editForm.dictType = row.dictType
|
||||
editForm.dictName = row.dictName
|
||||
editForm.remark = row.remark || ''
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editConfirm() {
|
||||
if (!editForm.dictType.trim()) { ElMessage.warning('请输入字典类型'); return }
|
||||
if (!editForm.dictName.trim()) { ElMessage.warning('请输入字典名称'); return }
|
||||
const payload = { id: editForm.id, dictType: editForm.dictType.trim(), dictName: editForm.dictName.trim(), remark: editForm.remark.trim() }
|
||||
try {
|
||||
if (editMode.value === 'create') { await httpPost('/sys/dict/type', payload); ElMessage.success('新增成功') }
|
||||
else { await httpPut('/sys/dict/type', payload); ElMessage.success('修改成功') }
|
||||
editVisible.value = false
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHandle(row: DictType) {
|
||||
try { await ElMessageBox.confirm(`确认删除字典类型 ${row.dictName} 吗?`, '删除确认', { type: 'warning' }) } catch { return }
|
||||
try { await httpDelete(`/sys/dict/type/${row.id}`); ElMessage.success('删除成功'); load() }
|
||||
catch (e: any) { ElMessage.error(e?.msg || e?.message || '删除失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-dict-type">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">字典类型</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 个字典类型</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增字典类型</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-filters-row">
|
||||
<el-input v-model="filters.dictType" placeholder="字典类型搜索" clearable class="rr-type-input" size="small" @keyup.enter="onSearch" />
|
||||
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'dictType', label: '字典类型', minWidth: 200, slot: 'dictType' },
|
||||
{ prop: 'dictName', label: '字典名称', minWidth: 200 },
|
||||
{ prop: 'remark', label: '备注', minWidth: 240 },
|
||||
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
|
||||
{ label: '操作', width: 180, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="error"
|
||||
empty-text="暂无字典类型"
|
||||
borderless
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #dictType="{ row }"><code class="rr-mono">{{ row.dictType }}</code></template>
|
||||
<template #createDate="{ row }"><span class="rr-time">{{ fmtDate(row.createDate) }}</span></template>
|
||||
<template #action="{ row }">
|
||||
<el-button text type="primary" size="small" @click="editHandle(row)">编辑</el-button>
|
||||
<el-button text type="primary" size="small" @click="deleteHandle(row)">删除</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
:title="editMode === 'create' ? '新增字典类型' : '编辑字典类型'"
|
||||
width="480"
|
||||
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-width="100px" label-position="right">
|
||||
<el-form-item label="字典类型" required>
|
||||
<el-input v-model="editForm.dictType" placeholder="例:user_status / gender" size="default" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="字典名称" required>
|
||||
<el-input v-model="editForm.dictName" placeholder="例:用户状态 / 性别" size="default" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="editForm.remark" type="textarea" :rows="2" placeholder="可选" maxlength="200" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="editConfirm">{{ editMode === 'create' ? '确认新增' : '确认修改' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-dict-type { padding: 16px; }
|
||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||
.rr-type-input { width: 240px; }
|
||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
.rr-mono { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; padding: 2px 6px; background: var(--el-fill-color-light); border-radius: 4px; color: var(--el-color-primary); }
|
||||
</style>
|
||||
152
src/modules/sys/FileListView.vue
Normal file
152
src/modules/sys/FileListView.vue
Normal file
@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 文件管理 - W4.16
|
||||
*
|
||||
* 后端: GET /sys/file/page
|
||||
* 字段: id/url/creator/createDate
|
||||
*/
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet, httpDelete } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface FileItem {
|
||||
id: string
|
||||
url: string
|
||||
creator: string | null
|
||||
createDate: string | number
|
||||
raw: any
|
||||
}
|
||||
|
||||
const list = ref<FileItem[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(20)
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n || isNaN(n)) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number | string; list: any[] }>('/sys/file/page', {
|
||||
page: page.value,
|
||||
limit: limit.value
|
||||
})
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
url: it.url || '--',
|
||||
creator: it.creator,
|
||||
createDate: it.createDate,
|
||||
raw: it
|
||||
}))
|
||||
total.value = Number(data.total) || 0
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '后端接口异常'
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
|
||||
async function copyText(text: string) {
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
ElMessage.success('链接已复制')
|
||||
} else {
|
||||
ElMessage.warning('请手动复制')
|
||||
}
|
||||
} catch { ElMessage.warning('复制失败') }
|
||||
}
|
||||
|
||||
async function deleteHandle(row: FileItem) {
|
||||
try { await ElMessageBox.confirm('确认删除该文件吗?', '删除确认', { type: 'warning' }) } catch { return }
|
||||
try { await httpDelete(`/sys/file/${row.id}`); ElMessage.success('删除成功'); load() }
|
||||
catch (e: any) { ElMessage.error(e?.msg || e?.message || '删除失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-file">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">文件管理</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 个文件</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="load" class="rr-btn-secondary">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'id', label: 'ID', width: 180, slot: 'id' },
|
||||
{ prop: 'url', label: '文件链接', minWidth: 480, slot: 'url' },
|
||||
{ prop: 'creator', label: '创建者', width: 140 },
|
||||
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
|
||||
{ label: '操作', width: 180, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="error"
|
||||
empty-text="暂无文件"
|
||||
borderless
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #id="{ row }"><code class="rr-mono-sm">{{ row.id }}</code></template>
|
||||
<template #url="{ row }">
|
||||
<a :href="row.url" target="_blank" class="rr-link">{{ row.url }}</a>
|
||||
</template>
|
||||
<template #createDate="{ row }"><span class="rr-time">{{ fmtDate(row.createDate) }}</span></template>
|
||||
<template #action="{ row }">
|
||||
<el-button text type="primary" size="small" @click="copyText(row.url)">复制链接</el-button>
|
||||
<el-button text type="primary" size="small" @click="deleteHandle(row)">删除</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-file { padding: 16px; }
|
||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
.rr-mono-sm { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 11px; padding: 2px 6px; background: var(--el-fill-color-light); border-radius: 4px; color: var(--el-color-primary); }
|
||||
.rr-link {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--el-color-primary);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
}
|
||||
.rr-link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
242
src/modules/sys/LogErrorView.vue
Normal file
242
src/modules/sys/LogErrorView.vue
Normal file
@ -0,0 +1,242 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 错误日志 - W4.16
|
||||
*
|
||||
* 后端: GET /sys/log/error/page
|
||||
* 字段: id/module/requestUri/requestMethod/requestParams/userAgent/ip/errorInfo/createDate
|
||||
*/
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { httpGet } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface LogEntry {
|
||||
id: string
|
||||
module: string
|
||||
requestUri: string
|
||||
requestMethod: string
|
||||
requestParams: string | null
|
||||
userAgent: string | null
|
||||
ip: string | null
|
||||
errorInfo: string | null
|
||||
createDate: string | number
|
||||
raw: any
|
||||
}
|
||||
|
||||
const list = ref<LogEntry[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(20)
|
||||
const filters = ref({ module: '' })
|
||||
|
||||
const detailVisible = ref(false)
|
||||
const currentRow = ref<LogEntry | null>(null)
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n || isNaN(n)) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function previewError(info?: string | null): string {
|
||||
if (!info) return '--'
|
||||
const firstLine = info.split('\n').find(l => l.trim()) || info
|
||||
return firstLine.length > 80 ? firstLine.substring(0, 80) + '...' : firstLine
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number | string; list: any[] }>('/sys/log/error/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.value.module ? { module: filters.value.module } : {})
|
||||
})
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
module: it.module || '--',
|
||||
requestUri: it.requestUri || '--',
|
||||
requestMethod: it.requestMethod || '--',
|
||||
requestParams: it.requestParams,
|
||||
userAgent: it.userAgent,
|
||||
ip: it.ip,
|
||||
errorInfo: it.errorInfo,
|
||||
createDate: it.createDate,
|
||||
raw: it
|
||||
}))
|
||||
total.value = Number(data.total) || 0
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '后端接口异常'
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { filters.value.module = ''; page.value = 1; load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
function onView(row: LogEntry) { currentRow.value = row; detailVisible.value = true }
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-log-error">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">错误日志</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 条错误记录</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-filters-row">
|
||||
<el-input v-model="filters.module" placeholder="模块名" clearable class="rr-module-input" size="small" @keyup.enter="onSearch" />
|
||||
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'module', label: '模块', width: 100, slot: 'module' },
|
||||
{ prop: 'requestMethod', label: '方法', width: 80, slot: 'method' },
|
||||
{ prop: 'requestUri', label: '请求 URI', minWidth: 200, slot: 'uri' },
|
||||
{ prop: 'errorInfo', label: '错误信息', minWidth: 280, slot: 'error' },
|
||||
{ prop: 'ip', label: 'IP', width: 130 },
|
||||
{ prop: 'createDate', label: '时间', width: 170, slot: 'createDate' },
|
||||
{ label: '操作', width: 100, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="error"
|
||||
empty-text="暂无错误日志"
|
||||
borderless
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #module="{ row }"><el-tag size="small" effect="plain">{{ row.module }}</el-tag></template>
|
||||
<template #method="{ row }">
|
||||
<span :class="['rr-method', `rr-method-${(row.requestMethod || '').toLowerCase()}`]">{{ row.requestMethod }}</span>
|
||||
</template>
|
||||
<template #uri="{ row }"><code class="rr-mono-sm">{{ row.requestUri }}</code></template>
|
||||
<template #error="{ row }">
|
||||
<span class="rr-error-preview" :title="row.errorInfo">{{ previewError(row.errorInfo) }}</span>
|
||||
</template>
|
||||
<template #createDate="{ row }"><span class="rr-time">{{ fmtDate(row.createDate) }}</span></template>
|
||||
<template #action="{ row }">
|
||||
<el-button text type="primary" size="small" @click="onView(row)">详情</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="detailVisible"
|
||||
title="错误详情"
|
||||
width="720"
|
||||
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<div v-if="currentRow" class="rr-error-detail">
|
||||
<div class="rr-detail-row">
|
||||
<div class="rr-detail-label">模块</div>
|
||||
<div class="rr-detail-value">{{ currentRow.module }}</div>
|
||||
</div>
|
||||
<div class="rr-detail-row">
|
||||
<div class="rr-detail-label">请求</div>
|
||||
<div class="rr-detail-value">
|
||||
<span :class="['rr-method', `rr-method-${(currentRow.requestMethod || '').toLowerCase()}`]">{{ currentRow.requestMethod }}</span>
|
||||
<code class="rr-mono-sm" style="margin-left: 8px;">{{ currentRow.requestUri }}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rr-detail-row">
|
||||
<div class="rr-detail-label">IP</div>
|
||||
<div class="rr-detail-value">{{ currentRow.ip || '--' }}</div>
|
||||
</div>
|
||||
<div class="rr-detail-row">
|
||||
<div class="rr-detail-label">User Agent</div>
|
||||
<div class="rr-detail-value">{{ currentRow.userAgent || '--' }}</div>
|
||||
</div>
|
||||
<div class="rr-detail-row">
|
||||
<div class="rr-detail-label">时间</div>
|
||||
<div class="rr-detail-value">{{ fmtDate(currentRow.createDate) }}</div>
|
||||
</div>
|
||||
<div class="rr-detail-row rr-detail-full">
|
||||
<div class="rr-detail-label">错误堆栈</div>
|
||||
<div class="rr-detail-value">
|
||||
<pre class="rr-pre">{{ currentRow.errorInfo || '--' }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button type="primary" @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-log-error { padding: 16px; }
|
||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||
.rr-module-input { width: 180px; }
|
||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
.rr-mono-sm { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 11px; color: var(--el-text-color-regular); }
|
||||
.rr-error-preview {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--el-color-danger);
|
||||
word-break: break-all;
|
||||
}
|
||||
.rr-method {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
.rr-method-get { background: #DBEAFE; color: #2563EB; }
|
||||
.rr-method-post { background: #D1FAE5; color: #059669; }
|
||||
.rr-method-put { background: #FEF3C7; color: #D97706; }
|
||||
.rr-method-delete { background: #FEE2E2; color: #DC2626; }
|
||||
.rr-error-detail { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 20px; }
|
||||
.rr-detail-row { display: flex; flex-direction: column; gap: 4px; }
|
||||
.rr-detail-full { grid-column: 1 / -1; }
|
||||
.rr-detail-label { font-size: 12px; color: var(--el-text-color-secondary); font-weight: 500; }
|
||||
.rr-detail-value { font-size: 13px; color: var(--el-text-color-primary); word-break: break-all; }
|
||||
.rr-pre {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 6px;
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--el-color-danger);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
180
src/modules/sys/LogLoginView.vue
Normal file
180
src/modules/sys/LogLoginView.vue
Normal file
@ -0,0 +1,180 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 登录日志 - W4.14
|
||||
*
|
||||
* 后端: GET /sys/log/login/page
|
||||
* 字段: id/operation/status/userAgent/ip/creatorName/createDate
|
||||
*
|
||||
* 设计规范:v1.6
|
||||
*/
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { httpGet } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface LogEntry {
|
||||
id: string
|
||||
operation: number | null
|
||||
status: number | null
|
||||
userAgent: string | null
|
||||
ip: string | null
|
||||
creatorName: string | null
|
||||
createDate: string
|
||||
raw: any
|
||||
}
|
||||
|
||||
const OPERATION_LABEL: Record<number, string> = {
|
||||
0: '登录成功',
|
||||
1: '登录失败',
|
||||
2: '退出登录'
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<number, { label: string; type: string }> = {
|
||||
0: { label: '正常', type: 'success' },
|
||||
1: { label: '失败', type: 'danger' }
|
||||
}
|
||||
|
||||
const list = ref<LogEntry[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(20)
|
||||
|
||||
const filters = ref({ creatorName: '', ip: '' })
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n || isNaN(n)) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number | string; list: any[] }>('/sys/log/login/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.value.creatorName ? { creatorName: filters.value.creatorName } : {}),
|
||||
...(filters.value.ip ? { ip: filters.value.ip } : {})
|
||||
})
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
operation: it.operation,
|
||||
status: it.status,
|
||||
userAgent: it.userAgent,
|
||||
ip: it.ip,
|
||||
creatorName: it.creatorName,
|
||||
createDate: it.createDate,
|
||||
raw: it
|
||||
}))
|
||||
total.value = Number(data.total) || 0
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '后端接口异常'
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { filters.value.creatorName = ''; filters.value.ip = ''; page.value = 1; load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-log-login">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">登录日志</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 条登录记录</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-filters-row">
|
||||
<el-input
|
||||
v-model="filters.creatorName"
|
||||
placeholder="用户名搜索"
|
||||
clearable
|
||||
class="rr-name-input"
|
||||
size="small"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<el-input
|
||||
v-model="filters.ip"
|
||||
placeholder="IP 搜索"
|
||||
clearable
|
||||
class="rr-ip-input"
|
||||
size="small"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'creatorName', label: '用户', width: 140 },
|
||||
{ prop: 'operation', label: '操作', width: 100, slot: 'operation' },
|
||||
{ prop: 'status', label: '状态', width: 80, slot: 'status' },
|
||||
{ prop: 'ip', label: 'IP', width: 140 },
|
||||
{ prop: 'userAgent', label: 'User Agent', minWidth: 280 },
|
||||
{ prop: 'createDate', label: '时间', width: 170, slot: 'createDate' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="error"
|
||||
empty-text="暂无登录日志"
|
||||
borderless
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #operation="{ row }">
|
||||
<span>{{ OPERATION_LABEL[row.operation ?? -1] || '--' }}</span>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<el-tag
|
||||
v-if="row.status != null && STATUS_LABEL[row.status]"
|
||||
:type="STATUS_LABEL[row.status].type as any"
|
||||
size="small"
|
||||
effect="light"
|
||||
>{{ STATUS_LABEL[row.status].label }}</el-tag>
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
<template #createDate="{ row }">
|
||||
<span class="rr-time">{{ fmtDate(row.createDate) }}</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-log-login { padding: 16px; }
|
||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||
.rr-name-input { width: 200px; }
|
||||
.rr-ip-input { width: 200px; }
|
||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
</style>
|
||||
217
src/modules/sys/LogOperationView.vue
Normal file
217
src/modules/sys/LogOperationView.vue
Normal file
@ -0,0 +1,217 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 操作日志 - W4.14
|
||||
*
|
||||
* 后端: GET /sys/log/operation/page
|
||||
* 字段: id/module/operation/requestUri/requestMethod/requestParams/requestTime/userAgent/ip/status/creatorName/createDate
|
||||
*
|
||||
* 设计规范:v1.6
|
||||
*/
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { httpGet } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface LogEntry {
|
||||
id: string
|
||||
module: string
|
||||
operation: string
|
||||
requestUri: string
|
||||
requestMethod: string
|
||||
requestParams: string | null
|
||||
requestTime: number | null
|
||||
userAgent: string | null
|
||||
ip: string | null
|
||||
status: number | null
|
||||
creatorName: string | null
|
||||
createDate: string
|
||||
raw: any
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<number, { label: string; type: string }> = {
|
||||
0: { label: '成功', type: 'success' },
|
||||
1: { label: '失败', type: 'danger' }
|
||||
}
|
||||
|
||||
const list = ref<LogEntry[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(20)
|
||||
|
||||
const filters = ref({ creatorName: '', module: '' })
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n || isNaN(n)) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function requestTimeColor(ms: number | null): string {
|
||||
if (ms == null) return 'var(--el-text-color-regular)'
|
||||
if (ms > 1000) return 'var(--el-color-danger)'
|
||||
if (ms > 500) return 'var(--el-color-warning)'
|
||||
return 'var(--el-text-color-regular)'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number | string; list: any[] }>('/sys/log/operation/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.value.creatorName ? { creatorName: filters.value.creatorName } : {}),
|
||||
...(filters.value.module ? { module: filters.value.module } : {})
|
||||
})
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
module: it.module || '--',
|
||||
operation: it.operation || '--',
|
||||
requestUri: it.requestUri || '--',
|
||||
requestMethod: it.requestMethod || '--',
|
||||
requestParams: it.requestParams,
|
||||
requestTime: it.requestTime,
|
||||
userAgent: it.userAgent,
|
||||
ip: it.ip,
|
||||
status: it.status,
|
||||
creatorName: it.creatorName,
|
||||
createDate: it.createDate,
|
||||
raw: it
|
||||
}))
|
||||
total.value = Number(data.total) || 0
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '后端接口异常'
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { filters.value.creatorName = ''; filters.value.module = ''; page.value = 1; load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-log-operation">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">操作日志</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 条操作记录</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-filters-row">
|
||||
<el-input
|
||||
v-model="filters.creatorName"
|
||||
placeholder="操作用户"
|
||||
clearable
|
||||
class="rr-name-input"
|
||||
size="small"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<el-input
|
||||
v-model="filters.module"
|
||||
placeholder="模块名"
|
||||
clearable
|
||||
class="rr-module-input"
|
||||
size="small"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'creatorName', label: '操作用户', width: 130 },
|
||||
{ prop: 'module', label: '模块', width: 100, slot: 'module' },
|
||||
{ prop: 'operation', label: '操作', minWidth: 180 },
|
||||
{ prop: 'requestMethod', label: '方法', width: 80, slot: 'method' },
|
||||
{ prop: 'requestUri', label: '请求 URI', minWidth: 200, slot: 'uri' },
|
||||
{ prop: 'status', label: '状态', width: 80, slot: 'status' },
|
||||
{ prop: 'requestTime', label: '耗时(ms)', width: 100, slot: 'time' },
|
||||
{ prop: 'createDate', label: '时间', width: 170, slot: 'createDate' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="error"
|
||||
empty-text="暂无操作日志"
|
||||
borderless
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #module="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ row.module }}</el-tag>
|
||||
</template>
|
||||
<template #method="{ row }">
|
||||
<span :class="['rr-method', `rr-method-${(row.requestMethod || '').toLowerCase()}`]">
|
||||
{{ row.requestMethod }}
|
||||
</span>
|
||||
</template>
|
||||
<template #uri="{ row }">
|
||||
<code class="rr-mono-sm">{{ row.requestUri }}</code>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<el-tag
|
||||
v-if="row.status != null && STATUS_LABEL[row.status]"
|
||||
:type="STATUS_LABEL[row.status].type as any"
|
||||
size="small"
|
||||
effect="light"
|
||||
>{{ STATUS_LABEL[row.status].label }}</el-tag>
|
||||
<span v-else>--</span>
|
||||
</template>
|
||||
<template #time="{ row }">
|
||||
<span :style="{ color: requestTimeColor(row.requestTime) }">{{ row.requestTime ?? '--' }}</span>
|
||||
</template>
|
||||
<template #createDate="{ row }">
|
||||
<span class="rr-time">{{ fmtDate(row.createDate) }}</span>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-log-operation { padding: 16px; }
|
||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||
.rr-name-input { width: 180px; }
|
||||
.rr-module-input { width: 180px; }
|
||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
.rr-mono-sm { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 11px; color: var(--el-text-color-regular); }
|
||||
.rr-method {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
.rr-method-get { background: #DBEAFE; color: #2563EB; }
|
||||
.rr-method-post { background: #D1FAE5; color: #059669; }
|
||||
.rr-method-put { background: #FEF3C7; color: #D97706; }
|
||||
.rr-method-delete { background: #FEE2E2; color: #DC2626; }
|
||||
</style>
|
||||
283
src/modules/sys/MenuListView.vue
Normal file
283
src/modules/sys/MenuListView.vue
Normal file
@ -0,0 +1,283 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 菜单管理 - W4.14
|
||||
*
|
||||
* 后端: GET /sys/menu/list (返树结构)
|
||||
* 字段: id/pid/name/url/type/permissions/icon/sort/createDate
|
||||
*
|
||||
* 设计规范:v1.6
|
||||
*/
|
||||
import { onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface Menu {
|
||||
id: string
|
||||
pid: string
|
||||
name: string | null
|
||||
url: string
|
||||
type: number
|
||||
permissions: string
|
||||
icon: string
|
||||
sort: number
|
||||
createDate: string
|
||||
children?: Menu[]
|
||||
raw: any
|
||||
}
|
||||
|
||||
const TYPE_LABEL: Record<number, string> = {
|
||||
0: '菜单',
|
||||
1: '按钮'
|
||||
}
|
||||
|
||||
const list = ref<Menu[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editMode = ref<'create' | 'update'>('create')
|
||||
const editForm = reactive({
|
||||
id: null as number | null,
|
||||
pid: '0',
|
||||
name: '',
|
||||
url: '',
|
||||
type: 0,
|
||||
permissions: '',
|
||||
icon: '',
|
||||
sort: 0
|
||||
})
|
||||
|
||||
const parentOptions = ref<Array<{ value: string; label: string }>>([])
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n || isNaN(n)) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function flattenTree(menus: Menu[]): Menu[] {
|
||||
const out: Menu[] = []
|
||||
for (const m of menus) {
|
||||
out.push({ ...m, raw: m })
|
||||
if (m.children?.length) {
|
||||
out.push(...flattenTree(m.children))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await httpGet<any>('/sys/menu/list')
|
||||
const tree = Array.isArray(data) ? data : []
|
||||
list.value = flattenTree(tree)
|
||||
parentOptions.value = [
|
||||
{ value: '0', label: '顶级菜单' },
|
||||
...list.value.filter(m => m.type === 0).map(m => ({ value: m.id, label: m.name || m.url || '--' }))
|
||||
]
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '后端接口异常'
|
||||
list.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function createHandle() {
|
||||
editMode.value = 'create'
|
||||
editForm.id = null
|
||||
editForm.pid = '0'
|
||||
editForm.name = ''
|
||||
editForm.url = ''
|
||||
editForm.type = 0
|
||||
editForm.permissions = ''
|
||||
editForm.icon = ''
|
||||
editForm.sort = 0
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editHandle(row: Menu) {
|
||||
editMode.value = 'update'
|
||||
editForm.id = Number(row.id)
|
||||
editForm.pid = row.pid
|
||||
editForm.name = row.name || ''
|
||||
editForm.url = row.url || ''
|
||||
editForm.type = row.type
|
||||
editForm.permissions = row.permissions || ''
|
||||
editForm.icon = row.icon || ''
|
||||
editForm.sort = row.sort
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editConfirm() {
|
||||
if (!editForm.name.trim()) {
|
||||
ElMessage.warning('请输入菜单名称')
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
id: editForm.id,
|
||||
pid: editForm.pid,
|
||||
name: editForm.name.trim(),
|
||||
url: editForm.url.trim(),
|
||||
type: editForm.type,
|
||||
permissions: editForm.permissions.trim(),
|
||||
icon: editForm.icon.trim(),
|
||||
sort: editForm.sort
|
||||
}
|
||||
try {
|
||||
if (editMode.value === 'create') {
|
||||
await httpPost('/sys/menu', payload)
|
||||
ElMessage.success('新增成功')
|
||||
} else {
|
||||
await httpPut('/sys/menu', payload)
|
||||
ElMessage.success('修改成功')
|
||||
}
|
||||
editVisible.value = false
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHandle(row: Menu) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认删除菜单 ${row.name || row.url} 吗?`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch { return }
|
||||
try {
|
||||
await httpDelete(`/sys/menu/${row.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-menu">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">菜单管理</h2>
|
||||
<p class="rr-page-sub">共 {{ list.length }} 个菜单(树形)</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增菜单</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'name', label: '菜单名称', minWidth: 200 },
|
||||
{ prop: 'type', label: '类型', width: 80, slot: 'type' },
|
||||
{ prop: 'url', label: '路径/标识', minWidth: 200, slot: 'url' },
|
||||
{ prop: 'permissions', label: '权限标识', minWidth: 180 },
|
||||
{ prop: 'icon', label: '图标', width: 120 },
|
||||
{ prop: 'sort', label: '排序', width: 80 },
|
||||
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
|
||||
{ label: '操作', width: 180, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="list.length"
|
||||
:page="1"
|
||||
:limit="999"
|
||||
:show-pagination="false"
|
||||
:error="error"
|
||||
empty-text="暂无菜单"
|
||||
borderless
|
||||
row-key="id"
|
||||
>
|
||||
<template #type="{ row }">
|
||||
<el-tag :type="row.type === 0 ? 'primary' : 'info'" size="small" effect="light">
|
||||
{{ TYPE_LABEL[row.type] || '--' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
<template #url="{ row }">
|
||||
<code class="rr-mono">{{ row.url || '--' }}</code>
|
||||
</template>
|
||||
<template #createDate="{ row }">
|
||||
<span class="rr-time">{{ fmtDate(row.createDate) }}</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<el-button text type="primary" size="small" @click="editHandle(row)">编辑</el-button>
|
||||
<el-button text type="primary" size="small" @click="deleteHandle(row)">删除</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
:title="editMode === 'create' ? '新增菜单' : '编辑菜单'"
|
||||
width="600"
|
||||
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-width="100px" label-position="right">
|
||||
<el-form-item label="类型" required>
|
||||
<el-radio-group v-model="editForm.type">
|
||||
<el-radio :value="0">菜单</el-radio>
|
||||
<el-radio :value="1">按钮</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单名称" required>
|
||||
<el-input v-model="editForm.name" placeholder="菜单中文名" size="default" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="上级菜单">
|
||||
<el-select v-model="editForm.pid" placeholder="选择上级菜单" style="width: 100%" size="default">
|
||||
<el-option
|
||||
v-for="opt in parentOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:value="opt.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="路径/标识">
|
||||
<el-input v-model="editForm.url" placeholder="例:sys/user 或 openapi/app" size="default" />
|
||||
</el-form-item>
|
||||
<el-form-item label="权限标识">
|
||||
<el-input v-model="editForm.permissions" placeholder="例:sys:user:page" size="default" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标">
|
||||
<el-input v-model="editForm.icon" placeholder="例:icon-User" size="default" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="editForm.sort" :min="0" controls-position="right" size="default" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="editConfirm">
|
||||
{{ editMode === 'create' ? '确认新增' : '确认修改' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-menu { padding: 16px; }
|
||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
.rr-mono { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; padding: 2px 6px; background: var(--el-fill-color-light); border-radius: 4px; color: var(--el-color-primary); }
|
||||
</style>
|
||||
274
src/modules/sys/NoticeListView.vue
Normal file
274
src/modules/sys/NoticeListView.vue
Normal file
@ -0,0 +1,274 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 通知公告 - W4.18
|
||||
*
|
||||
* 后端: GET /sys/notice/page
|
||||
* 字段: id/title/content/type/status/publisherName/createDate
|
||||
*
|
||||
* 设计规范:v1.6
|
||||
*/
|
||||
import { onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface Notice {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
type: number
|
||||
status: 0 | 1
|
||||
publisherName: string | null
|
||||
createDate: string | number
|
||||
raw: any
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
1: { label: '已发布', type: 'success' },
|
||||
0: { label: '草稿', type: 'info' }
|
||||
}
|
||||
|
||||
const TYPE_MAP: Record<number, string> = {
|
||||
0: '系统通知',
|
||||
1: '公告',
|
||||
2: '活动'
|
||||
}
|
||||
|
||||
const list = ref<Notice[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const filters = reactive({ title: '' })
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editMode = ref<'create' | 'update'>('create')
|
||||
const editForm = reactive({
|
||||
id: null as number | null,
|
||||
title: '',
|
||||
content: '',
|
||||
type: 0,
|
||||
status: 1,
|
||||
publisherName: ''
|
||||
})
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n || isNaN(n)) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function previewContent(content?: string | null): string {
|
||||
if (!content) return '--'
|
||||
const plain = content.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
|
||||
return plain.length > 60 ? plain.substring(0, 60) + '...' : plain
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number | string; list: any[] }>('/sys/notice/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.title ? { title: filters.title } : {})
|
||||
})
|
||||
if (data && data.list) {
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
title: it.title || '--',
|
||||
content: it.content,
|
||||
type: it.type ?? 0,
|
||||
status: (it.status ?? 1) as 0 | 1,
|
||||
publisherName: it.publisherName,
|
||||
createDate: it.createDate,
|
||||
raw: it
|
||||
}))
|
||||
total.value = Number(data.total) || 0
|
||||
} else {
|
||||
list.value = []
|
||||
total.value = 0
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '后端接口异常'
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { filters.title = ''; page.value = 1; load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
|
||||
function createHandle() {
|
||||
editMode.value = 'create'
|
||||
editForm.id = null
|
||||
editForm.title = ''
|
||||
editForm.content = ''
|
||||
editForm.type = 0
|
||||
editForm.status = 1
|
||||
editForm.publisherName = ''
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editHandle(row: Notice) {
|
||||
editMode.value = 'update'
|
||||
editForm.id = Number(row.id)
|
||||
editForm.title = row.title
|
||||
editForm.content = row.content || ''
|
||||
editForm.type = row.type
|
||||
editForm.status = row.status
|
||||
editForm.publisherName = row.publisherName || ''
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editConfirm() {
|
||||
if (!editForm.title.trim()) { ElMessage.warning('请输入公告标题'); return }
|
||||
const payload = { id: editForm.id, title: editForm.title.trim(), content: editForm.content, type: editForm.type, status: editForm.status, publisherName: editForm.publisherName.trim() }
|
||||
try {
|
||||
if (editMode.value === 'create') { await httpPost('/sys/notice', payload); ElMessage.success('新增成功') }
|
||||
else { await httpPut('/sys/notice', payload); ElMessage.success('修改成功') }
|
||||
editVisible.value = false
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHandle(row: Notice) {
|
||||
try { await ElMessageBox.confirm(`确认删除公告 ${row.title} 吗?`, '删除确认', { type: 'warning' }) } catch { return }
|
||||
try { await httpDelete(`/sys/notice/${row.id}`); ElMessage.success('删除成功'); load() }
|
||||
catch (e: any) { ElMessage.error(e?.msg || e?.message || '删除失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-notice">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">通知公告</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 条公告</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增公告</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-filters-row">
|
||||
<el-input v-model="filters.title" placeholder="标题搜索" clearable class="rr-title-input" size="small" @keyup.enter="onSearch" />
|
||||
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'title', label: '标题', minWidth: 240 },
|
||||
{ prop: 'type', label: '类型', width: 100, slot: 'type' },
|
||||
{ prop: 'content', label: '内容', minWidth: 240, slot: 'content' },
|
||||
{ prop: 'status', label: '状态', width: 100, slot: 'status' },
|
||||
{ prop: 'publisherName', label: '发布人', width: 120 },
|
||||
{ prop: 'createDate', label: '发布时间', width: 170, slot: 'createDate' },
|
||||
{ label: '操作', width: 180, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="error"
|
||||
empty-text="暂无公告"
|
||||
borderless
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #type="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ TYPE_MAP[row.type] || '其他' }}</el-tag>
|
||||
</template>
|
||||
<template #content="{ row }">
|
||||
<span class="rr-content-preview" :title="row.content">{{ previewContent(row.content) }}</span>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<el-tag :type="STATUS_MAP[row.status].type as any" size="small" effect="light">{{ STATUS_MAP[row.status].label }}</el-tag>
|
||||
</template>
|
||||
<template #createDate="{ row }"><span class="rr-time">{{ fmtDate(row.createDate) }}</span></template>
|
||||
<template #action="{ row }">
|
||||
<el-button text type="primary" size="small" @click="editHandle(row)">编辑</el-button>
|
||||
<el-button text type="primary" size="small" @click="deleteHandle(row)">删除</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
:title="editMode === 'create' ? '新增公告' : '编辑公告'"
|
||||
width="720"
|
||||
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-width="100px" label-position="right">
|
||||
<el-form-item label="标题" required>
|
||||
<el-input v-model="editForm.title" placeholder="公告标题" size="default" maxlength="200" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-radio-group v-model="editForm.type">
|
||||
<el-radio :value="0">系统通知</el-radio>
|
||||
<el-radio :value="1">公告</el-radio>
|
||||
<el-radio :value="2">活动</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="内容">
|
||||
<el-input v-model="editForm.content" type="textarea" :rows="6" placeholder="公告内容" maxlength="2000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="editForm.status">
|
||||
<el-radio :value="1">发布</el-radio>
|
||||
<el-radio :value="0">草稿</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="发布人">
|
||||
<el-input v-model="editForm.publisherName" placeholder="可选" size="default" maxlength="50" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="editConfirm">{{ editMode === 'create' ? '确认新增' : '确认修改' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-notice { padding: 16px; }
|
||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||
.rr-title-input { width: 280px; }
|
||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
.rr-content-preview {
|
||||
display: inline-block;
|
||||
max-width: 280px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
</style>
|
||||
418
src/modules/sys/ParamsListView.vue
Normal file
418
src/modules/sys/ParamsListView.vue
Normal file
@ -0,0 +1,418 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 参数管理(原老 vue devtools/param) - 套老 vue param.vue 结构 + v1.9 设计规范
|
||||
*
|
||||
* 后端端点 (SysParamsController):
|
||||
* GET /sys/params/page 分页列表 (⚠️ admin 角色无 sys:params:page 权限,后端 500)
|
||||
* GET /sys/params/{id} 详情
|
||||
* POST /sys/params 新增
|
||||
* PUT /sys/params 修改
|
||||
* DELETE /sys/params 删除
|
||||
* GET /sys/params/code/{paramCode} 按 code 查 value
|
||||
*
|
||||
* 字段(来自 SysParamsDTO):
|
||||
* id / paramCode / paramValue / remark / createDate / updateDate
|
||||
*
|
||||
* 设计规范:§16 列表页 + §17.7 紧凑弹窗 + §17.8 主题色弹窗 + §17.9 列优化
|
||||
*/
|
||||
import { onMounted, ref, reactive, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
interface SysParams {
|
||||
id: string
|
||||
paramCode: string
|
||||
paramValue: string
|
||||
remark?: string | null
|
||||
createDate: string | number
|
||||
updateDate?: string | number | null
|
||||
raw: any
|
||||
}
|
||||
|
||||
const userStore = useUserStore()
|
||||
const userPerms = computed(() => userStore.permissions || [])
|
||||
// W4.7 后端 @PreAuthorize 严格匹配 hasAuthority('sys:params:page')
|
||||
// *:*:* 通配符不匹配精确权限字符串,必须精确包含
|
||||
const hasParamsPerm = computed(() =>
|
||||
userPerms.value.includes('sys:params:page') ||
|
||||
userPerms.value.includes('sys:params:all')
|
||||
)
|
||||
|
||||
const list = ref<SysParams[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
const filters = reactive({
|
||||
paramCode: ''
|
||||
})
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editMode = ref<'create' | 'update'>('create')
|
||||
const editForm = reactive({
|
||||
id: null as number | null,
|
||||
paramCode: '',
|
||||
paramValue: '',
|
||||
remark: ''
|
||||
})
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function previewValue(val?: string | null) {
|
||||
if (!val) return '--'
|
||||
if (val.length > 60) return val.substring(0, 60) + '...'
|
||||
return val
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number; list: any[] }>('/sys/params/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.paramCode ? { paramCode: filters.paramCode } : {})
|
||||
})
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
paramCode: it.paramCode || '--',
|
||||
paramValue: it.paramValue || '--',
|
||||
remark: it.remark || '--',
|
||||
createDate: it.createDate,
|
||||
updateDate: it.updateDate,
|
||||
raw: it
|
||||
}))
|
||||
total.value = data.total || 0
|
||||
} catch (e: any) {
|
||||
console.error('参数列表加载失败', e)
|
||||
list.value = []
|
||||
total.value = 0
|
||||
const msg = e?.msg || e?.message || ''
|
||||
loadError.value = msg.includes('500')
|
||||
? '后端接口异常 - 可能权限不足或参数 param_type 过滤错误'
|
||||
: (msg || '后端接口异常(详见 console)')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() {
|
||||
filters.paramCode = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
|
||||
function createHandle() {
|
||||
editMode.value = 'create'
|
||||
editForm.id = null
|
||||
editForm.paramCode = ''
|
||||
editForm.paramValue = ''
|
||||
editForm.remark = ''
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editHandle(row: SysParams) {
|
||||
editMode.value = 'update'
|
||||
editForm.id = Number(row.id)
|
||||
editForm.paramCode = row.paramCode
|
||||
editForm.paramValue = row.paramValue
|
||||
editForm.remark = row.remark === '--' ? '' : (row.remark || '')
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editConfirm() {
|
||||
if (!editForm.paramCode.trim()) {
|
||||
ElMessage.warning('请输入参数编码')
|
||||
return
|
||||
}
|
||||
if (!editForm.paramValue.trim()) {
|
||||
ElMessage.warning('请输入参数值')
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
id: editForm.id,
|
||||
paramCode: editForm.paramCode.trim(),
|
||||
paramValue: editForm.paramValue,
|
||||
remark: editForm.remark.trim()
|
||||
}
|
||||
try {
|
||||
if (editMode.value === 'create') {
|
||||
await httpPost('/sys/params', payload)
|
||||
ElMessage.success('新增成功')
|
||||
} else {
|
||||
await httpPut('/sys/params', payload)
|
||||
ElMessage.success('修改成功')
|
||||
}
|
||||
editVisible.value = false
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || (editMode.value === 'create' ? '新增失败' : '修改失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHandle(row: SysParams) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认删除参数 ${row.paramCode} 吗?删除后该参数将无法恢复。`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await httpDelete(`/sys/params/${row.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (hasParamsPerm.value) {
|
||||
load()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-params">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">参数管理</h2>
|
||||
<p class="rr-page-sub">
|
||||
<template v-if="hasParamsPerm">共 {{ total }} 个参数</template>
|
||||
<template v-else>当前角色无参数管理权限</template>
|
||||
</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
<el-button type="primary" class="rr-btn-primary" :disabled="!hasParamsPerm" @click="createHandle">+ 新增参数</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无权限提示卡 -->
|
||||
<el-alert
|
||||
v-if="!hasParamsPerm"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
style="margin-bottom: 16px;"
|
||||
>
|
||||
<template #title>
|
||||
<strong>权限不足</strong>
|
||||
</template>
|
||||
<p>当前账号没有 <code>sys:params:page</code> 权限,无法查看参数列表。</p>
|
||||
<p>请联系管理员在老后台 <code>/vue/sys/role</code> 给当前角色分配此权限。</p>
|
||||
</el-alert>
|
||||
|
||||
<template v-else>
|
||||
<div class="rr-filters-row">
|
||||
<el-input
|
||||
v-model="filters.paramCode"
|
||||
placeholder="参数编码搜索"
|
||||
clearable
|
||||
class="rr-code-input"
|
||||
size="small"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'paramCode', label: '参数编码', minWidth: 200, slot: 'paramCode' },
|
||||
{ prop: 'paramValue', label: '参数值', minWidth: 280, slot: 'paramValue' },
|
||||
{ prop: 'remark', label: '备注', minWidth: 160 },
|
||||
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
|
||||
{ prop: 'updateDate', label: '更新时间', width: 170, slot: 'updateDate' },
|
||||
{ label: '操作', width: 180, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="loadError"
|
||||
empty-text="暂无参数"
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #paramCode="{ row }">
|
||||
<code class="rr-mono">{{ row.paramCode }}</code>
|
||||
</template>
|
||||
|
||||
<template #paramValue="{ row }">
|
||||
<code class="rr-mono rr-param-value">{{ previewValue(row.paramValue) }}</code>
|
||||
</template>
|
||||
|
||||
<template #createDate="{ row }">
|
||||
<span class="rr-time">{{ fmtDate(row.createDate) }}</span>
|
||||
</template>
|
||||
|
||||
<template #updateDate="{ row }">
|
||||
<span class="rr-time">{{ fmtDate(row.updateDate) }}</span>
|
||||
</template>
|
||||
|
||||
<template #action="{ row }">
|
||||
<el-button
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="editHandle(row)"
|
||||
>编辑</el-button>
|
||||
<el-button
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="deleteHandle(row)"
|
||||
>删除</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
:title="editMode === 'create' ? '新增参数' : '编辑参数'"
|
||||
width="560"
|
||||
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-width="100px" label-position="right">
|
||||
<el-form-item label="参数编码" required>
|
||||
<el-input
|
||||
v-model="editForm.paramCode"
|
||||
placeholder="例如:OAUTH_TOKEN_EXPIRE"
|
||||
size="default"
|
||||
maxlength="100"
|
||||
:disabled="editMode === 'update'"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="参数值" required>
|
||||
<el-input
|
||||
v-model="editForm.paramValue"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="支持纯文本或 JSON 字符串"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="editForm.remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="可选:参数用途说明"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="editConfirm">
|
||||
{{ editMode === 'create' ? '确认新增' : '确认修改' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-params {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.rr-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rr-page-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rr-page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.rr-page-sub {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rr-page-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rr-filters-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 16px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.rr-code-input { width: 240px; }
|
||||
|
||||
.rr-list-card {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.rr-mono {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.rr-param-value {
|
||||
word-break: break-all;
|
||||
max-width: 100%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.rr-time {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
</style>
|
||||
227
src/modules/sys/PostListView.vue
Normal file
227
src/modules/sys/PostListView.vue
Normal file
@ -0,0 +1,227 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 岗位管理 - W4.16
|
||||
*
|
||||
* 后端: GET /sys/post/page
|
||||
* 字段: id/postCode/postName/sort/status/createDate
|
||||
*/
|
||||
import { onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface Post {
|
||||
id: string
|
||||
postCode: string
|
||||
postName: string
|
||||
sort: number
|
||||
status: 0 | 1
|
||||
createDate: string
|
||||
raw: any
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
1: { label: '正常', type: 'success' },
|
||||
0: { label: '停用', type: 'danger' }
|
||||
}
|
||||
|
||||
const list = ref<Post[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const filters = reactive({ postCode: '', postName: '' })
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editMode = ref<'create' | 'update'>('create')
|
||||
const editForm = reactive({ id: null as number | null, postCode: '', postName: '', sort: 0, status: 1 })
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n || isNaN(n)) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number | string; list: any[] }>('/sys/post/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.postCode ? { postCode: filters.postCode } : {}),
|
||||
...(filters.postName ? { postName: filters.postName } : {})
|
||||
})
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
postCode: it.postCode || '--',
|
||||
postName: it.postName || '--',
|
||||
sort: it.sort ?? 0,
|
||||
status: (it.status ?? 1) as 0 | 1,
|
||||
createDate: it.createDate,
|
||||
raw: it
|
||||
}))
|
||||
total.value = Number(data.total) || 0
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '后端接口异常'
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { filters.postCode = ''; filters.postName = ''; page.value = 1; load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
|
||||
function createHandle() {
|
||||
editMode.value = 'create'
|
||||
editForm.id = null
|
||||
editForm.postCode = ''
|
||||
editForm.postName = ''
|
||||
editForm.sort = 0
|
||||
editForm.status = 1
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editHandle(row: Post) {
|
||||
editMode.value = 'update'
|
||||
editForm.id = Number(row.id)
|
||||
editForm.postCode = row.postCode
|
||||
editForm.postName = row.postName
|
||||
editForm.sort = row.sort
|
||||
editForm.status = row.status
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editConfirm() {
|
||||
if (!editForm.postCode.trim()) { ElMessage.warning('请输入岗位编码'); return }
|
||||
if (!editForm.postName.trim()) { ElMessage.warning('请输入岗位名称'); return }
|
||||
const payload = { id: editForm.id, postCode: editForm.postCode.trim(), postName: editForm.postName.trim(), sort: editForm.sort, status: editForm.status }
|
||||
try {
|
||||
if (editMode.value === 'create') { await httpPost('/sys/post', payload); ElMessage.success('新增成功') }
|
||||
else { await httpPut('/sys/post', payload); ElMessage.success('修改成功') }
|
||||
editVisible.value = false
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHandle(row: Post) {
|
||||
try { await ElMessageBox.confirm(`确认删除岗位 ${row.postName} 吗?`, '删除确认', { type: 'warning', confirmButtonText: '删除' }) } catch { return }
|
||||
try { await httpDelete(`/sys/post/${row.id}`); ElMessage.success('删除成功'); load() }
|
||||
catch (e: any) { ElMessage.error(e?.msg || e?.message || '删除失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-post">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">岗位管理</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 个岗位</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增岗位</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-filters-row">
|
||||
<el-input v-model="filters.postCode" placeholder="岗位编码" clearable class="rr-code-input" size="small" @keyup.enter="onSearch" />
|
||||
<el-input v-model="filters.postName" placeholder="岗位名称" clearable class="rr-name-input" size="small" @keyup.enter="onSearch" />
|
||||
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'postCode', label: '岗位编码', width: 160, slot: 'postCode' },
|
||||
{ prop: 'postName', label: '岗位名称', minWidth: 200 },
|
||||
{ prop: 'sort', label: '排序', width: 80 },
|
||||
{ prop: 'status', label: '状态', width: 100, slot: 'status' },
|
||||
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
|
||||
{ label: '操作', width: 180, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="error"
|
||||
empty-text="暂无岗位"
|
||||
borderless
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #postCode="{ row }"><code class="rr-mono">{{ row.postCode }}</code></template>
|
||||
<template #status="{ row }">
|
||||
<el-tag :type="STATUS_MAP[row.status].type as any" size="small" effect="light">{{ STATUS_MAP[row.status].label }}</el-tag>
|
||||
</template>
|
||||
<template #createDate="{ row }"><span class="rr-time">{{ fmtDate(row.createDate) }}</span></template>
|
||||
<template #action="{ row }">
|
||||
<el-button text type="primary" size="small" @click="editHandle(row)">编辑</el-button>
|
||||
<el-button text type="primary" size="small" @click="deleteHandle(row)">删除</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
:title="editMode === 'create' ? '新增岗位' : '编辑岗位'"
|
||||
width="540"
|
||||
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-width="100px" label-position="right">
|
||||
<el-form-item label="岗位编码" required>
|
||||
<el-input v-model="editForm.postCode" placeholder="例:tech" size="default" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="岗位名称" required>
|
||||
<el-input v-model="editForm.postName" placeholder="例:技术岗" size="default" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="editForm.sort" :min="0" controls-position="right" size="default" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="editForm.status">
|
||||
<el-radio :value="1">正常</el-radio>
|
||||
<el-radio :value="0">停用</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="editConfirm">{{ editMode === 'create' ? '确认新增' : '确认修改' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-post { padding: 16px; }
|
||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||
.rr-code-input { width: 180px; }
|
||||
.rr-name-input { width: 180px; }
|
||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
.rr-mono { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; padding: 2px 6px; background: var(--el-fill-color-light); border-radius: 4px; color: var(--el-color-primary); }
|
||||
</style>
|
||||
261
src/modules/sys/ProductListView.vue
Normal file
261
src/modules/sys/ProductListView.vue
Normal file
@ -0,0 +1,261 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 商品管理 - W4.18
|
||||
*
|
||||
* 后端: GET /sys/product/page
|
||||
* 字段: id/name/categoryId/originPrice/price/stock/status/createDate
|
||||
*
|
||||
* 设计规范:v1.6
|
||||
*/
|
||||
import { onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface Product {
|
||||
id: string
|
||||
name: string
|
||||
categoryId: number | null
|
||||
originPrice: number | null
|
||||
price: number | null
|
||||
stock: number | null
|
||||
status: 0 | 1
|
||||
createDate: string | number
|
||||
raw: any
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
1: { label: '上架', type: 'success' },
|
||||
0: { label: '下架', type: 'info' }
|
||||
}
|
||||
|
||||
const list = ref<Product[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const filters = reactive({ name: '' })
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editMode = ref<'create' | 'update'>('create')
|
||||
const editForm = reactive({
|
||||
id: null as number | null,
|
||||
name: '',
|
||||
categoryId: 0,
|
||||
originPrice: 0,
|
||||
price: 0,
|
||||
stock: 0,
|
||||
status: 1
|
||||
})
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n || isNaN(n)) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
function fmtPrice(p?: number | null): string {
|
||||
if (p == null) return '--'
|
||||
return `¥ ${Number(p).toLocaleString()}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number | string; list: any[] }>('/sys/product/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.name ? { name: filters.name } : {})
|
||||
})
|
||||
if (data && data.list) {
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
name: it.name || '--',
|
||||
categoryId: it.categoryId ?? 0,
|
||||
originPrice: it.originPrice,
|
||||
price: it.price,
|
||||
stock: it.stock,
|
||||
status: (it.status ?? 0) as 0 | 1,
|
||||
createDate: it.createDate,
|
||||
raw: it
|
||||
}))
|
||||
total.value = Number(data.total) || 0
|
||||
} else {
|
||||
list.value = []
|
||||
total.value = 0
|
||||
}
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '后端接口异常'
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { filters.name = ''; page.value = 1; load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
|
||||
function createHandle() {
|
||||
editMode.value = 'create'
|
||||
editForm.id = null
|
||||
editForm.name = ''
|
||||
editForm.categoryId = 0
|
||||
editForm.originPrice = 0
|
||||
editForm.price = 0
|
||||
editForm.stock = 0
|
||||
editForm.status = 1
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editHandle(row: Product) {
|
||||
editMode.value = 'update'
|
||||
editForm.id = Number(row.id)
|
||||
editForm.name = row.name
|
||||
editForm.categoryId = row.categoryId ?? 0
|
||||
editForm.originPrice = row.originPrice ?? 0
|
||||
editForm.price = row.price ?? 0
|
||||
editForm.stock = row.stock ?? 0
|
||||
editForm.status = row.status
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editConfirm() {
|
||||
if (!editForm.name.trim()) { ElMessage.warning('请输入商品名称'); return }
|
||||
const payload = { id: editForm.id, name: editForm.name.trim(), categoryId: editForm.categoryId, originPrice: editForm.originPrice, price: editForm.price, stock: editForm.stock, status: editForm.status }
|
||||
try {
|
||||
if (editMode.value === 'create') { await httpPost('/sys/product', payload); ElMessage.success('新增成功') }
|
||||
else { await httpPut('/sys/product', payload); ElMessage.success('修改成功') }
|
||||
editVisible.value = false
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHandle(row: Product) {
|
||||
try { await ElMessageBox.confirm(`确认删除商品 ${row.name} 吗?`, '删除确认', { type: 'warning' }) } catch { return }
|
||||
try { await httpDelete(`/sys/product/${row.id}`); ElMessage.success('删除成功'); load() }
|
||||
catch (e: any) { ElMessage.error(e?.msg || e?.message || '删除失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-product">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">商品管理</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 个商品</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增商品</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-filters-row">
|
||||
<el-input v-model="filters.name" placeholder="商品名搜索" clearable class="rr-name-input" size="small" @keyup.enter="onSearch" />
|
||||
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'name', label: '商品名称', minWidth: 200 },
|
||||
{ prop: 'categoryId', label: '分类 ID', width: 100 },
|
||||
{ prop: 'originPrice', label: '原价', width: 120, slot: 'originPrice' },
|
||||
{ prop: 'price', label: '售价', width: 120, slot: 'price' },
|
||||
{ prop: 'stock', label: '库存', width: 100 },
|
||||
{ prop: 'status', label: '状态', width: 100, slot: 'status' },
|
||||
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
|
||||
{ label: '操作', width: 180, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="error"
|
||||
empty-text="暂无商品"
|
||||
borderless
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #originPrice="{ row }"><span class="rr-price">{{ fmtPrice(row.originPrice) }}</span></template>
|
||||
<template #price="{ row }"><span class="rr-price rr-price-main">{{ fmtPrice(row.price) }}</span></template>
|
||||
<template #status="{ row }">
|
||||
<el-tag :type="STATUS_MAP[row.status].type as any" size="small" effect="light">{{ STATUS_MAP[row.status].label }}</el-tag>
|
||||
</template>
|
||||
<template #createDate="{ row }"><span class="rr-time">{{ fmtDate(row.createDate) }}</span></template>
|
||||
<template #action="{ row }">
|
||||
<el-button text type="primary" size="small" @click="editHandle(row)">编辑</el-button>
|
||||
<el-button text type="primary" size="small" @click="deleteHandle(row)">删除</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
:title="editMode === 'create' ? '新增商品' : '编辑商品'"
|
||||
width="560"
|
||||
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-width="100px" label-position="right">
|
||||
<el-form-item label="商品名称" required>
|
||||
<el-input v-model="editForm.name" placeholder="商品名称" size="default" maxlength="100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类 ID">
|
||||
<el-input-number v-model="editForm.categoryId" :min="0" controls-position="right" size="default" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="原价">
|
||||
<el-input-number v-model="editForm.originPrice" :min="0" :precision="2" controls-position="right" size="default" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="售价">
|
||||
<el-input-number v-model="editForm.price" :min="0" :precision="2" controls-position="right" size="default" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="库存">
|
||||
<el-input-number v-model="editForm.stock" :min="0" controls-position="right" size="default" style="width: 100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-radio-group v-model="editForm.status">
|
||||
<el-radio :value="1">上架</el-radio>
|
||||
<el-radio :value="0">下架</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="editConfirm">{{ editMode === 'create' ? '确认新增' : '确认修改' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-product { padding: 16px; }
|
||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||
.rr-name-input { width: 240px; }
|
||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
.rr-price { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
.rr-price-main { color: var(--el-color-danger); font-weight: 600; }
|
||||
</style>
|
||||
194
src/modules/sys/RoleGroupListView.vue
Normal file
194
src/modules/sys/RoleGroupListView.vue
Normal file
@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 角色组管理 - W4.16
|
||||
*
|
||||
* 后端: GET /sys/roleGroup/page
|
||||
* 字段: id/operationId/name/createDate
|
||||
*/
|
||||
import { onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface RoleGroup {
|
||||
id: string
|
||||
operationId: string
|
||||
name: string
|
||||
createDate: string | number
|
||||
raw: any
|
||||
}
|
||||
|
||||
const list = ref<RoleGroup[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const filters = reactive({ name: '' })
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editMode = ref<'create' | 'update'>('create')
|
||||
const editForm = reactive({ id: null as number | null, name: '', operationId: '1' })
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n || isNaN(n)) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number | string; list: any[] }>('/sys/roleGroup/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.name ? { name: filters.name } : {})
|
||||
})
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
operationId: String(it.operationId || '--'),
|
||||
name: it.name || '--',
|
||||
createDate: it.createDate,
|
||||
raw: it
|
||||
}))
|
||||
total.value = Number(data.total) || 0
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '后端接口异常'
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { filters.name = ''; page.value = 1; load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
|
||||
function createHandle() {
|
||||
editMode.value = 'create'
|
||||
editForm.id = null
|
||||
editForm.name = ''
|
||||
editForm.operationId = '1'
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editHandle(row: RoleGroup) {
|
||||
editMode.value = 'update'
|
||||
editForm.id = Number(row.id)
|
||||
editForm.name = row.name
|
||||
editForm.operationId = row.operationId
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editConfirm() {
|
||||
if (!editForm.name.trim()) { ElMessage.warning('请输入角色组名'); return }
|
||||
const payload = { id: editForm.id, name: editForm.name.trim(), operationId: Number(editForm.operationId) || 1 }
|
||||
try {
|
||||
if (editMode.value === 'create') { await httpPost('/sys/roleGroup', payload); ElMessage.success('新增成功') }
|
||||
else { await httpPut('/sys/roleGroup', payload); ElMessage.success('修改成功') }
|
||||
editVisible.value = false
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHandle(row: RoleGroup) {
|
||||
try { await ElMessageBox.confirm(`确认删除角色组 ${row.name} 吗?`, '删除确认', { type: 'warning' }) } catch { return }
|
||||
try { await httpDelete(`/sys/roleGroup/${row.id}`); ElMessage.success('删除成功'); load() }
|
||||
catch (e: any) { ElMessage.error(e?.msg || e?.message || '删除失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-role-group">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">角色组管理</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 个角色组</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增角色组</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-filters-row">
|
||||
<el-input v-model="filters.name" placeholder="角色组名" clearable class="rr-name-input" size="small" @keyup.enter="onSearch" />
|
||||
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'name', label: '角色组名', minWidth: 200 },
|
||||
{ prop: 'operationId', label: '运营中心 ID', width: 160 },
|
||||
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
|
||||
{ label: '操作', width: 180, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="error"
|
||||
empty-text="暂无角色组"
|
||||
borderless
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #createDate="{ row }"><span class="rr-time">{{ fmtDate(row.createDate) }}</span></template>
|
||||
<template #action="{ row }">
|
||||
<el-button text type="primary" size="small" @click="editHandle(row)">编辑</el-button>
|
||||
<el-button text type="primary" size="small" @click="deleteHandle(row)">删除</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
:title="editMode === 'create' ? '新增角色组' : '编辑角色组'"
|
||||
width="480"
|
||||
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-width="100px" label-position="right">
|
||||
<el-form-item label="角色组名" required>
|
||||
<el-input v-model="editForm.name" placeholder="请输入角色组名" size="default" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="运营中心 ID">
|
||||
<el-input-number v-model="editForm.operationId" :min="0" controls-position="right" size="default" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="editConfirm">{{ editMode === 'create' ? '确认新增' : '确认修改' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-role-group { padding: 16px; }
|
||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||
.rr-name-input { width: 240px; }
|
||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
</style>
|
||||
370
src/modules/sys/RoleListView.vue
Normal file
370
src/modules/sys/RoleListView.vue
Normal file
@ -0,0 +1,370 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 角色管理 - 套老 vue role.vue 结构 + v1.9 设计规范
|
||||
*
|
||||
* 后端端点 (SysRoleController):
|
||||
* GET /sys/role/page 分页列表
|
||||
* GET /sys/role/list 列表(不分页)
|
||||
* GET /sys/role/{id} 详情
|
||||
* POST /sys/role 新增
|
||||
* PUT /sys/role 修改
|
||||
* DELETE /sys/role 删除
|
||||
*
|
||||
* 字段(来自 SysRoleDTO):
|
||||
* id / name / remark / operationId / roleGroupId
|
||||
* createDate / menuIdList / deptIdList
|
||||
*
|
||||
* 设计规范:§16 列表页 + §17.7 紧凑弹窗 + §17.8 主题色弹窗 + §17.9 列优化
|
||||
*/
|
||||
import { onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface SysRole {
|
||||
id: string
|
||||
name: string
|
||||
remark?: string | null
|
||||
operationId?: string | null
|
||||
roleGroupId?: string | null
|
||||
createDate: string | number
|
||||
menuIdList?: number[] | null
|
||||
deptIdList?: number[] | null
|
||||
raw: any
|
||||
}
|
||||
|
||||
const list = ref<SysRole[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
const filters = reactive({
|
||||
name: ''
|
||||
})
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editMode = ref<'create' | 'update'>('create')
|
||||
const editForm = reactive({
|
||||
id: null as number | null,
|
||||
name: '',
|
||||
remark: '',
|
||||
operationId: null as number | null,
|
||||
roleGroupId: null as number | null
|
||||
})
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number; list: any[] }>('/sys/role/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.name ? { name: filters.name } : {})
|
||||
})
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
name: it.name || '--',
|
||||
remark: it.remark || '--',
|
||||
operationId: it.operationId ? String(it.operationId) : '--',
|
||||
roleGroupId: it.roleGroupId ? String(it.roleGroupId) : '--',
|
||||
createDate: it.createDate,
|
||||
raw: it
|
||||
}))
|
||||
total.value = data.total || 0
|
||||
} catch (e: any) {
|
||||
console.error('角色列表加载失败', e)
|
||||
list.value = []
|
||||
total.value = 0
|
||||
loadError.value = e?.msg || e?.message || '后端接口异常(详见 console)'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() {
|
||||
filters.name = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
|
||||
function createHandle() {
|
||||
editMode.value = 'create'
|
||||
editForm.id = null
|
||||
editForm.name = ''
|
||||
editForm.remark = ''
|
||||
editForm.operationId = null
|
||||
editForm.roleGroupId = null
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
async function editHandle(row: SysRole) {
|
||||
editMode.value = 'update'
|
||||
try {
|
||||
const data = await httpGet<any>(`/sys/role/${row.id}`)
|
||||
editForm.id = Number(row.id)
|
||||
editForm.name = data.name || ''
|
||||
editForm.remark = data.remark || ''
|
||||
editForm.operationId = data.operationId ? Number(data.operationId) : null
|
||||
editForm.roleGroupId = data.roleGroupId ? Number(data.roleGroupId) : null
|
||||
editVisible.value = true
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '详情加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function editConfirm() {
|
||||
if (!editForm.name.trim()) {
|
||||
ElMessage.warning('请输入角色名称')
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
id: editForm.id,
|
||||
name: editForm.name.trim(),
|
||||
remark: editForm.remark.trim(),
|
||||
operationId: editForm.operationId,
|
||||
roleGroupId: editForm.roleGroupId
|
||||
}
|
||||
try {
|
||||
if (editMode.value === 'create') {
|
||||
await httpPost('/sys/role', payload)
|
||||
ElMessage.success('新增成功')
|
||||
} else {
|
||||
await httpPut('/sys/role', payload)
|
||||
ElMessage.success('修改成功')
|
||||
}
|
||||
editVisible.value = false
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || (editMode.value === 'create' ? '新增失败' : '修改失败'))
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHandle(row: SysRole) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认删除角色 ${row.name} 吗?删除后该角色关联的用户权限将被移除。`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await httpDelete(`/sys/role/${row.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-role">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">角色管理</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 个角色</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
<el-button type="primary" class="rr-btn-primary" @click="createHandle">+ 新增角色</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-filters-row">
|
||||
<el-input
|
||||
v-model="filters.name"
|
||||
placeholder="角色名搜索"
|
||||
clearable
|
||||
class="rr-name-input"
|
||||
size="small"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'name', label: '角色名', minWidth: 160 },
|
||||
{ prop: 'remark', label: '备注', minWidth: 200 },
|
||||
{ prop: 'operationId', label: '运营中心 ID', width: 140 },
|
||||
{ prop: 'roleGroupId', label: '角色组 ID', width: 160 },
|
||||
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
|
||||
{ label: '操作', width: 180, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="loadError"
|
||||
empty-text="暂无角色"
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #createDate="{ row }">
|
||||
<span class="rr-time">{{ fmtDate(row.createDate) }}</span>
|
||||
</template>
|
||||
|
||||
<template #action="{ row }">
|
||||
<el-button
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="editHandle(row)"
|
||||
>编辑</el-button>
|
||||
<el-button
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="deleteHandle(row)"
|
||||
>删除</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<el-dialog
|
||||
v-model="editVisible"
|
||||
:title="editMode === 'create' ? '新增角色' : '编辑角色'"
|
||||
width="560"
|
||||
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form label-width="100px" label-position="right">
|
||||
<el-form-item label="角色名" required>
|
||||
<el-input
|
||||
v-model="editForm.name"
|
||||
placeholder="请输入角色名"
|
||||
size="default"
|
||||
maxlength="50"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input
|
||||
v-model="editForm.remark"
|
||||
type="textarea"
|
||||
:rows="2"
|
||||
placeholder="可选:角色用途说明"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="运营中心 ID">
|
||||
<el-input-number
|
||||
v-model="editForm.operationId"
|
||||
placeholder="可选:所属运营中心"
|
||||
size="default"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色组 ID">
|
||||
<el-input-number
|
||||
v-model="editForm.roleGroupId"
|
||||
placeholder="可选:所属角色组"
|
||||
size="default"
|
||||
:min="0"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert type="info" :closable="false" style="margin-top: 8px;">
|
||||
<p>菜单权限和数据范围在编辑弹窗里只能修改基础信息</p>
|
||||
<p>完整菜单/部门配置请在老后台(/vue/sys/role)操作</p>
|
||||
</el-alert>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="editConfirm">
|
||||
{{ editMode === 'create' ? '确认新增' : '确认修改' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-role {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.rr-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rr-page-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rr-page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.rr-page-sub {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rr-page-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rr-filters-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 16px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.rr-name-input { width: 240px; }
|
||||
|
||||
.rr-list-card {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.rr-time {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
</style>
|
||||
194
src/modules/sys/ScheduleListView.vue
Normal file
194
src/modules/sys/ScheduleListView.vue
Normal file
@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 定时任务 - W4.16
|
||||
*
|
||||
* 后端: GET /sys/schedule/page
|
||||
* 字段: id/beanName/methodName/params/cron/expression/status/remark/createDate
|
||||
*/
|
||||
import { onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface Schedule {
|
||||
id: string
|
||||
beanName: string
|
||||
methodName: string
|
||||
params: string | null
|
||||
cron: string
|
||||
expression: string | null
|
||||
status: 0 | 1
|
||||
remark: string | null
|
||||
createDate: string | number
|
||||
raw: any
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
1: { label: '正常', type: 'success' },
|
||||
0: { label: '暂停', type: 'info' }
|
||||
}
|
||||
|
||||
const list = ref<Schedule[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const filters = reactive({ beanName: '' })
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n || isNaN(n)) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number | string; list: any[] }>('/sys/schedule/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.beanName ? { beanName: filters.beanName } : {})
|
||||
})
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
beanName: it.beanName || '--',
|
||||
methodName: it.methodName || '--',
|
||||
params: it.params,
|
||||
cron: it.cron || '--',
|
||||
expression: it.expression,
|
||||
status: (it.status ?? 1) as 0 | 1,
|
||||
remark: it.remark,
|
||||
createDate: it.createDate,
|
||||
raw: it
|
||||
}))
|
||||
total.value = Number(data.total) || 0
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '后端接口异常'
|
||||
list.value = []
|
||||
total.value = 0
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() { filters.beanName = ''; page.value = 1; load() }
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
|
||||
async function runHandle(row: Schedule) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认立即执行 ${row.beanName}.${row.methodName}?`, '执行确认', { type: 'info', confirmButtonText: '执行' })
|
||||
} catch { return }
|
||||
try {
|
||||
await httpPost(`/sys/schedule/run/${row.id}`)
|
||||
ElMessage.success('执行成功')
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '执行失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function pauseHandle(row: Schedule) {
|
||||
try {
|
||||
await httpPost(`/sys/schedule/pause/${row.id}`)
|
||||
ElMessage.success('暂停成功')
|
||||
load()
|
||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '暂停失败') }
|
||||
}
|
||||
|
||||
async function resumeHandle(row: Schedule) {
|
||||
try {
|
||||
await httpPost(`/sys/schedule/resume/${row.id}`)
|
||||
ElMessage.success('恢复成功')
|
||||
load()
|
||||
} catch (e: any) { ElMessage.error(e?.msg || e?.message || '恢复失败') }
|
||||
}
|
||||
|
||||
async function deleteHandle(row: Schedule) {
|
||||
try { await ElMessageBox.confirm(`确认删除定时任务 ${row.beanName} 吗?`, '删除确认', { type: 'warning' }) } catch { return }
|
||||
try { await httpDelete(`/sys/schedule/${row.id}`); ElMessage.success('删除成功'); load() }
|
||||
catch (e: any) { ElMessage.error(e?.msg || e?.message || '删除失败') }
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-schedule">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">定时任务</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 个定时任务</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-filters-row">
|
||||
<el-input v-model="filters.beanName" placeholder="Bean 名搜索" clearable class="rr-bean-input" size="small" @keyup.enter="onSearch" />
|
||||
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'beanName', label: 'Bean 名', minWidth: 200, slot: 'beanName' },
|
||||
{ prop: 'methodName', label: '方法名', width: 180 },
|
||||
{ prop: 'params', label: '参数', minWidth: 200 },
|
||||
{ prop: 'cron', label: 'Cron 表达式', minWidth: 160, slot: 'cron' },
|
||||
{ prop: 'remark', label: '备注', minWidth: 200 },
|
||||
{ prop: 'status', label: '状态', width: 100, slot: 'status' },
|
||||
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
|
||||
{ label: '操作', width: 280, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="error"
|
||||
empty-text="暂无定时任务"
|
||||
borderless
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #beanName="{ row }"><code class="rr-mono">{{ row.beanName }}</code></template>
|
||||
<template #cron="{ row }"><code class="rr-mono-sm">{{ row.cron }}</code></template>
|
||||
<template #status="{ row }">
|
||||
<el-tag :type="STATUS_MAP[row.status].type as any" size="small" effect="light">{{ STATUS_MAP[row.status].label }}</el-tag>
|
||||
</template>
|
||||
<template #createDate="{ row }"><span class="rr-time">{{ fmtDate(row.createDate) }}</span></template>
|
||||
<template #action="{ row }">
|
||||
<el-button text type="primary" size="small" @click="runHandle(row)">执行</el-button>
|
||||
<el-button v-if="row.status === 1" text type="warning" size="small" @click="pauseHandle(row)">暂停</el-button>
|
||||
<el-button v-else text type="primary" size="small" @click="resumeHandle(row)">恢复</el-button>
|
||||
<el-button text type="primary" size="small" @click="deleteHandle(row)">删除</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-schedule { padding: 16px; }
|
||||
.rr-page-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.rr-page-titles { display: flex; flex-direction: column; gap: 2px; }
|
||||
.rr-page-title { margin: 0; font-size: 18px; font-weight: 600; color: var(--el-text-color-primary); }
|
||||
.rr-page-sub { margin: 0; font-size: 12px; color: var(--el-text-color-secondary); }
|
||||
.rr-page-actions { display: flex; gap: 8px; }
|
||||
.rr-filters-row { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; padding: 12px 16px; background: var(--el-fill-color-light); border-radius: 8px; }
|
||||
.rr-bean-input { width: 240px; }
|
||||
.rr-list-card { background: var(--el-bg-color); border-radius: 12px; padding: 4px; }
|
||||
.rr-time { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; color: var(--el-text-color-regular); }
|
||||
.rr-mono { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 12px; padding: 2px 6px; background: var(--el-fill-color-light); border-radius: 4px; color: var(--el-color-primary); }
|
||||
.rr-mono-sm { font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace; font-size: 11px; color: var(--el-text-color-regular); }
|
||||
</style>
|
||||
455
src/modules/sys/UserListView.vue
Normal file
455
src/modules/sys/UserListView.vue
Normal file
@ -0,0 +1,455 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* F006 用户管理 - 套老 vue user.vue 结构 + v1.8 设计规范
|
||||
*
|
||||
* 后端端点 (SysUserController):
|
||||
* GET /sys/user/page 分页列表
|
||||
* GET /sys/user/{id} 详情
|
||||
* POST /sys/user 新增
|
||||
* PUT /sys/user 修改
|
||||
* PUT /sys/user/updateStatus 启用/停用
|
||||
* PUT /sys/user/password 修改密码(自己)
|
||||
* PUT /sys/user/resetPassword 重置密码(管理员)
|
||||
* DELETE /sys/user 删除
|
||||
*
|
||||
* 字段(来自 SysUserDTO):
|
||||
* id / username / password(WRITE_ONLY) / realName / headUrl
|
||||
* gender(0=男/1=女/2=保密) / email / mobile / deptId
|
||||
* superAdmin(0=否/1=是) / status(0=停用/1=正常) / remark
|
||||
* identifier / employeeId(工号) / userType(1=试用/2=正式/3=外包/99=AI公司)
|
||||
* joinDate / invitationUrl / invitationCode / xcxQrCode / operationId
|
||||
* createDate / deptName / roleNames / roleIdList / postIdList
|
||||
*
|
||||
* 设计规范:§16 列表页 + §17.7 紧凑弹窗 + §17.8 主题色弹窗 + §17.9 列优化
|
||||
*/
|
||||
import { onMounted, ref, reactive } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { httpGet, httpPost, httpPut, httpDelete } from '@/utils/request'
|
||||
import DataTable from '@/components/DataTable/DataTable.vue'
|
||||
|
||||
interface SysUser {
|
||||
id: string
|
||||
username: string
|
||||
realName: string
|
||||
headUrl?: string | null
|
||||
gender?: number | null
|
||||
email?: string | null
|
||||
mobile?: string | null
|
||||
deptId?: string | null
|
||||
deptName?: string | null
|
||||
superAdmin?: number
|
||||
status: 0 | 1
|
||||
remark?: string | null
|
||||
identifier?: string | null
|
||||
employeeId?: string | null
|
||||
userType?: number
|
||||
joinDate?: string | number | null
|
||||
createDate: string | number
|
||||
roleNames?: string | null
|
||||
roleIdList?: number[] | null
|
||||
postIdList?: number[] | null
|
||||
raw: any
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<number, { label: string; type: string }> = {
|
||||
1: { label: '正常', type: 'success' },
|
||||
0: { label: '停用', type: 'danger' }
|
||||
}
|
||||
|
||||
const USER_TYPE_MAP: Record<number, { label: string; type: string }> = {
|
||||
1: { label: '试用期', type: 'info' },
|
||||
2: { label: '正式', type: 'success' },
|
||||
3: { label: '外包', type: 'warning' },
|
||||
99: { label: 'AI 公司', type: 'info' }
|
||||
}
|
||||
|
||||
const GENDER_MAP: Record<number, string> = {
|
||||
0: '男',
|
||||
1: '女',
|
||||
2: '保密'
|
||||
}
|
||||
|
||||
const list = ref<SysUser[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const limit = ref(10)
|
||||
const loadError = ref<string | null>(null)
|
||||
|
||||
const filters = reactive({
|
||||
username: '',
|
||||
userType: ''
|
||||
})
|
||||
|
||||
const resetPwdVisible = ref(false)
|
||||
const resetPwdTarget = ref<SysUser | null>(null)
|
||||
const newPassword = ref('')
|
||||
|
||||
function fmtDate(ts?: string | number | null) {
|
||||
if (!ts) return '--'
|
||||
const n = typeof ts === 'string' ? Date.parse(ts) : ts
|
||||
if (!n) return String(ts)
|
||||
const d = new Date(n)
|
||||
if (isNaN(d.getTime())) return String(ts)
|
||||
const pad = (x: number) => String(x).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
loadError.value = null
|
||||
try {
|
||||
const data = await httpGet<{ total: number; list: any[] }>('/sys/user/page', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(filters.username ? { username: filters.username } : {}),
|
||||
...(filters.userType !== '' ? { userType: Number(filters.userType) } : {})
|
||||
})
|
||||
list.value = (data.list || []).map((it: any) => ({
|
||||
id: String(it.id),
|
||||
username: it.username || '--',
|
||||
realName: it.realName || '--',
|
||||
headUrl: it.headUrl,
|
||||
gender: it.gender,
|
||||
email: it.email,
|
||||
mobile: it.mobile,
|
||||
deptId: it.deptId ? String(it.deptId) : null,
|
||||
deptName: it.deptName || '--',
|
||||
superAdmin: it.superAdmin ?? 0,
|
||||
status: (it.status ?? 0) as 0 | 1,
|
||||
remark: it.remark,
|
||||
identifier: it.identifier,
|
||||
employeeId: it.employeeId || '--',
|
||||
userType: it.userType,
|
||||
joinDate: it.joinDate,
|
||||
createDate: it.createDate,
|
||||
roleNames: it.roleNames,
|
||||
roleIdList: it.roleIdList,
|
||||
postIdList: it.postIdList,
|
||||
raw: it
|
||||
}))
|
||||
total.value = data.total || 0
|
||||
} catch (e: any) {
|
||||
console.error('用户列表加载失败', e)
|
||||
list.value = []
|
||||
total.value = 0
|
||||
loadError.value = e?.msg || e?.message || '后端接口异常(详见 console)'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() { page.value = 1; load() }
|
||||
function onReset() {
|
||||
filters.username = ''
|
||||
filters.userType = ''
|
||||
page.value = 1
|
||||
load()
|
||||
}
|
||||
function onPageChange(p: number) { page.value = p; load() }
|
||||
function onLimitChange(l: number) { limit.value = l; page.value = 1; load() }
|
||||
|
||||
async function toggleStatus(row: SysUser) {
|
||||
const newStatus = row.status === 1 ? 0 : 1
|
||||
const action = newStatus === 1 ? '启用' : '停用'
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认${action}用户 ${row.username} 吗?`,
|
||||
`${action}确认`,
|
||||
{ type: 'info', confirmButtonText: action, cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await httpPut('/sys/user/updateStatus', {
|
||||
id: Number(row.id),
|
||||
status: newStatus
|
||||
})
|
||||
ElMessage.success(`${action}成功`)
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || `${action}失败`)
|
||||
}
|
||||
}
|
||||
|
||||
function resetPwdHandle(row: SysUser) {
|
||||
resetPwdTarget.value = row
|
||||
newPassword.value = ''
|
||||
resetPwdVisible.value = true
|
||||
}
|
||||
|
||||
async function resetPwdConfirm() {
|
||||
if (!resetPwdTarget.value) return
|
||||
if (!newPassword.value.trim()) {
|
||||
ElMessage.warning('请输入新密码')
|
||||
return
|
||||
}
|
||||
if (newPassword.value.length < 6) {
|
||||
ElMessage.warning('密码至少 6 位')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await httpPut('/sys/user/resetPassword', {
|
||||
id: Number(resetPwdTarget.value.id),
|
||||
password: newPassword.value
|
||||
})
|
||||
ElMessage.success(`密码已重置 (用户: ${resetPwdTarget.value.username})`)
|
||||
resetPwdVisible.value = false
|
||||
resetPwdTarget.value = null
|
||||
newPassword.value = ''
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '重置密码失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteHandle(row: SysUser) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认删除用户 ${row.username} 吗?删除后无法恢复。`,
|
||||
'删除确认',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await httpDelete(`/sys/user/${row.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
load()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.msg || e?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { load() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rr-sys-user">
|
||||
<div class="rr-page-header">
|
||||
<div class="rr-page-titles">
|
||||
<h2 class="rr-page-title">用户管理</h2>
|
||||
<p class="rr-page-sub">共 {{ total }} 个用户(含 {{ USER_TYPE_MAP[99] ? USER_TYPE_MAP[99].label : 'AI公司' }} 账号)</p>
|
||||
</div>
|
||||
<div class="rr-page-actions">
|
||||
<el-button @click="onReset" class="rr-btn-secondary">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rr-filters-row">
|
||||
<el-input
|
||||
v-model="filters.username"
|
||||
placeholder="用户名搜索"
|
||||
clearable
|
||||
class="rr-username-input"
|
||||
size="small"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<el-select
|
||||
v-model="filters.userType"
|
||||
placeholder="用户类型"
|
||||
class="rr-type-select"
|
||||
clearable
|
||||
size="small"
|
||||
>
|
||||
<el-option value="1" label="试用期" />
|
||||
<el-option value="2" label="正式员工" />
|
||||
<el-option value="3" label="外包员工" />
|
||||
<el-option value="99" label="AI 公司" />
|
||||
</el-select>
|
||||
<el-button class="rr-btn-secondary" size="small" @click="onSearch">查询</el-button>
|
||||
</div>
|
||||
|
||||
<div class="rr-list-card">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ prop: 'username', label: '用户名', width: 200, slot: 'username' },
|
||||
{ prop: 'realName', label: '姓名', width: 140 },
|
||||
{ prop: 'employeeId', label: '工号', width: 120 },
|
||||
{ prop: 'mobile', label: '手机号', width: 130 },
|
||||
{ prop: 'userType', label: '类型', width: 100, slot: 'userType' },
|
||||
{ prop: 'status', label: '状态', width: 80, slot: 'status' },
|
||||
{ prop: 'createDate', label: '创建时间', width: 170, slot: 'createDate' },
|
||||
{ label: '操作', width: 260, slot: 'action' }
|
||||
]"
|
||||
:data="list"
|
||||
:loading="loading"
|
||||
:total="total"
|
||||
:page="page"
|
||||
:limit="limit"
|
||||
:show-pagination="true"
|
||||
:error="loadError"
|
||||
empty-text="暂无用户"
|
||||
row-key="id"
|
||||
@page-change="onPageChange"
|
||||
@limit-change="onLimitChange"
|
||||
>
|
||||
<template #username="{ row }">
|
||||
<code class="rr-username-code">{{ row.username }}</code>
|
||||
</template>
|
||||
|
||||
<template #userType="{ row }">
|
||||
<el-tag
|
||||
v-if="row.userType != null && USER_TYPE_MAP[row.userType]"
|
||||
:type="USER_TYPE_MAP[row.userType].type as any"
|
||||
size="small"
|
||||
effect="light"
|
||||
>{{ USER_TYPE_MAP[row.userType].label }}</el-tag>
|
||||
<span v-else class="rr-muted">--</span>
|
||||
</template>
|
||||
|
||||
<template #status="{ row }">
|
||||
<el-tag
|
||||
:type="STATUS_MAP[row.status].type as any"
|
||||
size="small"
|
||||
effect="light"
|
||||
>{{ STATUS_MAP[row.status].label }}</el-tag>
|
||||
</template>
|
||||
|
||||
<template #createDate="{ row }">
|
||||
<span class="rr-time">{{ fmtDate(row.createDate) }}</span>
|
||||
</template>
|
||||
|
||||
<template #action="{ row }">
|
||||
<el-button
|
||||
v-if="row.status === 1"
|
||||
text
|
||||
type="warning"
|
||||
size="small"
|
||||
@click="toggleStatus(row)"
|
||||
>停用</el-button>
|
||||
<el-button
|
||||
v-else
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="toggleStatus(row)"
|
||||
>启用</el-button>
|
||||
<el-button
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="resetPwdHandle(row)"
|
||||
>重置密码</el-button>
|
||||
<el-button
|
||||
text
|
||||
type="primary"
|
||||
size="small"
|
||||
@click="deleteHandle(row)"
|
||||
>删除</el-button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- 重置密码弹窗 -->
|
||||
<el-dialog
|
||||
v-model="resetPwdVisible"
|
||||
title="重置密码"
|
||||
width="480"
|
||||
:style="{ borderRadius: '12px', overflow: 'hidden' }"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<p v-if="resetPwdTarget" style="margin-bottom: 16px;">
|
||||
用户: <code class="rr-username-code">{{ resetPwdTarget.username }}</code>
|
||||
({{ resetPwdTarget.realName }})
|
||||
</p>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="新密码" required>
|
||||
<el-input
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
placeholder="请输入新密码(至少 6 位)"
|
||||
show-password
|
||||
size="default"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert type="warning" :closable="false" style="margin-top: 8px;">
|
||||
<p>重置后请通过安全渠道告知用户</p>
|
||||
<p>用户下次登录需使用新密码</p>
|
||||
</el-alert>
|
||||
<template #footer>
|
||||
<el-button @click="resetPwdVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="resetPwdConfirm">确认重置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rr-sys-user {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.rr-page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.rr-page-titles {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rr-page-title {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.rr-page-sub {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.rr-page-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rr-filters-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 16px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.rr-username-input { width: 240px; }
|
||||
.rr-type-select { width: 140px; }
|
||||
|
||||
.rr-list-card {
|
||||
background: var(--el-bg-color);
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.rr-username-code {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
background: var(--el-fill-color-light);
|
||||
border-radius: 4px;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.rr-time {
|
||||
font-family: 'SF Mono', Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.rr-muted {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@ -215,15 +215,15 @@ const routes: RouteRecordRaw[] = [
|
||||
// job 1 个
|
||||
{ path: '/job/schedule', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'job-schedule' }), meta: { title: '定时任务' } },
|
||||
// order 9 个
|
||||
{ path: '/order/ordergood', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'order-ordergood' }), meta: { title: '商品订单' } },
|
||||
{ path: '/order/serviceorder', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'order-serviceorder' }), meta: { title: '服务订单' } },
|
||||
{ path: '/order/groupsupplierorder', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'order-group-supplier' }), meta: { title: '商品拼团订单' } },
|
||||
{ path: '/order/groupserviceorder', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'order-group-service' }), meta: { title: '服务拼团订单' } },
|
||||
{ path: '/order/ordergood/after', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'order-ordergood-after' }), meta: { title: '商品售后订单' } },
|
||||
{ path: '/order/orderserviceafter', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'order-orderserviceafter' }), meta: { title: '服务售后订单' } },
|
||||
{ path: '/order/ordergood/commission', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'order-ordergood-commission' }), meta: { title: '实物订单分佣统计' } },
|
||||
{ path: '/order/serviceorder/commission', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'order-serviceorder-commission' }), meta: { title: '服务订单分佣统计' } },
|
||||
{ path: '/order/base-price-analysis', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'order-base-price-analysis' }), meta: { title: '基础价格分析表' } },
|
||||
{ path: '/order/ordergood', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '商品订单' } },
|
||||
{ path: '/order/serviceorder', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '服务订单' } },
|
||||
{ path: '/order/groupsupplierorder', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '商品拼团订单' } },
|
||||
{ path: '/order/groupserviceorder', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '服务拼团订单' } },
|
||||
{ path: '/order/ordergood/commission', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '实物订单分佣统计' } },
|
||||
{ path: '/order/serviceorder/commission', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '服务订单分佣统计' } },
|
||||
{ path: '/order/ordergood/after', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '商品售后订单' } },
|
||||
{ path: '/order/orderserviceafter', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '服务售后订单' } },
|
||||
{ path: '/order/base-price-analysis', component: () => import('@/modules/order/OrderListView.vue'), meta: { title: '基础价格分析表' } },
|
||||
// other 7 个
|
||||
{ path: '/other/brand', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'other-brand' }), meta: { title: '品牌管理' } },
|
||||
{ path: '/other/category/good', component: () => import('@/modules/generic/MockTableView.vue'), props: () => ({ configKey: 'other-category-good' }), meta: { title: '平台分类' } },
|
||||
|
||||
155
src/stores/inquiry.ts
Normal file
155
src/stores/inquiry.ts
Normal file
@ -0,0 +1,155 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { httpGet, httpPost } from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 询价 Store
|
||||
* W3.7 改用 httpGet 统一封装
|
||||
*
|
||||
* F002 W2 起调真 API:
|
||||
* - GET /sys/openapi/inquiry/list?page=&limit=&status=
|
||||
* - GET /sys/openapi/inquiry/info?id=
|
||||
* - POST /sys/openapi/inquiry/publish
|
||||
* - POST /sys/openapi/inquiry/quote
|
||||
* - POST /sys/openapi/inquiry/cancel
|
||||
*
|
||||
* 字段映射(后端 db 字段 -> 前端展示):
|
||||
* - id -> 单号
|
||||
* - customerName -> 客户
|
||||
* - productName -> 商品
|
||||
* - purchaseQuantity+unit -> 数量
|
||||
* - status -> 状态
|
||||
* - createDate -> 时间
|
||||
*/
|
||||
|
||||
export type InquiryStatus = 'pending' | 'quoting' | 'done' | 'cancelled' | string
|
||||
|
||||
export interface Inquiry {
|
||||
id: string
|
||||
customer: string
|
||||
product: string
|
||||
qty: string
|
||||
status: InquiryStatus
|
||||
time: string
|
||||
raw?: any
|
||||
}
|
||||
|
||||
export interface InquiryListResult {
|
||||
list: Inquiry[]
|
||||
total: number
|
||||
page: number
|
||||
limit: number
|
||||
isLive: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
const STATUS_MAP: Record<string, { label: string; type: string }> = {
|
||||
pending: { label: '待报价', type: 'primary' },
|
||||
quoting: { label: '报价中', type: 'warning' },
|
||||
done: { label: '已成交', type: 'success' },
|
||||
cancelled: { label: '已取消', type: 'danger' }
|
||||
}
|
||||
|
||||
export const STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'pending', label: '待报价' },
|
||||
{ value: 'quoting', label: '报价中' },
|
||||
{ value: 'done', label: '已成交' },
|
||||
{ value: 'cancelled', label: '已取消' }
|
||||
]
|
||||
|
||||
export function statusLabel(s: string) {
|
||||
return STATUS_MAP[s]?.label || s || '未知'
|
||||
}
|
||||
export function statusType(s: string): any {
|
||||
return (STATUS_MAP[s]?.type || 'info') as any
|
||||
}
|
||||
|
||||
function normalizeInquiry(it: any): Inquiry {
|
||||
return {
|
||||
id: String(it.id || it.rfqId || it.inquiryId || 'RFQ-?'),
|
||||
customer: it.customerName || it.customer || it.customerCode || '--',
|
||||
product: it.productName || it.product || it.productCode || '--',
|
||||
qty: it.purchaseQuantity
|
||||
? `${it.purchaseQuantity}${it.unit ? ' ' + it.unit : ''}`
|
||||
: it.qty || '--',
|
||||
status: it.status || 'pending',
|
||||
time: (it.createDate || it.createTime || '').toString().slice(0, 16),
|
||||
raw: it
|
||||
}
|
||||
}
|
||||
|
||||
export const useInquiryStore = defineStore('inquiry', () => {
|
||||
const list = ref<Inquiry[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const limit = ref(20)
|
||||
const statusFilter = ref<string>('')
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const lastFetchedAt = ref(0)
|
||||
const isLive = ref(false)
|
||||
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / limit.value)))
|
||||
|
||||
async function fetchList(force = false) {
|
||||
if (loading.value) return
|
||||
if (!force && lastFetchedAt.value && Date.now() - lastFetchedAt.value < 60 * 1000) {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const data = await httpGet<any[]>('/sys/openapi/inquiry/list', {
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
...(statusFilter.value ? { status: statusFilter.value } : {})
|
||||
})
|
||||
list.value = (Array.isArray(data) ? data : []).map(normalizeInquiry)
|
||||
total.value = list.value.length + (page.value - 1) * limit.value // 简化 total(后端不返)
|
||||
isLive.value = true
|
||||
lastFetchedAt.value = Date.now()
|
||||
} catch (e: any) {
|
||||
error.value = e?.msg || e?.message || '询价列表加载失败'
|
||||
isLive.value = false
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情优先复用 store.list 缓存(W2 D4 踩坑:后端 /info 期望 Long 不接受雪花 ID 字符串)
|
||||
* 不命中再调 fetchOne
|
||||
*/
|
||||
async function fetchOne(id: string): Promise<Inquiry | null> {
|
||||
try {
|
||||
const data = await httpGet<any>(`/sys/openapi/inquiry/${id}`)
|
||||
return data ? normalizeInquiry(data) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* W3.4 报价:POST /sys/openapi/inquiry/quote
|
||||
* 入参 id + price + remark
|
||||
* 业务异常交给调用方处理(用 try/catch,err.msg 给 toast)
|
||||
*/
|
||||
async function quote(id: string, price: number, remark: string): Promise<any> {
|
||||
return await httpPost<any>('/sys/openapi/inquiry/quote', { id, price, remark })
|
||||
}
|
||||
|
||||
function setStatusFilter(s: string) {
|
||||
statusFilter.value = s
|
||||
page.value = 1
|
||||
}
|
||||
function setPage(p: number) {
|
||||
page.value = p
|
||||
}
|
||||
|
||||
return {
|
||||
list, total, page, limit, statusFilter, loading, error, isLive, pageCount,
|
||||
lastFetchedAt,
|
||||
fetchList, fetchOne, quote, setStatusFilter, setPage
|
||||
}
|
||||
})
|
||||
@ -1,41 +1,114 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { httpGet } from '@/utils/request'
|
||||
|
||||
/**
|
||||
* 用户 Store
|
||||
* 架构:token 存 localStorage(同源 cookie 受限),Pinia 只存 user info
|
||||
* 后期可加 token 自动刷新逻辑
|
||||
* W3.7:改用 httpGet 统一封装(自动 token + 401 跳登录)
|
||||
*/
|
||||
const STORAGE_KEY = 'access_token'
|
||||
|
||||
function readStorage(key: string, fallback = ''): string {
|
||||
try {
|
||||
return localStorage.getItem(key) || fallback
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function safeParseArray(raw: string): string[] {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const v = JSON.parse(raw)
|
||||
return Array.isArray(v) ? v : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(key: string, val: string) {
|
||||
try {
|
||||
if (val) localStorage.setItem(key, val)
|
||||
else localStorage.removeItem(key)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref<string | null>(localStorage.getItem(STORAGE_KEY))
|
||||
const username = ref<string>(localStorage.getItem('username') || '')
|
||||
const permissions = ref<string[]>(JSON.parse(localStorage.getItem('permissions') || '[]'))
|
||||
const token = ref<string | null>(readStorage(STORAGE_KEY) || null)
|
||||
const username = ref<string>(readStorage('username'))
|
||||
const realName = ref<string>(readStorage('realName'))
|
||||
const headUrl = ref<string>(readStorage('headUrl'))
|
||||
const superAdmin = ref<number>(Number(readStorage('superAdmin')) || 0)
|
||||
const permissions = ref<string[]>(safeParseArray(readStorage('permissions')))
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const displayName = computed(() => realName.value || username.value)
|
||||
const initial = computed(() => (realName.value || username.value || '?').slice(0, 1).toUpperCase())
|
||||
|
||||
function setAuth(payload: { token: string; username: string; permissions?: string[] }) {
|
||||
function setAuth(payload: {
|
||||
token: string
|
||||
username: string
|
||||
realName?: string
|
||||
headUrl?: string
|
||||
superAdmin?: number
|
||||
permissions?: string[]
|
||||
}) {
|
||||
token.value = payload.token
|
||||
username.value = payload.username
|
||||
realName.value = payload.realName || ''
|
||||
headUrl.value = payload.headUrl || ''
|
||||
superAdmin.value = payload.superAdmin || 0
|
||||
permissions.value = payload.permissions || []
|
||||
localStorage.setItem(STORAGE_KEY, payload.token)
|
||||
localStorage.setItem('username', payload.username)
|
||||
localStorage.setItem('permissions', JSON.stringify(payload.permissions || []))
|
||||
writeStorage(STORAGE_KEY, payload.token)
|
||||
writeStorage('username', payload.username)
|
||||
writeStorage('realName', payload.realName || '')
|
||||
writeStorage('headUrl', payload.headUrl || '')
|
||||
writeStorage('superAdmin', String(payload.superAdmin || 0))
|
||||
writeStorage('permissions', JSON.stringify(payload.permissions || []))
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = null
|
||||
username.value = ''
|
||||
realName.value = ''
|
||||
headUrl.value = ''
|
||||
superAdmin.value = 0
|
||||
permissions.value = []
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('permissions')
|
||||
;['access_token', 'username', 'realName', 'headUrl', 'superAdmin', 'permissions'].forEach(k =>
|
||||
writeStorage(k, '')
|
||||
)
|
||||
}
|
||||
|
||||
function hasPermission(code: string): boolean {
|
||||
if (superAdmin.value === 1) return true
|
||||
return permissions.value.includes('*:*:*') || permissions.value.includes(code)
|
||||
}
|
||||
|
||||
return { token, username, permissions, isLoggedIn, setAuth, logout, hasPermission }
|
||||
/**
|
||||
* 登录后调 /sys/user/info 拿真实用户信息
|
||||
* 失败静默:头像/姓名是装饰,不影响后续菜单/看板
|
||||
*/
|
||||
async function fetchUserInfo() {
|
||||
if (!token.value) return null
|
||||
try {
|
||||
const u = await httpGet<any>('/sys/user/info')
|
||||
realName.value = u.realName || ''
|
||||
username.value = u.username || username.value
|
||||
headUrl.value = u.headUrl || ''
|
||||
superAdmin.value = u.superAdmin || 0
|
||||
writeStorage('realName', realName.value)
|
||||
writeStorage('username', username.value)
|
||||
writeStorage('headUrl', headUrl.value)
|
||||
writeStorage('superAdmin', String(superAdmin.value))
|
||||
return u
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token, username, realName, headUrl, superAdmin, permissions,
|
||||
isLoggedIn, displayName, initial,
|
||||
setAuth, logout, hasPermission, fetchUserInfo
|
||||
}
|
||||
})
|
||||
|
||||
@ -13,7 +13,7 @@ export interface ThemePreset {
|
||||
export const THEME_PRESETS: ThemePreset[] = [
|
||||
{ value: 'tech-blue', name: '科技蓝', color: '#2563EB' },
|
||||
{ value: 'red', name: '中国红', color: '#DC2626' },
|
||||
{ value: 'purple', name: 'Notion 紫', color: '#5645D4' },
|
||||
{ value: 'purple', name: '深空紫', color: '#5645D4' },
|
||||
{ value: 'dark', name: '暗夜黑', color: '#0F172A' }
|
||||
]
|
||||
|
||||
|
||||
114
src/utils/request.ts
Normal file
114
src/utils/request.ts
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* F006 统一 HTTP 客户端
|
||||
* W3.7 基础:所有 store 用这个 http 实例,自动带 token,401 跳登录,错误 toast
|
||||
*
|
||||
* 用法:
|
||||
* import { http } from '@/utils/request'
|
||||
* const res = await http.get('/sys/menu/nav')
|
||||
* // 业务错误已 reject(用 try/catch 处理),成功自动返回 data.data
|
||||
*/
|
||||
import axios, { type AxiosInstance, type InternalAxiosRequestConfig } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
export interface ApiEnvelope<T = any> {
|
||||
code: number
|
||||
msg?: string | null
|
||||
data: T
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'access_token'
|
||||
|
||||
export const http: AxiosInstance = axios.create({
|
||||
baseURL: '',
|
||||
timeout: 15000
|
||||
})
|
||||
|
||||
http.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
// 跳过 OAuth2 登录请求(用 Basic auth 不加 Bearer)
|
||||
if (config.url?.includes('/auth/oauth/token')) return config
|
||||
const token = localStorage.getItem(STORAGE_KEY)
|
||||
if (token) {
|
||||
config.headers = config.headers ?? {}
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => {
|
||||
const body = response.data as ApiEnvelope | undefined
|
||||
if (body && typeof body === 'object' && 'code' in body) {
|
||||
if (body.code === 0) return response
|
||||
// 业务错:401 token 过期 / 其它业务错
|
||||
if (body.code === 401 || body.code === 401000) {
|
||||
handleAuthExpired()
|
||||
return Promise.reject(body)
|
||||
}
|
||||
// 业务错(不弹 toast,留给调用方决定)
|
||||
return Promise.reject(body)
|
||||
}
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
const status = error.response?.status
|
||||
if (status === 401) {
|
||||
handleAuthExpired()
|
||||
} else {
|
||||
const msg =
|
||||
error.response?.data?.msg ||
|
||||
error.response?.data?.message ||
|
||||
error.message ||
|
||||
'请求失败'
|
||||
ElMessage.error(msg)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
function handleAuthExpired() {
|
||||
ElMessage.warning('登录已过期,请重新登录')
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('realName')
|
||||
localStorage.removeItem('headUrl')
|
||||
localStorage.removeItem('superAdmin')
|
||||
localStorage.removeItem('permissions')
|
||||
// 用 location 跳,避免 router 状态被污染
|
||||
const base = import.meta.env.BASE_URL || '/admin/'
|
||||
window.location.href = base + 'login'
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化业务调用:
|
||||
* const data = await httpGet<Menu[]>('/sys/menu/nav')
|
||||
* 等价于 const res = await http.get(...); res.data.data
|
||||
*/
|
||||
export async function httpGet<T = any>(url: string, params?: Record<string, any>): Promise<T> {
|
||||
const res = await http.get<ApiEnvelope<T>>(url, { params })
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function httpPost<T = any>(url: string, body?: any): Promise<T> {
|
||||
const res = await http.post<ApiEnvelope<T>>(url, body)
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function httpPut<T = any>(url: string, body?: any): Promise<T> {
|
||||
const res = await http.put<ApiEnvelope<T>>(url, body)
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
export async function httpDelete<T = any>(url: string, params?: Record<string, any>): Promise<T> {
|
||||
const res = await http.delete<ApiEnvelope<T>>(url, { params })
|
||||
return res.data.data
|
||||
}
|
||||
|
||||
/** 手动清 token(登出时用) */
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
localStorage.removeItem('username')
|
||||
localStorage.removeItem('realName')
|
||||
localStorage.removeItem('headUrl')
|
||||
localStorage.removeItem('superAdmin')
|
||||
localStorage.removeItem('permissions')
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user