工单可以关联客户
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { customerApi } from '@/api/customer'
|
||||
import { useUsersStore } from '@/stores/users'
|
||||
import AppToast from '@/components/AppToast.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const usersStore = useUsersStore()
|
||||
|
||||
const customer = ref(null)
|
||||
const phones = ref([])
|
||||
const emails = ref([])
|
||||
const companies = ref([])
|
||||
const loading = ref(false)
|
||||
|
||||
const toast = ref({ show: false, message: '', type: 'success' })
|
||||
|
||||
// 获取客户详情
|
||||
async function fetchCustomerDetail() {
|
||||
const id = route.params.id
|
||||
if (!id) {
|
||||
router.push('/customer')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await customerApi.get({ id: parseInt(id) })
|
||||
if (res.errCode === 0) {
|
||||
customer.value = res.data.customer
|
||||
phones.value = res.data.phones || []
|
||||
emails.value = res.data.emails || []
|
||||
companies.value = res.data.companies || []
|
||||
// 预加载创建者信息
|
||||
if (customer.value?.created_by) {
|
||||
usersStore.fetchUser(customer.value.created_by)
|
||||
}
|
||||
} else {
|
||||
showToast(res.errMsg || t('message.error'), 'error')
|
||||
router.push('/customer')
|
||||
}
|
||||
} catch {
|
||||
showToast(t('message.error'), 'error')
|
||||
router.push('/customer')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 返回列表
|
||||
function goBack() {
|
||||
router.push('/customer')
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
// 获取标签文本
|
||||
function getLabelText(label) {
|
||||
const key = `customer.label_${label}`
|
||||
const text = t(key)
|
||||
return text === key ? label : text
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
toast.value = { show: true, message, type }
|
||||
setTimeout(() => (toast.value.show = false), 3000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCustomerDetail()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 p-6 dark:bg-dk-base">
|
||||
<div class="mx-auto max-w-4xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
@click="goBack"
|
||||
class="flex items-center gap-1 text-gray-600 hover:text-gray-900 dark:text-dk-subtle dark:hover:text-dk-text"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{{ t('common.back') }}
|
||||
</button>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-dk-text">{{ t('customer.detail_title') }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"></div>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div v-else-if="customer" class="space-y-6">
|
||||
<!-- Basic Info Card -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-dk-muted dark:bg-dk-card">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-dk-text">{{ t('customer.basic_info') }}</h2>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label class="text-sm text-gray-500 dark:text-dk-subtle">{{ t('customer.name') }}</label>
|
||||
<p class="text-lg font-medium text-gray-900 dark:text-dk-text">
|
||||
{{ (customer.last_name || '') + (customer.first_name ? ' ' + customer.first_name : '') }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-500 dark:text-dk-subtle">{{ t('customer.salutation') }}</label>
|
||||
<p class="text-gray-900 dark:text-dk-text">
|
||||
{{ customer.title ? t(`customer.salutation_${customer.title.toLowerCase()}`) : '-' }}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-500 dark:text-dk-subtle">{{ t('customer.created_at') }}</label>
|
||||
<p class="text-gray-900 dark:text-dk-text">{{ formatDate(customer.created_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm text-gray-500 dark:text-dk-subtle">{{ t('customer.created_by') }}</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<img
|
||||
:src="usersStore.getAvatarUrlFromUserID(customer.created_by)"
|
||||
class="h-6 w-6 rounded-full"
|
||||
alt="avatar"
|
||||
/>
|
||||
<span class="text-gray-900 dark:text-dk-text">
|
||||
{{ usersStore.getUsernameFromUserID(customer.created_by) || 'ID: ' + customer.created_by }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phones Card -->
|
||||
<div v-if="phones.length > 0" class="rounded-lg border border-gray-200 bg-white p-6 dark:border-dk-muted dark:bg-dk-card">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-dk-text">{{ t('customer.phones') }}</h2>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="phone in phones"
|
||||
:key="phone.id"
|
||||
class="flex items-center justify-between rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-dk-muted dark:bg-dk-muted"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-gray-500 dark:text-dk-subtle">+{{ phone.prefix }}</span>
|
||||
<span class="font-medium text-gray-900 dark:text-dk-text">{{ phone.phone }}</span>
|
||||
<span
|
||||
class="rounded px-2 py-0.5 text-xs"
|
||||
:class="phone.is_primary ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-gray-200 text-gray-600 dark:bg-dk-muted dark:text-dk-subtle'"
|
||||
>
|
||||
{{ getLabelText(phone.label) }}
|
||||
<span v-if="phone.is_primary" class="ml-1">({{ t('customer.primary') }})</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emails Card -->
|
||||
<div v-if="emails.length > 0" class="rounded-lg border border-gray-200 bg-white p-6 dark:border-dk-muted dark:bg-dk-card">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-dk-text">{{ t('customer.emails') }}</h2>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="email in emails"
|
||||
:key="email.id"
|
||||
class="flex items-center justify-between rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-dk-muted dark:bg-dk-muted"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-medium text-gray-900 dark:text-dk-text">{{ email.email }}</span>
|
||||
<span
|
||||
class="rounded px-2 py-0.5 text-xs"
|
||||
:class="email.is_primary ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'bg-gray-200 text-gray-600 dark:bg-dk-muted dark:text-dk-subtle'"
|
||||
>
|
||||
{{ getLabelText(email.label) }}
|
||||
<span v-if="email.is_primary" class="ml-1">({{ t('customer.primary') }})</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Companies Card -->
|
||||
<div v-if="companies.length > 0" class="rounded-lg border border-gray-200 bg-white p-6 dark:border-dk-muted dark:bg-dk-card">
|
||||
<h2 class="mb-4 text-lg font-semibold text-gray-900 dark:text-dk-text">{{ t('customer.companies') }}</h2>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="company in companies"
|
||||
:key="company.id"
|
||||
class="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-dk-muted dark:bg-dk-muted"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium text-gray-900 dark:text-dk-text">{{ company.company_name }}</span>
|
||||
<span
|
||||
v-if="company.is_primary"
|
||||
class="rounded px-2 py-0.5 text-xs bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
>
|
||||
{{ t('customer.primary') }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="company.department || company.position" class="mt-1 text-sm text-gray-500 dark:text-dk-subtle">
|
||||
<span v-if="company.department">{{ company.department }}</span>
|
||||
<span v-if="company.department && company.position"> · </span>
|
||||
<span v-if="company.position">{{ company.position }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Not Found -->
|
||||
<div v-else class="rounded-lg border border-gray-200 bg-white p-12 text-center dark:border-dk-muted dark:bg-dk-card">
|
||||
<p class="text-gray-500 dark:text-dk-subtle">{{ t('customer.not_found') }}</p>
|
||||
<button
|
||||
@click="goBack"
|
||||
class="mt-4 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
{{ t('common.back') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<AppToast
|
||||
v-if="toast.show"
|
||||
:message="toast.message"
|
||||
:type="toast.type"
|
||||
@close="toast.show = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import CustomerFormModal from './CustomerFormModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const customerId = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(() => {
|
||||
const id = route.params.id
|
||||
if (!id) {
|
||||
router.push('/customer')
|
||||
return
|
||||
}
|
||||
customerId.value = parseInt(id)
|
||||
loading.value = false
|
||||
})
|
||||
|
||||
function onClose() {
|
||||
router.push('/customer')
|
||||
}
|
||||
|
||||
function onSubmitted() {
|
||||
router.push(`/customer/detail/${customerId.value}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!loading && customerId">
|
||||
<CustomerFormModal
|
||||
:title="t('customer.edit_title')"
|
||||
:customer="{ id: customerId }"
|
||||
@close="onClose"
|
||||
@submit="onSubmitted"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,267 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { customerApi } from '@/api/customer'
|
||||
|
||||
const props = defineProps({
|
||||
title: String,
|
||||
customer: Object,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'submit'])
|
||||
const { t } = useI18n()
|
||||
|
||||
const form = ref({
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
title: 'Unit',
|
||||
phones: [{ prefix: '853', phone: '', label: 'mobile', is_primary: true }],
|
||||
emails: [{ email: '', label: 'work', is_primary: true }],
|
||||
companies: [{ company_name: '', department: '', position: '', is_primary: true }],
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const errors = ref({})
|
||||
|
||||
const titleOptions = ['Unit', 'Mr', 'Ms']
|
||||
const phoneLabels = ['mobile', 'work', 'home', 'other']
|
||||
const emailLabels = ['work', 'personal', 'other']
|
||||
const prefixOptions = ['853', '852', '86']
|
||||
|
||||
// 编辑模式填充数据
|
||||
watch(() => props.customer, async (customer) => {
|
||||
if (customer) {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await customerApi.get({ id: customer.id })
|
||||
if (res.errCode === 0) {
|
||||
const data = res.data
|
||||
form.value = {
|
||||
first_name: data.customer.first_name || '',
|
||||
last_name: data.customer.last_name || '',
|
||||
title: data.customer.title || 'Unit',
|
||||
phones: data.phones?.length > 0 ? data.phones.map(p => ({
|
||||
prefix: p.prefix || '853',
|
||||
phone: p.phone || '',
|
||||
label: p.label || 'mobile',
|
||||
is_primary: p.is_primary || false,
|
||||
})) : [{ prefix: '853', phone: '', label: 'mobile', is_primary: true }],
|
||||
emails: data.emails?.length > 0 ? data.emails.map(e => ({
|
||||
email: e.email || '',
|
||||
label: e.label || 'work',
|
||||
is_primary: e.is_primary || false,
|
||||
})) : [{ email: '', label: 'work', is_primary: true }],
|
||||
companies: data.companies?.length > 0 ? data.companies.map(c => ({
|
||||
company_name: c.company_name || '',
|
||||
department: c.department || '',
|
||||
position: c.position || '',
|
||||
is_primary: c.is_primary || false,
|
||||
})) : [{ company_name: '', department: '', position: '', is_primary: true }],
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
function addPhone() {
|
||||
form.value.phones.push({ prefix: '853', phone: '', label: 'mobile', is_primary: false })
|
||||
}
|
||||
|
||||
function removePhone(index) {
|
||||
form.value.phones.splice(index, 1)
|
||||
if (form.value.phones.length === 1) {
|
||||
form.value.phones[0].is_primary = true
|
||||
}
|
||||
}
|
||||
|
||||
function setPrimaryPhone(index) {
|
||||
form.value.phones.forEach((p, i) => (p.is_primary = i === index))
|
||||
}
|
||||
|
||||
function addEmail() {
|
||||
form.value.emails.push({ email: '', label: 'work', is_primary: false })
|
||||
}
|
||||
|
||||
function removeEmail(index) {
|
||||
form.value.emails.splice(index, 1)
|
||||
if (form.value.emails.length === 1) {
|
||||
form.value.emails[0].is_primary = true
|
||||
}
|
||||
}
|
||||
|
||||
function setPrimaryEmail(index) {
|
||||
form.value.emails.forEach((e, i) => (e.is_primary = i === index))
|
||||
}
|
||||
|
||||
function addCompany() {
|
||||
form.value.companies.push({ company_name: '', department: '', position: '', is_primary: false })
|
||||
}
|
||||
|
||||
function removeCompany(index) {
|
||||
form.value.companies.splice(index, 1)
|
||||
if (form.value.companies.length === 1) {
|
||||
form.value.companies[0].is_primary = true
|
||||
}
|
||||
}
|
||||
|
||||
function setPrimaryCompany(index) {
|
||||
form.value.companies.forEach((c, i) => (c.is_primary = i === index))
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
errors.value = {}
|
||||
|
||||
// 基本验证(姓必填,名可选)
|
||||
if (!form.value.last_name?.trim()) {
|
||||
errors.value.last_name = t('validation.required')
|
||||
}
|
||||
|
||||
// 过滤空数据
|
||||
const payload = {
|
||||
...form.value,
|
||||
phones: form.value.phones.filter(p => p.phone.trim()),
|
||||
emails: form.value.emails.filter(e => e.email.trim()),
|
||||
companies: form.value.companies.filter(c => c.company_name.trim()),
|
||||
}
|
||||
|
||||
if (Object.keys(errors.value).length > 0) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const api = props.customer ? customerApi.update : customerApi.add
|
||||
const params = props.customer ? { ...payload, id: props.customer.id } : payload
|
||||
const res = await api(params)
|
||||
if (res.errCode === 0) {
|
||||
emit('submit')
|
||||
} else {
|
||||
errors.value.submit = res.errMsg || t('message.error')
|
||||
}
|
||||
} catch {
|
||||
errors.value.submit = t('message.error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg bg-white p-6 dark:bg-dk-card">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-dk-text">{{ title }}</h2>
|
||||
<button @click="$emit('close')" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="submit" class="space-y-4">
|
||||
<!-- Basic Info -->
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.title') }}</label>
|
||||
<select v-model="form.title" class="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text">
|
||||
<option v-for="opt in titleOptions" :key="opt" :value="opt">{{ t(`customer.salutation_${opt.toLowerCase()}`) }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.last_name') }} *</label>
|
||||
<input v-model="form.last_name" type="text" class="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text" />
|
||||
<p v-if="errors.last_name" class="mt-1 text-xs text-red-500">{{ errors.last_name }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.first_name') }}</label>
|
||||
<input v-model="form.first_name" type="text" class="w-full rounded-lg border border-gray-300 px-3 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text" />
|
||||
<p v-if="errors.first_name" class="mt-1 text-xs text-red-500">{{ errors.first_name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Phones -->
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.phones') }}</label>
|
||||
<button type="button" @click="addPhone" class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">+ {{ t('customer.add_phone') }}</button>
|
||||
</div>
|
||||
<div v-for="(phone, index) in form.phones" :key="index" class="mb-2 flex items-center gap-2">
|
||||
<select v-model="phone.prefix" class="w-20 rounded-lg border border-gray-300 px-2 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text">
|
||||
<option v-for="p in prefixOptions" :key="p" :value="p">+{{ p }}</option>
|
||||
</select>
|
||||
<input v-model="phone.phone" type="text" :placeholder="t('customer.phone_number')" class="flex-1 rounded-lg border border-gray-300 px-3 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text" />
|
||||
<select v-model="phone.label" class="w-24 rounded-lg border border-gray-300 px-2 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text">
|
||||
<option v-for="l in phoneLabels" :key="l" :value="l">{{ t(`customer.label_${l}`) }}</option>
|
||||
</select>
|
||||
<button type="button" @click="setPrimaryPhone(index)" :class="['rounded px-2 py-1 text-xs', phone.is_primary ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'text-gray-500']">
|
||||
{{ phone.is_primary ? t('customer.primary') : t('customer.set_primary') }}
|
||||
</button>
|
||||
<button v-if="form.phones.length > 1" type="button" @click="removePhone(index)" class="text-red-500 hover:text-red-600">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Emails -->
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.emails') }}</label>
|
||||
<button type="button" @click="addEmail" class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">+ {{ t('customer.add_email') }}</button>
|
||||
</div>
|
||||
<div v-for="(email, index) in form.emails" :key="index" class="mb-2 flex items-center gap-2">
|
||||
<input v-model="email.email" type="email" :placeholder="t('customer.email_address')" class="flex-1 rounded-lg border border-gray-300 px-3 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text" />
|
||||
<select v-model="email.label" class="w-24 rounded-lg border border-gray-300 px-2 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text">
|
||||
<option v-for="l in emailLabels" :key="l" :value="l">{{ t(`customer.label_${l}`) }}</option>
|
||||
</select>
|
||||
<button type="button" @click="setPrimaryEmail(index)" :class="['rounded px-2 py-1 text-xs', email.is_primary ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'text-gray-500']">
|
||||
{{ email.is_primary ? t('customer.primary') : t('customer.set_primary') }}
|
||||
</button>
|
||||
<button v-if="form.emails.length > 1" type="button" @click="removeEmail(index)" class="text-red-500 hover:text-red-600">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Companies -->
|
||||
<div>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.companies') }}</label>
|
||||
<button type="button" @click="addCompany" class="text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400">+ {{ t('customer.add_company') }}</button>
|
||||
</div>
|
||||
<div v-for="(company, index) in form.companies" :key="index" class="mb-3 rounded-lg border border-gray-200 p-3 dark:border-dk-muted">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<button type="button" @click="setPrimaryCompany(index)" :class="['rounded px-2 py-1 text-xs', company.is_primary ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400' : 'text-gray-500']">
|
||||
{{ company.is_primary ? t('customer.primary') : t('customer.set_primary') }}
|
||||
</button>
|
||||
<button v-if="form.companies.length > 1" type="button" @click="removeCompany(index)" class="text-red-500 hover:text-red-600">
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<input v-model="company.company_name" type="text" :placeholder="t('customer.company_name')" class="rounded-lg border border-gray-300 px-3 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text" />
|
||||
<input v-model="company.department" type="text" :placeholder="t('customer.department')" class="rounded-lg border border-gray-300 px-3 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text" />
|
||||
<input v-model="company.position" type="text" :placeholder="t('customer.position')" class="rounded-lg border border-gray-300 px-3 py-2 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errors.submit" class="text-sm text-red-500">{{ errors.submit }}</p>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" @click="$emit('close')" class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-dk-muted dark:text-dk-text">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" :disabled="loading" class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50">
|
||||
{{ loading ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,276 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { customerApi } from '@/api/customer'
|
||||
import AppToast from '@/components/AppToast.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import CustomerFormModal from './CustomerFormModal.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const customers = ref([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
|
||||
const showAddModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const editingCustomer = ref(null)
|
||||
const deletingCustomer = ref(null)
|
||||
|
||||
const toast = ref({ show: false, message: '', type: 'success' })
|
||||
|
||||
// 客户列表
|
||||
async function fetchCustomers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await customerApi.list({
|
||||
page: currentPage.value,
|
||||
page_size: pageSize.value,
|
||||
search: searchQuery.value,
|
||||
})
|
||||
if (res.errCode === 0) {
|
||||
customers.value = res.data.customers || []
|
||||
total.value = res.data.total || 0
|
||||
}
|
||||
} catch {
|
||||
showToast(t('message.error'), 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
currentPage.value = 1
|
||||
fetchCustomers()
|
||||
}
|
||||
|
||||
function onPageChange(page) {
|
||||
currentPage.value = page
|
||||
fetchCustomers()
|
||||
}
|
||||
|
||||
// 新增客户
|
||||
function openAddModal() {
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
function onCustomerAdded() {
|
||||
showAddModal.value = false
|
||||
showToast(t('message.add_success'), 'success')
|
||||
fetchCustomers()
|
||||
}
|
||||
|
||||
// 编辑客户
|
||||
function openEditModal(customer) {
|
||||
editingCustomer.value = customer
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
function onCustomerUpdated() {
|
||||
showEditModal.value = false
|
||||
editingCustomer.value = null
|
||||
showToast(t('message.update_success'), 'success')
|
||||
fetchCustomers()
|
||||
}
|
||||
|
||||
// 删除客户
|
||||
function confirmDelete(customer) {
|
||||
deletingCustomer.value = customer
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deletingCustomer.value) return
|
||||
try {
|
||||
const res = await customerApi.delete({ id: deletingCustomer.value.id })
|
||||
if (res.errCode === 0) {
|
||||
showToast(t('message.delete_success'), 'success')
|
||||
fetchCustomers()
|
||||
} else {
|
||||
showToast(res.errMsg || t('message.error'), 'error')
|
||||
}
|
||||
} catch {
|
||||
showToast(t('message.error'), 'error')
|
||||
} finally {
|
||||
showDeleteConfirm.value = false
|
||||
deletingCustomer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function showToast(message, type = 'success') {
|
||||
toast.value = { show: true, message, type }
|
||||
setTimeout(() => (toast.value.show = false), 3000)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchCustomers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 p-6 dark:bg-dk-base">
|
||||
<div class="mx-auto max-w-6xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-dk-text">{{ t('customer.title') }}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dk-subtle">{{ t('customer.subtitle') }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="openAddModal"
|
||||
class="flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ t('customer.add') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="mb-4 flex gap-3">
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
@keyup.enter="onSearch"
|
||||
type="text"
|
||||
:placeholder="t('customer.search_placeholder')"
|
||||
class="w-full rounded-lg border border-gray-300 px-4 py-2 pl-10 text-sm focus:border-blue-500 focus:outline-none dark:border-dk-muted dark:bg-dk-card dark:text-dk-text"
|
||||
/>
|
||||
<svg class="absolute left-3 top-2.5 h-4 w-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<button
|
||||
@click="onSearch"
|
||||
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-card dark:text-dk-text"
|
||||
>
|
||||
{{ t('common.search') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Customer List -->
|
||||
<div class="rounded-lg border border-gray-200 bg-white dark:border-dk-muted dark:bg-dk-card">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-gray-200 bg-gray-50 dark:border-dk-muted dark:bg-dk-muted">
|
||||
<tr>
|
||||
<th class="px-4 py-3 font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.name') }}</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.salutation') }}</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.phone') }}</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.email') }}</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.company') }}</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-700 dark:text-dk-subtle">{{ t('customer.created_at') }}</th>
|
||||
<th class="px-4 py-3 font-medium text-gray-700 dark:text-dk-subtle">{{ t('common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="customer in customers"
|
||||
:key="customer.id"
|
||||
class="border-b border-gray-100 hover:bg-gray-50 dark:border-dk-muted dark:hover:bg-dk-muted"
|
||||
>
|
||||
<td class="px-4 py-3">
|
||||
<router-link
|
||||
:to="`/customer/detail/${customer.id}`"
|
||||
class="font-medium text-blue-600 hover:text-blue-700 hover:underline dark:text-blue-400"
|
||||
>
|
||||
{{ (customer.last_name || '') + (customer.first_name ? ' ' + customer.first_name : '') }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-dk-subtle">{{ customer.title ? t(`customer.salutation_${customer.title.toLowerCase()}`) : '-' }}</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-dk-subtle">{{ customer.primary_phone || '-' }}</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-dk-subtle">{{ customer.primary_email || '-' }}</td>
|
||||
<td class="px-4 py-3 text-gray-600 dark:text-dk-subtle">{{ customer.primary_company || '-' }}</td>
|
||||
<td class="px-4 py-3 text-gray-500 dark:text-dk-subtle">{{ new Date(customer.created_at).toLocaleString() }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="customer.edit"
|
||||
@click="openEditModal(customer)"
|
||||
class="text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
>
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="customer.edit"
|
||||
@click="confirmDelete(customer)"
|
||||
class="text-red-600 hover:text-red-700 dark:text-red-400"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="customers.length === 0 && !loading">
|
||||
<td colspan="7" class="px-4 py-8 text-center text-gray-500 dark:text-dk-subtle">
|
||||
{{ t('customer.no_data') }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div v-if="total > pageSize" class="flex items-center justify-between border-t border-gray-200 px-4 py-3 dark:border-dk-muted">
|
||||
<div class="text-sm text-gray-500 dark:text-dk-subtle">
|
||||
{{ t('common.total') }} {{ total }} {{ t('common.items') }}
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
v-for="page in Math.ceil(total / pageSize)"
|
||||
:key="page"
|
||||
@click="onPageChange(page)"
|
||||
:class="[
|
||||
'rounded px-3 py-1 text-sm',
|
||||
currentPage === page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-dk-muted dark:bg-dk-card dark:text-dk-text',
|
||||
]"
|
||||
>
|
||||
{{ page }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Modal -->
|
||||
<CustomerFormModal
|
||||
v-if="showAddModal"
|
||||
:title="t('customer.add_title')"
|
||||
@close="showAddModal = false"
|
||||
@submit="onCustomerAdded"
|
||||
/>
|
||||
|
||||
<!-- Edit Modal -->
|
||||
<CustomerFormModal
|
||||
v-if="showEditModal"
|
||||
:title="t('customer.edit_title')"
|
||||
:customer="editingCustomer"
|
||||
@close="showEditModal = false"
|
||||
@submit="onCustomerUpdated"
|
||||
/>
|
||||
|
||||
<!-- Delete Confirm -->
|
||||
<ConfirmDialog
|
||||
v-model="showDeleteConfirm"
|
||||
:title="t('customer.delete_title')"
|
||||
:message="t('customer.delete_confirm')"
|
||||
@confirm="doDelete"
|
||||
danger
|
||||
/>
|
||||
|
||||
<!-- Toast -->
|
||||
<AppToast
|
||||
v-if="toast.show"
|
||||
:message="toast.message"
|
||||
:type="toast.type"
|
||||
@close="toast.show = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user