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:
hankin
2026-05-11 07:24:19 +00:00
parent 0f4c6b7924
commit 815cbf9d8c
2526 changed files with 11875 additions and 804148 deletions
-3
View File
@@ -1,3 +0,0 @@
# 开发环境 API 基础路径
# Vite 开发服务器通过 proxy 转发 /api → http://127.0.0.1:8000
VITE_API_BASE_URL=
-3
View File
@@ -1,3 +0,0 @@
# 生产环境 API 基础路径
# Nginx 统一代理,前端和后端同域,无需跨域
VITE_API_BASE_URL=
+51
View File
@@ -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 });
});
});
+27
View File
@@ -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 });
});
+37
View File
@@ -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 });
});
});
+35
View File
@@ -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 });
}
});
});
+74
View File
@@ -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();
}
}
});
});
+38
View File
@@ -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();
});
});
+36
View File
@@ -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();
}
});
});
+68
View File
@@ -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();
});
});
+46
View File
@@ -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 });
});
});
+56
View File
@@ -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();
}
});
});
+51
View File
@@ -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();
}
});
});
+50
View File
@@ -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 });
});
});
+2111 -2042
View File
File diff suppressed because it is too large Load Diff
+31 -26
View File
@@ -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"
}
}
+68
View File
@@ -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,
},
],
*/
});
+3
View File
@@ -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) => {
+21 -2
View File
@@ -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) {
+67 -1
View File
@@ -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;
+26
View File
@@ -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
View File
@@ -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,
}
})
+2
View File
@@ -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>
+279
View File
@@ -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>
+123 -1
View File
@@ -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" />
+156 -3
View File
@@ -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],
+151 -22
View File
@@ -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>
<!-- 待确认列表方案AAI预填 + 人工审核 -->
<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>
+236 -72
View File
@@ -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>
+401 -9
View File
@@ -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>
+556
View File
@@ -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>
+5 -113
View File
@@ -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>
+7 -1
View File
@@ -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>
+91
View File
@@ -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>
+101 -1
View File
@@ -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>