v0.2.0: CRM/ERP 系统升级 - 清理 .gitignore 并移除误提交的 venv/env/db 文件
- 更新 .gitignore:全面覆盖环境变量、数据库、日志、缓存、上传文件 - 移除误跟踪的 server/venv/、crm_data.db、.env 文件 - 新增 server/.env.example 模板 - 新增合同管理、利润核算、AI教练等功能模块 - 新增 Playwright e2e 测试套件 - 前后端多项功能升级和 bug 修复
This commit is contained in:
@@ -1,3 +0,0 @@
|
||||
# 开发环境 API 基础路径
|
||||
# Vite 开发服务器通过 proxy 转发 /api → http://127.0.0.1:8000
|
||||
VITE_API_BASE_URL=
|
||||
@@ -1,3 +0,0 @@
|
||||
# 生产环境 API 基础路径
|
||||
# Nginx 统一代理,前端和后端同域,无需跨域
|
||||
VITE_API_BASE_URL=
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 前端路由守卫与权限 E2E 测试
|
||||
* 覆盖: 未登录重定向 / 已登录访问 /login / Token 失效后跳转
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// 不使用全局 auth
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.describe('路由守卫', () => {
|
||||
test('未登录访问受保护页面被重定向到登录页', async ({ page }) => {
|
||||
await page.goto('/customers');
|
||||
await page.waitForURL(/\/login/);
|
||||
await expect(page.locator('.login-title')).toBeVisible();
|
||||
});
|
||||
|
||||
test('未登录访问订单页被重定向', async ({ page }) => {
|
||||
await page.goto('/orders');
|
||||
await page.waitForURL(/\/login/);
|
||||
});
|
||||
|
||||
test('未登录访问设置页被重定向', async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
await page.waitForURL(/\/login/);
|
||||
});
|
||||
|
||||
test('登录页直接可访问', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
// 不会重定向,直接显示登录页
|
||||
await expect(page.locator('.login-title')).toContainText('CRM');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Token 销毁后行为', () => {
|
||||
test('清除 Token 后访问受保护页面被重定向', async ({ page }) => {
|
||||
// 先登录
|
||||
await page.goto('/login');
|
||||
await page.getByPlaceholder('用户名').fill('admin');
|
||||
await page.getByPlaceholder('密码').fill('123456');
|
||||
await page.getByRole('button', { name: '登 录' }).click();
|
||||
await page.waitForURL('/', { timeout: 10000 });
|
||||
|
||||
// 清除 localStorage(模拟 Token 销毁)
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
|
||||
// 访问受保护页面
|
||||
await page.goto('/customers');
|
||||
// 应被重定向到登录页
|
||||
await page.waitForURL(/\/login/, { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 全局认证 Setup
|
||||
* 通过 UI 登录后持久化 storageState,后续所有测试复用登录态
|
||||
*/
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
|
||||
const authFile = 'e2e/.auth/user.json';
|
||||
|
||||
setup('authenticate', async ({ page }) => {
|
||||
// 1. 访问登录页
|
||||
await page.goto('/login');
|
||||
await expect(page.locator('.login-title')).toContainText('天津硕博霖 CRM 系统');
|
||||
|
||||
// 2. 填写表单
|
||||
await page.getByPlaceholder('用户名').fill('admin');
|
||||
await page.getByPlaceholder('密码').fill('123456');
|
||||
|
||||
// 3. 点击登录
|
||||
await page.getByRole('button', { name: '登 录' }).click();
|
||||
|
||||
// 4. 等待跳转到首页 (工作台)
|
||||
await page.waitForURL('/', { timeout: 10000 });
|
||||
await expect(page.locator('.el-menu--vertical').first()).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// 5. 持久化认证状态 (localStorage token + cookies)
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 业务闭环 E2E 测试 (v4)
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe.serial('业务闭环: 客户 → 订单 → 发货', () => {
|
||||
test('Step 1: 新增客户', async ({ page }) => {
|
||||
await page.goto('/customers');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('button', { name: '新增客户' }).click();
|
||||
await expect(page.locator('.el-dialog')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
await page.getByPlaceholder('请输入客户公司名称').fill(`闭环_${Date.now()}`);
|
||||
await page.getByPlaceholder('联系人姓名').fill('测试联系人');
|
||||
await page.getByPlaceholder('所属行业').fill('测试行业');
|
||||
|
||||
await page.locator('.el-dialog__footer').getByRole('button', { name: /确|保存|提交/ }).click();
|
||||
await expect(page.locator('.el-message--success')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('Step 2: 创建订单', async ({ page }) => {
|
||||
await page.goto('/orders');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('button', { name: '新建订单' }).click();
|
||||
// el-drawer 打开
|
||||
await expect(page.getByLabel('新建订单')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText('订单明细')).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('Step 3: 验证订单列表', async ({ page }) => {
|
||||
await page.goto('/orders');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('.el-table').first()).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 合同管理 E2E 测试 (修正版)
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('合同管理', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/contracts');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('合同列表正确加载', async ({ page }) => {
|
||||
await expect(page.locator('.el-table').first()).toBeVisible({ timeout: 5000 });
|
||||
// 用表头列来验证
|
||||
await expect(page.getByRole('columnheader', { name: '合同编号' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('新增合同弹窗可打开', async ({ page }) => {
|
||||
const addBtn = page.getByRole('button', { name: /新增|新建|创建/ });
|
||||
if (await addBtn.isVisible()) {
|
||||
await addBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
// 可能是 dialog 或全屏
|
||||
await expect(page.getByLabel('新增合同').first()).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('合同详情页导航', async ({ page }) => {
|
||||
const viewBtn = page.locator('.el-table').getByRole('button', { name: /详情|查看/ }).first();
|
||||
if (await viewBtn.isVisible({ timeout: 3000 })) {
|
||||
await viewBtn.click();
|
||||
await page.waitForURL(/contracts\/detail/, { timeout: 5000 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 客户管理 E2E 测试 (修正版)
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('客户管理', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/customers');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('客户列表正确加载', async ({ page }) => {
|
||||
await expect(page.locator('.el-table').first()).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByRole('button', { name: '新增客户' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('新增客户完整流程', async ({ page }) => {
|
||||
await page.getByRole('button', { name: '新增客户' }).click();
|
||||
await expect(page.locator('.el-dialog')).toBeVisible({ timeout: 3000 });
|
||||
|
||||
const timestamp = Date.now();
|
||||
await page.getByPlaceholder('请输入客户公司名称').fill(`E2E测试客户_${timestamp}`);
|
||||
await page.getByPlaceholder('所属行业').fill('自动化测试');
|
||||
await page.getByPlaceholder('联系人姓名').fill('张测试');
|
||||
|
||||
// 选择客户级别
|
||||
await page.locator('.el-dialog .el-select').first().click();
|
||||
await page.waitForTimeout(500);
|
||||
// 选项: A级重点 / B级普通 / C级长尾
|
||||
await page.getByRole('option').first().click();
|
||||
|
||||
// 提交
|
||||
await page.locator('.el-dialog__footer').getByRole('button', { name: /确|保存|提交/ }).click();
|
||||
await expect(page.locator('.el-dialog')).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('关键词搜索客户', async ({ page }) => {
|
||||
const searchInput = page.getByPlaceholder(/搜索|客户名称|关键词/);
|
||||
if (await searchInput.isVisible()) {
|
||||
await searchInput.fill('中石化');
|
||||
await page.getByRole('button', { name: '搜索' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('.el-table').first()).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('查看客户档案详情', async ({ page }) => {
|
||||
const viewBtn = page.locator('.el-table').getByRole('button', { name: '查看档案' }).first();
|
||||
if (await viewBtn.isVisible({ timeout: 3000 })) {
|
||||
await viewBtn.click();
|
||||
await page.waitForURL(/customers\/detail/);
|
||||
}
|
||||
});
|
||||
|
||||
test('编辑客户信息', async ({ page }) => {
|
||||
const editBtn = page.locator('.el-table').getByRole('button', { name: '编辑' }).first();
|
||||
if (await editBtn.isVisible({ timeout: 3000 })) {
|
||||
await editBtn.click();
|
||||
await expect(page.locator('.el-dialog')).toBeVisible({ timeout: 3000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('分页翻页', async ({ page }) => {
|
||||
const pagination = page.locator('.el-pagination');
|
||||
if (await pagination.isVisible()) {
|
||||
const nextBtn = pagination.locator('.btn-next');
|
||||
if (await nextBtn.isEnabled()) {
|
||||
await nextBtn.click();
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('.el-table').first()).toBeVisible();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 工作台 Dashboard 测试
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('工作台', () => {
|
||||
test('统计卡片正确显示', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// 实际卡片标题
|
||||
await expect(page.getByText('本月新增订单')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText('待出库发货')).toBeVisible();
|
||||
await expect(page.getByText('库存预警 SKU')).toBeVisible();
|
||||
await expect(page.getByText('本月预计营收')).toBeVisible();
|
||||
});
|
||||
|
||||
test('侧边栏菜单可点击导航', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// 菜单在子菜单组中,需先展开 "业务线"
|
||||
await page.getByText('业务线').click();
|
||||
await page.waitForTimeout(500);
|
||||
await page.getByRole('menuitem', { name: '客户管理' }).click();
|
||||
await page.waitForURL('/customers');
|
||||
await expect(page).toHaveURL('/customers');
|
||||
});
|
||||
|
||||
test('快捷操作按钮可见', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.getByRole('button', { name: '新建订单' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '安排发货' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '库存入库' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* 财务模块 E2E 测试 (修正版)
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('报销大盘', () => {
|
||||
test('报销大盘页面加载', async ({ page }) => {
|
||||
await page.goto('/finance');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('body')).toContainText(/票据|发票|报销|费用/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('票据相关内容可见', async ({ page }) => {
|
||||
await page.goto('/finance');
|
||||
await page.waitForLoadState('networkidle');
|
||||
// 只要页面有相关内容加载
|
||||
await expect(page.locator('body')).toContainText(/报销|票据|费用|支出/, { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('销项发票', () => {
|
||||
test('销项发票页面加载', async ({ page }) => {
|
||||
await page.goto('/finance/sales-invoices');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('body')).toContainText(/销项发票|发票/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('销项发票列表', async ({ page }) => {
|
||||
await page.goto('/finance/sales-invoices');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const table = page.locator('.el-table').first();
|
||||
if (await table.isVisible({ timeout: 5000 })) {
|
||||
await expect(table).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 登录页面测试
|
||||
* 覆盖: 页面渲染 / 正确登录 / 错误密码 / 空字段校验 / 跳转
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
// 登录测试不使用全局 auth,独立登录
|
||||
test.use({ storageState: { cookies: [], origins: [] } });
|
||||
|
||||
test.describe('登录页面', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
});
|
||||
|
||||
test('页面正确渲染', async ({ page }) => {
|
||||
// 标题
|
||||
await expect(page.locator('.login-title')).toContainText('天津硕博霖 CRM 系统');
|
||||
await expect(page.locator('.login-subtitle')).toContainText('v2.0');
|
||||
|
||||
// 表单元素
|
||||
await expect(page.getByPlaceholder('用户名')).toBeVisible();
|
||||
await expect(page.getByPlaceholder('密码')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '登 录' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('正确账密登录后跳转工作台', async ({ page }) => {
|
||||
await page.getByPlaceholder('用户名').fill('admin');
|
||||
await page.getByPlaceholder('密码').fill('123456');
|
||||
await page.getByRole('button', { name: '登 录' }).click();
|
||||
|
||||
// 等待跳转到首页
|
||||
await page.waitForURL('/', { timeout: 10000 });
|
||||
// 侧边栏菜单应出现
|
||||
await expect(page.locator('.el-menu--vertical').first()).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('错误密码弹出错误提示', async ({ page }) => {
|
||||
await page.getByPlaceholder('用户名').fill('admin');
|
||||
await page.getByPlaceholder('密码').fill('wrong123');
|
||||
await page.getByRole('button', { name: '登 录' }).click();
|
||||
|
||||
// Element Plus 的 ElMessage 错误提示
|
||||
await expect(page.locator('.el-message--error')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('空字段显示校验提示', async ({ page }) => {
|
||||
// 直接点登录
|
||||
await page.getByRole('button', { name: '登 录' }).click();
|
||||
|
||||
// el-form 校验提示
|
||||
await expect(page.locator('.el-form-item__error')).toHaveCount(2);
|
||||
await expect(page.locator('.el-form-item__error').first()).toContainText('请输入用户名');
|
||||
});
|
||||
|
||||
test('密码少于6位提示校验', async ({ page }) => {
|
||||
await page.getByPlaceholder('用户名').fill('admin');
|
||||
await page.getByPlaceholder('密码').fill('123');
|
||||
await page.getByRole('button', { name: '登 录' }).click();
|
||||
|
||||
await expect(page.locator('.el-form-item__error')).toContainText('密码至少 6 位');
|
||||
});
|
||||
|
||||
test('未登录访问首页被重定向到登录页', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForURL(/\/login/);
|
||||
await expect(page.locator('.login-title')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 发货、利润、日志、复盘页面 (修正版)
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('发货记录', () => {
|
||||
test('发货页面加载', async ({ page }) => {
|
||||
await page.goto('/shipping');
|
||||
await page.waitForLoadState('networkidle');
|
||||
// 验证页面加载了发货相关内容
|
||||
await expect(page.locator('body')).toContainText(/发货|物流|订单/, { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('利润核算', () => {
|
||||
test('利润核算页面加载', async ({ page }) => {
|
||||
await page.goto('/profit');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('body')).toContainText(/利润|核算|成本/, { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('销售日志', () => {
|
||||
test('销售日志列表加载', async ({ page }) => {
|
||||
await page.goto('/logs');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('body')).toContainText(/销售日志|日志/, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('新增日志入口', async ({ page }) => {
|
||||
await page.goto('/logs');
|
||||
await page.waitForLoadState('networkidle');
|
||||
const addBtn = page.getByRole('button', { name: /新增|新建|撰写|写/ });
|
||||
if (await addBtn.isVisible({ timeout: 3000 })) {
|
||||
await expect(addBtn).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('AI 智能复盘', () => {
|
||||
test('复盘页面加载', async ({ page }) => {
|
||||
await page.goto('/reports');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expect(page.locator('body')).toContainText(/复盘|报告|AI/, { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 订单管理 E2E 测试 (v4 - 基于源码修正)
|
||||
* 新建订单是 el-drawer size=85%
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('订单管理', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/orders');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('订单列表正确加载', async ({ page }) => {
|
||||
await expect(page.locator('.el-table').first()).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByRole('button', { name: '新建订单' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('打开新建订单抽屉', async ({ page }) => {
|
||||
await page.getByRole('button', { name: '新建订单' }).click();
|
||||
// el-drawer 打开
|
||||
await expect(page.getByLabel('新建订单')).toBeVisible({ timeout: 5000 });
|
||||
// 验证关键元素
|
||||
await expect(page.getByText('订单明细')).toBeVisible();
|
||||
});
|
||||
|
||||
test('新建订单页面元素验证', async ({ page }) => {
|
||||
await page.getByRole('button', { name: '新建订单' }).click();
|
||||
await expect(page.getByLabel('新建订单')).toBeVisible({ timeout: 5000 });
|
||||
|
||||
await expect(page.getByRole('button', { name: /确认开单/ })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '取消' })).toBeVisible();
|
||||
await expect(page.getByText('添加产品行')).toBeVisible();
|
||||
});
|
||||
|
||||
test('筛选订单', async ({ page }) => {
|
||||
await page.getByRole('button', { name: '检索' }).click();
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('.el-table').first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('查看订单详情', async ({ page }) => {
|
||||
const detailBtn = page.locator('.el-table').getByRole('button', { name: '详情' }).first();
|
||||
if (await detailBtn.isVisible({ timeout: 3000 })) {
|
||||
await detailBtn.click();
|
||||
// 详情也是 el-drawer
|
||||
await expect(page.getByLabel('订单全景详情')).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('发货操作入口', async ({ page }) => {
|
||||
const shipBtn = page.locator('.el-table').getByRole('button', { name: '发货' }).first();
|
||||
if (await shipBtn.isVisible({ timeout: 3000 }) && await shipBtn.isEnabled()) {
|
||||
await expect(shipBtn).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* 产品与库存 E2E 测试
|
||||
* 覆盖: 分类树 / SKU 列表 / 新增 SKU / 库存入库 / 流水查看
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('产品与库存', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/products');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('页面正确加载', async ({ page }) => {
|
||||
// 表格和操作按钮可见
|
||||
await expect(page.locator('.el-table')).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('分类树可见', async ({ page }) => {
|
||||
// 左侧分类树
|
||||
const tree = page.locator('.el-tree');
|
||||
if (await tree.isVisible({ timeout: 3000 })) {
|
||||
await expect(tree).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('新增 SKU 弹窗', async ({ page }) => {
|
||||
const addBtn = page.getByRole('button', { name: /新增|新建.*SKU|新建产品/ });
|
||||
if (await addBtn.isVisible()) {
|
||||
await addBtn.click();
|
||||
await expect(page.locator('.el-dialog')).toBeVisible({ timeout: 3000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('SKU 搜索', async ({ page }) => {
|
||||
const searchInput = page.getByPlaceholder(/搜索|产品|SKU|关键词/);
|
||||
if (await searchInput.isVisible()) {
|
||||
await searchInput.fill('壳牌');
|
||||
await page.waitForTimeout(1000);
|
||||
// 表格应刷新
|
||||
await expect(page.locator('.el-table')).toBeVisible();
|
||||
}
|
||||
});
|
||||
|
||||
test('库存操作入口', async ({ page }) => {
|
||||
// 表格中应有库存/入库相关按钮
|
||||
const inventoryBtn = page.locator('.el-table').getByRole('button', { name: /入库|库存|编辑/ }).first();
|
||||
if (await inventoryBtn.isVisible({ timeout: 3000 })) {
|
||||
await expect(inventoryBtn).toBeVisible();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 系统设置 E2E 测试 (根据真实截图修正)
|
||||
* 三个 Tab:
|
||||
* - 部门与员工管理
|
||||
* - 角色与权限控制 (RBAC)
|
||||
* - 企业账号配置
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test.describe('系统设置', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/settings');
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('设置页面正确加载', async ({ page }) => {
|
||||
// 三个 Tab 标题
|
||||
await expect(page.getByText('部门与员工管理')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText('角色与权限控制')).toBeVisible();
|
||||
await expect(page.getByText('企业账号配置')).toBeVisible();
|
||||
});
|
||||
|
||||
test('部门与员工管理 tab 内容', async ({ page }) => {
|
||||
const deptTab = page.getByText('部门与员工管理').first();
|
||||
await deptTab.click();
|
||||
await page.waitForTimeout(1000);
|
||||
// 应显示部门树或员工列表
|
||||
await expect(page.locator('body')).toContainText(/部门|员工/, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('角色与权限控制 tab', async ({ page }) => {
|
||||
await page.getByText('角色与权限控制').first().click();
|
||||
await page.waitForTimeout(1500);
|
||||
// 左侧应有角色列表区域
|
||||
await expect(page.getByText('平台运营角色')).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText('新增角色')).toBeVisible();
|
||||
});
|
||||
|
||||
test('新增角色按钮', async ({ page }) => {
|
||||
await page.getByText('角色与权限控制').first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.getByText('新增角色')).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('企业账号配置 tab', async ({ page }) => {
|
||||
await page.getByText('企业账号配置').first().click();
|
||||
await page.waitForTimeout(1000);
|
||||
await expect(page.locator('body')).toContainText(/企业|账号/, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
Generated
+2111
-2042
File diff suppressed because it is too large
Load Diff
+31
-26
@@ -1,27 +1,32 @@
|
||||
{
|
||||
"name": "shbl-crm-frontend",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.ts,.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0",
|
||||
"pinia": "^2.2.0",
|
||||
"axios": "^1.7.0",
|
||||
"element-plus": "^2.9.0",
|
||||
"@element-plus/icons-vue": "^2.3.0",
|
||||
"pinia-plugin-persistedstate": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"typescript": "~5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
{
|
||||
"name": "shbl-crm-frontend",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.ts,.tsx",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:debug": "playwright test --debug"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.0",
|
||||
"vue-router": "^4.4.0",
|
||||
"pinia": "^2.2.0",
|
||||
"axios": "^1.7.0",
|
||||
"element-plus": "^2.9.0",
|
||||
"@element-plus/icons-vue": "^2.3.0",
|
||||
"pinia-plugin-persistedstate": "^3.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"@playwright/test": "^1.49.0",
|
||||
"typescript": "~5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* SHBL-ERP CRM Playwright E2E 配置
|
||||
*
|
||||
* 运行方式:
|
||||
* npx playwright test # 全量
|
||||
* npx playwright test --headed # 有头模式
|
||||
* npx playwright test --ui # UI 模式
|
||||
* npx playwright test tests/login.spec.ts # 单文件
|
||||
*
|
||||
* 前置:
|
||||
* 1. 确保后端 uvicorn 已启动 (http://localhost:8000)
|
||||
* 2. 确保前端 vite dev 已启动 (http://localhost:5173)
|
||||
* 3. 数据库已有可用的管理员账号 (admin / admin123)
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
fullyParallel: false, // CRM 测试有数据依赖,串行执行
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: [
|
||||
['list'],
|
||||
['html', { open: 'never' }],
|
||||
],
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:80',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
// 中文字体 viewport
|
||||
locale: 'zh-CN',
|
||||
timezoneId: 'Asia/Shanghai',
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
|
||||
projects: [
|
||||
// ── 先执行全局登录,保存认证状态 ──
|
||||
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||
|
||||
// ── 主测试套件 ──
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: './e2e/.auth/user.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
|
||||
/* 注释掉可选的自动启动 dev servers:
|
||||
webServer: [
|
||||
{
|
||||
command: 'cd ../server && venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000',
|
||||
port: 8000,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
{
|
||||
command: 'npm run dev',
|
||||
port: 5173,
|
||||
reuseExistingServer: true,
|
||||
},
|
||||
],
|
||||
*/
|
||||
});
|
||||
@@ -34,6 +34,9 @@ request.interceptors.request.use(
|
||||
if (userStore.token) {
|
||||
config.headers.Authorization = `Bearer ${userStore.token}`
|
||||
}
|
||||
if (userStore.currentCompanyId) {
|
||||
config.headers['X-Company-Id'] = userStore.currentCompanyId
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ChatDotRound, Select, Close } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { useChatStore } from '@/store/chat'
|
||||
@@ -7,6 +8,20 @@ import { ElMessage } from 'element-plus'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const chatStore = useChatStore()
|
||||
const route = useRoute()
|
||||
|
||||
// 路由感知:嗅探当前页面上下文
|
||||
const currentContext = computed(() => {
|
||||
const path = route.path
|
||||
const query = route.query
|
||||
if (path.includes('/customers/detail') && query.id) {
|
||||
return { context_type: 'customer', customer_id: query.id as string }
|
||||
}
|
||||
if (path.includes('/contracts/detail') && query.id) {
|
||||
return { context_type: 'contract', contract_id: query.id as string }
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// 悬浮球状态
|
||||
const isChatOpen = ref(false)
|
||||
@@ -119,7 +134,11 @@ const sendMessage = async () => {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({ message: userMsg, conversation_id: chatStore.conversationId })
|
||||
body: JSON.stringify({
|
||||
message: userMsg,
|
||||
conversation_id: chatStore.conversationId,
|
||||
...(currentContext.value ? { context: currentContext.value } : {}),
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -21,7 +21,10 @@ import {
|
||||
Setting,
|
||||
Fold,
|
||||
Expand,
|
||||
ArrowDown
|
||||
ArrowDown,
|
||||
OfficeBuilding,
|
||||
Stamp,
|
||||
TrendCharts
|
||||
} from '@element-plus/icons-vue'
|
||||
import FloatingChat from '@/components/FloatingChat.vue'
|
||||
|
||||
@@ -95,6 +98,15 @@ const handleCommand = (command: string) => {
|
||||
openPwdDialog()
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换公司 */
|
||||
const handleSwitchCompany = (companyId: string) => {
|
||||
if (companyId === userStore.currentCompanyId) return
|
||||
userStore.switchCompany(companyId)
|
||||
ElMessage.success(`已切换至:${userStore.currentCompanyName}`)
|
||||
// 刷新当前路由数据
|
||||
router.replace({ path: route.fullPath, query: { ...route.query, _t: Date.now().toString() } })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -133,6 +145,10 @@ const handleCommand = (command: string) => {
|
||||
<el-icon><Tickets /></el-icon>
|
||||
<template #title>订单管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/contracts">
|
||||
<el-icon><Stamp /></el-icon>
|
||||
<template #title>合同管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/shipping">
|
||||
<el-icon><Van /></el-icon>
|
||||
<template #title>发货记录</template>
|
||||
@@ -157,6 +173,10 @@ const handleCommand = (command: string) => {
|
||||
</template>
|
||||
<el-menu-item index="/finance/sales-invoices">销项发票</el-menu-item>
|
||||
<el-menu-item index="/finance">报销管理</el-menu-item>
|
||||
<el-menu-item index="/profit">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
<template #title>利润核算</template>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-sub-menu index="oa">
|
||||
@@ -202,6 +222,34 @@ const handleCommand = (command: string) => {
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- 公司视角切换 -->
|
||||
<el-dropdown
|
||||
v-if="userStore.companies.length > 0"
|
||||
trigger="click"
|
||||
@command="handleSwitchCompany"
|
||||
style="margin-right: 20px;"
|
||||
>
|
||||
<span class="company-dropdown">
|
||||
<el-icon><OfficeBuilding /></el-icon>
|
||||
<span class="company-name">{{ userStore.currentCompanyName }}</span>
|
||||
<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="c in userStore.companies"
|
||||
:key="c.id"
|
||||
:command="c.id"
|
||||
:class="{ 'is-active-company': c.id === userStore.currentCompanyId }"
|
||||
>
|
||||
{{ c.name }}
|
||||
<el-tag v-if="c.id === userStore.currentCompanyId" size="small" type="success" style="margin-left: 8px;">当前</el-tag>
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
|
||||
<!-- 用户头像 -->
|
||||
<el-dropdown @command="handleCommand">
|
||||
<span class="user-dropdown">
|
||||
<el-avatar :size="30" src="https://cube.elemecdn.com/3/7c/3ea6beec64369c2642b92c6726f1epng.png" />
|
||||
@@ -339,6 +387,24 @@ const handleCommand = (command: string) => {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.company-dropdown {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
.company-dropdown:hover {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
.company-name {
|
||||
margin: 0 4px;
|
||||
}
|
||||
.is-active-company {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.main {
|
||||
background-color: #f0f2f5;
|
||||
padding: 20px;
|
||||
|
||||
@@ -46,6 +46,24 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/orders/index.vue'),
|
||||
meta: { title: '订单管理' },
|
||||
},
|
||||
{
|
||||
path: 'orders/detail',
|
||||
name: 'OrderDetail',
|
||||
component: () => import('@/views/orders/OrderDetail.vue'),
|
||||
meta: { title: '订单详情' },
|
||||
},
|
||||
{
|
||||
path: 'contracts',
|
||||
name: 'Contracts',
|
||||
component: () => import('@/views/contracts/index.vue'),
|
||||
meta: { title: '合同管理' },
|
||||
},
|
||||
{
|
||||
path: 'contracts/detail',
|
||||
name: 'ContractDetail',
|
||||
component: () => import('@/views/contracts/ContractDetail.vue'),
|
||||
meta: { title: '合同详情' },
|
||||
},
|
||||
{
|
||||
path: 'shipping',
|
||||
name: 'Shipping',
|
||||
@@ -58,6 +76,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/products/index.vue'),
|
||||
meta: { title: '产品与库存' },
|
||||
},
|
||||
{
|
||||
path: 'profit',
|
||||
name: 'Profit',
|
||||
component: () => import('@/views/profit/index.vue'),
|
||||
meta: { title: '利润核算' },
|
||||
},
|
||||
{
|
||||
path: 'finance',
|
||||
name: 'Finance',
|
||||
@@ -116,10 +140,12 @@ router.beforeEach(async (to: any, _from: any, next: any) => {
|
||||
if (!userStore.userInfo) {
|
||||
try {
|
||||
await userStore.fetchUserInfo()
|
||||
await userStore.fetchCompanies()
|
||||
console.log('[Auth Guard] 用户信息已获取:', {
|
||||
username: userStore.username,
|
||||
dataScope: userStore.dataScope,
|
||||
menuKeys: userStore.menuKeys,
|
||||
currentCompanyId: userStore.currentCompanyId,
|
||||
})
|
||||
next({ ...to, replace: true })
|
||||
} catch {
|
||||
|
||||
+127
-73
@@ -1,73 +1,127 @@
|
||||
/**
|
||||
* 用户状态管理 (Pinia)
|
||||
* Token 持久化 + /auth/me 获取完整用户上下文(含 data_scope, menu_keys)
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import request from '@/api/request'
|
||||
|
||||
// 用户信息类型(对应后端 CurrentUserPayload)
|
||||
interface UserInfo {
|
||||
user_id: string
|
||||
username: string
|
||||
real_name: string | null
|
||||
dept_id: string | null
|
||||
dept_name: string | null
|
||||
role_id: string | null
|
||||
role_name: string | null
|
||||
data_scope: string
|
||||
menu_keys: string[]
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// ---- State ----
|
||||
const token = ref<string>(localStorage.getItem('crm_token') || '')
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
|
||||
// ---- Getters ----
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const username = computed(() => userInfo.value?.username || '')
|
||||
const realName = computed(() => userInfo.value?.real_name || userInfo.value?.username || '')
|
||||
const dataScope = computed(() => userInfo.value?.data_scope || 'self')
|
||||
const menuKeys = computed(() => userInfo.value?.menu_keys || [])
|
||||
|
||||
// ---- Actions ----
|
||||
|
||||
/** 登录:POST /api/auth/login → 拿 Token */
|
||||
async function login(loginUsername: string, password: string) {
|
||||
const data = await request.post('/api/auth/login', {
|
||||
username: loginUsername,
|
||||
password,
|
||||
}) as any
|
||||
|
||||
token.value = data.access_token
|
||||
localStorage.setItem('crm_token', data.access_token)
|
||||
}
|
||||
|
||||
/** 获取用户信息:GET /api/auth/me → 完整 Payload */
|
||||
async function fetchUserInfo() {
|
||||
const data = await request.get('/api/auth/me') as unknown as UserInfo
|
||||
userInfo.value = data
|
||||
}
|
||||
|
||||
/** 登出 */
|
||||
function logout() {
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
localStorage.removeItem('crm_token')
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
userInfo,
|
||||
isLoggedIn,
|
||||
username,
|
||||
realName,
|
||||
dataScope,
|
||||
menuKeys,
|
||||
login,
|
||||
fetchUserInfo,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
/**
|
||||
* 用户状态管理 (Pinia)
|
||||
* Token 持久化 + /auth/me 获取完整用户上下文(含 data_scope, menu_keys)
|
||||
* 公司视角切换 + localStorage 持久化
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import request from '@/api/request'
|
||||
|
||||
// 用户信息类型(对应后端 CurrentUserPayload)
|
||||
interface UserInfo {
|
||||
user_id: string
|
||||
username: string
|
||||
real_name: string | null
|
||||
dept_id: string | null
|
||||
dept_name: string | null
|
||||
role_id: string | null
|
||||
role_name: string | null
|
||||
data_scope: string
|
||||
menu_keys: string[]
|
||||
}
|
||||
|
||||
// 公司信息类型
|
||||
interface CompanyInfo {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
is_active: boolean
|
||||
}
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// ---- State ----
|
||||
const token = ref<string>(localStorage.getItem('crm_token') || '')
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
const currentCompanyId = ref<string>(localStorage.getItem('crm_company_id') || '')
|
||||
const companies = ref<CompanyInfo[]>([])
|
||||
|
||||
// ---- Getters ----
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const username = computed(() => userInfo.value?.username || '')
|
||||
const realName = computed(() => userInfo.value?.real_name || userInfo.value?.username || '')
|
||||
const dataScope = computed(() => userInfo.value?.data_scope || 'self')
|
||||
const menuKeys = computed(() => userInfo.value?.menu_keys || [])
|
||||
const currentCompanyName = computed(() => {
|
||||
const co = companies.value.find((item: CompanyInfo) => item.id === currentCompanyId.value)
|
||||
return co?.name || '选择公司'
|
||||
})
|
||||
const currentCompanyCode = computed(() => {
|
||||
const co = companies.value.find((item: CompanyInfo) => item.id === currentCompanyId.value)
|
||||
return co?.code || ''
|
||||
})
|
||||
|
||||
// ---- Actions ----
|
||||
|
||||
/** 登录:POST /api/auth/login → 拿 Token */
|
||||
async function login(loginUsername: string, password: string) {
|
||||
const data = await request.post('/api/auth/login', {
|
||||
username: loginUsername,
|
||||
password,
|
||||
}) as any
|
||||
|
||||
token.value = data.access_token
|
||||
localStorage.setItem('crm_token', data.access_token)
|
||||
}
|
||||
|
||||
/** 获取用户信息:GET /api/auth/me → 完整 Payload */
|
||||
async function fetchUserInfo() {
|
||||
const data = await request.get('/api/auth/me') as unknown as UserInfo
|
||||
userInfo.value = data
|
||||
}
|
||||
|
||||
/** 获取公司列表并设置默认 */
|
||||
async function fetchCompanies() {
|
||||
try {
|
||||
const data = await request.get('/api/companies') as any
|
||||
companies.value = data.companies || []
|
||||
const defaultId = data.default_company_id
|
||||
|
||||
// 如果当前没有选中公司,或选中的公司不在列表中,设为默认
|
||||
if (
|
||||
!currentCompanyId.value ||
|
||||
!companies.value.find((item: CompanyInfo) => item.id === currentCompanyId.value)
|
||||
) {
|
||||
const fallbackId = defaultId || (companies.value[0]?.id || '')
|
||||
switchCompany(fallbackId)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[fetchCompanies] 获取公司列表失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换公司视角 */
|
||||
function switchCompany(companyId: string) {
|
||||
currentCompanyId.value = companyId
|
||||
localStorage.setItem('crm_company_id', companyId)
|
||||
}
|
||||
|
||||
/** 登出 */
|
||||
function logout() {
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
currentCompanyId.value = ''
|
||||
companies.value = []
|
||||
localStorage.removeItem('crm_token')
|
||||
localStorage.removeItem('crm_company_id')
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
userInfo,
|
||||
currentCompanyId,
|
||||
companies,
|
||||
isLoggedIn,
|
||||
username,
|
||||
realName,
|
||||
dataScope,
|
||||
menuKeys,
|
||||
currentCompanyName,
|
||||
currentCompanyCode,
|
||||
login,
|
||||
fetchUserInfo,
|
||||
fetchCompanies,
|
||||
switchCompany,
|
||||
logout,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -37,6 +37,8 @@ async function handleLogin() {
|
||||
await userStore.login(form.username, form.password)
|
||||
// 2. 拉取用户信息(data_scope, menu_keys 等)
|
||||
await userStore.fetchUserInfo()
|
||||
// 3. 拉取公司列表并设置默认
|
||||
await userStore.fetchCompanies()
|
||||
|
||||
ElMessage.success(`欢迎回来,${userStore.realName}`)
|
||||
const redirect = (route.query.redirect as string) || '/'
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 合同详情页 —— 含执行进度面板 + 一键推单 + 双签盖章上传
|
||||
*/
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Check, Upload, ShoppingCart, Document } from '@element-plus/icons-vue'
|
||||
import request from '@/api/request'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const contractId = computed(() => route.query.id as string)
|
||||
const loading = ref(false)
|
||||
const contract = ref<any>({})
|
||||
|
||||
const fetchContract = async () => {
|
||||
if (!contractId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data: any = await request.get(`/api/contracts/${contractId.value}`)
|
||||
contract.value = data
|
||||
} catch { /* */ } finally { loading.value = false }
|
||||
}
|
||||
|
||||
// --- 一键生成订单 ---
|
||||
const generating = ref(false)
|
||||
const handleGenerateOrder = () => {
|
||||
ElMessageBox.confirm('确定要从此合同一键生成订单吗?', '生成订单', {
|
||||
confirmButtonText: '确定生成', cancelButtonText: '取消', type: 'info',
|
||||
}).then(async () => {
|
||||
generating.value = true
|
||||
try {
|
||||
const result: any = await request.post(`/api/contracts/${contractId.value}/generate-order`)
|
||||
ElMessage.success(`订单生成成功:${result.order_no}`)
|
||||
fetchContract()
|
||||
} catch { /* */ } finally { generating.value = false }
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// --- 上传双签盖章版 ---
|
||||
const getUploadHeaders = () => ({
|
||||
Authorization: `Bearer ${localStorage.getItem('crm_token') || ''}`,
|
||||
'X-Company-Id': localStorage.getItem('crm_company_id') || '',
|
||||
})
|
||||
const handleUploadSuccess = (response: any) => {
|
||||
if (response.code === 200) {
|
||||
ElMessage.success('双签盖章版上传成功')
|
||||
fetchContract()
|
||||
} else {
|
||||
ElMessage.error(response.message || '上传失败')
|
||||
}
|
||||
}
|
||||
|
||||
// --- 辅助 ---
|
||||
const getStatusLabel = (s: string) => {
|
||||
const map: Record<string, string> = { draft: '草稿', active: '执行中', completed: '已完成', cancelled: '已取消' }
|
||||
return map[s] || s
|
||||
}
|
||||
|
||||
// --- 下载合同 Word ---
|
||||
const downloadingDoc = ref(false)
|
||||
const handleDownloadContract = async () => {
|
||||
downloadingDoc.value = true
|
||||
try {
|
||||
const res = await fetch(`/api/contracts/${contractId.value}/generate`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('crm_token') || ''}`,
|
||||
'X-Company-Id': localStorage.getItem('crm_company_id') || '',
|
||||
},
|
||||
})
|
||||
if (!res.ok) throw new Error('Download failed')
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `合同_${contract.value.contract_no || contractId.value}.docx`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch { ElMessage.error('合同文档生成失败') }
|
||||
finally { downloadingDoc.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => fetchContract())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contract-detail-container" v-loading="loading">
|
||||
<el-page-header @back="router.back()" :title="'返回'" :content="`合同 ${contract.contract_no || ''}`" />
|
||||
|
||||
<div class="detail-grid" v-if="contract.id">
|
||||
<!-- 基本信息卡片 -->
|
||||
<el-card shadow="never">
|
||||
<template #header><span style="font-weight: bold;">合同基本信息</span></template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="合同编号">{{ contract.contract_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag>{{ getStatusLabel(contract.status) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="买方客户">{{ contract.buyer_customer_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="卖方公司">{{ contract.seller_company_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="付款条件">{{ contract.payment_terms }}</el-descriptions-item>
|
||||
<el-descriptions-item label="运费条款">{{ contract.shipping_terms }}</el-descriptions-item>
|
||||
<el-descriptions-item label="不含税金额">¥{{ (contract.total_amount_excl_tax || 0).toFixed(2) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="含税金额">¥{{ (contract.total_amount_incl_tax || 0).toFixed(2) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="大写金额" :span="2">{{ contract.total_amount_cn || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="签约日期">{{ contract.sign_date || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="负责人">{{ contract.salesperson_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ contract.remark || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 合同明细 -->
|
||||
<el-card shadow="never">
|
||||
<template #header><span style="font-weight: bold;">合同明细</span></template>
|
||||
<el-table :data="contract.items || []" stripe border size="small">
|
||||
<el-table-column prop="sku_code" label="产品编码" width="140" />
|
||||
<el-table-column prop="sku_name" label="产品名称" min-width="200" />
|
||||
<el-table-column prop="spec" label="规格" width="120" />
|
||||
<el-table-column prop="unit" label="单位" width="80" />
|
||||
<el-table-column prop="qty" label="数量" width="100" align="right" />
|
||||
<el-table-column label="单价" width="120" align="right">
|
||||
<template #default="scope">¥{{ (scope.row.unit_price || 0).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计" width="140" align="right">
|
||||
<template #default="scope">¥{{ (scope.row.sub_total || 0).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 执行进度 -->
|
||||
<el-card shadow="never" v-if="contract.progress">
|
||||
<template #header><span style="font-weight: bold;">执行进度</span></template>
|
||||
<el-steps :active="progressStep" finish-status="success" align-center>
|
||||
<el-step title="双签" :description="contract.progress.is_signed ? '已完成' : '待签署'" />
|
||||
<el-step title="生成订单" :description="contract.progress.has_order ? '已生成' : '待生成'" />
|
||||
<el-step title="发货" :description="contract.progress.has_shipped ? '已发货' : '待发货'" />
|
||||
<el-step title="开票" :description="contract.progress.has_invoice ? '已开票' : '待开票'" />
|
||||
<el-step title="回款" :description="contract.progress.is_paid ? '已回款' : '待回款'" />
|
||||
</el-steps>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作区 -->
|
||||
<el-card shadow="never">
|
||||
<template #header><span style="font-weight: bold;">操作</span></template>
|
||||
<div style="display: flex; gap: 16px; flex-wrap: wrap;">
|
||||
<el-button type="success" :icon="Document" :loading="downloadingDoc" @click="handleDownloadContract">下载合同</el-button>
|
||||
|
||||
<el-button type="primary" :icon="ShoppingCart" :loading="generating"
|
||||
:disabled="!!(contract.linked_order_id)" @click="handleGenerateOrder">
|
||||
{{ contract.linked_order_id ? '已生成订单' : '一键生成订单' }}
|
||||
</el-button>
|
||||
|
||||
<el-upload
|
||||
:action="`/api/contracts/${contractId}/upload-signed`"
|
||||
:headers="getUploadHeaders()"
|
||||
:show-file-list="false"
|
||||
:on-success="handleUploadSuccess"
|
||||
accept=".pdf,.jpg,.png"
|
||||
>
|
||||
<el-button type="warning" :icon="Upload">上传双签盖章版</el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
export default {
|
||||
computed: {
|
||||
progressStep() {
|
||||
const p = (this as any).contract?.progress
|
||||
if (!p) return 0
|
||||
if (p.is_paid) return 5
|
||||
if (p.has_invoice) return 4
|
||||
if (p.has_shipped) return 3
|
||||
if (p.has_order) return 2
|
||||
if (p.is_signed) return 1
|
||||
return 0
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contract-detail-container { display: flex; flex-direction: column; gap: 20px; }
|
||||
.detail-grid { display: flex; flex-direction: column; gap: 20px; margin-top: 16px; }
|
||||
</style>
|
||||
@@ -0,0 +1,279 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 合同管理列表页
|
||||
* GET /api/contracts (分页 + 搜索 + 状态筛选)
|
||||
* POST /api/contracts (新增)
|
||||
* DELETE /api/contracts/{id} (软删除)
|
||||
*/
|
||||
import { ref, reactive, onMounted, nextTick, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Search, Plus, View, Delete, Document } from '@element-plus/icons-vue'
|
||||
import { ElMessageBox, ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
|
||||
// --- 数据 ---
|
||||
const contractData = ref<any[]>([])
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const total = ref(0)
|
||||
const searchForm = reactive({ keyword: '', status: '' })
|
||||
|
||||
// --- 搜索/分页 ---
|
||||
const fetchContracts = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { page: currentPage.value, size: pageSize.value }
|
||||
if (searchForm.keyword) params.keyword = searchForm.keyword
|
||||
if (searchForm.status) params.status = searchForm.status
|
||||
const data: any = await request.get('/api/contracts', { params })
|
||||
contractData.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
} catch { /* 统一处理 */ } finally { loading.value = false }
|
||||
}
|
||||
const handleSearch = () => { currentPage.value = 1; fetchContracts() }
|
||||
const handlePageChange = () => fetchContracts()
|
||||
|
||||
// --- 新增合同弹窗 ---
|
||||
const addDialogVisible = ref(false)
|
||||
const addFormRef = ref<FormInstance>()
|
||||
const addSubmitting = ref(false)
|
||||
const skuOptions = ref<any[]>([])
|
||||
const customerOptions = ref<any[]>([])
|
||||
|
||||
const addForm = reactive({
|
||||
buyer_customer_id: '',
|
||||
payment_terms: '货到付全款',
|
||||
shipping_terms: '买方自提',
|
||||
delivery_terms: '',
|
||||
remark: '',
|
||||
items: [{ sku_id: '', qty: 1, unit_price: 0, sub_total: 0 }] as any[],
|
||||
})
|
||||
|
||||
const addFormRules = reactive<FormRules>({
|
||||
buyer_customer_id: [{ required: true, message: '请选择买方客户', trigger: 'change' }],
|
||||
})
|
||||
|
||||
const paymentTermsOptions = [
|
||||
'预付全款订货', '预付30%订货,到货前付清', '预付50%订货,到货前付清',
|
||||
'货到付全款', '开具发票后30天内付款', '开具发票45天付款',
|
||||
'开具发票60天付款', '开具发票90天付款',
|
||||
]
|
||||
const shippingTermsOptions = [
|
||||
'买方自提', '卖方免费送达天津指定地点', '卖方免费送达指定地点', '物流发货,运费买方承担',
|
||||
]
|
||||
|
||||
const addContractItem = () => {
|
||||
addForm.items.push({ sku_id: '', qty: 1, unit_price: 0, sub_total: 0 })
|
||||
}
|
||||
const removeContractItem = (index: number) => {
|
||||
if (addForm.items.length > 1) addForm.items.splice(index, 1)
|
||||
}
|
||||
const calcSubTotal = (item: any) => {
|
||||
item.sub_total = +(item.qty * item.unit_price).toFixed(2)
|
||||
}
|
||||
const contractTotal = computed(() => addForm.items.reduce((s: number, i: any) => s + (i.sub_total || 0), 0))
|
||||
|
||||
const handleSkuChange = (item: any) => {
|
||||
const sku = skuOptions.value.find((s: any) => s.id === item.sku_id)
|
||||
if (sku) {
|
||||
item.unit_price = sku.standard_price || 0
|
||||
calcSubTotal(item)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddContract = async () => {
|
||||
addForm.buyer_customer_id = ''
|
||||
addForm.payment_terms = '货到付全款'
|
||||
addForm.shipping_terms = '买方自提'
|
||||
addForm.delivery_terms = ''
|
||||
addForm.remark = ''
|
||||
addForm.items = [{ sku_id: '', qty: 1, unit_price: 0, sub_total: 0 }]
|
||||
addDialogVisible.value = true
|
||||
// 拉取客户、SKU 和卖方公司信息
|
||||
try {
|
||||
const [custRes, skuRes, companyRes] = await Promise.all([
|
||||
request.get('/api/customers/search', { params: { q: '%' } }),
|
||||
request.get('/api/products/skus', { params: { page: 1, size: 100 } }),
|
||||
request.get('/api/companies/current'),
|
||||
])
|
||||
customerOptions.value = (custRes as any) || []
|
||||
skuOptions.value = ((skuRes as any)?.items || skuRes) || []
|
||||
sellerCompanyName.value = (companyRes as any)?.full_info?.company_name || (companyRes as any)?.name || '当前公司'
|
||||
} catch { /* */ }
|
||||
}
|
||||
|
||||
const sellerCompanyName = ref('当前公司')
|
||||
|
||||
const submitAddContract = async () => {
|
||||
addSubmitting.value = true
|
||||
try {
|
||||
await request.post('/api/contracts', {
|
||||
buyer_customer_id: addForm.buyer_customer_id,
|
||||
payment_terms: addForm.payment_terms,
|
||||
shipping_terms: addForm.shipping_terms,
|
||||
delivery_terms: addForm.delivery_terms || null,
|
||||
remark: addForm.remark,
|
||||
items: addForm.items.filter((i: any) => i.sku_id),
|
||||
})
|
||||
ElMessage.success('合同创建成功')
|
||||
addDialogVisible.value = false
|
||||
fetchContracts()
|
||||
} catch { /* */ } finally { addSubmitting.value = false }
|
||||
}
|
||||
|
||||
// --- 查看详情 ---
|
||||
const viewDetail = (row: any) => {
|
||||
router.push({ path: '/contracts/detail', query: { id: row.id } })
|
||||
}
|
||||
|
||||
// --- 删除 ---
|
||||
const deleteContract = (row: any) => {
|
||||
ElMessageBox.confirm(`确定要删除合同 "${row.contract_no}" 吗?`, '确认删除', {
|
||||
confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning',
|
||||
}).then(async () => {
|
||||
await request.delete(`/api/contracts/${row.id}`)
|
||||
ElMessage.success('合同已删除')
|
||||
fetchContracts()
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// --- 辅助 ---
|
||||
const getStatusType = (s: string) => {
|
||||
const map: Record<string, string> = { draft: 'info', active: '', completed: 'success', cancelled: 'danger' }
|
||||
return map[s] || 'info'
|
||||
}
|
||||
const getStatusLabel = (s: string) => {
|
||||
const map: Record<string, string> = { draft: '草稿', active: '执行中', completed: '已完成', cancelled: '已取消' }
|
||||
return map[s] || s
|
||||
}
|
||||
|
||||
onMounted(() => fetchContracts())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="contract-list-container">
|
||||
<!-- 搜索区 -->
|
||||
<el-card shadow="never" class="filter-section">
|
||||
<div class="filter-wrapper">
|
||||
<el-form :inline="true" :model="searchForm" class="filter-form">
|
||||
<el-form-item label="合同编号">
|
||||
<el-input v-model="searchForm.keyword" placeholder="搜索合同编号" clearable @keyup.enter="handleSearch" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="searchForm.status" placeholder="全部" clearable style="width: 140px">
|
||||
<el-option label="草稿" value="draft" />
|
||||
<el-option label="执行中" value="active" />
|
||||
<el-option label="已完成" value="completed" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div class="action-buttons">
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
||||
<el-button type="success" :icon="Plus" @click="handleAddContract">新增合同</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-card shadow="never" class="table-section">
|
||||
<el-table :data="contractData" stripe border v-loading="loading">
|
||||
<el-table-column prop="contract_no" label="合同编号" width="200" />
|
||||
<el-table-column prop="buyer_customer_name" label="买方客户" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="seller_company_name" label="卖方公司" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="合同金额" width="140" align="right">
|
||||
<template #default="scope">
|
||||
¥{{ (scope.row.total_amount_incl_tax || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="payment_terms" label="付款条件" width="200" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag :type="getStatusType(scope.row.status)" effect="light">{{ getStatusLabel(scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="双签" width="80" align="center">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.is_signed" type="success" effect="plain">已签</el-tag>
|
||||
<el-tag v-else type="info" effect="plain">未签</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="170" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" link :icon="View" @click="viewDetail(scope.row)">详情</el-button>
|
||||
<el-button type="danger" link :icon="Delete" @click="deleteContract(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="pagination-section">
|
||||
<el-pagination v-model:current-page="currentPage" v-model:page-size="pageSize" :page-sizes="[10, 20, 50]"
|
||||
background layout="total, prev, pager, next, jumper" :total="total"
|
||||
@current-change="handlePageChange" @size-change="handlePageChange" />
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 新增合同弹窗 -->
|
||||
<el-dialog v-model="addDialogVisible" title="新增合同" width="780px" destroy-on-close>
|
||||
<el-form ref="addFormRef" :model="addForm" :rules="addFormRules" label-width="100px">
|
||||
<el-form-item label="买方客户" prop="buyer_customer_id">
|
||||
<el-select v-model="addForm.buyer_customer_id" placeholder="请选择客户" filterable style="width: 100%">
|
||||
<el-option v-for="c in customerOptions" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="卖方公司">
|
||||
<el-input :model-value="sellerCompanyName" disabled />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px;">卖方信息由系统设置中的企业账号配置自动填充</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="付款条件">
|
||||
<el-select v-model="addForm.payment_terms" style="width: 100%">
|
||||
<el-option v-for="t in paymentTermsOptions" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="运费条款">
|
||||
<el-select v-model="addForm.shipping_terms" style="width: 100%">
|
||||
<el-option v-for="t in shippingTermsOptions" :key="t" :label="t" :value="t" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="货期">
|
||||
<el-input v-model="addForm.delivery_terms" placeholder="如:下单后15个工作日发货" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="addForm.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">合同明细</el-divider>
|
||||
<div v-for="(item, idx) in addForm.items" :key="idx" style="display: flex; gap: 8px; margin-bottom: 12px; align-items: center;">
|
||||
<el-select v-model="item.sku_id" placeholder="选择产品" filterable style="flex: 2" @change="handleSkuChange(item)">
|
||||
<el-option v-for="s in skuOptions" :key="s.id" :label="`${s.name} (${s.sku_code})`" :value="s.id" />
|
||||
</el-select>
|
||||
<el-input-number v-model="item.qty" :min="0.01" :precision="2" style="flex: 1" @change="calcSubTotal(item)" />
|
||||
<el-input-number v-model="item.unit_price" :min="0" :precision="2" style="flex: 1" @change="calcSubTotal(item)" />
|
||||
<span style="min-width: 80px; text-align: right;">¥{{ item.sub_total.toFixed(2) }}</span>
|
||||
<el-button type="danger" link @click="removeContractItem(idx)" :disabled="addForm.items.length <= 1">删除</el-button>
|
||||
</div>
|
||||
<el-button type="primary" link @click="addContractItem">+ 添加明细行</el-button>
|
||||
<div style="margin-top: 12px; text-align: right; font-weight: bold;">合计:¥{{ contractTotal.toFixed(2) }}</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="addSubmitting" @click="submitAddContract">确定提交</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.contract-list-container { display: flex; flex-direction: column; gap: 20px; }
|
||||
.filter-section { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); }
|
||||
.filter-wrapper { display: flex; justify-content: space-between; flex-wrap: wrap; align-items: flex-start; }
|
||||
.filter-form .el-form-item { margin-bottom: 0; margin-right: 20px; }
|
||||
.action-buttons { display: flex; gap: 10px; }
|
||||
.table-section { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); }
|
||||
.pagination-section { margin-top: 20px; display: flex; justify-content: flex-end; }
|
||||
</style>
|
||||
@@ -43,6 +43,7 @@ const customer = reactive({
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
ai_persona: null as any,
|
||||
billing_info: null as any,
|
||||
})
|
||||
|
||||
const fetchCustomer = async () => {
|
||||
@@ -148,6 +149,67 @@ const fetchProducts = async (customerId: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 开票信息管理 (Billing Info) ---
|
||||
const billingDialogVisible = ref(false)
|
||||
const billingSubmitting = ref(false)
|
||||
const billingFormRef = ref<FormInstance>()
|
||||
const billingForm = reactive({
|
||||
company_name: '',
|
||||
tax_id: '',
|
||||
bank_name: '',
|
||||
bank_account: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
})
|
||||
|
||||
const openBillingDialog = () => {
|
||||
if (customer.billing_info) {
|
||||
Object.assign(billingForm, {
|
||||
company_name: customer.billing_info.company_name || '',
|
||||
tax_id: customer.billing_info.tax_id || '',
|
||||
bank_name: customer.billing_info.bank_name || '',
|
||||
bank_account: customer.billing_info.bank_account || '',
|
||||
address: customer.billing_info.address || '',
|
||||
phone: customer.billing_info.phone || '',
|
||||
})
|
||||
} else {
|
||||
Object.assign(billingForm, {
|
||||
company_name: '',
|
||||
tax_id: '',
|
||||
bank_name: '',
|
||||
bank_account: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
})
|
||||
}
|
||||
billingDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitBilling = async () => {
|
||||
if (!billingFormRef.value) return
|
||||
billingSubmitting.value = true
|
||||
try {
|
||||
await request.put(`/api/customers/${customer.id}`, { billing_info: billingForm })
|
||||
ElMessage.success('开票信息已保存')
|
||||
billingDialogVisible.value = false
|
||||
fetchCustomer()
|
||||
} catch {
|
||||
} finally {
|
||||
billingSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteBilling = () => {
|
||||
ElMessageBox.confirm('确定要删除此客户的开票信息吗?', '警告', { type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
await request.put(`/api/customers/${customer.id}`, { billing_info: null })
|
||||
ElMessage.success('开票信息已删除')
|
||||
fetchCustomer()
|
||||
} catch {}
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
// --- 级别显示映射 ---
|
||||
const levelMap: Record<string, { text: string; type: string }> = {
|
||||
A: { text: 'A级 · 核心客户', type: 'danger' },
|
||||
@@ -227,8 +289,40 @@ onMounted(fetchCustomer)
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 1.5 开票信息卡片 -->
|
||||
<el-card shadow="never" class="profile-header mt-20" v-if="customer.id && customer.billing_info && Object.keys(customer.billing_info).length > 0">
|
||||
<template #header>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-weight: bold; color: #303133;">🏦 开票信息 (Billing Info)</span>
|
||||
<div>
|
||||
<el-button type="primary" link @click="openBillingDialog">编辑</el-button>
|
||||
<el-button type="danger" link @click="deleteBilling">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-descriptions :column="2" class="desc-box" border>
|
||||
<el-descriptions-item label="企业全称">{{ customer.billing_info.company_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="纳税人识别号">{{ customer.billing_info.tax_id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开户银行">{{ customer.billing_info.bank_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="银行账号">{{ customer.billing_info.bank_account || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开票地址" :span="2">{{ customer.billing_info.address || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开票电话" :span="2">{{ customer.billing_info.phone || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="profile-header mt-20" v-else-if="customer.id">
|
||||
<template #header>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-weight: bold; color: #303133;">🏦 开票信息 (Billing Info)</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty description="暂无开票信息" :image-size="60">
|
||||
<el-button type="primary" @click="openBillingDialog">新增开票信息</el-button>
|
||||
</el-empty>
|
||||
</el-card>
|
||||
|
||||
<!-- 2. Tabs 面板 -->
|
||||
<el-card shadow="never" class="tabs-card" v-if="customer.id">
|
||||
<el-card shadow="never" class="tabs-card mt-20" v-if="customer.id">
|
||||
<el-tabs>
|
||||
<el-tab-pane label="AI 企业画像" name="persona">
|
||||
<div v-if="customer.ai_persona && Object.keys(customer.ai_persona).length > 0" class="persona-container">
|
||||
@@ -380,6 +474,34 @@ onMounted(fetchCustomer)
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 开票信息表单弹窗 -->
|
||||
<el-dialog v-model="billingDialogVisible" title="编辑开票信息" width="500px" destroy-on-close>
|
||||
<el-form ref="billingFormRef" :model="billingForm" label-width="110px">
|
||||
<el-form-item label="企业全称">
|
||||
<el-input v-model="billingForm.company_name" placeholder="请输入开票抬头" />
|
||||
</el-form-item>
|
||||
<el-form-item label="纳税人识别号">
|
||||
<el-input v-model="billingForm.tax_id" placeholder="请输入统一社会信用代码/税号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开户银行">
|
||||
<el-input v-model="billingForm.bank_name" placeholder="请输入开户行名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="银行账号">
|
||||
<el-input v-model="billingForm.bank_account" placeholder="请输入银行账号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开票地址">
|
||||
<el-input v-model="billingForm.address" placeholder="请输入注册地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="企业电话">
|
||||
<el-input v-model="billingForm.phone" placeholder="请输入企业电话" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="billingDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="billingSubmitting" @click="submitBilling">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 悬浮按钮 - 写新日志 -->
|
||||
<el-tooltip content="写新跟进记录" placement="left">
|
||||
<el-button v-if="customer.id" type="primary" circle size="large" class="floating-btn" @click="handleAddLog" :icon="CirclePlus" />
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Search, Plus, View, Edit, Box, Upload, Download } from '@element-plus/icons-vue'
|
||||
import { Search, Plus, View, Edit, Box, Upload, Download, Sort } from '@element-plus/icons-vue'
|
||||
import { ElMessageBox, ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
import { useUserStore } from '@/store/user'
|
||||
@@ -77,13 +77,21 @@ const addForm = reactive({
|
||||
phone: '',
|
||||
address: '',
|
||||
remark: '',
|
||||
billing_info: {
|
||||
company_name: '',
|
||||
tax_id: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
bank_name: '',
|
||||
bank_account: '',
|
||||
},
|
||||
})
|
||||
const addFormRules = reactive<FormRules>({
|
||||
name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
|
||||
})
|
||||
|
||||
const handleAddCustomer = () => {
|
||||
Object.assign(addForm, { name: '', level: 'B', industry: '', contact: '', phone: '', address: '', remark: '' })
|
||||
Object.assign(addForm, { name: '', level: 'B', industry: '', contact: '', phone: '', address: '', remark: '', billing_info: { company_name: '', tax_id: '', address: '', phone: '', bank_name: '', bank_account: '' } })
|
||||
addDialogVisible.value = true
|
||||
nextTick(() => addFormRef.value?.clearValidate())
|
||||
}
|
||||
@@ -117,12 +125,21 @@ const editForm = reactive({
|
||||
phone: '',
|
||||
address: '',
|
||||
remark: '',
|
||||
billing_info: {
|
||||
company_name: '',
|
||||
tax_id: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
bank_name: '',
|
||||
bank_account: '',
|
||||
},
|
||||
})
|
||||
const editFormRules = reactive<FormRules>({
|
||||
name: [{ required: true, message: '请输入客户名称', trigger: 'blur' }],
|
||||
})
|
||||
|
||||
const editCustomer = (row: any) => {
|
||||
const bi = row.billing_info || {}
|
||||
Object.assign(editForm, {
|
||||
id: row.id,
|
||||
name: row.name || '',
|
||||
@@ -132,6 +149,14 @@ const editCustomer = (row: any) => {
|
||||
phone: row.phone || '',
|
||||
address: row.address || '',
|
||||
remark: row.remark || '',
|
||||
billing_info: {
|
||||
company_name: bi.company_name || '',
|
||||
tax_id: bi.tax_id || '',
|
||||
address: bi.address || '',
|
||||
phone: bi.phone || '',
|
||||
bank_name: bi.bank_name || '',
|
||||
bank_account: bi.bank_account || '',
|
||||
},
|
||||
})
|
||||
editDialogVisible.value = true
|
||||
nextTick(() => editFormRef.value?.clearValidate())
|
||||
@@ -247,6 +272,46 @@ const handleExport = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 客户转移 ---
|
||||
const transferDialogVisible = ref(false)
|
||||
const transferForm = reactive({ customerId: '', customerName: '', newOwnerId: '' })
|
||||
const transferSubmitting = ref(false)
|
||||
const userOptions = ref<any[]>([])
|
||||
|
||||
const openTransferDialog = async (row: any) => {
|
||||
transferForm.customerId = row.id
|
||||
transferForm.customerName = row.name
|
||||
transferForm.newOwnerId = ''
|
||||
transferDialogVisible.value = true
|
||||
// 拉取员工列表
|
||||
try {
|
||||
const data: any = await request.get('/api/settings/users', { params: { page: 1, size: 100 } })
|
||||
userOptions.value = (data.items || []).filter((u: any) => u.status === 1)
|
||||
} catch {
|
||||
ElMessage.error('获取员工列表失败')
|
||||
}
|
||||
}
|
||||
|
||||
const submitTransfer = async () => {
|
||||
if (!transferForm.newOwnerId) {
|
||||
ElMessage.warning('请选择目标负责人')
|
||||
return
|
||||
}
|
||||
transferSubmitting.value = true
|
||||
try {
|
||||
await request.put(`/api/customers/${transferForm.customerId}/transfer`, {
|
||||
new_owner_id: transferForm.newOwnerId,
|
||||
})
|
||||
ElMessage.success('客户转移成功')
|
||||
transferDialogVisible.value = false
|
||||
fetchCustomers()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '转移失败')
|
||||
} finally {
|
||||
transferSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 初始化 ---
|
||||
onMounted(() => {
|
||||
fetchCustomers()
|
||||
@@ -308,11 +373,12 @@ onMounted(() => {
|
||||
<el-table-column prop="address" label="地址" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="创建时间" width="170" />
|
||||
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button type="primary" link :icon="View" @click="viewDetails(scope.row)">查看档案</el-button>
|
||||
<template v-if="!scope.row.is_deleted">
|
||||
<el-button type="primary" link :icon="Edit" @click="editCustomer(scope.row)">编辑</el-button>
|
||||
<el-button v-if="isAdmin" type="warning" link :icon="Sort" @click="openTransferDialog(scope.row)">转移</el-button>
|
||||
<el-button type="danger" link :icon="Box" @click="archiveCustomer(scope.row)">归档</el-button>
|
||||
</template>
|
||||
<el-button v-else type="success" link @click="restoreCustomer(scope.row)">恢复</el-button>
|
||||
@@ -363,6 +429,38 @@ onMounted(() => {
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="addForm.remark" type="textarea" :rows="2" placeholder="备注" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">客户开票信息(选填)</el-divider>
|
||||
<el-form-item label="开票公司">
|
||||
<el-input v-model="addForm.billing_info.company_name" placeholder="开票公司全称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="税号">
|
||||
<el-input v-model="addForm.billing_info.tax_id" placeholder="纳税人识别号" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="地址">
|
||||
<el-input v-model="addForm.billing_info.address" placeholder="地址" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="电话">
|
||||
<el-input v-model="addForm.billing_info.phone" placeholder="电话" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="开户行">
|
||||
<el-input v-model="addForm.billing_info.bank_name" placeholder="开户行" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="银行账号">
|
||||
<el-input v-model="addForm.billing_info.bank_account" placeholder="银行账号" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addDialogVisible = false">取消</el-button>
|
||||
@@ -398,6 +496,38 @@ onMounted(() => {
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="editForm.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">客户开票信息(选填)</el-divider>
|
||||
<el-form-item label="开票公司">
|
||||
<el-input v-model="editForm.billing_info.company_name" placeholder="开票公司全称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="税号">
|
||||
<el-input v-model="editForm.billing_info.tax_id" placeholder="纳税人识别号" />
|
||||
</el-form-item>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="地址">
|
||||
<el-input v-model="editForm.billing_info.address" placeholder="地址" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="电话">
|
||||
<el-input v-model="editForm.billing_info.phone" placeholder="电话" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="开户行">
|
||||
<el-input v-model="editForm.billing_info.bank_name" placeholder="开户行" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="银行账号">
|
||||
<el-input v-model="editForm.billing_info.bank_account" placeholder="银行账号" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
@@ -429,6 +559,29 @@ onMounted(() => {
|
||||
<div class="el-upload__text">将文件拖到此处,或 <em>点击上传</em></div>
|
||||
</el-upload>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 7. 客户转移弹窗 -->
|
||||
<el-dialog v-model="transferDialogVisible" title="客户转移" width="440px" destroy-on-close>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="客户名称">
|
||||
<el-input :value="transferForm.customerName" disabled />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标负责人" required>
|
||||
<el-select v-model="transferForm.newOwnerId" placeholder="请选择负责人" filterable style="width: 100%">
|
||||
<el-option
|
||||
v-for="u in userOptions"
|
||||
:key="u.id"
|
||||
:label="`${u.real_name} (${u.username})`"
|
||||
:value="u.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="transferDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="transferSubmitting" @click="submitTransfer">确认转移</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -92,11 +92,13 @@ const handleGenerate = async () => {
|
||||
|
||||
try {
|
||||
const token = userStore.token || localStorage.getItem('crm_token') || ''
|
||||
const companyId = userStore.currentCompanyId || localStorage.getItem('crm_company_id') || ''
|
||||
const res = await fetch('/api/reports/generate', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-Company-Id': companyId,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
start_date: dateRange.value[0],
|
||||
|
||||
@@ -134,11 +134,65 @@ interface OcrQueueItem {
|
||||
}
|
||||
const ocrQueue = ref<OcrQueueItem[]>([])
|
||||
|
||||
// --- 待确认列表(方案A:AI预填 + 人工审核) ---
|
||||
interface PendingInvoice {
|
||||
issuer: string
|
||||
receiver_customer_id: string
|
||||
invoice_number: string
|
||||
amount: number
|
||||
billing_date: string
|
||||
remark: string
|
||||
_buyer_text: string // OCR 识别的购买方原文(辅助展示)
|
||||
_complete: boolean // 数据是否完整
|
||||
}
|
||||
const pendingInvoices = ref<PendingInvoice[]>([])
|
||||
const pendingSubmitting = ref(false)
|
||||
|
||||
const submitAllPending = async () => {
|
||||
const valid = pendingInvoices.value.filter(p => p.issuer && p.invoice_number && p.amount > 0 && p.receiver_customer_id)
|
||||
if (!valid.length) {
|
||||
ElMessage.warning('没有可提交的完整发票,请补全必填字段')
|
||||
return
|
||||
}
|
||||
pendingSubmitting.value = true
|
||||
let ok = 0
|
||||
for (const inv of valid) {
|
||||
try {
|
||||
await request.post('/api/finance/sales-invoices', {
|
||||
issuer: inv.issuer,
|
||||
receiver_customer_id: inv.receiver_customer_id,
|
||||
invoice_number: inv.invoice_number,
|
||||
amount: inv.amount,
|
||||
billing_date: inv.billing_date,
|
||||
remark: inv.remark || 'AI批量导入',
|
||||
})
|
||||
ok++
|
||||
} catch {}
|
||||
}
|
||||
pendingSubmitting.value = false
|
||||
ElMessage.success(`批量创建完成:${ok}/${valid.length} 成功`)
|
||||
// 移除已成功的,保留失败的
|
||||
pendingInvoices.value = pendingInvoices.value.filter(p => !(p.issuer && p.invoice_number && p.amount > 0 && p.receiver_customer_id))
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const removePending = (idx: number) => { pendingInvoices.value.splice(idx, 1) }
|
||||
const clearPending = () => { pendingInvoices.value = [] }
|
||||
|
||||
// 为待确认列表行搜索客户
|
||||
const searchCustomerForPending = async (query: string, row: PendingInvoice) => {
|
||||
if (!query) return
|
||||
await handleCustomerSearch(query)
|
||||
if (customerOptions.value.length > 0) {
|
||||
row.receiver_customer_id = customerOptions.value[0].id
|
||||
}
|
||||
}
|
||||
|
||||
const handleOCRUpload = async (_file: UploadFile, fileList: any[]) => {
|
||||
const rawFiles = fileList.filter((f: any) => f.raw).map((f: any) => f.raw as File)
|
||||
if (!rawFiles.length) return
|
||||
|
||||
// 单文件:填入表单(旧逻辑)
|
||||
// 单文件:填入表单
|
||||
if (rawFiles.length === 1) {
|
||||
const file = rawFiles[0]
|
||||
uploadProcessing.value = true
|
||||
@@ -164,7 +218,7 @@ const handleOCRUpload = async (_file: UploadFile, fileList: any[]) => {
|
||||
addForm.invoice_number = aiData.invoice_num || aiData.invoice_number || addForm.invoice_number
|
||||
addForm.amount = parseFloat(aiData.amount) || addForm.amount
|
||||
addForm.billing_date = aiData.date || addForm.billing_date
|
||||
const buyerName = (aiData.buyer_name || aiData.customer_name || '').replace(/[\r\n\t]/g, '').trim()
|
||||
const buyerName = (aiData.buyer || aiData.buyer_name || aiData.customer_name || '').replace(/[\r\n\t]/g, '').trim()
|
||||
if (buyerName) {
|
||||
await handleCustomerSearch(buyerName)
|
||||
if (customerOptions.value.length > 0) {
|
||||
@@ -185,7 +239,7 @@ const handleOCRUpload = async (_file: UploadFile, fileList: any[]) => {
|
||||
return
|
||||
}
|
||||
|
||||
// 多文件:批量处理,逐个 OCR + 自动创建
|
||||
// 多文件:批量 OCR → 暂存到待确认列表
|
||||
ocrQueue.value = rawFiles.map(f => ({ name: f.name, status: 'waiting' as const, message: '' }))
|
||||
uploadProcessing.value = true
|
||||
uploadAbortController = new AbortController()
|
||||
@@ -218,7 +272,7 @@ const handleOCRUpload = async (_file: UploadFile, fileList: any[]) => {
|
||||
const invoiceNumber = aiData.invoice_num || aiData.invoice_number || ''
|
||||
const amount = parseFloat(aiData.amount) || 0
|
||||
const billingDate = aiData.date || new Date().toISOString().split('T')[0]
|
||||
const buyerName = (aiData.buyer_name || aiData.customer_name || '').replace(/[\r\n\t]/g, '').trim()
|
||||
const buyerName = (aiData.buyer || aiData.buyer_name || aiData.customer_name || '').replace(/[\r\n\t]/g, '').trim()
|
||||
|
||||
// 自动查找客户
|
||||
let customerId = ''
|
||||
@@ -227,17 +281,22 @@ const handleOCRUpload = async (_file: UploadFile, fileList: any[]) => {
|
||||
if (customerOptions.value.length > 0) customerId = customerOptions.value[0].id
|
||||
}
|
||||
|
||||
if (issuer && invoiceNumber && amount > 0 && customerId) {
|
||||
await request.post('/api/finance/sales-invoices', {
|
||||
issuer, receiver_customer_id: customerId, invoice_number: invoiceNumber,
|
||||
amount, billing_date: billingDate, remark: 'AI批量导入',
|
||||
})
|
||||
ocrQueue.value[i].status = 'success'
|
||||
ocrQueue.value[i].message = `✅ ${invoiceNumber}`
|
||||
} else {
|
||||
ocrQueue.value[i].status = 'error'
|
||||
ocrQueue.value[i].message = '⚠️ 数据不完整,请手动创建'
|
||||
}
|
||||
const isComplete = !!(issuer && invoiceNumber && amount > 0 && customerId)
|
||||
pendingInvoices.value.push({
|
||||
issuer,
|
||||
receiver_customer_id: customerId,
|
||||
invoice_number: invoiceNumber,
|
||||
amount,
|
||||
billing_date: billingDate,
|
||||
remark: '',
|
||||
_buyer_text: buyerName,
|
||||
_complete: isComplete,
|
||||
})
|
||||
|
||||
ocrQueue.value[i].status = isComplete ? 'success' : 'error'
|
||||
ocrQueue.value[i].message = isComplete
|
||||
? `✅ ${invoiceNumber || file.name}`
|
||||
: `⚠️ 待补全 → 已加入确认列表`
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') break
|
||||
ocrQueue.value[i].status = 'error'
|
||||
@@ -248,11 +307,10 @@ const handleOCRUpload = async (_file: UploadFile, fileList: any[]) => {
|
||||
uploadProcessing.value = false
|
||||
uploadAbortController = null
|
||||
if (!signal.aborted) {
|
||||
const ok = ocrQueue.value.filter(q => q.status === 'success').length
|
||||
ElMessage.success(`批量处理完成:${ok}/${rawFiles.length} 自动创建成功`)
|
||||
handleSearch()
|
||||
const complete = pendingInvoices.value.filter(p => p._complete).length
|
||||
ElMessage.success(`AI 解析完成:${complete}/${rawFiles.length} 数据完整,请在下方确认列表中审核后提交`)
|
||||
}
|
||||
setTimeout(() => { ocrQueue.value = [] }, 5000)
|
||||
// 不自动清空队列,由用户手动关闭
|
||||
}
|
||||
|
||||
// --- Dialog 关闭保护 ---
|
||||
@@ -436,13 +494,13 @@ onMounted(fetchList)
|
||||
</el-card>
|
||||
|
||||
<!-- 新增发票弹窗 -->
|
||||
<el-dialog v-model="addDialogVisible" title="发票智能录入" width="600px" destroy-on-close :before-close="handleDialogClose">
|
||||
<el-dialog v-model="addDialogVisible" title="发票智能录入" width="960px" destroy-on-close :before-close="handleDialogClose">
|
||||
<div style="margin-bottom: 20px" v-loading="uploadProcessing || aiParsing" :element-loading-text="uploadProcessing ? '文件上传中...' : '🤖 AI 正在识别发票表单字段...'">
|
||||
<el-upload
|
||||
drag
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.md,.ofd,.xml"
|
||||
multiple
|
||||
@change="handleOCRUpload"
|
||||
>
|
||||
@@ -450,10 +508,14 @@ onMounted(fetchList)
|
||||
<div class="el-upload__text">
|
||||
将发票原件(PDF/图片)拖到此处,或 <em>点击上传进行AI填单</em>
|
||||
</div>
|
||||
<div class="el-upload__tip" style="font-size:12px;color:#909399">支持 PDF/图片/MD 文件。单文件自动填入表单,多文件自动批量创建发票</div>
|
||||
<div class="el-upload__tip" style="font-size:12px;color:#909399">支持 PDF / 图片 / MD / OFD / XML 文件。单文件自动填入表单,多文件自动批量创建发票</div>
|
||||
</el-upload>
|
||||
<!-- 批量处理队列 -->
|
||||
<div v-if="ocrQueue.length" style="margin-top: 10px">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px">
|
||||
<span style="font-size:13px; color:#606266; font-weight:500">📋 解析进度</span>
|
||||
<el-button size="small" link @click="ocrQueue = []">清空</el-button>
|
||||
</div>
|
||||
<div v-for="(item, idx) in ocrQueue" :key="idx" style="display:flex; justify-content:space-between; align-items:center; padding: 4px 8px; border-bottom: 1px solid #f0f0f0; font-size: 13px">
|
||||
<span style="color:#606266; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:280px">{{ item.name }}</span>
|
||||
<el-tag v-if="item.status === 'waiting'" size="small" type="info" effect="plain">等待中</el-tag>
|
||||
@@ -462,6 +524,67 @@ onMounted(fetchList)
|
||||
<el-tag v-else size="small" type="danger" effect="dark">{{ item.message }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 待确认列表(方案A:AI预填 + 人工审核) -->
|
||||
<div v-if="pendingInvoices.length" style="margin-top: 16px">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
|
||||
<span style="font-size:14px; font-weight:600; color:#303133">🛒 待确认发票({{ pendingInvoices.length }}条)</span>
|
||||
<div>
|
||||
<el-button size="small" @click="clearPending">清空</el-button>
|
||||
<el-button type="primary" size="small" :loading="pendingSubmitting" @click="submitAllPending">
|
||||
✅ 一键全部创建
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="pendingInvoices" border stripe size="small" max-height="300px" style="width:100%">
|
||||
<el-table-column label="开票方" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.issuer" size="small" placeholder="开票方"
|
||||
:class="{ 'field-warning': !row.issuer }" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发票号" min-width="140">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.invoice_number" size="small" placeholder="发票号"
|
||||
:class="{ 'field-warning': !row.invoice_number }" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="金额" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-input-number v-model="row.amount" :min="0" :precision="2" size="small" style="width:85px"
|
||||
:class="{ 'field-warning': !row.amount || row.amount <= 0 }" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="日期" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-date-picker v-model="row.billing_date" type="date" value-format="YYYY-MM-DD" size="small" style="width:100%" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="受票客户" min-width="160">
|
||||
<template #default="{ row }">
|
||||
<el-select
|
||||
v-model="row.receiver_customer_id"
|
||||
filterable remote clearable reserve-keyword
|
||||
:placeholder="row._buyer_text || '搜索客户'"
|
||||
:remote-method="(q: string) => handleCustomerSearch(q)"
|
||||
:loading="customerSearchLoading"
|
||||
size="small" style="width:100%"
|
||||
:class="{ 'field-warning': !row.receiver_customer_id }"
|
||||
>
|
||||
<el-option v-for="item in customerOptions" :key="item.id" :label="item.name" :value="item.id">
|
||||
<span>{{ item.name }}</span>
|
||||
<span style="color: #999; font-size: 11px; margin-left: 6px">{{ item.level }}级</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="" width="40">
|
||||
<template #default="{ $index }">
|
||||
<el-button type="danger" link size="small" @click="removePending($index)">✕</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="addFormRef" :model="addForm" :rules="addFormRules" label-width="100px">
|
||||
@@ -558,4 +681,10 @@ onMounted(fetchList)
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
:deep(.field-warning .el-input__wrapper),
|
||||
:deep(.field-warning .el-input-number .el-input__wrapper),
|
||||
:deep(.field-warning .el-select .el-input__wrapper) {
|
||||
box-shadow: 0 0 0 1px #e6a23c inset !important;
|
||||
background: #fdf6ec !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* Tab 2: 购物车式新建报销(AI 一键生成草稿)
|
||||
* Tab 3: 报销大盘与审批(A4 打印 + Excel 导出 + 审批态只读)
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { Search, Plus, Delete, View, Check, Close, RefreshLeft, Download, Printer, Link } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules, UploadFile } from 'element-plus'
|
||||
@@ -43,89 +43,142 @@ const fetchInvoices = async () => {
|
||||
|
||||
watch(invCategory, () => { invPage.value = 1; fetchInvoices() })
|
||||
|
||||
// ── 拖拽上传 + AI 解析链路(批量队列) ──
|
||||
// ── 拖拽上传 + 分流(XML/ZIP即时入池, 图片PDF入队列) ──
|
||||
const uploadProcessing = ref(false)
|
||||
const aiParsing = ref(false)
|
||||
|
||||
interface QueueItem {
|
||||
name: string
|
||||
status: 'waiting' | 'processing' | 'success' | 'error'
|
||||
status: 'waiting' | 'processing' | 'success' | 'error' | 'queued'
|
||||
message: string
|
||||
taskId?: string
|
||||
}
|
||||
const uploadQueue = ref<QueueItem[]>([])
|
||||
let uploadAbortController: AbortController | null = null
|
||||
|
||||
const handleUploadChange = async (_file: UploadFile, fileList: any[]) => {
|
||||
// 收集所有新增文件的 raw
|
||||
const rawFiles = fileList.filter((f: any) => f.raw).map((f: any) => f.raw as File)
|
||||
if (!rawFiles.length) return
|
||||
|
||||
// 初始化队列
|
||||
uploadQueue.value = rawFiles.map(f => ({ name: f.name, status: 'waiting' as const, message: '' }))
|
||||
uploadQueue.value = rawFiles.map(f => ({ name: f.name, status: 'processing' as const, message: '上传中...' }))
|
||||
uploadProcessing.value = true
|
||||
uploadAbortController = new AbortController()
|
||||
const signal = uploadAbortController.signal
|
||||
|
||||
for (let i = 0; i < rawFiles.length; i++) {
|
||||
if (signal.aborted) break
|
||||
|
||||
const file = rawFiles[i]
|
||||
uploadQueue.value[i].status = 'processing'
|
||||
uploadQueue.value[i].message = '🤖 AI 正在解析...'
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('scene', 'invoice')
|
||||
|
||||
const response = await fetch('/api/finance/ocr', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${userStore.token}` },
|
||||
body: formData,
|
||||
signal,
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const result = await response.json()
|
||||
if (result.code !== 200) throw new Error(result.message || 'OCR 识别失败')
|
||||
|
||||
const ocrResult = result.data || {}
|
||||
const aiData = ocrResult.ocr_data || {}
|
||||
const fileUrl = ocrResult.file_url || `/uploads/${file.name}`
|
||||
|
||||
const ocrSuccess = !!(aiData.merchant || aiData.merchant_name || aiData.amount)
|
||||
const merchant = aiData.merchant || aiData.merchant_name || '(AI 未识别)'
|
||||
const amount = parseFloat(aiData.amount) || 0
|
||||
const invoiceDate = aiData.date || new Date().toISOString().slice(0, 10)
|
||||
|
||||
await request.post('/api/finance/invoices', {
|
||||
merchant_name: merchant, amount, invoice_date: invoiceDate,
|
||||
type: invCategory.value, file_url: fileUrl, ai_extracted_data: aiData,
|
||||
})
|
||||
|
||||
uploadQueue.value[i].status = 'success'
|
||||
uploadQueue.value[i].message = ocrSuccess
|
||||
? `✅ ${merchant} ¥${amount}`
|
||||
: '⚠️ 已上传,AI未能识别'
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') break
|
||||
uploadQueue.value[i].status = 'error'
|
||||
uploadQueue.value[i].message = `❌ ${e.message || '处理失败'}`
|
||||
try {
|
||||
const formData = new FormData()
|
||||
for (const file of rawFiles) {
|
||||
formData.append('files', file)
|
||||
}
|
||||
}
|
||||
formData.append('scene', 'invoice')
|
||||
formData.append('inv_type', invCategory.value)
|
||||
|
||||
uploadProcessing.value = false
|
||||
uploadAbortController = null
|
||||
if (!signal.aborted) {
|
||||
const successCount = uploadQueue.value.filter(q => q.status === 'success').length
|
||||
ElMessage.success(`批量处理完成:${successCount}/${rawFiles.length} 成功`)
|
||||
const response = await fetch('/api/finance/upload-batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': `Bearer ${userStore.token}`, 'X-Company-Id': userStore.currentCompanyId || '' },
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||||
const result = await response.json()
|
||||
if (result.code !== 200) throw new Error(result.message || '上传失败')
|
||||
|
||||
const batchResults = result.data?.results || []
|
||||
uploadQueue.value = batchResults.map((r: any) => ({
|
||||
name: r.filename || '',
|
||||
status: r.action === 'pooled' ? 'success' : r.action === 'queued' ? 'queued' : 'error',
|
||||
message: r.message || '',
|
||||
taskId: r.task_id,
|
||||
}))
|
||||
|
||||
ElMessage.success(result.message || '批量处理完成')
|
||||
fetchInvoices()
|
||||
if (batchResults.some((r: any) => r.action === 'queued')) {
|
||||
fetchOcrTasks()
|
||||
}
|
||||
} catch (e: any) {
|
||||
uploadQueue.value = rawFiles.map(f => ({ name: f.name, status: 'error' as const, message: `❌ ${e.message}` }))
|
||||
ElMessage.error(e.message || '上传失败')
|
||||
} finally {
|
||||
uploadProcessing.value = false
|
||||
}
|
||||
|
||||
// 3秒后清空队列
|
||||
setTimeout(() => { uploadQueue.value = [] }, 3000)
|
||||
}
|
||||
|
||||
// ── OCR 任务队列 ──
|
||||
const ocrTasksLoading = ref(false)
|
||||
const ocrTasks = ref<any[]>([])
|
||||
const ocrTasksTotal = ref(0)
|
||||
const ocrTasksPage = ref(1)
|
||||
const ocrTasksStatusFilter = ref('')
|
||||
|
||||
const fetchOcrTasks = async () => {
|
||||
ocrTasksLoading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { page: ocrTasksPage.value, size: 20 }
|
||||
if (ocrTasksStatusFilter.value) params.status = ocrTasksStatusFilter.value
|
||||
const data: any = await request.get('/api/finance/ocr-tasks', { params })
|
||||
ocrTasks.value = data?.items || []
|
||||
ocrTasksTotal.value = data?.total || 0
|
||||
} catch {}
|
||||
finally { ocrTasksLoading.value = false }
|
||||
}
|
||||
|
||||
const retryOcrTask = async (taskId: string) => {
|
||||
try {
|
||||
await request.post(`/api/finance/ocr-tasks/${taskId}/retry`)
|
||||
ElMessage.success('任务已重新入队')
|
||||
fetchOcrTasks()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const deleteOcrTask = async (taskId: string) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定取消此任务?', '确认')
|
||||
} catch { return }
|
||||
try {
|
||||
await request.delete(`/api/finance/ocr-tasks/${taskId}`)
|
||||
ElMessage.success('任务已取消')
|
||||
fetchOcrTasks()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const changePriority = async (taskId: string, delta: number) => {
|
||||
const task = ocrTasks.value.find((t: any) => t.id === taskId)
|
||||
if (!task) return
|
||||
try {
|
||||
await request.put(`/api/finance/ocr-tasks/${taskId}/priority`, { priority: Math.max(1, task.priority + delta) })
|
||||
fetchOcrTasks()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 手动录入弹窗(for OCR failed tasks)
|
||||
const manualDialogVisible = ref(false)
|
||||
const manualTaskId = ref('')
|
||||
const manualForm = reactive({ merchant_name: '', amount: 0, invoice_date: '' })
|
||||
|
||||
const openManualInput = (task: any) => {
|
||||
manualTaskId.value = task.id
|
||||
const ocr = task.ocr_result || {}
|
||||
manualForm.merchant_name = ocr.merchant || ocr.merchant_name || ''
|
||||
manualForm.amount = parseFloat(ocr.amount) || 0
|
||||
manualForm.invoice_date = ocr.date || ''
|
||||
manualDialogVisible.value = true
|
||||
}
|
||||
|
||||
const submitManualInput = async () => {
|
||||
if (!manualForm.merchant_name.trim()) { ElMessage.warning('请填写开票方'); return }
|
||||
try {
|
||||
await request.post(`/api/finance/ocr-tasks/${manualTaskId.value}/manual`, {
|
||||
merchant_name: manualForm.merchant_name,
|
||||
amount: manualForm.amount,
|
||||
invoice_date: manualForm.invoice_date || null,
|
||||
})
|
||||
ElMessage.success('手动录入成功,发票已入池')
|
||||
manualDialogVisible.value = false
|
||||
fetchOcrTasks()
|
||||
fetchInvoices()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const ocrStatusLabel = (s: string) => ({ pending: '⏳ 待处理', processing: '⚙️ 处理中', success: '✅ 已完成', failed: '❌ 失败', manual: '✏️ 手动' }[s] || s)
|
||||
const ocrStatusType = (s: string) => ({ pending: 'info', processing: 'warning', success: 'success', failed: 'danger', manual: '' }[s] || 'info')
|
||||
|
||||
// 手动录入弹窗
|
||||
const invDialogVisible = ref(false)
|
||||
const invSubmitting = ref(false)
|
||||
@@ -456,12 +509,10 @@ const exportExcel = async () => {
|
||||
const formatCurrency = (v: number) => `¥${v?.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`
|
||||
|
||||
onMounted(() => { fetchInvoices() })
|
||||
onBeforeUnmount(() => {
|
||||
if (uploadAbortController) { uploadAbortController.abort(); uploadAbortController = null }
|
||||
})
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'create') fetchAvailableInvoices()
|
||||
if (tab === 'expenses') fetchExpenses()
|
||||
if (tab === 'ocr-queue') fetchOcrTasks()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -477,21 +528,21 @@ watch(activeTab, (tab) => {
|
||||
|
||||
<!-- 拖拽上传区(支持多文件) -->
|
||||
<div class="upload-zone">
|
||||
<el-upload drag :auto-upload="false" :show-file-list="false" accept=".pdf,.jpg,.jpeg,.png"
|
||||
<el-upload drag :auto-upload="false" :show-file-list="false" accept=".pdf,.jpg,.jpeg,.png,.md,.ofd,.xml,.zip"
|
||||
multiple @change="handleUploadChange">
|
||||
<div class="upload-inner">
|
||||
<el-icon style="font-size:40px; color:#409eff"><Plus /></el-icon>
|
||||
<div class="upload-text">拖拽发票文件到此处,或 <em>点击上传</em></div>
|
||||
<div class="upload-hint">支持 PDF / JPG / PNG / MD,可一次选择多个文件批量上传,AI 自动排队解析</div>
|
||||
<div class="upload-hint">支持 ZIP / XML / OFD(即时入池) | PDF / JPG / PNG(后台排队 OCR)</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<!-- 批量上传队列状态 -->
|
||||
<!-- 批量上传结果状态 -->
|
||||
<div v-if="uploadQueue.length" class="upload-queue">
|
||||
<div v-for="(item, idx) in uploadQueue" :key="idx" class="queue-item">
|
||||
<span class="queue-name">{{ item.name }}</span>
|
||||
<el-tag v-if="item.status === 'waiting'" size="small" type="info" effect="plain">等待中</el-tag>
|
||||
<el-tag v-else-if="item.status === 'processing'" size="small" type="warning" effect="dark">处理中...</el-tag>
|
||||
<el-tag v-if="item.status === 'processing'" size="small" type="warning" effect="dark">上传中...</el-tag>
|
||||
<el-tag v-else-if="item.status === 'success'" size="small" type="success" effect="dark">{{ item.message }}</el-tag>
|
||||
<el-tag v-else-if="item.status === 'queued'" size="small" type="primary" effect="plain">{{ item.message }}</el-tag>
|
||||
<el-tag v-else size="small" type="danger" effect="dark">{{ item.message }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
@@ -548,6 +599,97 @@ watch(activeTab, (tab) => {
|
||||
@size-change="(s: number) => { invSize = s; invPage = 1; fetchInvoices() }" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ═══════ Tab 1.5: OCR 处理队列 ═══════ -->
|
||||
<el-tab-pane name="ocr-queue">
|
||||
<template #label>
|
||||
<span>📋 OCR 队列
|
||||
<el-badge v-if="ocrTasks.filter((t: any) => t.status === 'pending' || t.status === 'processing').length"
|
||||
:value="ocrTasks.filter((t: any) => t.status === 'pending' || t.status === 'processing').length"
|
||||
type="warning" style="margin-left:4px" />
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="tab-toolbar">
|
||||
<el-form :inline="true" class="filter-form">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="ocrTasksStatusFilter" clearable placeholder="全部" style="width:130px"
|
||||
@change="() => { ocrTasksPage = 1; fetchOcrTasks() }">
|
||||
<el-option label="待处理" value="pending" />
|
||||
<el-option label="处理中" value="processing" />
|
||||
<el-option label="已完成" value="success" />
|
||||
<el-option label="失败" value="failed" />
|
||||
<el-option label="手动" value="manual" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="fetchOcrTasks">刷新</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-alert type="info" :closable="false" show-icon style="flex:1; margin-left:16px">
|
||||
<template #title>处理策略:工作时间限流(1并发+60s间隔),17:00-20:00 全速处理</template>
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<el-table :data="ocrTasks" v-loading="ocrTasksLoading" stripe border style="width:100%" height="calc(100vh - 380px)">
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="original_name" label="文件名" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span style="font-family: Consolas, monospace;">{{ row.original_name }}</span>
|
||||
<el-tag size="small" effect="plain" style="margin-left:6px">{{ row.file_ext }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="(ocrStatusType(row.status) as any)" effect="dark" size="small">{{ ocrStatusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="优先级" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<span style="font-weight:bold">{{ row.priority }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="重试" width="70" align="center">
|
||||
<template #default="{ row }">{{ row.retry_count }}/{{ row.max_retries }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="error_message" label="错误信息" min-width="200" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.error_message" style="color:#f56c6c; font-size:12px">{{ row.error_message }}</span>
|
||||
<span v-else-if="row.status === 'success'" style="color:#67c23a; font-size:12px">已入池</span>
|
||||
<span v-else style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="uploader_name" label="上传人" width="90" align="center" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="155" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending'">
|
||||
<el-button type="primary" link size="small" @click="changePriority(row.id, -10)">↑ 提前</el-button>
|
||||
<el-button type="info" link size="small" @click="changePriority(row.id, 10)">↓ 推后</el-button>
|
||||
<el-button type="danger" link size="small" @click="deleteOcrTask(row.id)">取消</el-button>
|
||||
</template>
|
||||
<template v-else-if="row.status === 'failed'">
|
||||
<el-button type="warning" link size="small" @click="retryOcrTask(row.id)">🔄 重试</el-button>
|
||||
<el-button type="success" link size="small" @click="openManualInput(row)">✏️ 手动</el-button>
|
||||
<el-button type="danger" link size="small" @click="deleteOcrTask(row.id)">删除</el-button>
|
||||
</template>
|
||||
<template v-else-if="row.status === 'success'">
|
||||
<el-tag size="small" type="success" effect="plain">已入池</el-tag>
|
||||
</template>
|
||||
<template v-else-if="row.status === 'processing'">
|
||||
<el-tag size="small" type="warning" effect="dark">处理中...</el-tag>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span style="color:#c0c4cc">-</span>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination style="margin-top:12px; justify-content:flex-end" background
|
||||
layout="total, prev, pager, next" :total="ocrTasksTotal" :page-size="20" :current-page="ocrTasksPage"
|
||||
@current-change="(p: number) => { ocrTasksPage = p; fetchOcrTasks() }" />
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ═══════ Tab 2: 新建报销(AI 一键生成草稿) ═══════ -->
|
||||
<el-tab-pane label="📝 新建报销" name="create">
|
||||
<el-row :gutter="20">
|
||||
@@ -743,6 +885,28 @@ watch(activeTab, (tab) => {
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- ═══════ OCR 失败任务手动录入弹窗 ═══════ -->
|
||||
<el-dialog v-model="manualDialogVisible" title="✏️ 手动录入发票信息" width="450px" destroy-on-close>
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px">
|
||||
<template #title>OCR 未能识别此发票,请手动填写关键字段后入池</template>
|
||||
</el-alert>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="开票方" required>
|
||||
<el-input v-model="manualForm.merchant_name" placeholder="开票方名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="金额" required>
|
||||
<el-input-number v-model="manualForm.amount" :min="0" :precision="2" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开票日期">
|
||||
<el-date-picker v-model="manualForm.invoice_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="manualDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitManualInput">确认录入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Search, Document, Plus, Refresh } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
/**
|
||||
* CRM 销售日志
|
||||
* GET /api/sales-logs (分页 + keyword 搜索)
|
||||
* POST /api/sales-logs (创建日志)
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Search, Plus, Refresh, EditPen, Delete } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(false)
|
||||
const logs = ref<any[]>([])
|
||||
@@ -15,6 +21,7 @@ const size = ref(20)
|
||||
const keyword = ref('')
|
||||
const dateRange = ref<string[]>([])
|
||||
|
||||
// --- 拉取日志列表 ---
|
||||
const fetchLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -37,7 +44,169 @@ const fetchLogs = async () => {
|
||||
const handleSearch = () => { page.value = 1; fetchLogs() }
|
||||
const handlePageChange = (p: number) => { page.value = p; fetchLogs() }
|
||||
|
||||
onMounted(fetchLogs)
|
||||
// --- 写日志弹窗 ---
|
||||
const newLogVisible = ref(false)
|
||||
const newLogSubmitting = ref(false)
|
||||
const newLogForm = reactive({
|
||||
content: '',
|
||||
customer_id: '',
|
||||
contact_ids: [] as string[],
|
||||
log_date: new Date().toISOString().slice(0, 10),
|
||||
})
|
||||
const customerOptions = ref<any[]>([])
|
||||
const customerSearchLoading = ref(false)
|
||||
const contactOptions = ref<any[]>([])
|
||||
const contactLoading = ref(false)
|
||||
|
||||
const openNewLogDialog = () => {
|
||||
newLogForm.content = ''
|
||||
newLogForm.customer_id = ''
|
||||
newLogForm.contact_ids = []
|
||||
newLogForm.log_date = new Date().toISOString().slice(0, 10)
|
||||
contactOptions.value = []
|
||||
newLogVisible.value = true
|
||||
}
|
||||
|
||||
// 选择客户后加载联系人
|
||||
const onCustomerChange = async (customerId: string) => {
|
||||
newLogForm.contact_ids = []
|
||||
contactOptions.value = []
|
||||
if (!customerId) return
|
||||
contactLoading.value = true
|
||||
try {
|
||||
const data: any = await request.get(`/api/customers/${customerId}/contacts`)
|
||||
contactOptions.value = data || []
|
||||
} catch { /* ignore */ } finally {
|
||||
contactLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 远程搜索客户
|
||||
const searchCustomers = async (q: string) => {
|
||||
if (!q || q.length < 1) return
|
||||
customerSearchLoading.value = true
|
||||
try {
|
||||
const data: any = await request.get('/api/customers/search', { params: { q } })
|
||||
customerOptions.value = data || []
|
||||
} catch { /* ignore */ } finally {
|
||||
customerSearchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitNewLog = async () => {
|
||||
if (!newLogForm.content.trim()) {
|
||||
ElMessage.warning('请输入日志内容')
|
||||
return
|
||||
}
|
||||
newLogSubmitting.value = true
|
||||
try {
|
||||
const body: Record<string, any> = {
|
||||
content: newLogForm.content,
|
||||
log_date: newLogForm.log_date,
|
||||
}
|
||||
if (newLogForm.customer_id) body.customer_id = newLogForm.customer_id
|
||||
if (newLogForm.contact_ids.length) body.contact_ids = newLogForm.contact_ids
|
||||
await request.post('/api/sales-logs', body)
|
||||
ElMessage.success('日志创建成功')
|
||||
newLogVisible.value = false
|
||||
fetchLogs()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '创建失败')
|
||||
} finally {
|
||||
newLogSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 查看详情 ---
|
||||
const detailVisible = ref(false)
|
||||
const detailRow = ref<any>({})
|
||||
const viewDetail = (row: any) => {
|
||||
detailRow.value = row
|
||||
detailVisible.value = true
|
||||
}
|
||||
|
||||
// --- 编辑日志 ---
|
||||
const editVisible = ref(false)
|
||||
const editSubmitting = ref(false)
|
||||
const editForm = reactive({
|
||||
id: '',
|
||||
content: '',
|
||||
customer_id: '',
|
||||
contact_ids: [] as string[],
|
||||
log_date: '',
|
||||
})
|
||||
|
||||
const openEditDialog = async (row: any) => {
|
||||
editForm.id = row.id
|
||||
editForm.content = row.content
|
||||
editForm.customer_id = row.customer_id || ''
|
||||
editForm.contact_ids = row.contact_ids || []
|
||||
editForm.log_date = row.log_date || ''
|
||||
// 加载客户选项(如果有关联客户)
|
||||
if (row.customer_name && row.customer_id) {
|
||||
customerOptions.value = [{ id: row.customer_id, name: row.customer_name }]
|
||||
await onCustomerChange(row.customer_id)
|
||||
}
|
||||
editVisible.value = true
|
||||
}
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!editForm.content.trim()) {
|
||||
ElMessage.warning('日志内容不能为空')
|
||||
return
|
||||
}
|
||||
editSubmitting.value = true
|
||||
try {
|
||||
const body: Record<string, any> = { content: editForm.content }
|
||||
if (editForm.log_date) body.log_date = editForm.log_date
|
||||
body.customer_id = editForm.customer_id || null
|
||||
body.contact_ids = editForm.contact_ids
|
||||
await request.put(`/api/sales-logs/${editForm.id}`, body)
|
||||
ElMessage.success('日志更新成功')
|
||||
editVisible.value = false
|
||||
fetchLogs()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(e?.response?.data?.message || '更新失败')
|
||||
} finally {
|
||||
editSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// --- 删除日志 ---
|
||||
const handleDelete = async (row: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除这条日志?删除后不可恢复。', '删除确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
await request.delete(`/api/sales-logs/${row.id}`)
|
||||
ElMessage.success('日志已删除')
|
||||
fetchLogs()
|
||||
} catch (e: any) {
|
||||
if (e !== 'cancel') {
|
||||
ElMessage.error(e?.response?.data?.message || '删除失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 权限判断 ---
|
||||
const userStore = useUserStore()
|
||||
const isAdmin = computed(() => userStore.dataScope === 'all')
|
||||
const canOperate = (row: any) => {
|
||||
return isAdmin.value || row.salesperson_id === userStore.userInfo?.user_id
|
||||
}
|
||||
|
||||
// --- ?action=new 自动弹出 ---
|
||||
onMounted(() => {
|
||||
fetchLogs()
|
||||
if (route.query.action === 'new') {
|
||||
openNewLogDialog()
|
||||
}
|
||||
})
|
||||
watch(() => route.query.action, (v) => {
|
||||
if (v === 'new') openNewLogDialog()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -45,7 +214,7 @@ onMounted(fetchLogs)
|
||||
<!-- 搜索栏 -->
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-row :gutter="12" align="middle">
|
||||
<el-col :span="8">
|
||||
<el-col :span="7">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="搜索日志内容..."
|
||||
@@ -67,10 +236,10 @@ onMounted(fetchLogs)
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-col :span="9">
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
|
||||
<el-button :icon="Refresh" @click="keyword = ''; dateRange = []; handleSearch()">重置</el-button>
|
||||
<el-button type="success" :icon="Plus" @click="router.push('/logs?action=new')">写日志</el-button>
|
||||
<el-button type="success" :icon="Plus" @click="openNewLogDialog">写日志</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
@@ -91,6 +260,13 @@ onMounted(fetchLogs)
|
||||
{{ row.created_at?.slice(0, 16)?.replace('T', ' ') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link :icon="EditPen" @click="viewDetail(row)">查看</el-button>
|
||||
<el-button v-if="canOperate(row)" type="warning" link :icon="EditPen" @click="openEditDialog(row)">编辑</el-button>
|
||||
<el-button v-if="canOperate(row)" type="danger" link :icon="Delete" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrap" v-if="total > size">
|
||||
@@ -103,6 +279,192 @@ onMounted(fetchLogs)
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 写日志弹窗 -->
|
||||
<el-dialog v-model="newLogVisible" title="写销售日志" width="560px" destroy-on-close>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="日志日期" required>
|
||||
<el-date-picker
|
||||
v-model="newLogForm.log_date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联客户">
|
||||
<el-select
|
||||
v-model="newLogForm.customer_id"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
placeholder="输入客户名称搜索(可选)"
|
||||
:remote-method="searchCustomers"
|
||||
:loading="customerSearchLoading"
|
||||
@change="onCustomerChange"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in customerOptions"
|
||||
:key="c.id"
|
||||
:label="c.name"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联联系人" v-if="newLogForm.customer_id">
|
||||
<el-select
|
||||
v-model="newLogForm.contact_ids"
|
||||
multiple
|
||||
clearable
|
||||
:loading="contactLoading"
|
||||
placeholder="选择联系人(可多选,用于生成联系人画像)"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="c in contactOptions"
|
||||
:key="c.id"
|
||||
:label="c.name + (c.position ? ` (${c.position})` : '')"
|
||||
:value="c.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px">
|
||||
选择联系人后,AI 将自动提取并更新联系人画像 (Buyer Persona)
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="日志内容" required>
|
||||
<el-input
|
||||
v-model="newLogForm.content"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
placeholder="记录今日客户拜访、沟通、跟进等内容..."
|
||||
maxlength="5000"
|
||||
show-word-limit
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="newLogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="newLogSubmitting" @click="submitNewLog">提交日志</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 查看详情弹窗(含 AI 教练评估面板) -->
|
||||
<el-dialog v-model="detailVisible" title="日志详情" width="680px">
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="日期">{{ detailRow.log_date }}</el-descriptions-item>
|
||||
<el-descriptions-item label="关联客户">{{ detailRow.customer_name || '未关联' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="记录人">{{ detailRow.author_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ detailRow.created_at?.slice(0, 16)?.replace('T', ' ') }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="detail-content">
|
||||
<h4 style="margin: 16px 0 8px">日志内容</h4>
|
||||
<div class="content-box">{{ detailRow.content }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 🧠 AI 教练评估面板 -->
|
||||
<el-card shadow="never" style="margin-top: 16px; border-radius: 8px;" class="coaching-card">
|
||||
<template #header>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-weight: bold;">🧠 AI 教练评估</span>
|
||||
<el-tag v-if="detailRow.ai_coaching_feedback" type="success" size="small" effect="dark">已分析</el-tag>
|
||||
<el-tag v-else type="info" size="small" effect="plain">等待分析</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-if="detailRow.ai_coaching_feedback">
|
||||
<!-- MEDDIC 评分 -->
|
||||
<div v-if="detailRow.ai_coaching_feedback.meddic" style="margin-bottom: 16px;">
|
||||
<h5 style="margin: 0 0 8px; color: #606266;">📊 MEDDIC 评分</h5>
|
||||
<div class="meddic-grid">
|
||||
<div v-for="(val, key) in detailRow.ai_coaching_feedback.meddic" :key="key" class="meddic-item">
|
||||
<div class="meddic-label">{{ key }}</div>
|
||||
<el-progress :percentage="(val || 0) * 10" :stroke-width="8"
|
||||
:color="(val || 0) >= 7 ? '#67c23a' : (val || 0) >= 4 ? '#e6a23c' : '#f56c6c'" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SPIN 提问建议 -->
|
||||
<div v-if="detailRow.ai_coaching_feedback.spin_questions" style="margin-bottom: 16px;">
|
||||
<h5 style="margin: 0 0 8px; color: #606266;">💡 SPIN 提问建议</h5>
|
||||
<ul style="padding-left: 20px; margin: 0; line-height: 1.8;">
|
||||
<li v-for="(q, i) in detailRow.ai_coaching_feedback.spin_questions" :key="i" style="color: #303133; font-size: 13px;">{{ q }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 教练总评 -->
|
||||
<div v-if="detailRow.ai_coaching_feedback.summary">
|
||||
<h5 style="margin: 0 0 8px; color: #606266;">📝 教练总评</h5>
|
||||
<div class="content-box" style="font-size: 13px;">{{ detailRow.ai_coaching_feedback.summary }}</div>
|
||||
</div>
|
||||
|
||||
<!-- 原始 JSON 兜底 -->
|
||||
<div v-if="!detailRow.ai_coaching_feedback.meddic && !detailRow.ai_coaching_feedback.spin_questions && !detailRow.ai_coaching_feedback.summary">
|
||||
<pre style="font-size: 12px; background: #f5f7fa; padding: 8px; border-radius: 4px; overflow-x: auto;">{{ JSON.stringify(detailRow.ai_coaching_feedback, null, 2) }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<el-skeleton :rows="4" animated />
|
||||
<p style="color: #909399; font-size: 13px; margin-top: 8px; text-align: center;">
|
||||
AI 教练正在分析此日志,反馈将在 Dify Workflow 处理完成后自动显示
|
||||
</p>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="detailVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 编辑日志弹窗 -->
|
||||
<el-dialog v-model="editVisible" title="编辑销售日志" width="560px" destroy-on-close>
|
||||
<el-form label-width="80px">
|
||||
<el-form-item label="日志日期">
|
||||
<el-date-picker
|
||||
v-model="editForm.log_date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联客户">
|
||||
<el-select
|
||||
v-model="editForm.customer_id"
|
||||
filterable
|
||||
remote
|
||||
clearable
|
||||
placeholder="输入客户名称搜索"
|
||||
:remote-method="searchCustomers"
|
||||
:loading="customerSearchLoading"
|
||||
@change="onCustomerChange"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="c in customerOptions" :key="c.id" :label="c.name" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关联联系人" v-if="editForm.customer_id">
|
||||
<el-select
|
||||
v-model="editForm.contact_ids"
|
||||
multiple clearable
|
||||
:loading="contactLoading"
|
||||
placeholder="选择联系人"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option v-for="c in contactOptions" :key="c.id" :label="c.name + (c.position ? ` (${c.position})` : '')" :value="c.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="日志内容" required>
|
||||
<el-input v-model="editForm.content" type="textarea" :rows="6" maxlength="5000" show-word-limit />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="editSubmitting" @click="submitEdit">保存修改</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -121,4 +483,34 @@ onMounted(fetchLogs)
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.content-box {
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
min-height: 80px;
|
||||
}
|
||||
.meddic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
.meddic-item {
|
||||
background: #f5f7fa;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.meddic-label {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.coaching-card {
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #f5f3ff 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,556 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 订单全景详情(独立全屏页)
|
||||
* 包含:商品明细、发货记录、关联发票(含开票+关联/上传)、收款追踪
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { ArrowLeft, Van, Tickets, Upload, Link as LinkIcon } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const orderId = computed(() => route.query.id as string)
|
||||
|
||||
// ═══════ 订单数据 ═══════
|
||||
const loading = ref(false)
|
||||
const order = ref<any>({})
|
||||
|
||||
const fetchOrder = async () => {
|
||||
if (!orderId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data: any = await request.get(`/api/orders/${orderId.value}`)
|
||||
order.value = data
|
||||
paymentAmount.value = data.paid_amount || 0
|
||||
fetchShippingHistory()
|
||||
fetchOrderInvoices()
|
||||
} catch { ElMessage.error('加载订单失败') }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
// ═══════ 发货记录 ═══════
|
||||
const shippingHistory = ref<any[]>([])
|
||||
const shippingHistoryLoading = ref(false)
|
||||
const fetchShippingHistory = async () => {
|
||||
shippingHistoryLoading.value = true
|
||||
try {
|
||||
const data: any = await request.get(`/api/shipping/order/${orderId.value}`)
|
||||
shippingHistory.value = data?.shipments || []
|
||||
} catch {}
|
||||
finally { shippingHistoryLoading.value = false }
|
||||
}
|
||||
|
||||
// ═══════ 关联发票 ═══════
|
||||
const orderInvoices = ref<any[]>([])
|
||||
const invoicesLoading = ref(false)
|
||||
const fetchOrderInvoices = async () => {
|
||||
invoicesLoading.value = true
|
||||
try {
|
||||
const data: any = await request.get(`/api/orders/${orderId.value}/invoices`)
|
||||
orderInvoices.value = data || []
|
||||
} catch {}
|
||||
finally { invoicesLoading.value = false }
|
||||
}
|
||||
|
||||
// ═══════ 收款标记 ═══════
|
||||
const paymentAmount = ref(0)
|
||||
const paymentSaving = ref(false)
|
||||
const handleMarkPayment = async () => {
|
||||
if (!order.value.id) return
|
||||
paymentSaving.value = true
|
||||
try {
|
||||
await request.put(`/api/orders/${order.value.id}/payment`, { paid_amount: paymentAmount.value })
|
||||
ElMessage.success('收款状态已更新')
|
||||
await fetchOrder()
|
||||
} catch {}
|
||||
finally { paymentSaving.value = false }
|
||||
}
|
||||
|
||||
// ═══════ 开票明细预览弹窗 ═══════
|
||||
const invoicePreviewVisible = ref(false)
|
||||
const invoicePreviewLoading = ref(false)
|
||||
const invoicePreview = ref<any>({})
|
||||
const invoiceMode = ref<'full' | 'batch'>('full')
|
||||
const selectedShippingId = ref('')
|
||||
|
||||
const openInvoicePreview = async (mode: 'full' | 'batch', shippingId?: string) => {
|
||||
invoiceMode.value = mode
|
||||
selectedShippingId.value = shippingId || ''
|
||||
invoicePreviewLoading.value = true
|
||||
invoicePreviewVisible.value = true
|
||||
try {
|
||||
const params: any = { mode }
|
||||
if (mode === 'batch' && shippingId) params.shipping_id = shippingId
|
||||
const data: any = await request.get(`/api/orders/${orderId.value}/invoice-detail-preview`, { params })
|
||||
invoicePreview.value = data
|
||||
} catch { ElMessage.error('生成开票明细失败') }
|
||||
finally { invoicePreviewLoading.value = false }
|
||||
}
|
||||
|
||||
// ═══════ 关联已有发票 ═══════
|
||||
const linkDialogVisible = ref(false)
|
||||
const unlinkSearchKey = ref('')
|
||||
const unlinkedInvoices = ref<any[]>([])
|
||||
const unlinkedLoading = ref(false)
|
||||
|
||||
const openLinkDialog = async () => {
|
||||
linkDialogVisible.value = true
|
||||
unlinkSearchKey.value = ''
|
||||
await fetchUnlinkedInvoices()
|
||||
}
|
||||
const fetchUnlinkedInvoices = async () => {
|
||||
unlinkedLoading.value = true
|
||||
try {
|
||||
const params: any = {}
|
||||
if (unlinkSearchKey.value) params.keyword = unlinkSearchKey.value
|
||||
const data: any = await request.get('/api/orders/unlinked-invoices', { params })
|
||||
unlinkedInvoices.value = data || []
|
||||
} catch {}
|
||||
finally { unlinkedLoading.value = false }
|
||||
}
|
||||
const handleLinkInvoice = async (inv: any) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认将发票 ${inv.invoice_number}(¥${inv.amount})关联到本订单?`, '关联确认')
|
||||
} catch { return }
|
||||
try {
|
||||
await request.post(`/api/orders/${orderId.value}/invoices/link`, {
|
||||
invoice_id: inv.id,
|
||||
shipping_record_id: selectedShippingId.value || null,
|
||||
})
|
||||
ElMessage.success('发票已关联')
|
||||
linkDialogVisible.value = false
|
||||
invoicePreviewVisible.value = false
|
||||
fetchOrderInvoices()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ═══════ 创建新发票(OCR + 手动) ═══════
|
||||
const createInvVisible = ref(false)
|
||||
const createInvForm = reactive({
|
||||
invoice_number: '',
|
||||
amount: 0,
|
||||
issuer: '',
|
||||
billing_date: '',
|
||||
remark: '',
|
||||
})
|
||||
const ocrLoading = ref(false)
|
||||
const ocrMessage = ref('')
|
||||
const createInvSubmitting = ref(false)
|
||||
|
||||
const openCreateInvoice = () => {
|
||||
createInvForm.invoice_number = ''
|
||||
createInvForm.amount = invoicePreview.value?.total_amount || 0
|
||||
createInvForm.issuer = invoicePreview.value?.seller_name || ''
|
||||
createInvForm.billing_date = new Date().toISOString().slice(0, 10)
|
||||
createInvForm.remark = ''
|
||||
ocrMessage.value = ''
|
||||
createInvVisible.value = true
|
||||
}
|
||||
|
||||
const handleUploadInvoiceFile = async (uploadFile: any) => {
|
||||
ocrLoading.value = true
|
||||
ocrMessage.value = '正在识别发票...'
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', uploadFile.raw || uploadFile.file)
|
||||
formData.append('scene', 'invoice')
|
||||
const resp: any = await request.post('/api/finance/ocr', formData)
|
||||
const ocrData = resp?.ocr_data || {}
|
||||
ocrMessage.value = resp?.message || '识别完成'
|
||||
|
||||
if (ocrData.invoice_number) createInvForm.invoice_number = ocrData.invoice_number
|
||||
if (ocrData.amount) createInvForm.amount = parseFloat(ocrData.amount) || createInvForm.amount
|
||||
if (ocrData.merchant) createInvForm.issuer = ocrData.merchant
|
||||
if (ocrData.date) createInvForm.billing_date = ocrData.date
|
||||
|
||||
if (ocrData.invoice_number || ocrData.amount) {
|
||||
ElMessage.success('发票识别成功,已自动填充')
|
||||
} else {
|
||||
ElMessage.warning('自动识别未提取到关键字段,请手动填写')
|
||||
}
|
||||
} catch (e: any) {
|
||||
ocrMessage.value = '识别失败'
|
||||
ElMessage.error('发票识别失败,请手动填写')
|
||||
}
|
||||
finally { ocrLoading.value = false }
|
||||
}
|
||||
|
||||
const submitCreateInvoice = async () => {
|
||||
if (!createInvForm.invoice_number.trim()) { ElMessage.warning('请填写发票号'); return }
|
||||
if (createInvForm.amount <= 0) { ElMessage.warning('金额需大于0'); return }
|
||||
if (!createInvForm.issuer.trim()) { ElMessage.warning('请填写开票方名称'); return }
|
||||
|
||||
createInvSubmitting.value = true
|
||||
try {
|
||||
await request.post(`/api/orders/${orderId.value}/invoices/create`, {
|
||||
invoice_number: createInvForm.invoice_number,
|
||||
amount: createInvForm.amount,
|
||||
issuer: createInvForm.issuer,
|
||||
billing_date: createInvForm.billing_date,
|
||||
remark: createInvForm.remark,
|
||||
receiver_customer_id: invoicePreview.value?.customer_id || null,
|
||||
shipping_record_id: selectedShippingId.value || null,
|
||||
})
|
||||
ElMessage.success('发票创建并关联成功')
|
||||
createInvVisible.value = false
|
||||
invoicePreviewVisible.value = false
|
||||
fetchOrderInvoices()
|
||||
} catch {}
|
||||
finally { createInvSubmitting.value = false }
|
||||
}
|
||||
|
||||
// ═══════ 辅助 ═══════
|
||||
const activeTab = ref('items')
|
||||
const formatCurrency = (v: number) => `¥${(v || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`
|
||||
const shippingTagType = (s: string) => ({ pending: 'info', partial: 'warning', shipped: 'success' }[s] || 'info')
|
||||
const shippingLabel = (s: string) => ({ pending: '待发货', partial: '部分发货', shipped: '已发货' }[s] || s)
|
||||
const paymentTagType = (s: string) => ({ unpaid: 'danger', partial: 'warning', cleared: 'success' }[s] || 'info')
|
||||
const paymentLabel = (s: string) => ({ unpaid: '未收款', partial: '部分收款', cleared: '已结清' }[s] || s)
|
||||
|
||||
const goBack = () => router.push('/orders')
|
||||
|
||||
onMounted(fetchOrder)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="order-detail-page" v-loading="loading">
|
||||
<!-- 顶部导航 -->
|
||||
<div class="page-header">
|
||||
<el-button :icon="ArrowLeft" @click="goBack">返回订单列表</el-button>
|
||||
<div class="header-title">
|
||||
<h2>订单全景详情</h2>
|
||||
<el-tag v-if="order.order_no" type="primary" effect="dark" size="large" style="margin-left:12px; font-family: Consolas, monospace;">
|
||||
{{ order.order_no }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 订单概览卡片 -->
|
||||
<el-card class="overview-card" v-if="order.id">
|
||||
<el-descriptions :column="4" border>
|
||||
<el-descriptions-item label="客户">
|
||||
<b>{{ order.customer_name }}</b>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="业务员">{{ order.salesperson_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下单日期">{{ order.order_date }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单金额">
|
||||
<b style="color:#e6a23c; font-size:16px">{{ formatCurrency(order.total_amount) }}</b>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="已收款">
|
||||
<b style="color:#67c23a">{{ formatCurrency(order.paid_amount || 0) }}</b>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="未收款">
|
||||
<b style="color:#f56c6c">{{ formatCurrency((order.total_amount || 0) - (order.paid_amount || 0)) }}</b>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="发货状态">
|
||||
<el-tag :type="(shippingTagType(order.shipping_state) as any)" effect="dark" size="small">{{ shippingLabel(order.shipping_state) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="收款状态">
|
||||
<el-tag :type="(paymentTagType(order.payment_state) as any)" size="small">{{ paymentLabel(order.payment_state) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="4">{{ order.remark || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- Tabs 内容区 -->
|
||||
<el-card class="content-card" v-if="order.id">
|
||||
<el-tabs v-model="activeTab" type="border-card">
|
||||
<!-- Tab 1: 商品明细 -->
|
||||
<el-tab-pane label="📦 商品明细" name="items">
|
||||
<el-table :data="order.items || []" border stripe style="width:100%">
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="sku_code" label="SKU" width="160">
|
||||
<template #default="{ row }"><span class="mono-bold">{{ row.sku_code }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sku_name" label="产品名称" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="spec" label="规格" width="100" />
|
||||
<el-table-column label="单价" width="120" align="right">
|
||||
<template #default="{ row }">{{ formatCurrency(row.unit_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="qty" label="订购" width="80" align="center" />
|
||||
<el-table-column label="已发" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.shipped_qty >= row.qty ? '#67c23a' : '#e6a23c', fontWeight: 'bold' }">{{ row.shipped_qty }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计" width="120" align="right">
|
||||
<template #default="{ row }"><b>{{ formatCurrency(row.sub_total) }}</b></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 发货记录 -->
|
||||
<el-tab-pane label="🚛 发货记录" name="shipping">
|
||||
<div v-loading="shippingHistoryLoading">
|
||||
<el-empty v-if="!shippingHistory.length" description="暂无发货记录" />
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item
|
||||
v-for="ship in shippingHistory" :key="ship.id"
|
||||
:timestamp="ship.ship_date" placement="top"
|
||||
:color="ship.status === 'delivered' ? '#67c23a' : '#409eff'"
|
||||
>
|
||||
<el-card shadow="hover" class="timeline-card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
|
||||
<span class="mono-bold">{{ ship.shipping_no }}</span>
|
||||
<el-tag size="small" :type="ship.status === 'delivered' ? 'success' : 'primary'" effect="dark">
|
||||
{{ ship.status === 'delivered' ? '已签收' : '运输中' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p style="margin:4px 0; font-size:13px; color:#606266">物流:{{ ship.carrier || '-' }} | 单号:{{ ship.tracking_no || '-' }}</p>
|
||||
<p style="margin:4px 0; font-size:13px; color:#606266">操作人:{{ ship.operator_name || '-' }}</p>
|
||||
<el-table :data="ship.items" border size="small" style="margin-top:8px">
|
||||
<el-table-column prop="sku_code" label="SKU" width="140" />
|
||||
<el-table-column prop="sku_name" label="产品" min-width="130" />
|
||||
<el-table-column prop="spec" label="规格" width="100">
|
||||
<template #default="{ row }">{{ row.spec || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发货数量" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.shipped_qty }} {{ row.unit || '' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 3: 关联发票(增强) -->
|
||||
<el-tab-pane label="🎟️ 关联发票" name="invoices">
|
||||
<div v-loading="invoicesLoading">
|
||||
<!-- 操作按钮区 -->
|
||||
<div class="invoice-actions">
|
||||
<el-button type="primary" :icon="Tickets" @click="openInvoicePreview('full')">整体开票</el-button>
|
||||
<el-dropdown v-if="shippingHistory.length" trigger="click" @command="(id: string) => openInvoicePreview('batch', id)">
|
||||
<el-button type="success" :icon="Van">按发货批次开票<el-icon class="el-icon--right"><arrow-down /></el-icon></el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item v-for="ship in shippingHistory" :key="ship.id" :command="ship.id">
|
||||
{{ ship.shipping_no }} ({{ ship.ship_date }})
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
|
||||
<!-- 已关联发票列表 -->
|
||||
<el-empty v-if="!orderInvoices.length" description="暂无关联发票" style="margin-top:16px" />
|
||||
<el-table v-else :data="orderInvoices" border stripe size="small" style="margin-top:16px">
|
||||
<el-table-column prop="invoice_number" label="发票号" width="180">
|
||||
<template #default="{ row }"><span class="mono-bold">{{ row.invoice_number }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="issuer" label="开票方" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="receiver_name" label="购买方" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="金额" width="120" align="right">
|
||||
<template #default="{ row }"><b>{{ formatCurrency(row.amount) }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="billing_date" label="开票日期" width="110" />
|
||||
<el-table-column label="回款状态" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.payment_status === '已回款' ? 'success' : row.payment_status === '部分回款' ? 'warning' : 'danger'" size="small">
|
||||
{{ row.payment_status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="payment_due_date" label="回款截止日" width="110" />
|
||||
</el-table>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 4: 收款追踪 -->
|
||||
<el-tab-pane label="💰 收款追踪" name="payment">
|
||||
<el-descriptions :column="2" border style="margin-bottom: 16px;">
|
||||
<el-descriptions-item label="订单金额"><b>{{ formatCurrency(order.total_amount) }}</b></el-descriptions-item>
|
||||
<el-descriptions-item label="已收款"><b style="color:#67c23a">{{ formatCurrency(order.paid_amount || 0) }}</b></el-descriptions-item>
|
||||
<el-descriptions-item label="未收款"><b style="color:#f56c6c">{{ formatCurrency((order.total_amount || 0) - (order.paid_amount || 0)) }}</b></el-descriptions-item>
|
||||
<el-descriptions-item label="收款状态">
|
||||
<el-tag :type="(paymentTagType(order.payment_state) as any)" size="small">{{ paymentLabel(order.payment_state) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-form :inline="true" style="margin-top: 12px;">
|
||||
<el-form-item label="录入收款金额">
|
||||
<el-input-number v-model="paymentAmount" :min="0" :precision="2" style="width: 200px" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="paymentSaving" @click="handleMarkPayment">更新收款</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="success" @click="paymentAmount = order.total_amount; handleMarkPayment()">一键结清</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
<!-- ═══════ 开票明细预览弹窗 ═══════ -->
|
||||
<el-dialog v-model="invoicePreviewVisible" title="📋 开票明细预览" width="800px" destroy-on-close>
|
||||
<div v-loading="invoicePreviewLoading">
|
||||
<el-descriptions :column="2" border style="margin-bottom:16px">
|
||||
<el-descriptions-item label="买方(客户)"><b>{{ invoicePreview.buyer_name }}</b></el-descriptions-item>
|
||||
<el-descriptions-item label="卖方(我方)"><b>{{ invoicePreview.seller_name }}</b></el-descriptions-item>
|
||||
<el-descriptions-item label="订单编号">{{ invoicePreview.order_no }}</el-descriptions-item>
|
||||
<el-descriptions-item label="开票模式">
|
||||
<el-tag :type="invoiceMode === 'full' ? 'primary' : 'success'" size="small" effect="dark">
|
||||
{{ invoiceMode === 'full' ? '整体开票' : '按发货批次' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-table :data="invoicePreview.items || []" border stripe size="small">
|
||||
<el-table-column type="index" label="#" width="50" />
|
||||
<el-table-column prop="sku_code" label="SKU" width="140" />
|
||||
<el-table-column prop="sku_name" label="产品名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="spec" label="规格" width="90" />
|
||||
<el-table-column label="数量" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.qty }} {{ row.unit || '' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单价" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatCurrency(row.unit_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计" width="110" align="right">
|
||||
<template #default="{ row }"><b>{{ formatCurrency(row.sub_total) }}</b></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="preview-total">
|
||||
合计金额:<b style="color:#e6a23c; font-size:18px">{{ formatCurrency(invoicePreview.total_amount || 0) }}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="invoicePreviewVisible = false">取消</el-button>
|
||||
<el-button type="warning" :icon="LinkIcon" @click="openLinkDialog">关联已有发票</el-button>
|
||||
<el-button type="primary" :icon="Upload" @click="openCreateInvoice">创建/上传发票</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- ═══════ 关联已有发票弹窗 ═══════ -->
|
||||
<el-dialog v-model="linkDialogVisible" title="🔗 关联已有发票" width="650px" destroy-on-close>
|
||||
<el-input v-model="unlinkSearchKey" placeholder="搜索发票号..." clearable style="margin-bottom:12px"
|
||||
@input="fetchUnlinkedInvoices" />
|
||||
<el-table v-loading="unlinkedLoading" :data="unlinkedInvoices" border stripe size="small" max-height="400">
|
||||
<el-table-column prop="invoice_number" label="发票号" width="160">
|
||||
<template #default="{ row }"><span class="mono-bold">{{ row.invoice_number }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="issuer" label="开票方" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column prop="receiver_name" label="购买方" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="金额" width="110" align="right">
|
||||
<template #default="{ row }"><b>{{ formatCurrency(row.amount) }}</b></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="billing_date" label="开票日期" width="100" />
|
||||
<el-table-column label="" width="80" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" size="small" @click="handleLinkInvoice(row)">关联</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!unlinkedInvoices.length && !unlinkedLoading" description="暂无未关联的发票" />
|
||||
</el-dialog>
|
||||
|
||||
<!-- ═══════ 创建新发票弹窗(OCR + 手动) ═══════ -->
|
||||
<el-dialog v-model="createInvVisible" title="📄 创建发票并关联" width="550px" destroy-on-close>
|
||||
<el-alert type="info" :closable="false" style="margin-bottom:16px" show-icon>
|
||||
<template #title>
|
||||
上传发票文件(XML / PDF / 图片),系统自动识别填充;识别失败可手动填写
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
accept=".xml,.pdf,.png,.jpg,.jpeg,.ofd"
|
||||
@change="handleUploadInvoiceFile"
|
||||
>
|
||||
<el-button type="success" :icon="Upload" :loading="ocrLoading">
|
||||
{{ ocrLoading ? '识别中...' : '上传发票文件' }}
|
||||
</el-button>
|
||||
</el-upload>
|
||||
<el-tag v-if="ocrMessage" :type="ocrMessage.includes('成功') ? 'success' : 'warning'" style="margin-top:8px">
|
||||
{{ ocrMessage }}
|
||||
</el-tag>
|
||||
|
||||
<el-divider />
|
||||
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="发票号" required>
|
||||
<el-input v-model="createInvForm.invoice_number" placeholder="如 12345678" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开票金额" required>
|
||||
<el-input-number v-model="createInvForm.amount" :min="0" :precision="2" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开票方" required>
|
||||
<el-input v-model="createInvForm.issuer" placeholder="卖方公司名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开票日期">
|
||||
<el-date-picker v-model="createInvForm.billing_date" type="date" value-format="YYYY-MM-DD" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="createInvForm.remark" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="createInvVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="createInvSubmitting" @click="submitCreateInvoice">确认创建并关联</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.order-detail-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
.header-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.header-title h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.overview-card {
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
box-shadow: 0 1px 4px rgba(0,21,41,0.08);
|
||||
}
|
||||
.content-card {
|
||||
flex: 1;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
box-shadow: 0 1px 4px rgba(0,21,41,0.08);
|
||||
}
|
||||
.mono-bold {
|
||||
font-family: Consolas, 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
color: #409eff;
|
||||
}
|
||||
.timeline-card {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.timeline-card p {
|
||||
line-height: 1.4;
|
||||
}
|
||||
.invoice-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.preview-total {
|
||||
margin-top: 16px;
|
||||
text-align: right;
|
||||
font-size: 15px;
|
||||
}
|
||||
</style>
|
||||
@@ -4,11 +4,14 @@
|
||||
* 列表 + 开单 + 详情(含发货记录 timeline)+ 安排发货弹窗(防超发)
|
||||
*/
|
||||
import { ref, reactive, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Search, Plus, View, Delete, Van } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// ════════════════════════ 订单列表 ════════════════════════
|
||||
const loading = ref(false)
|
||||
const orderList = ref<any[]>([])
|
||||
@@ -43,34 +46,8 @@ const paymentLabel = (s: string) => ({ unpaid: '未收款', partial: '部分收
|
||||
const formatCurrency = (v: number) => `¥${v?.toLocaleString('zh-CN', { minimumFractionDigits: 2 })}`
|
||||
|
||||
// ════════════════════════ 订单详情 ════════════════════════
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const currentOrder = ref<any>({})
|
||||
const activeDetailTab = ref('items')
|
||||
const shippingHistory = ref<any[]>([])
|
||||
const shippingHistoryLoading = ref(false)
|
||||
|
||||
const openDetail = async (row: any) => {
|
||||
activeDetailTab.value = 'items'
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
shippingHistory.value = []
|
||||
try {
|
||||
const data: any = await request.get(`/api/orders/${row.id}`)
|
||||
currentOrder.value = data
|
||||
// 同时加载发货轨迹
|
||||
fetchShippingHistory(row.id)
|
||||
} catch {}
|
||||
finally { detailLoading.value = false }
|
||||
}
|
||||
|
||||
const fetchShippingHistory = async (orderId: string) => {
|
||||
shippingHistoryLoading.value = true
|
||||
try {
|
||||
const data: any = await request.get(`/api/shipping/order/${orderId}`)
|
||||
shippingHistory.value = data?.shipments || []
|
||||
} catch {}
|
||||
finally { shippingHistoryLoading.value = false }
|
||||
const openDetail = (row: any) => {
|
||||
router.push({ path: '/orders/detail', query: { id: row.id } })
|
||||
}
|
||||
|
||||
// ════════════════════════ 沉浸式开单 ════════════════════════
|
||||
@@ -241,10 +218,6 @@ const submitShipping = async () => {
|
||||
ElMessage.success('发货成功,库存已同步扣减')
|
||||
shipDialogVisible.value = false
|
||||
fetchOrders()
|
||||
// 如果详情也开着,刷新它
|
||||
if (detailVisible.value && currentOrder.value.id === shipOrder.value.id) {
|
||||
openDetail(shipOrder.value)
|
||||
}
|
||||
} catch {}
|
||||
finally { shipSubmitting.value = false }
|
||||
}
|
||||
@@ -381,87 +354,6 @@ onMounted(fetchOrders)
|
||||
</template>
|
||||
</el-drawer>
|
||||
|
||||
<!-- ═══════ 4. 订单详情抽屉(含发货轨迹 tab)═══════ -->
|
||||
<el-drawer v-model="detailVisible" title="订单全景详情" size="750px">
|
||||
<div v-loading="detailLoading">
|
||||
<template v-if="currentOrder.id">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="订单编号"><span class="order-id-bold">{{ currentOrder.order_no }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="客户">{{ currentOrder.customer_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="业务员">{{ currentOrder.salesperson_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下单日期">{{ currentOrder.order_date }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单金额"><b style="color:#e6a23c">{{ formatCurrency(currentOrder.total_amount) }}</b></el-descriptions-item>
|
||||
<el-descriptions-item label="已收款">{{ formatCurrency(currentOrder.paid_amount || 0) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发货状态">
|
||||
<el-tag :type="(shippingTagType(currentOrder.shipping_state) as any)" effect="dark" size="small">{{ shippingLabel(currentOrder.shipping_state) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="收款状态">
|
||||
<el-tag :type="(paymentTagType(currentOrder.payment_state) as any)" size="small">{{ paymentLabel(currentOrder.payment_state) }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ currentOrder.remark || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-tabs v-model="activeDetailTab" style="margin-top:16px">
|
||||
<!-- Tab 1: 商品明细 -->
|
||||
<el-tab-pane label="📦 商品明细" name="items">
|
||||
<el-table :data="currentOrder.items || []" border stripe style="width:100%">
|
||||
<el-table-column prop="sku_code" label="SKU" width="180"><template #default="{ row }"><span class="sku-bold">{{ row.sku_code }}</span></template></el-table-column>
|
||||
<el-table-column prop="sku_name" label="产品名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="spec" label="规格" width="100" />
|
||||
<el-table-column label="单价" width="110" align="right"><template #default="{ row }">{{ formatCurrency(row.unit_price) }}</template></el-table-column>
|
||||
<el-table-column prop="qty" label="订购" width="70" align="center" />
|
||||
<el-table-column label="已发" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :style="{ color: row.shipped_qty >= row.qty ? '#67c23a' : '#e6a23c', fontWeight: 'bold' }">{{ row.shipped_qty }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计" width="110" align="right"><template #default="{ row }"><b>{{ formatCurrency(row.sub_total) }}</b></template></el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- Tab 2: 发货记录 -->
|
||||
<el-tab-pane label="🚛 发货记录" name="shipping">
|
||||
<div v-loading="shippingHistoryLoading">
|
||||
<el-empty v-if="!shippingHistory.length" description="暂无发货记录" />
|
||||
<el-timeline v-else>
|
||||
<el-timeline-item
|
||||
v-for="ship in shippingHistory" :key="ship.id"
|
||||
:timestamp="ship.ship_date" placement="top"
|
||||
:color="ship.status === 'delivered' ? '#67c23a' : '#409eff'"
|
||||
>
|
||||
<el-card shadow="hover" class="timeline-card">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px">
|
||||
<span class="order-id-bold">{{ ship.shipping_no }}</span>
|
||||
<el-tag size="small" :type="ship.status === 'delivered' ? 'success' : 'primary'" effect="dark">
|
||||
{{ ship.status === 'delivered' ? '已签收' : '运输中' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<p style="margin:4px 0; font-size:13px; color:#606266">物流:{{ ship.carrier || '-' }} | 单号:{{ ship.tracking_no || '-' }}</p>
|
||||
<p style="margin:4px 0; font-size:13px; color:#606266">操作人:{{ ship.operator_name || '-' }}</p>
|
||||
<el-table :data="ship.items" border size="small" style="margin-top:8px">
|
||||
<el-table-column prop="sku_code" label="SKU" width="140" />
|
||||
<el-table-column prop="sku_name" label="产品" min-width="130" />
|
||||
<el-table-column prop="spec" label="规格" width="100">
|
||||
<template #default="{ row }">{{ row.spec || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发货数量" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.shipped_qty }} {{ row.unit || '' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 详情页快速发货按钮 -->
|
||||
<div v-if="currentOrder.shipping_state !== 'shipped'" style="margin-top:16px; text-align:right">
|
||||
<el-button type="success" :icon="Van" @click="openShipDialog(currentOrder)">安排发货</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
|
||||
<!-- ═══════ 5. 安排发货弹窗(防超发)═══════ -->
|
||||
<el-dialog v-model="shipDialogVisible" title="🚛 安排发货" width="750px" destroy-on-close>
|
||||
|
||||
@@ -239,6 +239,7 @@ const invForm = reactive({
|
||||
qty: 1,
|
||||
reason: 'purchase',
|
||||
remark: '',
|
||||
purchase_unit_price: 0,
|
||||
})
|
||||
|
||||
const invRules = reactive<FormRules>({
|
||||
@@ -259,7 +260,7 @@ const outReasons = [
|
||||
|
||||
const openInventory = (row: any) => {
|
||||
currentInvSku.value = row
|
||||
Object.assign(invForm, { direction: 'in', qty: 1, reason: 'purchase', remark: '' })
|
||||
Object.assign(invForm, { direction: 'in', qty: 1, reason: 'purchase', remark: '', purchase_unit_price: 0 })
|
||||
invDialogVisible.value = true
|
||||
nextTick(() => invFormRef.value?.clearValidate())
|
||||
}
|
||||
@@ -281,6 +282,7 @@ const submitInventory = async () => {
|
||||
change_qty: changeQty,
|
||||
reason: invForm.reason,
|
||||
remark: invForm.remark || null,
|
||||
purchase_unit_price: invForm.direction === 'in' ? invForm.purchase_unit_price : 0,
|
||||
})
|
||||
ElMessage.success('库存变更成功')
|
||||
invDialogVisible.value = false
|
||||
@@ -529,6 +531,10 @@ onMounted(async () => {
|
||||
<el-form-item label="变更数量" prop="qty">
|
||||
<el-input-number v-model="invForm.qty" :min="1" :max="99999" controls-position="right" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="invForm.direction === 'in'" label="采购单价">
|
||||
<el-input-number v-model="invForm.purchase_unit_price" :min="0" :precision="2" controls-position="right" style="width:100%" />
|
||||
<div style="font-size: 12px; color: #909399; margin-top: 4px;">用于 MWA 加权成本计算,0 表示不记入成本</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="操作备注">
|
||||
<el-input v-model="invForm.remark" type="textarea" :rows="2" placeholder="非必填,记录特殊单号或说明" />
|
||||
</el-form-item>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 利润核算报表页
|
||||
* GET /api/profit/report (按订单维度聚合)
|
||||
*/
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
|
||||
const loading = ref(false)
|
||||
const report = ref<any>({ orders: [], total_revenue: 0, total_profit: 0, overall_profit_rate: 0 })
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
|
||||
const fetchReport = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = {}
|
||||
if (dateRange.value) {
|
||||
params.start_date = dateRange.value[0]
|
||||
params.end_date = dateRange.value[1]
|
||||
}
|
||||
const data: any = await request.get('/api/profit/report', { params })
|
||||
report.value = data
|
||||
} catch { /* */ } finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => fetchReport())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profit-container">
|
||||
<!-- 筛选 -->
|
||||
<el-card shadow="never" class="filter-section">
|
||||
<div style="display: flex; gap: 16px; align-items: center;">
|
||||
<el-date-picker v-model="dateRange" type="daterange" range-separator="至"
|
||||
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" />
|
||||
<el-button type="primary" @click="fetchReport">查询</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 汇总卡片 -->
|
||||
<div class="summary-cards">
|
||||
<el-card shadow="never">
|
||||
<div class="stat-label">总营收</div>
|
||||
<div class="stat-value">¥{{ (report.total_revenue || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}</div>
|
||||
</el-card>
|
||||
<el-card shadow="never">
|
||||
<div class="stat-label">总利润</div>
|
||||
<div class="stat-value" :class="{ 'text-success': report.total_profit > 0, 'text-danger': report.total_profit < 0 }">
|
||||
¥{{ (report.total_profit || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 }) }}
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card shadow="never">
|
||||
<div class="stat-label">综合利润率</div>
|
||||
<div class="stat-value">{{ report.overall_profit_rate || 0 }}%</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 明细表 -->
|
||||
<el-card shadow="never">
|
||||
<template #header><span style="font-weight: bold;">订单利润明细</span></template>
|
||||
<el-table :data="report.orders || []" stripe border v-loading="loading">
|
||||
<el-table-column prop="order_no" label="订单编号" width="200" />
|
||||
<el-table-column prop="order_date" label="订单日期" width="120" />
|
||||
<el-table-column label="营收" width="160" align="right">
|
||||
<template #default="scope">¥{{ (scope.row.revenue || 0).toFixed(2) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="利润" width="160" align="right">
|
||||
<template #default="scope">
|
||||
<span :class="{ 'text-success': scope.row.profit > 0, 'text-danger': scope.row.profit < 0 }">
|
||||
¥{{ (scope.row.profit || 0).toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="利润率" width="120" align="center">
|
||||
<template #default="scope">{{ scope.row.profit_rate || 0 }}%</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.profit-container { display: flex; flex-direction: column; gap: 20px; }
|
||||
.filter-section { border-radius: 8px; border: none; box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08); }
|
||||
.summary-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
.stat-label { font-size: 14px; color: #909399; margin-bottom: 8px; }
|
||||
.stat-value { font-size: 28px; font-weight: bold; color: #303133; }
|
||||
.text-success { color: #67c23a; }
|
||||
.text-danger { color: #f56c6c; }
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@
|
||||
* Tab2: 角色管理(新增/编辑弹窗 + 权限分配面板 + JSONB menu_keys)
|
||||
*/
|
||||
import { ref, reactive, onMounted, nextTick } from 'vue'
|
||||
import { Plus, Edit, Key, Lock, Setting, User, Operation, Check } from '@element-plus/icons-vue'
|
||||
import { Plus, Edit, Key, Lock, Setting, User, Operation, Check, OfficeBuilding } from '@element-plus/icons-vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import request from '@/api/request'
|
||||
|
||||
@@ -280,6 +280,57 @@ const submitRole = async () => {
|
||||
finally { roleSubmitting.value = false }
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Tab 3: 企业账号配置
|
||||
// ==========================================
|
||||
const companyLoading = ref(false)
|
||||
const companySaving = ref(false)
|
||||
const companyForm = reactive({
|
||||
name: '',
|
||||
code: '',
|
||||
full_info: {
|
||||
company_name: '',
|
||||
tax_id: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
bank_name: '',
|
||||
bank_account: '',
|
||||
},
|
||||
})
|
||||
|
||||
const fetchCompany = async () => {
|
||||
companyLoading.value = true
|
||||
try {
|
||||
const data: any = await request.get('/api/companies/current')
|
||||
if (data) {
|
||||
companyForm.name = data.name || ''
|
||||
companyForm.code = data.code || ''
|
||||
const info = data.full_info || {}
|
||||
Object.assign(companyForm.full_info, {
|
||||
company_name: info.company_name || '',
|
||||
tax_id: info.tax_id || '',
|
||||
address: info.address || '',
|
||||
phone: info.phone || '',
|
||||
bank_name: info.bank_name || '',
|
||||
bank_account: info.bank_account || '',
|
||||
})
|
||||
}
|
||||
} catch (e) { console.warn('[Settings] fetchCompany error:', e) }
|
||||
finally { companyLoading.value = false }
|
||||
}
|
||||
|
||||
const saveCompany = async () => {
|
||||
companySaving.value = true
|
||||
try {
|
||||
await request.put('/api/companies/current', {
|
||||
name: companyForm.name,
|
||||
full_info: companyForm.full_info,
|
||||
})
|
||||
ElMessage.success('企业信息已保存')
|
||||
} catch { /* 统一处理 */ }
|
||||
finally { companySaving.value = false }
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// 初始化
|
||||
// ==========================================
|
||||
@@ -289,6 +340,7 @@ onMounted(async () => {
|
||||
flatDepts.value = flattenDepts(orgTreeData.value)
|
||||
await fetchUsers()
|
||||
await fetchRoles()
|
||||
fetchCompany()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -429,6 +481,54 @@ onMounted(async () => {
|
||||
</el-row>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ============================================== -->
|
||||
<!-- Tab 3: 企业账号配置 -->
|
||||
<!-- ============================================== -->
|
||||
<el-tab-pane name="company">
|
||||
<template #label>
|
||||
<span class="custom-tabs-label"><el-icon><OfficeBuilding /></el-icon><span>企业账号配置</span></span>
|
||||
</template>
|
||||
|
||||
<el-card shadow="never" class="perm-card" v-loading="companyLoading">
|
||||
<template #header>
|
||||
<div class="perm-header">
|
||||
<div class="card-header-bold">🏢 当前公司卖方信息</div>
|
||||
<el-button type="success" :icon="Check" :loading="companySaving" @click="saveCompany">保存</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form label-width="130px" style="max-width: 700px;">
|
||||
<el-divider content-position="left">基本信息</el-divider>
|
||||
<el-form-item label="公司简称">
|
||||
<el-input v-model="companyForm.name" placeholder="系统内简称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="公司编码">
|
||||
<el-input v-model="companyForm.code" disabled />
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">开票 / 合同卖方信息</el-divider>
|
||||
<el-form-item label="企业全称">
|
||||
<el-input v-model="companyForm.full_info.company_name" placeholder="营业执照上的全称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="纳税人识别号">
|
||||
<el-input v-model="companyForm.full_info.tax_id" placeholder="统一社会信用代码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="注册地址">
|
||||
<el-input v-model="companyForm.full_info.address" placeholder="公司注册地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="企业电话">
|
||||
<el-input v-model="companyForm.full_info.phone" placeholder="公司电话" />
|
||||
</el-form-item>
|
||||
<el-form-item label="开户银行">
|
||||
<el-input v-model="companyForm.full_info.bank_name" placeholder="开户行名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="银行账号">
|
||||
<el-input v-model="companyForm.full_info.bank_account" placeholder="银行账号" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
</el-tabs>
|
||||
</el-card>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user