前端问题优化

This commit is contained in:
文睿 张 2026-09-21 16:53:44 +08:00
parent 85d9683754
commit 837cc5787b
7 changed files with 148 additions and 36 deletions

View File

@ -1,8 +1,8 @@
import request from './index' import request from './index'
export const partnersApi = { export const partnersApi = {
list(role) { list(role, params = {}) {
return request.get('/partners', { params: { role } }) return request.get('/partners', { params: { role, ...params } })
}, },
create(data) { create(data) {
return request.post('/partners', data) return request.post('/partners', data)

View File

@ -9,7 +9,7 @@
> >
<slot /> <slot />
<template #empty> <template #empty>
<span>暂无数据</span> <span>{{ emptyText }}</span>
</template> </template>
</el-table> </el-table>
<BasePagination <BasePagination
@ -37,7 +37,11 @@ defineProps({
}, },
currentPage: Number, currentPage: Number,
pageSize: Number, pageSize: Number,
total: Number total: Number,
emptyText: {
type: String,
default: '暂无数据'
}
}) })
const emit = defineEmits(['update:currentPage', 'page-change', 'selection-change']) const emit = defineEmits(['update:currentPage', 'page-change', 'selection-change'])

View File

@ -62,7 +62,7 @@ describe('伙伴表单必填校验', () => {
beforeEach(() => { beforeEach(() => {
warnSpy = vi.spyOn(ElMessage, 'warning').mockImplementation(() => {}) warnSpy = vi.spyOn(ElMessage, 'warning').mockImplementation(() => {})
partnersApi.list.mockReset().mockResolvedValue({ data: [] }) partnersApi.list.mockReset().mockResolvedValue({ data: { items: [], total: 0 } })
partnersApi.create.mockReset().mockResolvedValue({ data: {} }) partnersApi.create.mockReset().mockResolvedValue({ data: {} })
partnersApi.update.mockReset().mockResolvedValue({ data: {} }) partnersApi.update.mockReset().mockResolvedValue({ data: {} })
}) })
@ -126,7 +126,7 @@ describe('伙伴表单必填校验', () => {
describe('伙伴列表的合作状态显示', () => { describe('伙伴列表的合作状态显示', () => {
beforeEach(() => { beforeEach(() => {
partnersApi.list.mockReset().mockResolvedValue({ data: [] }) partnersApi.list.mockReset().mockResolvedValue({ data: { items: [], total: 0 } })
}) })
afterEach(() => { afterEach(() => {
@ -135,7 +135,7 @@ describe('伙伴列表的合作状态显示', () => {
test('合作状态列显示中文,不把英文枚举值直接给用户', async () => { test('合作状态列显示中文,不把英文枚举值直接给用户', async () => {
partnersApi.list.mockResolvedValue({ partnersApi.list.mockResolvedValue({
data: [{ id: 1, partner_code: 'SUP-001', legal_name: '某某钢材有限公司', supplier_status: 'active' }] data: { items: [{ id: 1, partner_code: 'SUP-001', legal_name: '某某钢材有限公司', supplier_status: 'active' }], total: 1 }
}) })
const wrapper = mountPartner() const wrapper = mountPartner()
await flushPromises() await flushPromises()
@ -147,7 +147,7 @@ describe('伙伴列表的合作状态显示', () => {
test('未收录的状态原样显示,不伪装成已知状态', async () => { test('未收录的状态原样显示,不伪装成已知状态', async () => {
partnersApi.list.mockResolvedValue({ partnersApi.list.mockResolvedValue({
data: [{ id: 2, partner_code: 'SUP-002', legal_name: '某某轴承厂', supplier_status: 'unknown_state' }] data: { items: [{ id: 2, partner_code: 'SUP-002', legal_name: '某某轴承厂', supplier_status: 'unknown_state' }], total: 1 }
}) })
const wrapper = mountPartner() const wrapper = mountPartner()
await flushPromises() await flushPromises()
@ -160,7 +160,7 @@ describe('伙伴列表的合作状态显示', () => {
describe('新增伙伴不发生表单残留', () => { describe('新增伙伴不发生表单残留', () => {
beforeEach(() => { beforeEach(() => {
partnersApi.list.mockReset().mockResolvedValue({ partnersApi.list.mockReset().mockResolvedValue({
data: [{ id: 1, partner_code: 'SUP-001', legal_name: '某某钢材有限公司', supplier_status: 'active' }] data: { items: [{ id: 1, partner_code: 'SUP-001', legal_name: '某某钢材有限公司', supplier_status: 'active' }], total: 1 }
}) })
partnersApi.create.mockReset().mockResolvedValue({ data: {} }) partnersApi.create.mockReset().mockResolvedValue({ data: {} })
partnersApi.update.mockReset().mockResolvedValue({ data: {} }) partnersApi.update.mockReset().mockResolvedValue({ data: {} })
@ -191,3 +191,57 @@ describe('新增伙伴不发生表单残留', () => {
wrapper.unmount() wrapper.unmount()
}) })
}) })
describe('伙伴列表分页', () => {
beforeEach(() => {
partnersApi.list.mockReset()
})
afterEach(() => { vi.restoreAllMocks() })
test('首次加载取第一页,offset 从 0 开始', async () => {
partnersApi.list.mockResolvedValue({ data: { items: [], total: 0 } })
const wrapper = mountPartner()
await flushPromises()
expect(partnersApi.list).toHaveBeenLastCalledWith('supplier', { limit: 20, offset: 0 })
wrapper.unmount()
})
test('翻到第二页时按页大小换算出 offset', async () => {
partnersApi.list.mockResolvedValue({ data: { items: [], total: 40 } })
const wrapper = mountPartner()
await flushPromises()
await wrapper.find('.btn-next').trigger('click')
await flushPromises()
expect(partnersApi.list).toHaveBeenLastCalledWith('supplier', { limit: 20, offset: 20 })
wrapper.unmount()
})
test('切换角色时页码归零,不会停在上一个角色的第 N 页', async () => {
partnersApi.list.mockResolvedValue({ data: { items: [], total: 40 } })
const wrapper = mountPartner()
await flushPromises()
await wrapper.find('.btn-next').trigger('click')
await flushPromises()
expect(partnersApi.list).toHaveBeenLastCalledWith('supplier', { limit: 20, offset: 20 })
await wrapper.setProps({ role: 'customer' })
await flushPromises()
expect(partnersApi.list).toHaveBeenLastCalledWith('customer', { limit: 20, offset: 0 })
wrapper.unmount()
})
test('总数透传给分页组件,用户能看到一共多少条', async () => {
partnersApi.list.mockResolvedValue({ data: { items: [], total: 42 } })
const wrapper = mountPartner()
await flushPromises()
expect(wrapper.text()).toContain('共 42 条')
wrapper.unmount()
})
})

View File

@ -4,7 +4,15 @@
<div><h2>{{ title }}</h2><p>业务伙伴独立于本公司的组织数据,可按合作组织配置{{ title }}身份和状态。</p></div> <div><h2>{{ title }}</h2><p>业务伙伴独立于本公司的组织数据,可按合作组织配置{{ title }}身份和状态。</p></div>
<el-button type="primary" @click="openCreate">新增{{ title }}</el-button> <el-button type="primary" @click="openCreate">新增{{ title }}</el-button>
</div> </div>
<el-table v-loading="loading" :data="rows" stripe> <BaseTable
:data="rows"
:loading="loading"
:empty-text="`暂无${title}`"
:current-page="pagination.currentPage"
:page-size="pagination.pageSize"
:total="pagination.total"
@page-change="load"
>
<el-table-column prop="partner_code" label="伙伴编码" width="140" /> <el-table-column prop="partner_code" label="伙伴编码" width="140" />
<el-table-column prop="legal_name" label="企业名称" min-width="180" /> <el-table-column prop="legal_name" label="企业名称" min-width="180" />
<el-table-column prop="unified_credit_code" label="统一社会信用代码" min-width="180" /> <el-table-column prop="unified_credit_code" label="统一社会信用代码" min-width="180" />
@ -12,8 +20,7 @@
<el-table-column prop="contact_phone" label="联系电话" width="140" /> <el-table-column prop="contact_phone" label="联系电话" width="140" />
<el-table-column :prop="statusField" label="合作状态" width="110" :formatter="statusFormatter" /> <el-table-column :prop="statusField" label="合作状态" width="110" :formatter="statusFormatter" />
<el-table-column label="操作" width="100"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">编辑</el-button></template></el-table-column> <el-table-column label="操作" width="100"><template #default="{ row }"><el-button link type="primary" @click="openEdit(row)">编辑</el-button></template></el-table-column>
</el-table> </BaseTable>
<el-empty v-if="!loading && rows.length === 0" :description="`暂无${title}`" />
<el-dialog v-model="dialogVisible" :title="`${editingId ? '编辑' : '新增'}${title}`" width="560px"> <el-dialog v-model="dialogVisible" :title="`${editingId ? '编辑' : '新增'}${title}`" width="560px">
<el-form :model="form" label-width="130px"> <el-form :model="form" label-width="130px">
@ -39,6 +46,7 @@ import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { partnersApi } from '../../api/partners' import { partnersApi } from '../../api/partners'
import { replaceAllKeys } from '../../utils/replaceAllKeys' import { replaceAllKeys } from '../../utils/replaceAllKeys'
import BaseTable from '../../components/business/BaseTable.vue'
const props = defineProps({ role: { type: String, required: true }, title: { type: String, required: true } }) const props = defineProps({ role: { type: String, required: true }, title: { type: String, required: true } })
const rows = ref([]) const rows = ref([])
@ -46,6 +54,8 @@ const loading = ref(false)
const saving = ref(false) const saving = ref(false)
const dialogVisible = ref(false) const dialogVisible = ref(false)
const editingId = ref(null) const editingId = ref(null)
// 每页条数跟物料等列表页保持一致,避免同一个系统里两种翻页手感
const pagination = reactive({ currentPage: 1, pageSize: 20, total: 0 })
const statusField = computed(() => `${props.role}_status`) const statusField = computed(() => `${props.role}_status`)
// 合作状态英文枚举到中文的对照。列表列与编辑弹窗共用这一份,避免两处各写一遍后失步。 // 合作状态英文枚举到中文的对照。列表列与编辑弹窗共用这一份,避免两处各写一遍后失步。
const STATUS_LABELS = { active: '启用', suspended: '暂停', eliminated: '淘汰', pending: '待审核', inactive: '停用' } const STATUS_LABELS = { active: '启用', suspended: '暂停', eliminated: '淘汰', pending: '待审核', inactive: '停用' }
@ -54,7 +64,20 @@ function statusFormatter(row, column, cellValue) { return STATUS_LABELS[cellValu
const form = reactive(emptyForm()) const form = reactive(emptyForm())
function emptyForm() { return { partner_code: '', legal_name: '', unified_credit_code: '', contact_name: '', contact_phone: '', address: '', status: 'active', is_supplier: false, is_customer: false } } function emptyForm() { return { partner_code: '', legal_name: '', unified_credit_code: '', contact_name: '', contact_phone: '', address: '', status: 'active', is_supplier: false, is_customer: false } }
async function load() { loading.value = true; try { const response = await partnersApi.list(props.role); rows.value = response.data || [] } finally { loading.value = false } } async function load(currentPage = pagination.currentPage) {
loading.value = true
try {
const offset = (currentPage - 1) * pagination.pageSize
const response = await partnersApi.list(props.role, { limit: pagination.pageSize, offset })
rows.value = response.data?.items || []
pagination.total = response.data?.total || 0
pagination.currentPage = currentPage
} finally {
loading.value = false
}
}
// 换角色等于换了一份数据集,页码必须归零,否则会停在上一个角色的第 N 页上
watch(() => props.role, () => load(1))
// 用 replaceAllKeys 而不是 Object.assign:后者删不掉上次编辑残留的 id, // 用 replaceAllKeys 而不是 Object.assign:后者删不掉上次编辑残留的 id,
// 新增时会把别人的 id 一起提交上去。 // 新增时会把别人的 id 一起提交上去。
function openCreate() { editingId.value = null; replaceAllKeys(form, emptyForm()); dialogVisible.value = true } function openCreate() { editingId.value = null; replaceAllKeys(form, emptyForm()); dialogVisible.value = true }
@ -71,10 +94,10 @@ async function save() {
if (!form.partner_code.trim()) { ElMessage.warning('请填写伙伴编码'); return } if (!form.partner_code.trim()) { ElMessage.warning('请填写伙伴编码'); return }
if (!form.legal_name.trim()) { ElMessage.warning('请填写企业名称'); return } if (!form.legal_name.trim()) { ElMessage.warning('请填写企业名称'); return }
saving.value = true saving.value = true
// 保存后停在第几页由 load 的默认参数决定:留在当前页,不把用户甩回第一页
try { if (editingId.value) await partnersApi.update(editingId.value, payload()); else await partnersApi.create(payload()); ElMessage.success('保存成功'); dialogVisible.value = false; await load() } finally { saving.value = false } try { if (editingId.value) await partnersApi.update(editingId.value, payload()); else await partnersApi.create(payload()); ElMessage.success('保存成功'); dialogVisible.value = false; await load() } finally { saving.value = false }
} }
watch(() => props.role, load) onMounted(() => load(1))
onMounted(load)
</script> </script>
<style scoped> <style scoped>

View File

@ -283,12 +283,32 @@ func (h *Handler) ListPartners(c *gin.Context) {
if !ok { if !ok {
return return
} }
items, err := h.partnerSvc.List(orgID, c.Query("role")) // 分页参数的默认值与上限跟物料等列表接口保持一致,前端不必记两套规则
limit := 20
offset := 0
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
if limit > 100 {
limit = 100
}
}
}
if o := c.Query("offset"); o != "" {
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
offset = parsed
}
}
items, total, err := h.partnerSvc.List(orgID, c.Query("role"), limit, offset)
if err != nil { if err != nil {
response.BadRequest(c, err.Error()) response.BadRequest(c, err.Error())
return return
} }
response.Success(c, items) if items == nil {
items = []model.BusinessPartner{}
}
response.PaginatedSuccess(c, items, limit, offset, total)
} }
func (h *Handler) CreatePartner(c *gin.Context) { func (h *Handler) CreatePartner(c *gin.Context) {
orgID, ok := organizationID(c) orgID, ok := organizationID(c)

View File

@ -11,28 +11,39 @@ type PartnerRepository struct{}
func NewPartnerRepository() *PartnerRepository { return &PartnerRepository{} } func NewPartnerRepository() *PartnerRepository { return &PartnerRepository{} }
func (r *PartnerRepository) List(organizationID int64, role string) ([]model.BusinessPartner, error) { func (r *PartnerRepository) List(organizationID int64, role string, limit, offset int) ([]model.BusinessPartner, int64, error) {
// 角色过滤写成一份,由计数和取数共用:两处各写一遍,改一处漏一处就会让
// 总数和列表对不上(总数算的是全部,列表却只有供应商)。
filter := `FROM organization_partners op
JOIN business_partners bp ON bp.id = op.business_partner_id
WHERE op.organization_id = ?`
args := []interface{}{organizationID}
if role == "supplier" {
filter += ` AND op.is_supplier = TRUE`
}
if role == "customer" {
filter += ` AND op.is_customer = TRUE`
}
var total int64
if err := database.DB.QueryRow(`SELECT COUNT(*) `+filter, args...).Scan(&total); err != nil {
return nil, 0, err
}
query := `SELECT op.id, op.organization_id, bp.id, op.partner_code, bp.legal_name, query := `SELECT op.id, op.organization_id, bp.id, op.partner_code, bp.legal_name,
COALESCE(bp.unified_credit_code, ''), COALESCE(bp.contact_name, ''), COALESCE(bp.unified_credit_code, ''), COALESCE(bp.contact_name, ''),
COALESCE(bp.contact_phone, ''), COALESCE(bp.address, ''), COALESCE(bp.contact_phone, ''), COALESCE(bp.address, ''),
op.is_supplier, op.is_customer, COALESCE(op.supplier_status, ''), op.is_supplier, op.is_customer, COALESCE(op.supplier_status, ''),
COALESCE(op.customer_status, ''), COALESCE(op.settlement_currency, ''), COALESCE(op.customer_status, ''), COALESCE(op.settlement_currency, ''),
COALESCE(op.payment_terms, '') COALESCE(op.payment_terms, '')
FROM organization_partners op ` + filter + `
JOIN business_partners bp ON bp.id = op.business_partner_id ORDER BY op.partner_code
WHERE op.organization_id = ?` LIMIT ? OFFSET ?`
args := []interface{}{organizationID}
if role == "supplier" {
query += ` AND op.is_supplier = TRUE`
}
if role == "customer" {
query += ` AND op.is_customer = TRUE`
}
query += ` ORDER BY op.partner_code`
rows, err := database.DB.Query(query, args...) // 计数查询已经用完 args,这里追加分页参数不会再影响到它
rows, err := database.DB.Query(query, append(args, limit, offset)...)
if err != nil { if err != nil {
return nil, err return nil, 0, err
} }
defer rows.Close() defer rows.Close()
items := make([]model.BusinessPartner, 0) items := make([]model.BusinessPartner, 0)
@ -42,11 +53,11 @@ func (r *PartnerRepository) List(organizationID int64, role string) ([]model.Bus
&item.LegalName, &item.UnifiedCreditCode, &item.ContactName, &item.ContactPhone, &item.LegalName, &item.UnifiedCreditCode, &item.ContactName, &item.ContactPhone,
&item.Address, &item.IsSupplier, &item.IsCustomer, &item.SupplierStatus, &item.Address, &item.IsSupplier, &item.IsCustomer, &item.SupplierStatus,
&item.CustomerStatus, &item.SettlementCurrency, &item.PaymentTerms); err != nil { &item.CustomerStatus, &item.SettlementCurrency, &item.PaymentTerms); err != nil {
return nil, err return nil, 0, err
} }
items = append(items, item) items = append(items, item)
} }
return items, rows.Err() return items, total, rows.Err()
} }
func (r *PartnerRepository) Create(item *model.BusinessPartner) (err error) { func (r *PartnerRepository) Create(item *model.BusinessPartner) (err error) {

View File

@ -199,11 +199,11 @@ type PartnerService struct{ repo *repository.PartnerRepository }
func NewPartnerService() *PartnerService { func NewPartnerService() *PartnerService {
return &PartnerService{repo: repository.NewPartnerRepository()} return &PartnerService{repo: repository.NewPartnerRepository()}
} }
func (s *PartnerService) List(orgID int64, role string) ([]model.BusinessPartner, error) { func (s *PartnerService) List(orgID int64, role string, limit, offset int) ([]model.BusinessPartner, int64, error) {
if role != "" && role != "supplier" && role != "customer" { if role != "" && role != "supplier" && role != "customer" {
return nil, errors.New("role must be supplier or customer") return nil, 0, errors.New("role must be supplier or customer")
} }
return s.repo.List(orgID, role) return s.repo.List(orgID, role, limit, offset)
} }
// Create 新建伙伴。请求体可能带着前端表单残留的 id,这里不读取 item.ID, // Create 新建伙伴。请求体可能带着前端表单残留的 id,这里不读取 item.ID,