feat: 发票管理

This commit is contained in:
小屁孩 committed 2026-01-31 14:05:06 +08:00
1 parent fa056ae272
commit 2032950bb7
18 files changed
+2300 -325

No files matched your search

+38 -32
View File
@@ -3,32 +3,32 @@ import Axios from './https'
// 发票详情接口类型定义
export interface Invoice {
id: string;
invoiceNo: string;
user: string;
userType: string;
userTypeLabel: string;
orderNo: string;
time: string;
status: 'pending' | 'success' | 'rejected' | 'completed';
statusLabel: string;
invoice_no: string;
applicant_name: string;
applicant_phone: string;
receiver_email: string;
order_no: string;
order_id: string;
order_package_name: string;
created_at: string;
updated_at: string;
status: 'pending' | 'completed' | 'rejected';
status_display: string;
amount: number;
invoiceTitle: string;
taxId?: string;
title: string;
taxpayer_id?: string;
content?: string;
remark?: string;
// 详情字段
applyTime?: string;
phone?: string;
email?: string;
invoiceType?: string;
rejectReason?: string;
rejectTime?: string;
rejectUser?: string;
finishTime?: string;
approveUser?: string;
approveTime?: string;
fileName?: string;
fileSize?: string;
invoice_type: string;
invoice_type_display: string;
// 审核/上传相关
reject_reason?: string;
rejected_at?: string;
rejected_by?: string;
uploaded_at?: string;
uploaded_by?: string;
uploader_name?: string;
invoice_file_url?: string;
}
// 发票查询请求参数
@@ -55,27 +55,29 @@ export interface InvoiceQueryResponse {
message: string;
timestamp: string;
data: {
list: Invoice[];
total: number;
invoices: Invoice[];
total_count: number;
page: number;
page_size: number;
total_pages: number;
total_pages?: number;
};
}
// 发票统计数据接口响应
export interface InvoiceSummaryResponse {
code: string;
message: string;
timestamp: string;
data: {
total: number;
pending: number;
completed: number;
rejected: number;
total_count: number;
pending_count: number;
completed_count: number;
rejected_count: number;
};
}
/**
* 查询发票列表
*/
@@ -101,8 +103,12 @@ export function getInvoiceDetail(id: string): Promise<ApiResponse<Invoice>> {
/**
* 上传发票文件
*/
export function uploadInvoice(id: string, data: any): Promise<ApiResponse> {
return Axios.post(`/api/v1/invoices/${id}/upload`, data)
export function uploadInvoice(data: FormData): Promise<ApiResponse> {
return Axios.post('/api/v1/invoices/upload', data, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
/**
@@ -18,6 +18,7 @@ import DashboardIcon from './svg/DashboardIcon.vue'
import CustomersIcon from './svg/CustomersIcon.vue'
import LicensesIcon from './svg/LicensesIcon.vue'
import InvoicesIcon from './svg/InvoicesIcon.vue'
import EnterpriseLeadsIcon from './svg/EnterpriseLeadsIcon.vue'
import RolesIcon from './svg/RolesIcon.vue'
import UsersIcon from './svg/UsersIcon.vue'
@@ -35,6 +36,7 @@ const iconMap = {
'customers': CustomersIcon,
'licenses': LicensesIcon,
'invoices': InvoicesIcon,
'enterprise-leads': EnterpriseLeadsIcon,
'roles': RolesIcon,
'users': UsersIcon
}
@@ -0,0 +1,5 @@
<template>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 3.5C3 2.67157 3.67157 2 4.5 2H11.5C12.3284 2 13 2.67157 13 3.5V6H15.5C16.3284 6 17 6.67157 17 7.5V16.5C17 17.3284 16.3284 18 15.5 18H4.5C3.67157 18 3 17.3284 3 16.5V3.5ZM4.5 3.5V16.5H15.5V7.5H12V3.5H4.5ZM6.5 6H9.5C9.77614 6 10 6.22386 10 6.5C10 6.77614 9.77614 7 9.5 7H6.5C6.22386 7 6 6.77614 6 6.5C6 6.22386 6.22386 6 6.5 6ZM6.5 9H11.5C11.7761 9 12 9.22386 12 9.5C12 9.77614 11.7761 10 11.5 10H6.5C6.22386 10 6 9.77614 6 9.5C6 9.22386 6.22386 9 6.5 9ZM6.5 12H11.5C11.7761 12 12 12.2239 12 12.5C12 12.7761 11.7761 13 11.5 13H6.5C6.22386 13 6 12.7761 6 12.5C6 12.2239 6.22386 12 6.5 12Z" />
</svg>
</template>
@@ -64,8 +64,10 @@ const { t } = useI18n()
const defaultNavItems = computed(() => [
{ id: "dashboard", label: t('navigation.menu.dashboard'), href: "/dashboard", icon: "dashboard" },
{ id: "customers", label: t('navigation.menu.customers'), href: "/customers", icon: "customers" },
{ id: "enterprise-leads", label: t('navigation.menu.enterpriseLeads'), href: "/enterprise-leads", icon: "enterprise-leads" },
{ id: "licenses", label: t('navigation.menu.licenses'), href: "/licenses", icon: "licenses" },
{ id: "invoices", label: t('navigation.menu.invoices'), href: "/invoices", icon: "invoices" }
{ id: "invoices", label: t('navigation.menu.invoices'), href: "/invoices", icon: "invoices" },
{ id: "packages", label: t('navigation.menu.packages'), href: "/packages", icon: "packages" }
// 暂时隐藏角色和系统用户菜单,后续版本再开启
// { id: "roles", label: t('navigation.menu.roles'), href: "/roles", icon: "roles" },
// { id: "users", label: t('navigation.menu.users'), href: "/users", icon: "users" }
+144 -4
View File
@@ -54,7 +54,9 @@
"menu": {
"dashboard": "Dashboard",
"customers": "Customer Management",
"enterpriseLeads": "Enterprise Leads",
"licenses": "License Management",
"invoices": "Invoice Management",
"roles": "Roles & Permissions",
"users": "System Users"
},
@@ -75,10 +77,11 @@
"breadcrumb": {
"dashboard": "Dashboard",
"customers": "Customer Management",
"licenses": "License Management",
"invoices": "Invoice Management",
"invoiceDetail": "Invoice Detail",
"licenseList": "License List",
"enterpriseLeads": "Enterprise Leads",
"licenses": "License Management",
"invoices": "Invoice Management",
"invoiceDetail": "Invoice Detail",
"licenseList": "License List",
"createLicense": "Create License",
"viewLicense": "View License",
"roles": "Roles & Permissions",
@@ -90,6 +93,143 @@
"unknown": "Unknown Page"
}
},
"enterpriseLeads": {
"title": "Enterprise Leads",
"stats": {
"total": "Total Leads",
"pending": "Pending",
"contacting": "Contacted",
"completed": "Converted",
"rejected": "Rejected"
},
"filter": {
"searchPlaceholder": "Search by keywords",
"search": "Search",
"statusPlaceholder": "All Status",
"allStatus": "All Status"
},
"table": {
"title": "Enterprise Leads List",
"id": "Lead ID",
"company": "Company Name",
"contact": "Contact",
"phone": "Phone",
"submittedAt": "Submitted At",
"status": "Status",
"actions": "Actions"
},
"status": {
"pending": "Pending",
"contacting": "Contacted",
"completed": "Converted",
"rejected": "Rejected"
},
"actions": {
"view": "View",
"edit": "Edit",
"delete": "Delete",
"refresh": "Refresh"
}
},
"invoices": {
"title": "Invoice Management",
"detailTitle": "Invoice Application Detail",
"stats": {
"all": "Total Applications",
"pending": "Pending",
"success": "Invoiced",
"rejected": "Rejected"
},
"filter": {
"searchLabel": "Search Invoice/Order No:",
"searchPlaceholder": "Enter invoice or order number",
"statusLabel": "Status:",
"allStatus": "All Status",
"pending": "Pending",
"success": "Invoiced",
"rejected": "Rejected",
"timeLabel": "Apply Time:",
"reset": "Reset",
"query": "Filter"
},
"table": {
"invoiceNo": "Invoice App No",
"user": "User",
"orderNo": "Order No",
"applyTime": "Apply Time",
"status": "Status",
"amount": "Amount",
"operation": "Operation",
"listTitle": "Invoice Application List"
},
"actions": {
"view": "View",
"upload": "Upload",
"reject": "Reject",
"back": "Back",
"submit": "Submit",
"cancel": "Cancel",
"confirmUpload": "Confirm Upload",
"confirmReject": "Confirm Reject"
},
"detail": {
"basicInfo": "Basic Information",
"invoiceInfo": "Invoice Information",
"rejectInfo": "Reject Information",
"approveInfo": "Approve Information",
"invoiceNo": "Invoice App No",
"orderNo": "Related Order No",
"applyTime": "Apply Time",
"status": "Status",
"userInfo": "User Info",
"phone": "Phone",
"amount": "Amount",
"email": "Email",
"invoiceType": "Invoice Type",
"invoiceTitle": "Invoice Title",
"taxId": "Tax ID",
"content": "Content",
"remark": "Remark",
"rejectReason": "Reject Reason",
"rejectTime": "Reject Time",
"rejectUser": "Reject By",
"finishTime": "Finish Time",
"approveUser": "Upload By",
"fileName": "Invoice File",
"approveTime": "Upload Time"
},
"dialog": {
"uploadTitle": "Upload Invoice File",
"rejectTitle": "Reject Invoice Application",
"rejectAlert": "After rejection, the user will be notified and need to resubmit.",
"reasonLabel": "Reject Reason",
"reasonTip": "Please select or enter the specific reason",
"reasonPlaceholder": "Please select reason",
"remarkLabel": "Details",
"remarkPlaceholder": "Enter detailed instructions to help user modify information",
"uploadLabel": "Upload Invoice File",
"uploadDraggerText": "Click to upload / drag to this area",
"uploadTip": "Support PDF format. Max 10MB",
"finishTimeLabel": "Finish Time",
"finishTimePlaceholder": "Select date and time",
"remarkPlaceholderUpload": "Enter remark",
"reasons": {
"title_error": "Incorrect title info",
"tax_id_error": "Incorrect tax ID",
"attachment_error": "Unclear or wrong attachment",
"other": "Other reasons"
}
},
"messages": {
"uploadSuccess": "Invoice uploaded successfully",
"rejectSuccess": "Application rejected",
"fetchError": "Failed to fetch data",
"fetchDetailError": "Failed to fetch details"
}
},
"common": {
"cancel": "Cancel",
"confirm": "Confirm"
+139
View File
@@ -54,7 +54,9 @@
"menu": {
"dashboard": "ダッシュボード",
"customers": "顧客管理",
"enterpriseLeads": "企業リード",
"licenses": "ライセンス管理",
"invoices": "発票管理",
"roles": "ロールと権限",
"users": "システムユーザー"
},
@@ -75,6 +77,7 @@
"breadcrumb": {
"dashboard": "ダッシュボード",
"customers": "顧客管理",
"enterpriseLeads": "企業リード",
"licenses": "ライセンス管理",
"invoices": "発票管理",
"invoiceDetail": "発票申請の詳細",
@@ -90,6 +93,142 @@
"unknown": "未知ページ"
}
},
"enterpriseLeads": {
"title": "企業リード",
"stats": {
"total": "リード総数",
"pending": "未連絡",
"contacting": "連絡済み",
"completed": "成約済み",
"rejected": "失注"
},
"filter": {
"searchPlaceholder": "キーワードを入力して検索",
"search": "検索",
"statusPlaceholder": "全ステータス",
"allStatus": "全ステータス"
},
"table": {
"title": "企業リード一覧",
"id": "リードID",
"company": "企業名",
"contact": "担当者",
"phone": "連絡先電話",
"submittedAt": "提出時間",
"status": "ステータス",
"actions": "操作"
},
"status": {
"pending": "未連絡",
"contacting": "連絡済み",
"completed": "成約済み",
"rejected": "失注"
},
"actions": {
"view": "表示",
"edit": "編集",
"delete": "削除",
"refresh": "更新"
}
},
"invoices": {
"title": "発票管理",
"detailTitle": "発票申請の詳細",
"stats": {
"all": "全申請数",
"pending": "保留中",
"success": "発行済み",
"rejected": "却下済み"
},
"filter": {
"searchLabel": "発票番号/注文番号で検索:",
"searchPlaceholder": "発票番号または注文番号を入力",
"statusLabel": "ステータス:",
"allStatus": "全ステータス",
"pending": "保留中",
"success": "発行済み",
"rejected": "却下済み",
"timeLabel": "申請時間:",
"reset": "リセット",
"query": "検索"
},
"table": {
"invoiceNo": "発票申請番号",
"user": "ユーザー",
"orderNo": "注文番号",
"applyTime": "申請時間",
"status": "ステータス",
"amount": "金額",
"operation": "操作",
"listTitle": "発票申請一覧"
},
"actions": {
"view": "表示",
"upload": "アップロード",
"reject": "却下",
"back": "戻る",
"submit": "送信",
"cancel": "キャンセル",
"confirmUpload": "アップロード確定",
"confirmReject": "却下確定"
},
"detail": {
"basicInfo": "基本情報",
"invoiceInfo": "発票情報",
"rejectInfo": "却下情報",
"approveInfo": "承認情報",
"invoiceNo": "発票申請番号",
"orderNo": "関連注文番号",
"applyTime": "申請時間",
"status": "ステータス",
"userInfo": "ユーザー情報",
"phone": "電話番号",
"amount": "金額",
"email": "メールアドレス",
"invoiceType": "発票タイプ",
"invoiceTitle": "発票名義",
"taxId": "納税者識別番号",
"content": "内容",
"remark": "備考",
"rejectReason": "却下理由",
"rejectTime": "却下時間",
"rejectUser": "却下担当者",
"finishTime": "完了時間",
"approveUser": "アップロード担当者",
"fileName": "ファイル名",
"approveTime": "アップロード時間"
},
"dialog": {
"uploadTitle": "発票ファイルのアップロード",
"rejectTitle": "発票申請の却下",
"rejectAlert": "却下後、ユーザーに通知され、再申請が必要になります。",
"reasonLabel": "却下理由",
"reasonTip": "具体的な却下理由を選択または入力してください",
"reasonPlaceholder": "理由を選択してください",
"remarkLabel": "詳細説明",
"remarkPlaceholder": "ユーザーが情報を修正しやすくなるよう、詳細な説明を入力してください",
"uploadLabel": "発票ファイルのアップロード",
"uploadDraggerText": "クリックしてアップロード / ドラッグ&ドロップ",
"uploadTip": "PDF形式に対応。最大10MB",
"finishTimeLabel": "完了時間",
"finishTimePlaceholder": "日時を選択",
"remarkPlaceholderUpload": "備考を入力してください",
"reasons": {
"title_error": "名義情報の誤り",
"tax_id_error": "納税者識別番号の誤り",
"attachment_error": "添付ファイルが不鮮明または誤り",
"other": "その他の理由"
}
},
"messages": {
"uploadSuccess": "アップロードに成功しました",
"rejectSuccess": "申請を却下しました",
"fetchError": "データの取得に失敗しました",
"fetchDetailError": "詳細の取得に失敗しました"
}
},
"common": {
"cancel": "キャンセル",
"confirm": "確認"
+200 -4
View File
@@ -54,8 +54,10 @@
"menu": {
"dashboard": "仪表盘",
"customers": "客户管理",
"enterpriseLeads": "企业线索",
"licenses": "授权管理",
"invoices": "发票管理",
"packages": "套餐管理",
"roles": "角色权限",
"users": "系统用户"
},
@@ -76,10 +78,12 @@
"breadcrumb": {
"dashboard": "仪表盘",
"customers": "客户管理",
"licenses": "授权管理",
"invoices": "发票管理",
"invoiceDetail": "发票申请详情",
"licenseList": "授权列表",
"enterpriseLeads": "企业线索",
"licenses": "授权管理",
"invoices": "发票管理",
"packages": "套餐管理",
"invoiceDetail": "发票申请详情",
"licenseList": "授权列表",
"createLicense": "创建授权",
"viewLicense": "查看授权",
"roles": "角色权限",
@@ -91,6 +95,198 @@
"unknown": "未知页面"
}
},
"enterpriseLeads": {
"title": "企业线索",
"stats": {
"total": "线索总数",
"pending": "待联系",
"contacting": "已联系",
"completed": "已成交",
"rejected": "已失效"
},
"filter": {
"searchPlaceholder": "请输入关键词搜索",
"search": "搜索",
"statusPlaceholder": "全部状态",
"allStatus": "全部状态"
},
"table": {
"title": "企业线索列表",
"id": "线索ID",
"company": "企业名称",
"contact": "联系人",
"phone": "联系电话",
"submittedAt": "提交时间",
"status": "状态",
"actions": "操作"
},
"status": {
"pending": "待联系",
"contacting": "已联系",
"completed": "已成交",
"rejected": "已失效"
},
"actions": {
"view": "查看",
"edit": "编辑",
"delete": "删除",
"refresh": "刷新"
},
"detail": {
"title": "企业线索详情 - {company}",
"basicInfo": "企业基本信息",
"requirementInfo": "需求信息",
"followUpInfo": "跟进信息",
"email": "邮箱地址",
"description": "具体需求描述",
"otherInfo": "其他补充信息",
"followUpDate": "跟进日期",
"followUpRecord": "跟进记录",
"internalRemark": "内部备注"
},
"edit": {
"title": "编辑企业线索 - {company}",
"save": "保存更改",
"datePlaceholder": "选择日期",
"recordPlaceholder": "记录与客户的沟通情况,反馈,下一步计划...",
"remarkPlaceholder": "内部使用的备注信息,客户不可见",
"statusRequired": "请选择状态"
}
},
"packages": {
"title": "套餐管理",
"table": {
"title": "价格套餐管理",
"id": "套餐ID",
"name": "套餐名称",
"price": "价格",
"cycle": "周期",
"createdAt": "创建时间",
"updatedAt": "更新时间",
"status": "状态",
"actions": "操作"
},
"status": {
"enabled": "已启用",
"disabled": "已禁用"
},
"edit": {
"title": "编辑套餐",
"name": "套餐名称",
"type": "套餐类型",
"price": "价格",
"cycle": "周期/描述",
"description": "套餐描述",
"features": "套餐特性",
"status": "状态",
"sort": "显示顺序",
"remark": "备注",
"save": "保存更改",
"descPlaceholder": "体验完整功能,适合新用户试用评估",
"remarkPlaceholder": "请输入备注",
"featurePlaceholder": "请输入特性内容"
}
},
"invoices": {
"title": "发票管理",
"detailTitle": "发票申请详情",
"stats": {
"all": "全部发票申请",
"pending": "待处理申请",
"success": "已开票",
"rejected": "已驳回"
},
"filter": {
"searchLabel": "搜索发票号/订单号:",
"searchPlaceholder": "输入发票号或订单号",
"statusLabel": "开票状态:",
"allStatus": "全部状态",
"pending": "待处理",
"success": "已开票",
"rejected": "已驳回",
"timeLabel": "申请时间:",
"reset": "重置",
"query": "筛选"
},
"table": {
"invoiceNo": "发票申请号",
"user": "用户",
"orderNo": "订单号",
"applyTime": "申请时间",
"status": "开票状态",
"amount": "发票金额",
"operation": "操作",
"listTitle": "发票申请列表"
},
"actions": {
"view": "查看",
"upload": "上传",
"reject": "驳回",
"back": "返回",
"submit": "提交",
"cancel": "取消",
"confirmUpload": "确认上传",
"confirmReject": "确认驳回"
},
"detail": {
"basicInfo": "基本信息",
"invoiceInfo": "开票信息",
"rejectInfo": "驳回信息",
"approveInfo": "通过信息",
"invoiceNo": "发票申请号",
"orderNo": "关联订单号",
"applyTime": "申请时间",
"status": "开票状态",
"userInfo": "用户信息",
"phone": "联系电话",
"amount": "开票金额",
"email": "收票邮箱",
"invoiceType": "发票类型",
"invoiceTitle": "发票抬头",
"taxId": "纳税人识别号",
"content": "开票内容",
"remark": "备注信息",
"rejectReason": "驳回原因",
"rejectTime": "驳回时间",
"rejectUser": "驳回人员",
"finishTime": "开票完成时间",
"approveUser": "上传人员",
"fileName": "发票文件",
"approveTime": "上传时间"
},
"dialog": {
"uploadTitle": "上传发票文件",
"rejectTitle": "驳回发票申请",
"rejectAlert": "驳回后,用户将收到通知并需要重新提交申请。",
"reasonLabel": "驳回原因",
"reasonTip": "请选择或输入驳回的具体原因",
"reasonPlaceholder": "请选择驳回原因",
"remarkLabel": "详细说明",
"remarkPlaceholder": "请输入详细的驳回说明,帮助用户更准确地修改信息",
"uploadLabel": "上传发票文件",
"uploadDraggerText": "点击上传 / 拖拽到此区域",
"uploadTip": "支持PDF格式。最大文件大小10MB",
"finishTimeLabel": "开票完成时间",
"finishTimePlaceholder": "选择日期时间",
"remarkPlaceholderUpload": "输入备注信息",
"reasons": {
"title_error": "发票抬头信息有误",
"tax_id_error": "纳税人识别号不正确",
"attachment_error": "附件不清晰或有误",
"other": "其他原因"
}
},
"messages": {
"uploadSuccess": "发票上传成功",
"rejectSuccess": "发票申请已驳回",
"fetchError": "获取数据失败",
"fetchDetailError": "获取详情失败"
}
},
"common": {
"cancel": "取消",
"confirm": "确定"
+10
View File
@@ -61,11 +61,21 @@ const routes: Array<RouteRecordRaw> = [
}
]
},
{
path: "/enterprise-leads",
component: () => import("@/views/EnterpriseLeads/index.vue"),
meta: { title: "企业线索", requiresAuth: true }
},
{
path: "/invoices",
component: () => import("@/views/Invoices/index.vue"),
meta: { title: "发票管理", requiresAuth: true }
},
{
path: "/packages",
component: () => import("@/views/Packages/index.vue"),
meta: { title: "套餐管理", requiresAuth: true }
},
{
path: "/invoices/detail/:id",
name: "invoice-detail",
+2 -1
View File
@@ -22,6 +22,7 @@ export interface BreadcrumbItem {
const routeBreadcrumbKeys: Record<string, string[]> = {
'/dashboard': ['dashboard'],
'/customers': ['customers'],
'/enterprise-leads': ['enterpriseLeads'],
'/licenses': ['licenses'],
'/invoices': ['invoices'],
'/invoices/detail': ['invoices', 'invoiceDetail'],
@@ -121,7 +122,7 @@ export function useBreadcrumb() {
// 根据路径段获取标题
function getSegmentTitle(segment: string): string {
const knownSegments = ['dashboard', 'customers', 'licenses', 'roles', 'users', 'login', 'settings', 'profile']
const knownSegments = ['dashboard', 'customers', 'enterprise-leads', 'licenses', 'roles', 'users', 'login', 'settings', 'profile']
if (knownSegments.includes(segment)) {
return t(`navigation.breadcrumb.${segment}`)
@@ -0,0 +1,234 @@
<template>
<div class="dialog_box">
<el-dialog v-model="visible" :title="t('enterpriseLeads.detail.title', { company: data?.company })"
width="800px" class="lead-detail-dialog" destroy-on-close>
<div v-if="data" class="detail-content">
<!-- 企业基本信息 -->
<div class="detail-section">
<h3 class="section-title">{{ t('enterpriseLeads.detail.basicInfo') }}</h3>
<div class="info-grid">
<div class="info-item">
<span class="label">{{ t('enterpriseLeads.table.company') }}:</span>
<span class="value">{{ data.company }}</span>
</div>
<div class="info-item">
<span class="label">{{ t('enterpriseLeads.table.contact') }}:</span>
<span class="value">{{ data.contact }}</span>
</div>
<div class="info-item">
<span class="label">{{ t('enterpriseLeads.table.phone') }}:</span>
<span class="value">{{ data.phone }}</span>
</div>
</div>
</div>
<!-- 需求信息 -->
<div class="detail-section">
<h3 class="section-title">{{ t('enterpriseLeads.detail.requirementInfo') }}</h3>
<div class="info-list">
<div class="info-item">
<span class="label">{{ t('enterpriseLeads.detail.email') }}:</span>
<span class="value">{{ data.email || 'wangming@sh-smart.com' }}</span>
</div>
<div class="info-item block">
<span class="label">{{ t('enterpriseLeads.detail.description') }}:</span>
<span class="value">{{ data.description ||
'我们需要为生产线上的500台设备部署专业版授权,同时为办公室的200台电脑部署基础版授权。需要支持离线授权功能,部署时间为下个月初。' }}</span>
</div>
<div class="info-item block">
<span class="label">{{ t('enterpriseLeads.detail.otherInfo') }}:</span>
<span class="value">{{ data.otherInfo || '预算范围在50-80万之间,决策时间约2周。' }}</span>
</div>
</div>
</div>
<!-- 跟进信息 -->
<div class="detail-section">
<h3 class="section-title">{{ t('enterpriseLeads.detail.followUpInfo') }}</h3>
<div class="info-grid">
<div class="info-item">
<span class="label">{{ t('enterpriseLeads.table.submittedAt') }}:</span>
<span class="value">{{ data.submittedAt }}</span>
</div>
<div class="info-item">
<span class="label">{{ t('enterpriseLeads.table.status') }}:</span>
<span class="status-value" :class="data.status">{{
t(`enterpriseLeads.status.${data.status}`) }}</span>
</div>
<div class="info-item">
<span class="label">{{ t('enterpriseLeads.detail.followUpDate') }}:</span>
<span class="value">{{ data.followUpDate || '2025-12-21' }}</span>
</div>
</div>
<div class="info-list mt-12">
<div class="info-item block">
<span class="label">{{ t('enterpriseLeads.detail.followUpRecord') }}:</span>
<span class="value">{{ data.followUpRecord || '已初步沟通,客户对专业版的AI功能非常感兴趣,需要提供详细的功能演示。'
}}</span>
</div>
<div class="info-item block">
<span class="label">{{ t('enterpriseLeads.detail.internalRemark') }}:</span>
<span class="value">{{ data.internalRemark || '高价值客户,需要安排技术专家进行演示。' }}</span>
</div>
</div>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
modelValue: boolean
data: any
}>()
const emit = defineEmits(['update:modelValue'])
const { t } = useI18n()
const visible = ref(props.modelValue)
watch(() => props.modelValue, (val) => {
visible.value = val
})
watch(visible, (val) => {
emit('update:modelValue', val)
})
</script>
<style lang="scss" scoped>
:deep(.el-dialog__headerbtn) {
top: 10px !important;
}
:deep(.el-dialog) {
border-radius: 8px;
overflow: hidden;
padding: 0 !important;
.el-dialog__header {
margin-right: 0;
padding: 20px 24px;
background: linear-gradient(90deg, #00928A 0%, #00D19E 100%) !important;
border-bottom: none;
display: flex;
align-items: center;
.el-dialog__title {
color: #fff !important;
font-size: 18px;
font-weight: 600;
}
.el-dialog__headerbtn {
top: 20px;
.el-dialog__close {
color: #fff !important;
font-size: 20px;
}
&:hover .el-dialog__close {
color: rgba(255, 255, 255, 0.8) !important;
}
}
}
.el-dialog__body {
padding: 24px;
}
}
.detail-content {
padding: 8px 0;
}
.detail-section {
margin-bottom: 24px;
border: 1px solid #E9E9E9;
&:last-child {
margin-bottom: 0;
}
}
.section-title {
font-size: 14px;
font-weight: 600;
color: #333;
margin-bottom: 16px;
padding-left: 0;
position: relative;
background-color: #FAFAFA;
padding: 11px 24px;
box-sizing: border-box;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding: 11px 24px;
box-sizing: border-box;
}
.info-list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 11px 24px;
box-sizing: border-box;
}
.info-item {
display: flex;
font-size: 14px;
line-height: 1.6;
&.block {
flex-direction: row;
align-items: flex-start;
}
.label {
color: #666;
flex-shrink: 0;
width: 100px;
}
.value {
color: #333;
}
.status-value {
font-weight: 500;
&.contacting {
color: #409eff;
}
&.pending {
color: #e6a23c;
}
&.completed {
color: #333;
}
&.rejected {
color: #999;
}
}
}
.mt-12 {
margin-top: 12px;
}
</style>
@@ -0,0 +1,219 @@
<template>
<div>
<el-dialog
v-model="visible"
:title="t('enterpriseLeads.edit.title', { company: data?.company })"
width="800px"
class="lead-edit-dialog"
destroy-on-close
>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-position="top"
class="edit-form"
>
<div class="form-row">
<el-form-item :label="t('enterpriseLeads.table.status')" prop="status" class="flex-1">
<el-select v-model="form.status" class="w-full" :placeholder="t('enterpriseLeads.filter.statusPlaceholder')">
<el-option :label="t('enterpriseLeads.status.pending')" value="pending" />
<el-option :label="t('enterpriseLeads.status.contacting')" value="contacting" />
<el-option :label="t('enterpriseLeads.status.completed')" value="completed" />
<el-option :label="t('enterpriseLeads.status.rejected')" value="rejected" />
</el-select>
</el-form-item>
<el-form-item :label="t('enterpriseLeads.detail.followUpDate')" prop="followUpDate" class="flex-1">
<el-date-picker
v-model="form.followUpDate"
type="date"
class="w-full"
:placeholder="t('enterpriseLeads.edit.datePlaceholder')"
format="YYYY/MM/DD"
value-format="YYYY-MM-DD"
/>
</el-form-item>
</div>
<el-form-item :label="t('enterpriseLeads.detail.followUpRecord')" prop="followUpRecord">
<el-input
v-model="form.followUpRecord"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
:placeholder="t('enterpriseLeads.edit.recordPlaceholder')"
/>
</el-form-item>
<el-form-item :label="t('enterpriseLeads.detail.internalRemark')" prop="internalRemark">
<el-input
v-model="form.internalRemark"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
:placeholder="t('enterpriseLeads.edit.remarkPlaceholder')"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" class="btn-save" @click="handleSave">
{{ t('enterpriseLeads.edit.save') }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import type { FormInstance, FormRules } from 'element-plus'
const props = defineProps<{
modelValue: boolean
data: any
}>()
const emit = defineEmits(['update:modelValue', 'save'])
const { t } = useI18n()
const visible = ref(props.modelValue)
const formRef = ref<FormInstance>()
const form = reactive({
status: '',
followUpDate: '',
followUpRecord: '',
internalRemark: ''
})
const rules = reactive<FormRules>({
status: [{ required: true, message: t('enterpriseLeads.edit.statusRequired'), trigger: 'change' }]
})
watch(() => props.modelValue, (val) => {
visible.value = val
if (val && props.data) {
form.status = props.data.status
form.followUpDate = props.data.followUpDate || '2026-01-14'
form.followUpRecord = props.data.followUpRecord || ''
form.internalRemark = props.data.internalRemark || ''
}
})
watch(visible, (val) => {
emit('update:modelValue', val)
})
const handleSave = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
emit('save', { ...props.data, ...form })
visible.value = false
}
})
}
</script>
<style lang="scss" scoped>
:deep(.el-dialog__headerbtn) {
top: 10px !important;
}
:deep(.el-dialog) {
border-radius: 8px;
overflow: hidden;
padding: 0;
.el-dialog__header {
margin-right: 0;
padding: 20px 24px;
background: linear-gradient(90deg, #00928A 0%, #00D19E 100%) !important;
border-bottom: none;
display: flex;
align-items: center;
.el-dialog__title {
color: #fff !important;
font-size: 18px;
font-weight: 600;
}
.el-dialog__headerbtn {
top: 20px;
.el-dialog__close {
color: #fff !important;
font-size: 20px;
}
&:hover .el-dialog__close {
color: rgba(255, 255, 255, 0.8) !important;
}
}
}
.el-dialog__body {
padding: 24px;
}
.el-dialog__footer {
padding: 0 24px 24px;
border-top: none;
}
}
.edit-form {
.form-row {
display: flex;
gap: 24px;
margin-bottom: 8px;
}
.flex-1 {
flex: 1;
}
:deep(.el-form-item__label) {
font-weight: 500;
color: #333;
padding-bottom: 8px;
}
:deep(.el-input__wrapper), :deep(.el-textarea__inner) {
background-color: #fff;
border-radius: 4px;
}
}
.w-full {
width: 100%;
}
.dialog-footer {
display: flex;
justify-content: center;
gap: 16px;
.el-button {
padding: 10px 32px;
height: 40px;
font-size: 14px;
border-radius: 4px;
}
.btn-save {
background-color: #00a870 !important;
border-color: #00a870 !important;
color: #fff !important;
&:hover {
background-color: #008f5d !important;
border-color: #008f5d !important;
}
}
}
</style>
@@ -0,0 +1,435 @@
<template>
<Layout :page-title="t('enterpriseLeads.title')">
<div class="enterprise-leads">
<div class="stats-grid">
<div v-for="stat in stats" :key="stat.key" class="stat-card">
<div class="stat-info">
<p class="stat-label">{{ t(`enterpriseLeads.stats.${stat.key}`) }}</p>
<p class="stat-value">{{ stat.value.toLocaleString() }}</p>
</div>
<div class="stat-icon" :class="stat.key">
<el-icon><component :is="stat.icon" /></el-icon>
</div>
</div>
</div>
<div class="filter-card">
<div class="filter-row">
<div class="search-group">
<el-input
v-model="filters.keyword"
:placeholder="t('enterpriseLeads.filter.searchPlaceholder')"
class="search-input"
clearable
>
<template #append>
<el-button class="search-btn">
<el-icon><Search /></el-icon>
</el-button>
</template>
</el-input>
</div>
<div class="status-group">
<el-select v-model="filters.status" class="status-select" :placeholder="t('enterpriseLeads.filter.statusPlaceholder')">
<el-option :label="t('enterpriseLeads.filter.allStatus')" value="" />
<el-option :label="t('enterpriseLeads.status.pending')" value="pending" />
<el-option :label="t('enterpriseLeads.status.contacting')" value="contacting" />
<el-option :label="t('enterpriseLeads.status.completed')" value="completed" />
<el-option :label="t('enterpriseLeads.status.rejected')" value="rejected" />
</el-select>
</div>
</div>
</div>
<div class="list-card">
<div class="list-header">
<div class="list-title">
<span class="title-bar"></span>
<span>{{ t('enterpriseLeads.table.title') }}</span>
</div>
<el-button class="refresh-btn" :icon="Refresh" text>
{{ t('enterpriseLeads.actions.refresh') }}
</el-button>
</div>
<el-table
:data="tableData"
stripe
class="leads-table"
:header-cell-style="{
backgroundColor: '#E6F7F3',
color: '#4F4F4F',
fontWeight: '600'
}"
>
<el-table-column prop="id" :label="t('enterpriseLeads.table.id')" width="100" />
<el-table-column prop="company" :label="t('enterpriseLeads.table.company')" min-width="220" />
<el-table-column prop="contact" :label="t('enterpriseLeads.table.contact')" min-width="120" />
<el-table-column prop="phone" :label="t('enterpriseLeads.table.phone')" min-width="140" />
<el-table-column prop="submittedAt" :label="t('enterpriseLeads.table.submittedAt')" min-width="160" />
<el-table-column :label="t('enterpriseLeads.table.status')" min-width="120">
<template #default="{ row }">
<span class="status-tag" :class="row.status">{{ t(`enterpriseLeads.status.${row.status}`) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('enterpriseLeads.table.actions')" width="200" fixed="right">
<template #default="{ row }">
<div class="action-buttons">
<el-button size="small" class="btn-view" @click="handleView(row)">{{ t('enterpriseLeads.actions.view') }}</el-button>
<el-button size="small" class="btn-edit" @click="handleEdit(row)">{{ t('enterpriseLeads.actions.edit') }}</el-button>
<el-button size="small" class="btn-delete">{{ t('enterpriseLeads.actions.delete') }}</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="prev, pager, next, jumper, sizes, total"
:total="total"
background
/>
</div>
</div>
</div>
<LeadDetailDialog
v-model="detailVisible"
:data="selectedLead"
/>
<LeadEditDialog
v-model="editVisible"
:data="selectedLead"
@save="handleUpdate"
/>
</Layout>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Layout from '@/components/common/layout/Layout.vue'
import { Document, Phone, CircleCheck, CircleClose, Refresh, User, OfficeBuilding, Search } from '@element-plus/icons-vue'
import LeadDetailDialog from './components/LeadDetailDialog.vue'
import LeadEditDialog from './components/LeadEditDialog.vue'
const { t } = useI18n()
const stats = ref([
{ key: 'total', value: 21978, icon: OfficeBuilding },
{ key: 'pending', value: 21978, icon: Phone },
{ key: 'contacting', value: 21978, icon: CircleCheck },
{ key: 'completed', value: 21978, icon: User }
])
const filters = ref({
keyword: '',
status: ''
})
const tableData = ref([
{
id: '1001',
company: '上海智能制造有限公司',
contact: '张教授',
phone: '13800138001',
submittedAt: '2024-01-15 10:30',
status: 'contacting'
},
{
id: '1002',
company: '上海智能制造有限公司',
contact: '张教授',
phone: '13800138001',
submittedAt: '2024-01-15 10:30',
status: 'contacting'
},
{
id: '1003',
company: '上海智能制造有限公司',
contact: '张教授',
phone: '13800138001',
submittedAt: '2024-01-15 10:30',
status: 'pending'
},
{
id: '1004',
company: '上海智能制造有限公司',
contact: '张教授',
phone: '13800138001',
submittedAt: '2024-01-15 10:30',
status: 'completed'
},
{
id: '1005',
company: '上海智能制造有限公司',
contact: '张教授',
phone: '13800138001',
submittedAt: '2024-01-15 10:30',
status: 'rejected'
}
])
const page = ref(1)
const pageSize = ref(10)
const total = ref(21978)
const detailVisible = ref(false)
const editVisible = ref(false)
const selectedLead = ref<any>(null)
const handleView = (row: any) => {
selectedLead.value = row
detailVisible.value = true
}
const handleEdit = (row: any) => {
selectedLead.value = row
editVisible.value = true
}
const handleUpdate = (updatedData: any) => {
const index = tableData.value.findIndex(item => item.id === updatedData.id)
if (index !== -1) {
tableData.value[index] = updatedData
}
}
</script>
<style lang="scss" scoped>
.enterprise-leads {
padding: 24px;
background-color: #f0f2f5;
min-height: calc(100vh - 80px);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 24px;
margin-bottom: 24px;
}
.stat-card {
background: #fff;
border-radius: 8px;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
border: none;
height: 100px;
}
.stat-label {
font-size: 14px;
color: #999;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: bold;
color: #00a870;
line-height: 1.2;
}
.stat-icon {
width: 56px;
height: 56px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
}
.stat-icon.total {
background: #f0f4ff;
color: #409eff;
}
.stat-icon.pending {
background: #fff8e6;
color: #e6a23c;
}
.stat-icon.contacting {
background: #e6f7f3;
color: #00a870;
}
.stat-icon.completed {
background: #f5f0ff;
color: #7c5cfc;
}
.filter-card {
background: #fff;
padding: 24px;
border-radius: 8px;
margin-bottom: 24px;
display: flex;
align-items: center;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
border: none;
}
.filter-row {
width: 100%;
display: flex;
gap: 20px;
align-items: center;
}
.search-group {
width: 320px;
}
.search-input :deep(.el-input__wrapper) {
border-radius: 4px 0 0 4px;
box-shadow: 1px 0 0 0 var(--el-input-border-color) inset, 0 1px 0 0 var(--el-input-border-color) inset, 0 -1px 0 0 var(--el-input-border-color) inset !important;
}
.search-btn {
background: #00a870 !important;
border-color: #00a870 !important;
color: #fff !important;
border-radius: 0 4px 4px 0;
width: 44px;
height: 32px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.status-select {
width: 160px;
}
.status-select :deep(.el-input__wrapper) {
border-radius: 4px;
}
.list-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
padding: 24px;
border: none;
}
.list-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.list-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: #333;
}
.title-bar {
width: 4px;
height: 18px;
background: #00a870;
border-radius: 2px;
}
.refresh-btn {
color: #666;
font-size: 14px;
}
.leads-table :deep(.el-table__header th) {
font-weight: 600;
height: 50px;
}
.status-tag {
font-size: 14px;
font-weight: 500;
}
.status-tag.contacting {
color: #409eff;
}
.status-tag.pending {
color: #e6a23c;
}
.status-tag.completed {
color: #333;
}
.status-tag.rejected {
color: #999;
}
.action-buttons {
display: flex;
gap: 8px;
}
.btn-view, .btn-edit, .btn-delete {
border: none;
padding: 4px 12px;
height: 28px;
font-size: 12px;
border-radius: 4px;
}
.btn-view, .btn-edit {
background: #e6f7f3 !important;
color: #00a870 !important;
}
.btn-delete {
background: #fff1f0 !important;
color: #f5222d !important;
}
.pagination-container {
display: flex;
justify-content: flex-end;
margin-top: 24px;
}
.pagination-container :deep(.el-pagination.is-background .el-pager li:not(.is-disabled).is-active) {
background-color: #00a870;
}
@media (max-width: 1400px) {
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 768px) {
.enterprise-leads {
padding: 16px;
}
.filter-row {
flex-direction: column;
align-items: stretch;
}
.status-select {
width: 100%;
}
}
</style>
+126 -96
View File
@@ -1,16 +1,17 @@
<template>
<Layout>
<Layout :page-title="t('invoices.detailTitle')">
<div class="invoice-detail-container" v-loading="loading">
<div class="detail-header">
<div class="header-left">
<span class="back-link" @click="handleBack">发票管理</span>
<span class="separator">/</span>
<span class="current-page">发票申请详情</span>
<el-button link @click="handleBack">
<el-icon><ArrowLeft /></el-icon>
{{ t('invoices.title') }}
</el-button>
</div>
<div class="header-actions">
<el-button v-if="detailData.status === 'pending'" class="btn-upload" type="primary" @click="handleUpload">上传发票</el-button>
<el-button v-if="detailData.status === 'pending'" class="btn-reject" type="danger" @click="handleReject">驳回申请</el-button>
<el-button @click="handleBack">返回</el-button>
<el-button v-if="detailData.status === 'pending'" class="btn-upload" type="primary" @click="handleUpload">{{ t('invoices.actions.upload') }}</el-button>
<el-button v-if="detailData.status === 'pending'" class="btn-reject" type="danger" @click="handleReject">{{ t('invoices.actions.reject') }}</el-button>
<el-button @click="handleBack">{{ t('invoices.actions.back') }}</el-button>
</div>
</div>
@@ -19,25 +20,25 @@
<div class="detail-card">
<div class="card-title">
<span class="title-line"></span>
<h3>基本信息</h3>
<h3>{{ t('invoices.detail.basicInfo') }}</h3>
</div>
<el-descriptions :column="4" class="info-descriptions">
<el-descriptions-item label="发票申请号">{{ detailData.invoiceNo }}</el-descriptions-item>
<el-descriptions-item label="关联订单号">{{ detailData.orderNo }}</el-descriptions-item>
<el-descriptions-item label="申请时间">{{ detailData.applyTime }}</el-descriptions-item>
<el-descriptions-item label="开票状态">
<el-descriptions-item :label="t('invoices.detail.invoiceNo')">{{ detailData.invoice_no }}</el-descriptions-item>
<el-descriptions-item :label="t('invoices.detail.orderNo')">{{ detailData.order_no }}</el-descriptions-item>
<el-descriptions-item :label="t('invoices.detail.applyTime')">{{ detailData.created_at }}</el-descriptions-item>
<el-descriptions-item :label="t('invoices.detail.status')">
<span :class="['status-tag', detailData.status]">
{{ detailData.statusLabel }}
{{ detailData.status_display }}
</span>
</el-descriptions-item>
<el-descriptions-item label="用户信息">
{{ detailData.user }} <span class="user-type">({{ detailData.userTypeLabel }})</span>
<el-descriptions-item :label="t('invoices.detail.userInfo')">
{{ detailData.applicant_name }} <span class="user-type">({{ detailData.order_package_name }})</span>
</el-descriptions-item>
<el-descriptions-item label="联系电话">{{ detailData.phone }}</el-descriptions-item>
<el-descriptions-item label="开票金额">
<el-descriptions-item :label="t('invoices.detail.phone')">{{ detailData.applicant_phone }}</el-descriptions-item>
<el-descriptions-item :label="t('invoices.detail.amount')">
<span class="amount-value">¥{{ (detailData.amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2 }) }}</span>
</el-descriptions-item>
<el-descriptions-item label="收票邮箱">{{ detailData.email }}</el-descriptions-item>
<el-descriptions-item :label="t('invoices.detail.email')">{{ detailData.receiver_email }}</el-descriptions-item>
</el-descriptions>
</div>
@@ -45,14 +46,14 @@
<div class="detail-card">
<div class="card-title">
<span class="title-line"></span>
<h3>开票信息</h3>
<h3>{{ t('invoices.detail.invoiceInfo') }}</h3>
</div>
<el-descriptions :column="4" class="info-descriptions">
<el-descriptions-item label="发票类型">{{ detailData.invoiceType }}</el-descriptions-item>
<el-descriptions-item label="发票抬头">{{ detailData.invoiceTitle }}</el-descriptions-item>
<el-descriptions-item label="纳税人识别号">{{ detailData.taxId }}</el-descriptions-item>
<el-descriptions-item label="开票内容">{{ detailData.content }}</el-descriptions-item>
<el-descriptions-item label="备注信息" :span="4">{{ detailData.remark }}</el-descriptions-item>
<el-descriptions-item :label="t('invoices.detail.invoiceType')">{{ detailData.invoice_type_display }}</el-descriptions-item>
<el-descriptions-item :label="t('invoices.detail.invoiceTitle')">{{ detailData.title }}</el-descriptions-item>
<el-descriptions-item :label="t('invoices.detail.taxId')">{{ detailData.taxpayer_id }}</el-descriptions-item>
<el-descriptions-item :label="t('invoices.detail.content')">{{ detailData.content }}</el-descriptions-item>
<el-descriptions-item :label="t('invoices.detail.remark')" :span="4">{{ detailData.remark }}</el-descriptions-item>
</el-descriptions>
</div>
@@ -60,20 +61,20 @@
<div class="detail-card reject-info-card" v-if="detailData.status === 'rejected'">
<div class="card-title">
<span class="title-line"></span>
<h3>驳回信息</h3>
<h3>{{ t('invoices.detail.rejectInfo') }}</h3>
</div>
<div class="info-content">
<div class="info-row">
<span class="label">驳回原因:</span>
<span class="value">{{ detailData.rejectReason }}</span>
<span class="label">{{ t('invoices.detail.rejectReason') }}:</span>
<span class="value">{{ detailData.reject_reason }}</span>
</div>
<div class="info-row">
<span class="label">驳回时间:</span>
<span class="value">{{ detailData.rejectTime }}</span>
<span class="label">{{ t('invoices.detail.rejectTime') }}:</span>
<span class="value">{{ detailData.rejected_at }}</span>
</div>
<div class="info-row">
<span class="label">驳回人员:</span>
<span class="value">{{ detailData.rejectUser }}</span>
<span class="label">{{ t('invoices.detail.rejectUser') }}:</span>
<span class="value">{{ detailData.rejected_by }}</span>
</div>
</div>
</div>
@@ -82,26 +83,28 @@
<div class="detail-card approve-info-card" v-if="detailData.status === 'completed'">
<div class="card-title">
<span class="title-line"></span>
<h3>通过信息</h3>
<h3>{{ t('invoices.detail.approveInfo') }}</h3>
</div>
<div class="info-content">
<div class="info-row">
<span class="label">开票完成时间:</span>
<span class="value">{{ detailData.finishTime }}</span>
<span class="label">{{ t('invoices.detail.finishTime') }}:</span>
<span class="value">{{ detailData.uploaded_at }}</span>
</div>
<div class="info-row">
<span class="label">上传人员:</span>
<span class="value">{{ detailData.approveUser }}</span>
<span class="label">{{ t('invoices.detail.approveUser') }}:</span>
<span class="value">{{ detailData.uploader_name }}</span>
</div>
<div class="info-row">
<span class="label">发票文件:</span>
<span class="label">{{ t('invoices.detail.fileName') }}:</span>
<span class="value">
<el-link type="primary" :underline="false">{{ detailData.fileName }} ({{ detailData.fileSize }})</el-link>
<el-link v-if="detailData.invoice_file_url" type="primary" :underline="false" :href="detailData.invoice_file_url" target="_blank">
{{ detailData.invoice_file_url.split('/').pop() }}
</el-link>
</span>
</div>
<div class="info-row">
<span class="label">上传时间:</span>
<span class="value">{{ detailData.approveTime }}</span>
<span class="label">{{ t('invoices.detail.approveTime') }}:</span>
<span class="value">{{ detailData.uploaded_at }}</span>
</div>
</div>
</div>
@@ -127,12 +130,16 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { ArrowLeft } from '@element-plus/icons-vue'
import Layout from '@/components/common/layout/Layout.vue'
import UploadInvoiceDialog from './components/UploadInvoiceDialog.vue'
import RejectInvoiceDialog from './components/RejectInvoiceDialog.vue'
import { getInvoiceDetail, uploadInvoice, rejectInvoice, type Invoice } from '@/api/invoice'
import { formatDateTime } from '@/utils/date'
const { t } = useI18n()
const router = useRouter()
const route = useRoute()
@@ -141,20 +148,19 @@ const rejectVisible = ref(false)
const loading = ref(false)
const detailData = ref<Partial<Invoice>>({
invoiceNo: '',
orderNo: '',
applyTime: '',
invoice_no: '',
order_no: '',
created_at: '',
status: 'pending',
statusLabel: '待处理',
user: '',
userType: '',
userTypeLabel: '',
phone: '',
status_display: t('invoices.filter.pending'),
applicant_name: '',
order_package_name: '',
applicant_phone: '',
amount: 0,
email: '',
invoiceType: '',
invoiceTitle: '',
taxId: '',
receiver_email: '',
invoice_type_display: '',
title: '',
taxpayer_id: '',
content: '',
remark: ''
})
@@ -166,11 +172,15 @@ const fetchDetail = async () => {
try {
const res = await getInvoiceDetail(id)
if (res.code === '000000' && res.data) {
detailData.value = res.data
const data = res.data
data.created_at = formatDateTime(data.created_at)
if (data.rejected_at) data.rejected_at = formatDateTime(data.rejected_at)
if (data.uploaded_at) data.uploaded_at = formatDateTime(data.uploaded_at)
detailData.value = data
}
} catch (error: any) {
console.error('Fetch invoice detail error:', error)
ElMessage.error(error.backendMessage || '获取详情失败')
ElMessage.error(error.backendMessage || t('invoices.messages.fetchDetailError'))
} finally {
loading.value = false
}
@@ -189,14 +199,32 @@ const handleUpload = () => {
}
const handleUploadSubmit = async (data: any) => {
console.log('handleUploadSubmit detail triggered:', data)
if (!detailData.value.invoice_no) {
console.error('No detail data invoice_no')
return
}
try {
const res = await uploadInvoice(detailData.value.id!, data)
const formData = new FormData()
formData.append('invoice_no', detailData.value.id!)
if (data.fileList && data.fileList.length > 0) {
const file = data.fileList[0].raw
console.log('File to upload (detail):', file)
formData.append('file', file)
} else {
ElMessage.warning(t('invoices.dialog.uploadTip'))
return
}
const res = await uploadInvoice(formData)
console.log('Upload response (detail):', res)
if (res.code === '000000') {
ElMessage.success('上传成功')
ElMessage.success(t('invoices.messages.uploadSuccess'))
fetchDetail()
}
} catch (error: any) {
ElMessage.error(error.backendMessage || '上传失败')
console.error('Upload error detail:', error)
ElMessage.error(error.backendMessage || t('invoices.messages.fetchDetailError'))
}
}
@@ -205,14 +233,20 @@ const handleReject = () => {
}
const handleRejectSubmit = async (data: any) => {
if (!detailData.value.id) return
try {
const res = await rejectInvoice(detailData.value.id!, data)
const reasonText = data.reason === 'other' ? data.suggestion : t(`invoices.dialog.reasons.${data.reason}`)
const res = await rejectInvoice(detailData.value.id, {
reject_reason: reasonText,
suggestion: data.suggestion
})
if (res.code === '000000') {
ElMessage.success('已驳回')
ElMessage.success(t('invoices.messages.rejectSuccess'))
fetchDetail()
}
} catch (error: any) {
ElMessage.error(error.backendMessage || '驳回失败')
console.error('Reject error detail:', error)
ElMessage.error(error.backendMessage || t('invoices.messages.fetchDetailError'))
}
}
</script>
@@ -220,7 +254,7 @@ const handleRejectSubmit = async (data: any) => {
<style lang="scss" scoped>
.invoice-detail-container {
padding: 24px;
background-color: #F0F2F5;
background-color: var(--app-bg-color);
min-height: calc(100vh - 80px);
}
@@ -229,30 +263,25 @@ const handleRejectSubmit = async (data: any) => {
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
background: #fff;
background: var(--app-content-bg);
padding: 16px 24px;
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
border: 1px solid var(--app-border-light);
box-shadow: var(--app-shadow);
.header-left {
display: flex;
align-items: center;
font-size: 14px;
.back-link {
color: #8C8C8C;
cursor: pointer;
&:hover { color: #019C7C; }
}
.separator {
margin: 0 8px;
color: #BFBFBF;
}
.current-page {
color: #262626;
font-weight: 500;
:deep(.el-button) {
font-size: 14px;
color: var(--app-text-secondary);
&:hover {
color: var(--el-color-primary);
}
.el-icon {
margin-right: 4px;
}
}
}
}
@@ -262,15 +291,15 @@ const handleRejectSubmit = async (data: any) => {
gap: 12px;
.btn-upload {
background-color: #019C7C;
border-color: #019C7C;
&:hover { background-color: #017c63; border-color: #017c63; }
background-color: var(--el-color-primary);
border-color: var(--el-color-primary);
&:hover { background-color: var(--el-color-primary-dark-2); border-color: var(--el-color-primary-dark-2); }
}
.btn-reject {
background-color: #F5222D;
border-color: #F5222D;
&:hover { background-color: #d32029; border-color: #d32029; }
background-color: var(--el-color-danger);
border-color: var(--el-color-danger);
&:hover { background-color: var(--el-color-danger-dark-2); border-color: var(--el-color-danger-dark-2); }
}
}
@@ -281,10 +310,11 @@ const handleRejectSubmit = async (data: any) => {
}
.detail-card {
background: #fff;
background: var(--app-content-bg);
padding: 24px;
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
border: 1px solid var(--app-border-light);
box-shadow: var(--app-shadow);
.card-title {
display: flex;
@@ -294,7 +324,7 @@ const handleRejectSubmit = async (data: any) => {
.title-line {
width: 4px;
height: 16px;
background: #019C7C;
background: var(--el-color-primary);
margin-right: 12px;
}
@@ -302,7 +332,7 @@ const handleRejectSubmit = async (data: any) => {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #262626;
color: var(--app-text-primary);
}
}
@@ -318,13 +348,13 @@ const handleRejectSubmit = async (data: any) => {
}
.label {
color: #595959;
color: var(--app-text-secondary);
width: 120px;
flex-shrink: 0;
}
.value {
color: #262626;
color: var(--app-text-primary);
}
}
}
@@ -333,11 +363,11 @@ const handleRejectSubmit = async (data: any) => {
:deep(.info-descriptions) {
.el-descriptions__label {
width: 120px;
color: #595959;
color: var(--app-text-secondary);
font-weight: normal;
}
.el-descriptions__content {
color: #262626;
color: var(--app-text-primary);
}
}
@@ -345,9 +375,9 @@ const handleRejectSubmit = async (data: any) => {
padding: 2px 8px;
border-radius: 2px;
font-size: 12px;
&.pending { background: #FFF7E6; color: #FAAD14; border: 1px solid #FFE7BA; }
&.completed { background: #F6FFED; color: #52C41A; border: 1px solid #B7EB8F; }
&.rejected { background: #FFF1F0; color: #F5222D; border: 1px solid #FFA39E; }
&.pending { background: var(--el-color-warning-light-9); color: var(--el-color-warning); border: 1px solid var(--el-color-warning-light-5); }
&.completed { background: var(--el-color-success-light-9); color: var(--el-color-success); border: 1px solid var(--el-color-success-light-5); }
&.rejected { background: var(--el-color-danger-light-9); color: var(--el-color-danger); border: 1px solid var(--el-color-danger-light-5); }
}
.amount-value {
@@ -355,7 +385,7 @@ const handleRejectSubmit = async (data: any) => {
}
.user-type {
color: #8C8C8C;
color: var(--app-text-secondary);
}
// 弹窗通用基础样式
@@ -1,7 +1,7 @@
<template>
<el-dialog
v-model="visible"
title="驳回发票申请"
:title="t('invoices.dialog.rejectTitle')"
width="640px"
class="reject-invoice-dialog"
:show-close="true"
@@ -10,47 +10,47 @@
<div class="reject-dialog-content">
<div class="reject-alert">
<el-icon class="alert-icon"><warning-filled /></el-icon>
<span class="alert-text">驳回后,用户将收到通知并需要重新提交申请。</span>
<span class="alert-text">{{ t('invoices.dialog.rejectAlert') }}</span>
</div>
<div class="info-summary" v-if="invoiceData">
<div class="summary-item">
<span class="label">发票申请号:</span>
<span class="value">{{ invoiceData.invoiceNo }}</span>
<span class="label">{{ t('invoices.detail.invoiceNo') }}:</span>
<span class="value">{{ invoiceData.invoice_no }}</span>
</div>
<div class="summary-item">
<span class="label">用户信息:</span>
<span class="value">{{ invoiceData.user }}</span>
<span class="label">{{ t('invoices.detail.userInfo') }}:</span>
<span class="value">{{ invoiceData.applicant_name }}</span>
</div>
<div class="summary-item full-width">
<span class="label">发票抬头:</span>
<span class="value">{{ invoiceData.invoiceTitle }}</span>
<span class="label">{{ t('invoices.detail.invoiceTitle') }}:</span>
<span class="value">{{ invoiceData.title }}</span>
</div>
</div>
<el-form :model="form" ref="formRef" label-position="top">
<div class="label-row">
<span class="section-label required">驳回原因</span>
<span class="label-tip">请选择或输入驳回的具体原因</span>
<span class="section-label required">{{ t('invoices.dialog.reasonLabel') }}</span>
<span class="label-tip">{{ t('invoices.dialog.reasonTip') }}</span>
</div>
<el-form-item prop="reason" :rules="[{ required: true, message: '请选择驳回原因', trigger: 'change' }]">
<el-select v-model="form.reason" placeholder="请选择驳回原因" style="width: 100%">
<el-option label="发票抬头信息有误" value="title_error" />
<el-option label="纳税人识别号不正确" value="tax_id_error" />
<el-option label="附件不清晰或有误" value="attachment_error" />
<el-option label="其他原因" value="other" />
<el-form-item prop="reason" :rules="[{ required: true, message: t('invoices.dialog.reasonPlaceholder'), trigger: 'change' }]">
<el-select v-model="form.reason" :placeholder="t('invoices.dialog.reasonPlaceholder')" style="width: 100%">
<el-option :label="t('invoices.dialog.reasons.title_error')" value="title_error" />
<el-option :label="t('invoices.dialog.reasons.tax_id_error')" value="tax_id_error" />
<el-option :label="t('invoices.dialog.reasons.attachment_error')" value="attachment_error" />
<el-option :label="t('invoices.dialog.reasons.other')" value="other" />
</el-select>
</el-form-item>
<div class="label-row">
<span class="section-label">详细说明</span>
<span class="section-label">{{ t('invoices.dialog.remarkLabel') }}</span>
</div>
<el-form-item prop="remark">
<el-form-item prop="suggestion">
<el-input
v-model="form.remark"
v-model="form.suggestion"
type="textarea"
:rows="4"
placeholder="请输入详细的驳回说明,帮助用户更准确地修改信息"
:placeholder="t('invoices.dialog.remarkPlaceholder')"
maxlength="200"
show-word-limit
/>
@@ -59,8 +59,8 @@
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="danger" @click="handleSubmit">确认驳回</el-button>
<el-button @click="visible = false">{{ t('invoices.actions.cancel') }}</el-button>
<el-button type="danger" @click="handleSubmit">{{ t('invoices.actions.confirmReject') }}</el-button>
</div>
</template>
</el-dialog>
@@ -68,9 +68,11 @@
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { WarningFilled } from '@element-plus/icons-vue'
const { t } = useI18n()
const props = defineProps<{
modelValue: boolean
invoiceData: any
@@ -82,7 +84,7 @@ const visible = ref(props.modelValue)
const formRef = ref()
const form = reactive({
reason: '',
remark: ''
suggestion: ''
})
watch(() => props.modelValue, (val) => {
@@ -103,7 +105,6 @@ const handleSubmit = async () => {
if (!formRef.value) return
await formRef.value.validate((valid: boolean) => {
if (valid) {
ElMessage.success('发票申请已驳回')
emit('submit', { ...form })
visible.value = false
}
@@ -1,7 +1,7 @@
<template>
<el-dialog
<el-dialog
v-model="visible"
title="上传发票文件"
:title="t('invoices.dialog.uploadTitle')"
width="680px"
class="upload-invoice-dialog"
:show-close="true"
@@ -11,30 +11,30 @@
<!-- 信息摘要 -->
<div class="info-summary" v-if="invoiceData">
<div class="summary-item">
<span class="label">发票申请号:</span>
<span class="value">{{ invoiceData.invoiceNo }}</span>
<span class="label">{{ t('invoices.detail.invoiceNo') }}:</span>
<span class="value">{{ invoiceData.invoice_no }}</span>
</div>
<div class="summary-item">
<span class="label">关联订单号:</span>
<span class="value">{{ invoiceData.orderNo }}</span>
<span class="label">{{ t('invoices.detail.orderNo') }}:</span>
<span class="value">{{ invoiceData.order_no }}</span>
</div>
<div class="summary-item">
<span class="label">用户信息:</span>
<span class="value">{{ invoiceData.user }} ({{ invoiceData.userTypeLabel || '个人版' }})</span>
<span class="label">{{ t('invoices.detail.userInfo') }}:</span>
<span class="value">{{ invoiceData.applicant_name }} ({{ invoiceData.order_package_name || '个人版' }})</span>
</div>
<div class="summary-item">
<span class="label">发票抬头:</span>
<span class="value">{{ invoiceData.invoiceTitle }}</span>
<span class="label">{{ t('invoices.detail.invoiceTitle') }}:</span>
<span class="value">{{ invoiceData.title }}</span>
</div>
<div class="summary-item">
<span class="label">开票金额:</span>
<span class="label">{{ t('invoices.detail.amount') }}:</span>
<span class="value">¥{{ (invoiceData.amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2 }) }}</span>
</div>
</div>
<!-- 上传区域 -->
<div class="upload-section">
<p class="section-label">上传发票文件</p>
<p class="section-label">{{ t('invoices.dialog.uploadLabel') }}</p>
<el-upload
class="invoice-uploader"
drag
@@ -44,11 +44,11 @@
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
<span class="upload-link">点击上传</span> / 拖拽到此区域
<span class="upload-link">{{ t('invoices.dialog.uploadDraggerText').split(' / ')[0] }}</span> / {{ t('invoices.dialog.uploadDraggerText').split(' / ')[1] }}
</div>
<template #tip>
<div class="el-upload__tip">
支持PDF格式。最大文件大小10MB
{{ t('invoices.dialog.uploadTip') }}
</div>
</template>
</el-upload>
@@ -56,34 +56,13 @@
<!-- 表单项 -->
<div class="form-section">
<div class="form-item">
<p class="section-label">开票完成时间</p>
<el-date-picker
v-model="form.finishTime"
type="datetime"
placeholder="选择日期时间"
format="YYYY/MM/DD HH:mm"
value-format="YYYY/MM/DD HH:mm"
style="width: 100%"
/>
</div>
<div class="form-item">
<p class="section-label">备注 (可选)</p>
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
placeholder="输入备注信息"
maxlength="500"
show-word-limit
/>
</div>
<!-- 移除了 API 不支持的 issued_at 和 remark 字段 -->
</div>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" class="btn-submit" @click="handleSubmit">确认上传</el-button>
<el-button @click="visible = false">{{ t('invoices.actions.cancel') }}</el-button>
<el-button type="primary" class="btn-submit" @click="handleSubmit">{{ t('invoices.actions.confirmUpload') }}</el-button>
</div>
</template>
</el-dialog>
@@ -91,9 +70,11 @@
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ElMessage } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
const { t } = useI18n()
const props = defineProps<{
modelValue: boolean
invoiceData: any
@@ -103,25 +84,39 @@ const emit = defineEmits(['update:modelValue', 'submit'])
const visible = ref(props.modelValue)
const form = reactive({
finishTime: '2026/01/14 06:20',
remark: '',
fileList: []
fileList: [] as any[]
})
watch(() => props.modelValue, (val) => {
visible.value = val
if (val) {
// Reset form when opening
form.fileList = []
}
})
watch(visible, (val) => {
emit('update:modelValue', val)
})
const handleChange = (_file: any, fileList: any) => {
form.fileList = fileList
}
const handleRemove = (_file: any, fileList: any) => {
form.fileList = fileList
}
const handleClose = () => {
// Reset form if needed
}
const handleSubmit = () => {
ElMessage.success('发票上传成功')
console.log('handleSubmit clicked, fileList:', form.fileList)
if (form.fileList.length === 0) {
ElMessage.warning(t('invoices.dialog.uploadTip'))
return
}
emit('submit', { ...form })
visible.value = false
}
+175 -121
View File
@@ -3,16 +3,24 @@
<div class="invoice-container">
<!-- 统计卡片 -->
<div class="stats-row">
<div v-for="stat in stats" :key="stat.label" class="stat-card" :class="stat.type">
<div v-for="stat in stats" :key="stat.key" class="stat-card" :class="stat.type">
<div class="stat-info">
<div class="stat-label">{{ stat.label }}</div>
<div class="stat-label">{{ t(`invoices.stats.${stat.type}`) }}</div>
<div class="stat-value">{{ stat.value }}</div>
</div>
<div class="stat-icon-wrapper">
<el-icon v-if="stat.icon === 'document'"><Document /></el-icon>
<el-icon v-else-if="stat.icon === 'warning'"><Warning /></el-icon>
<el-icon v-else-if="stat.icon === 'success'"><CircleCheck /></el-icon>
<el-icon v-else-if="stat.icon === 'error'"><CircleClose /></el-icon>
<el-icon v-if="stat.icon === 'document'">
<Document />
</el-icon>
<el-icon v-else-if="stat.icon === 'warning'">
<Warning />
</el-icon>
<el-icon v-else-if="stat.icon === 'success'">
<CircleCheck />
</el-icon>
<el-icon v-else-if="stat.icon === 'error'">
<CircleClose />
</el-icon>
</div>
</div>
</div>
@@ -20,34 +28,29 @@
<!-- 搜索和筛选 -->
<div class="filter-section">
<el-form :inline="true" :model="filterForm" class="filter-form">
<el-form-item label="搜索发票号/订单号:">
<el-input v-model="filterForm.keyword" placeholder="输入发票号或订单号" />
<el-form-item :label="t('invoices.filter.searchLabel')">
<el-input v-model="filterForm.keyword" :placeholder="t('invoices.filter.searchPlaceholder')" />
</el-form-item>
<el-form-item label="开票状态:">
<el-select v-model="filterForm.status" placeholder="全部状态" style="width: 150px">
<el-option label="全部状态" value="" />
<el-option label="待处理" value="pending" />
<el-option label="已开票" value="success" />
<el-option label="已驳回" value="rejected" />
<el-form-item :label="t('invoices.filter.statusLabel')">
<el-select v-model="filterForm.status" :placeholder="t('invoices.filter.allStatus')" style="width: 150px">
<el-option :label="t('invoices.filter.allStatus')" value="" />
<el-option :label="t('invoices.filter.pending')" value="pending" />
<el-option :label="t('invoices.filter.success')" value="success" />
<el-option :label="t('invoices.filter.rejected')" value="rejected" />
</el-select>
</el-form-item>
<div class="filter-right">
<div class="selected-count">
<!-- <span class="count-badge">{{ totalCount }}</span> -->
</div>
<el-form-item label="申请时间:">
<el-date-picker
v-model="filterForm.dateRange"
type="daterange"
range-separator="-"
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY年MM月DD日"
/>
<el-form-item :label="t('invoices.filter.timeLabel')">
<el-date-picker v-model="filterForm.dateRange" type="daterange" range-separator="-"
:start-placeholder="t('chart.licenseTrend.datePicker.startPlaceholder')"
:end-placeholder="t('chart.licenseTrend.datePicker.endPlaceholder')" format="YYYY/MM/DD" />
</el-form-item>
<el-form-item>
<el-button @click="resetFilter">重置</el-button>
<el-button type="primary" class="btn-filter" @click="handleFilter">筛选</el-button>
<el-button @click="resetFilter">{{ t('invoices.filter.reset') }}</el-button>
<el-button type="primary" class="btn-filter" @click="handleFilter">{{ t('invoices.filter.query') }}</el-button>
</el-form-item>
</div>
</el-form>
@@ -57,58 +60,53 @@
<div class="table-section" v-loading="loading">
<div class="table-header-title">
<span class="title-line"></span>
<h3>发票申请列表</h3>
<h3>{{ t('invoices.table.listTitle') }}</h3>
<!-- <el-button
type="primary"
class="btn-create"
@click="handleCreate"
style="margin-left: auto"
>新增发票申请</el-button
>{{ t('invoices.actions.create') }}</el-button
> -->
</div>
<el-table
:data="tableData"
stripe
style="width: 100%"
:header-cell-style="{
background: '#E6F7FF',
color: '#000',
borderRight: '1px solid #BAE7FF'
backgroundColor: 'var(--app-bg-color)',
color: 'var(--app-text-primary)',
borderRight: '1px solid var(--app-border-light)'
}"
:row-style="getRowStyle"
>
<el-table-column prop="invoiceNo" label="发票申请号" min-width="150" />
<el-table-column label="用户" min-width="120">
<el-table-column prop="invoice_no" :label="t('invoices.table.invoiceNo')" min-width="150" />
<el-table-column :label="t('invoices.table.user')" min-width="120">
<template #default="{ row }">
<div class="user-info">
<span class="user-name">{{ row.user }}</span>
<span class="user-type" :class="row.userType">{{ row.userTypeLabel }}</span>
<span class="user-name">{{ row.applicant_name }}</span>
<span class="user-type" :class="row.order_package_name">{{ row.order_package_name }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="orderNo" label="订单号" min-width="150" />
<el-table-column prop="time" label="申请时间" min-width="180" />
<el-table-column label="开票状态" width="100">
<el-table-column prop="order_no" :label="t('invoices.table.orderNo')" min-width="150" />
<el-table-column prop="created_at" :label="t('invoices.table.applyTime')" min-width="180" />
<el-table-column :label="t('invoices.table.status')" width="100">
<template #default="{ row }">
<span class="status-text" :class="row.status">{{ row.statusLabel }}</span>
<span class="status-text" :class="row.status">{{ row.status_display }}</span>
</template>
</el-table-column>
<el-table-column prop="amount" label="发票金额" width="120">
<el-table-column prop="amount" :label="t('invoices.table.amount')" width="120">
<template #default="{ row }">
¥{{ (row.amount || 0).toLocaleString(undefined, { minimumFractionDigits: 2 }) }}
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<el-table-column :label="t('invoices.table.operation')" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" style="color: #019c7c" @click="handleView(row)"
>查看</el-button
>
<el-button link type="primary" style="color: #019c7c" @click="handleView(row)">{{ t('invoices.actions.view') }}</el-button>
<template v-if="row.status === 'pending'">
<el-button link type="success" style="color: #52c41a" @click="handleUpload(row)"
>上传</el-button
>
<el-button link type="danger" style="color: #f5222d" @click="handleReject(row)"
>驳回</el-button
>
<el-button link type="success" style="color: #52c41a" @click="handleUpload(row)">{{ t('invoices.actions.upload') }}</el-button>
<el-button link type="danger" style="color: #f5222d" @click="handleReject(row)">{{ t('invoices.actions.reject') }}</el-button>
</template>
</template>
</el-table-column>
@@ -116,32 +114,18 @@
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 50, 100]"
layout="prev, pager, next, jumper, sizes, total"
:total="totalCount"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 20, 50, 100]"
layout="prev, pager, next, jumper, sizes, total" :total="totalCount" @size-change="handleSizeChange"
@current-change="handleCurrentChange">
</el-pagination>
</div>
</div>
<!-- 上传发票对话框 -->
<UploadInvoiceDialog
v-model="uploadVisible"
:invoice-data="currentRow"
@submit="handleUploadSubmit"
/>
<UploadInvoiceDialog v-model="uploadVisible" :invoice-data="currentRow" @submit="handleUploadSubmit" />
<!-- 驳回申请对话框 -->
<RejectInvoiceDialog
v-model="rejectVisible"
:invoice-data="currentRow"
@submit="handleRejectSubmit"
/>
<RejectInvoiceDialog v-model="rejectVisible" :invoice-data="currentRow" @submit="handleRejectSubmit" />
<!-- 新增申请对话框 -->
<el-dialog v-model="createVisible" title="新增发票申请" width="500px">
@@ -150,12 +134,7 @@
<el-input v-model="createForm.orderNo" placeholder="请输入订单号" />
</el-form-item>
<el-form-item label="开票金额">
<el-input-number
v-model="createForm.amount"
:precision="2"
:step="100"
style="width: 100%"
/>
<el-input-number v-model="createForm.amount" :precision="2" :step="100" style="width: 100%" />
</el-form-item>
<el-form-item label="抬头类型">
<el-radio-group v-model="createForm.userType">
@@ -182,18 +161,20 @@ import { Document, Warning, CircleCheck, CircleClose } from '@element-plus/icons
import { ElMessage } from 'element-plus'
import UploadInvoiceDialog from './components/UploadInvoiceDialog.vue'
import RejectInvoiceDialog from './components/RejectInvoiceDialog.vue'
import { getInvoices, getInvoiceSummary, type Invoice } from '@/api/invoice'
import { getInvoices, getInvoiceSummary, uploadInvoice, rejectInvoice, type Invoice } from '@/api/invoice'
import { formatDateTime } from '@/utils/date'
const { t } = useI18n()
const router = useRouter()
const stats = ref([
{ label: '全部发票申请', value: '0', type: 'all', icon: 'document', key: 'total' },
{ label: '待处理申请', value: '0', type: 'pending', icon: 'warning', key: 'pending' },
{ label: '已开票', value: '0', type: 'success', icon: 'success', key: 'completed' },
{ label: '已驳回', value: '0', type: 'rejected', icon: 'error', key: 'rejected' }
{ label: '全部发票申请', value: '0', type: 'all', icon: 'document', key: 'total_count' },
{ label: '待处理申请', value: '0', type: 'pending', icon: 'warning', key: 'pending_count' },
{ label: '已开票', value: '0', type: 'success', icon: 'success', key: 'completed_count' },
{ label: '已驳回', value: '0', type: 'rejected', icon: 'error', key: 'rejected_count' }
])
const filterForm = reactive({
keyword: '',
status: '',
@@ -210,16 +191,25 @@ const uploadVisible = ref(false)
const rejectVisible = ref(false)
const currentRow = ref<any>(null)
const getRowStyle = ({ rowIndex }: { rowIndex: number }) => {
return rowIndex % 2 === 1
? { backgroundColor: 'var(--app-bg-color)' }
: { backgroundColor: 'var(--app-content-bg)' }
}
const fetchSummary = async () => {
try {
const res = await getInvoiceSummary()
if (res.code === '000000' && res.data) {
stats.value.forEach(stat => {
if (stat.key && (res.data as any)[stat.key] !== undefined) {
stat.value = (res.data as any)[stat.key].toString()
stats.value.forEach((item: any) => {
const value = (res.data as any)[item.key]
if (value !== undefined) {
item.value = String(value)
}
})
}
} catch (error) {
console.error('Fetch summary error:', error)
}
@@ -240,12 +230,18 @@ const fetchData = async () => {
}
const res = await getInvoices(params)
if (res.code === '000000') {
tableData.value = res.data.list
totalCount.value = res.data.total
fetchSummary()
res.data.invoices.forEach((item: any) => {
item.created_at = formatDateTime(item.created_at)
})
tableData.value = res.data.invoices
totalCount.value = res.data.total_count
}
} catch (error: any) {
console.error('Fetch invoices error:', error)
ElMessage.error(error.backendMessage || '获取数据失败')
ElMessage.error(error.backendMessage || t('invoices.messages.fetchError'))
} finally {
loading.value = false
}
@@ -253,7 +249,7 @@ const fetchData = async () => {
onMounted(() => {
fetchData()
fetchSummary()
})
const createForm = reactive({
@@ -267,7 +263,7 @@ const handleCreate = () => {
}
const submitCreate = () => {
ElMessage.success('提交申请成功')
ElMessage.success(t('invoices.messages.createSuccess'))
createVisible.value = false
}
@@ -285,7 +281,7 @@ const resetFilter = () => {
}
const handleView = (row: any) => {
router.push(`/invoices/detail/${row.id || row.invoiceNo}`)
router.push(`/invoices/detail/${row.id || row.invoice_no}`)
}
const handleUpload = (row: any) => {
@@ -293,10 +289,35 @@ const handleUpload = (row: any) => {
uploadVisible.value = true
}
const handleUploadSubmit = (data: any) => {
console.log('Upload Submit:', data)
fetchData()
fetchSummary()
const handleUploadSubmit = async (data: any) => {
console.log('handleUploadSubmit triggered:', data)
if (!currentRow.value || !currentRow.value.invoice_no) {
console.error('No current row or invoice_no')
return
}
try {
const formData = new FormData()
formData.append('invoice_no', currentRow.value.id)
if (data.fileList && data.fileList.length > 0) {
const file = data.fileList[0].raw
console.log('File to upload:', file)
formData.append('file', file)
} else {
ElMessage.warning(t('invoices.dialog.uploadTip'))
return
}
const res = await uploadInvoice(formData)
console.log('Upload response:', res)
if (res.code === '000000') {
ElMessage.success(t('invoices.messages.uploadSuccess'))
fetchData()
fetchSummary()
}
} catch (error: any) {
console.error('Upload invoice error:', error)
ElMessage.error(error.backendMessage || t('invoices.messages.fetchError'))
}
}
const handleReject = (row: any) => {
@@ -304,10 +325,23 @@ const handleReject = (row: any) => {
rejectVisible.value = true
}
const handleRejectSubmit = (data: any) => {
console.log('Reject Submit:', data)
fetchData()
fetchSummary()
const handleRejectSubmit = async (data: any) => {
if (!currentRow.value?.id) return
try {
const reasonText = data.reason === 'other' ? data.suggestion : t(`invoices.dialog.reasons.${data.reason}`)
const res = await rejectInvoice(currentRow.value.id, {
reject_reason: reasonText,
suggestion: data.suggestion
})
if (res.code === '000000') {
ElMessage.success(t('invoices.messages.rejectSuccess'))
fetchData()
fetchSummary()
}
} catch (error: any) {
console.error('Reject error:', error)
ElMessage.error(error.backendMessage || t('invoices.messages.fetchError'))
}
}
const handleSizeChange = (val: number) => {
@@ -324,10 +358,11 @@ const handleCurrentChange = (val: number) => {
<style lang="scss" scoped>
.invoice-container {
padding: 24px;
background-color: #f0f2f5;
background-color: var(--app-bg-color);
min-height: calc(100vh - 80px);
}
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
@@ -336,26 +371,26 @@ const handleCurrentChange = (val: number) => {
}
.stat-card {
background: #fff;
background: var(--app-content-bg);
border-radius: 4px;
padding: 20px 24px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
border: 1px solid transparent;
box-shadow: var(--app-shadow);
border: 1px solid var(--app-border-light);
height: 96px;
.stat-label {
font-size: 14px;
color: #8c8c8c;
color: var(--app-text-secondary);
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #019c7c;
color: var(--el-color-primary);
line-height: 1.2;
}
@@ -370,31 +405,35 @@ const handleCurrentChange = (val: number) => {
}
&.all .stat-icon-wrapper {
background: #e6f7ff;
color: #1890ff;
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
&.pending .stat-icon-wrapper {
background: #fff7e6;
color: #faad14;
background: var(--el-color-warning-light-9);
color: var(--el-color-warning);
}
&.success .stat-icon-wrapper {
background: #f6ffed;
color: #52c41a;
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
&.rejected .stat-icon-wrapper {
background: #f9f0ff;
color: #722ed1;
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
}
.filter-section {
background: #fff;
background: var(--app-content-bg);
padding: 16px 24px;
border-radius: 4px;
margin-bottom: 24px;
display: flex;
align-items: center;
border: 1px solid #f0f0f0;
border: 1px solid var(--app-border-light);
.filter-form {
width: 100%;
@@ -408,7 +447,7 @@ const handleCurrentChange = (val: number) => {
.el-form-item__label {
font-weight: 500;
color: #262626;
color: var(--app-text-primary);
}
}
}
@@ -420,6 +459,7 @@ const handleCurrentChange = (val: number) => {
:deep(.el-form-item) {
margin-right: 12px;
&:last-child {
margin-right: 0;
}
@@ -427,10 +467,12 @@ const handleCurrentChange = (val: number) => {
}
}
.selected-count {
margin-right: 24px;
.count-badge {
background: #ff4d4f;
background: var(--el-color-danger);
color: #fff;
padding: 0 12px;
border-radius: 4px;
@@ -442,23 +484,28 @@ const handleCurrentChange = (val: number) => {
}
}
.btn-filter {
background-color: #019c7c;
border-color: #019c7c;
background-color: var(--el-color-primary);
border-color: var(--el-color-primary);
padding: 8px 24px;
&:hover {
background-color: #017c63;
border-color: #017c63;
background-color: var(--el-color-primary-dark-2);
border-color: var(--el-color-primary-dark-2);
}
}
.table-section {
background: #fff;
background: var(--app-content-bg);
padding: 24px;
border-radius: 4px;
border: 1px solid #f0f0f0;
border: 1px solid var(--app-border-light);
box-shadow: var(--app-shadow);
}
.table-header-title {
display: flex;
align-items: center;
@@ -494,10 +541,12 @@ const handleCurrentChange = (val: number) => {
border-radius: 2px;
height: 20px;
line-height: 20px;
&.personal {
background: #e6f7ff;
color: #1890ff;
}
&.enterprise {
background: #f6ffed;
color: #52c41a;
@@ -509,9 +558,11 @@ const handleCurrentChange = (val: number) => {
&.success {
color: #1890ff;
}
&.pending {
color: #faad14;
}
&.rejected {
color: #bfbfbf;
}
@@ -538,6 +589,7 @@ const handleCurrentChange = (val: number) => {
color: #262626;
height: 54px;
border-right: 1px solid #bae7ff;
&:last-child {
border-right: none;
}
@@ -557,8 +609,10 @@ const handleCurrentChange = (val: number) => {
.stats-row {
gap: 16px;
}
.stat-card {
padding: 16px;
.stat-value {
font-size: 24px;
}
@@ -0,0 +1,320 @@
<template>
<div class="loading_box">
<el-dialog v-model="visible" :title="t('packages.edit.title')" width="700px" class="package-edit-dialog"
destroy-on-close>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="edit-form">
<div class="form-row">
<el-form-item :label="t('packages.edit.name')" prop="name" class="flex-1">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item :label="t('packages.edit.type')" prop="type" class="flex-1">
<el-select v-model="form.type" class="w-full">
<el-option label="试用版" value="trial" />
<el-option label="基础版" value="basic" />
<el-option label="专业版" value="pro" />
<el-option label="定制版" value="custom" />
</el-select>
</el-form-item>
</div>
<div class="form-row">
<el-form-item :label="t('packages.edit.price')" prop="price" class="flex-1">
<el-input v-model="form.price" />
</el-form-item>
<el-form-item :label="t('packages.edit.cycle')" prop="cycle" class="flex-1">
<el-input v-model="form.cycle" />
</el-form-item>
</div>
<el-form-item :label="t('packages.edit.description')" prop="description">
<el-input v-model="form.description" type="textarea" :rows="4" maxlength="500" show-word-limit
:placeholder="t('packages.edit.descPlaceholder')" />
</el-form-item>
<div class="features-section">
<label class="section-label">{{ t('packages.edit.features') }}</label>
<div class="features-list">
<div v-for="(feature, index) in form.features" :key="index" class="feature-item">
<el-input v-model="form.features[index]"
:placeholder="t('packages.edit.featurePlaceholder')" />
<el-button class="btn-remove" @click="removeFeature(index)">
<el-icon>
<Minus />
</el-icon>
</el-button>
</div>
<el-button class="btn-add" @click="addFeature">
<el-icon>
<Plus />
</el-icon>
</el-button>
</div>
</div>
<div class="form-row mt-20">
<el-form-item :label="t('packages.edit.status')" prop="status" class="flex-1">
<el-select v-model="form.status" class="w-full">
<el-option :label="t('packages.status.enabled')" value="enabled" />
<el-option :label="t('packages.status.disabled')" value="disabled" />
</el-select>
</el-form-item>
<el-form-item :label="t('packages.edit.sort')" prop="sort" class="flex-1">
<el-input-number v-model="form.sort" :min="1" class="w-full" controls-position="right" />
</el-form-item>
</div>
<el-form-item :label="t('packages.edit.remark')" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="4" maxlength="500" show-word-limit
:placeholder="t('packages.edit.remarkPlaceholder')" />
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="visible = false">{{ t('common.cancel') }}</el-button>
<el-button type="primary" class="btn-save" @click="handleSave">
{{ t('packages.edit.save') }}
</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, watch, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { Plus, Minus } from '@element-plus/icons-vue'
import type { FormInstance, FormRules } from 'element-plus'
const props = defineProps<{
modelValue: boolean
data: any
}>()
const emit = defineEmits(['update:modelValue', 'save'])
const { t } = useI18n()
const visible = ref(props.modelValue)
const formRef = ref<FormInstance>()
const form = reactive({
name: '',
type: '',
price: '',
cycle: '',
description: '',
features: [] as string[],
status: 'enabled',
sort: 1,
remark: ''
})
const rules = reactive<FormRules>({
name: [{ required: true, message: t('common.required'), trigger: 'blur' }],
type: [{ required: true, message: t('common.required'), trigger: 'change' }],
price: [{ required: true, message: t('common.required'), trigger: 'blur' }],
cycle: [{ required: true, message: t('common.required'), trigger: 'blur' }],
description: [{ required: true, message: t('common.required'), trigger: 'blur' }],
status: [{ required: true, message: t('common.required'), trigger: 'change' }],
sort: [{ required: true, message: t('common.required'), trigger: 'blur' }]
})
watch(() => props.modelValue, (val) => {
visible.value = val
if (val && props.data) {
form.name = props.data.name || ''
form.type = props.data.type || 'trial'
form.price = props.data.price || ''
form.cycle = props.data.cycle || ''
form.description = props.data.description || '体验完整功能,适合新用户试用评估'
form.features = props.data.features || ['全部功能试用', '支持1台设备激活', '基础功能支持', '永久授权', '高级功能', '离线授权支持']
form.status = props.data.status || 'enabled'
form.sort = props.data.sort || 6
form.remark = props.data.remark || ''
}
})
watch(visible, (val) => {
emit('update:modelValue', val)
})
const addFeature = () => {
form.features.push('')
}
const removeFeature = (index: number) => {
form.features.splice(index, 1)
}
const handleSave = async () => {
if (!formRef.value) return
await formRef.value.validate((valid) => {
if (valid) {
emit('save', { ...props.data, ...form })
visible.value = false
}
})
}
</script>
<style lang="scss" scoped>
:deep(.el-dialog__headerbtn) {
top: 10px !important;
}
:deep(.el-dialog) {
border-radius: 8px;
overflow: hidden;
padding: 0;
.el-dialog__header {
margin-right: 0;
padding: 20px 24px;
background: linear-gradient(90deg, #00928A 0%, #00D19E 100%) !important;
border-bottom: none;
display: flex;
align-items: center;
.el-dialog__title {
color: #fff !important;
font-size: 18px;
font-weight: 600;
}
.el-dialog__headerbtn {
top: 20px;
.el-dialog__close {
color: #fff !important;
font-size: 20px;
}
}
}
.el-dialog__body {
padding: 24px;
}
}
.edit-form {
.form-row {
display: flex;
gap: 24px;
margin-bottom: 8px;
}
.flex-1 {
flex: 1;
}
:deep(.el-form-item__label) {
font-weight: 500;
color: #333;
padding-bottom: 8px;
&::before {
color: #f56c6c;
margin-right: 4px;
}
}
:deep(.el-input__wrapper),
:deep(.el-textarea__inner) {
background-color: #fff;
border-radius: 4px;
}
}
.features-section {
.section-label {
display: block;
font-weight: 500;
color: #333;
margin-bottom: 12px;
font-size: 14px;
&::before {
content: '*';
color: #f56c6c;
margin-right: 4px;
}
}
}
.features-list {
background: #f9f9f9;
border: 1px solid #eee;
border-radius: 4px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
.feature-item {
display: flex;
gap: 12px;
align-items: center;
}
.btn-remove {
width: 32px;
height: 32px;
padding: 0;
border: 1px dashed #ccc;
color: #999;
&:hover {
color: #f56c6c;
border-color: #f56c6c;
}
}
.btn-add {
width: 44px;
height: 44px;
padding: 0;
border: 1px dashed #ccc;
color: #999;
align-self: flex-start;
&:hover {
color: #00a870;
border-color: #00a870;
}
}
}
.w-full {
width: 100%;
}
.mt-20 {
margin-top: 20px;
}
:deep(.el-dialog__footer){
padding-top: 0px !important;
}
.dialog-footer {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 0px !important;
padding-bottom: 20px !important;
.el-button {
padding: 6px 32px;
height: 40px;
font-size: 14px;
border-radius: 4px;
}
.btn-save {
background-color: #00a870 !important;
border-color: #00a870 !important;
color: #fff !important;
}
}
</style>
+186
View File
@@ -0,0 +1,186 @@
<template>
<Layout :page-title="t('packages.title')">
<div class="packages-page">
<div class="list-card">
<div class="list-header">
<div class="list-title">
<span class="title-bar"></span>
<span>{{ t('packages.table.title') }}</span>
</div>
</div>
<el-table
:data="tableData"
stripe
class="packages-table"
:header-cell-style="{
backgroundColor: '#E6F7F3',
color: '#4F4F4F',
fontWeight: '600',
height: '50px'
}"
>
<el-table-column prop="id" :label="t('packages.table.id')" width="100" />
<el-table-column prop="name" :label="t('packages.table.name')" min-width="150" />
<el-table-column prop="price" :label="t('packages.table.price')" min-width="120">
<template #default="{ row }">
<span :class="{ 'price-free': row.price === '免费' }">{{ row.price }}</span>
</template>
</el-table-column>
<el-table-column prop="cycle" :label="t('packages.table.cycle')" min-width="180" />
<el-table-column prop="createdAt" :label="t('packages.table.createdAt')" min-width="180" />
<el-table-column prop="updatedAt" :label="t('packages.table.updatedAt')" min-width="180" />
<el-table-column :label="t('packages.table.status')" width="120">
<template #default="{ row }">
<span class="status-tag" :class="row.status">{{ t(`packages.status.${row.status}`) }}</span>
</template>
</el-table-column>
<el-table-column :label="t('packages.table.actions')" width="100" fixed="right">
<template #default="{ row }">
<el-button size="small" class="btn-edit" @click="handleEdit(row)">
{{ t('enterpriseLeads.actions.edit') }}
</el-button>
</template>
</el-table-column>
</el-table>
</div>
</div>
<PackageEditDialog
v-model="editVisible"
:data="selectedPackage"
@save="handleUpdate"
/>
</Layout>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Layout from '@/components/common/layout/Layout.vue'
import PackageEditDialog from './components/PackageEditDialog.vue'
const { t } = useI18n()
const tableData = ref([
{
id: '1',
name: '试用版',
price: '免费',
cycle: '有效期至本月25日',
createdAt: '2024-01-15 10:30',
updatedAt: '2024-01-15 10:30',
status: 'enabled',
type: 'trial',
sort: 1
},
{
id: '2',
name: '基础版',
price: '¥300',
cycle: '永久授权 (V1/V2/V3)',
createdAt: '2024-01-15 10:30',
updatedAt: '2024-01-15 10:30',
status: 'enabled',
type: 'basic',
sort: 2
},
{
id: '3',
name: '专业版',
price: '¥2000',
cycle: '永久授权 (V1/V2/V3)',
createdAt: '2024-01-15 10:30',
updatedAt: '2024-01-15 10:30',
status: 'enabled',
type: 'pro',
sort: 3
},
{
id: '4',
name: '定制版',
price: '定制方案',
cycle: '按需定价',
createdAt: '2024-01-15 10:30',
updatedAt: '2024-01-15 10:30',
status: 'enabled',
type: 'custom',
sort: 4
}
])
const editVisible = ref(false)
const selectedPackage = ref<any>(null)
const handleEdit = (row: any) => {
selectedPackage.value = row
editVisible.value = true
}
const handleUpdate = (updatedData: any) => {
const index = tableData.value.findIndex(item => item.id === updatedData.id)
if (index !== -1) {
tableData.value[index] = { ...tableData.value[index], ...updatedData }
}
}
</script>
<style lang="scss" scoped>
.packages-page {
padding: 24px;
background-color: #f0f2f5;
min-height: calc(100vh - 80px);
}
.list-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.05);
padding: 24px;
}
.list-header {
margin-bottom: 20px;
}
.list-title {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
color: #333;
}
.title-bar {
width: 4px;
height: 18px;
background: #00a870;
border-radius: 2px;
}
.status-tag {
font-size: 14px;
font-weight: 500;
&.enabled {
color: #409eff;
}
&.disabled {
color: #999;
}
}
.btn-edit {
border: none;
background: #e6f7f3 !important;
color: #00a870 !important;
padding: 4px 12px;
height: 28px;
font-size: 12px;
border-radius: 4px;
}
.price-free {
color: #666;
}
</style>