feat: initial commit and ignore qwen files

This commit is contained in:
dxc
2026-04-30 10:06:32 +08:00
commit def4f7d71f
55 changed files with 5252 additions and 0 deletions

24
frontend/Dockerfile Normal file
View File

@ -0,0 +1,24 @@
# ================================================
# PMS 前端 Dockerfile
# Node 18 + Vue 3 + Vite
# ================================================
FROM node:18-alpine
# 设置工作目录
WORKDIR /app
# 复制 package.json 和 package-lock.json
COPY package*.json ./
# 安装依赖
RUN npm install
# 复制源代码
COPY . .
# 暴露端口
EXPOSE 5173
# 启动命令(开发模式,监听所有网络接口)
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PMS 生产管理系统</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

27
frontend/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "pms-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.15",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"axios": "^1.6.5",
"element-plus": "^2.5.2",
"@element-plus/icons-vue": "^2.3.1",
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.3",
"@types/node": "^20.11.5",
"typescript": "^5.3.3",
"vite": "^5.0.11",
"vue-tsc": "^1.8.27"
}
}

24
frontend/src/App.vue Normal file
View File

@ -0,0 +1,24 @@
<template>
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>
<script setup lang="ts">
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #app {
width: 100%;
height: 100%;
font-family: 'Helvetica Neue', Arial, sans-serif;
}
</style>

117
frontend/src/api/bom.ts Normal file
View File

@ -0,0 +1,117 @@
import axios from 'axios'
// 统一使用 /api 作为 baseURL与 Vite proxy 配置匹配
const api = axios.create({
baseURL: '/api',
timeout: 10000
})
// ─── 成品搜索 ────────────────────────────────────────────────────────────
export interface BomTarget {
id: number
name: string
spec_model: string | null
bom_no: string | null
version: string | null
bom_key: string // 唯一组合键 "parent_id_bom_no_version"
}
export interface SearchResponse {
items: BomTarget[]
total: number
page: number
size: number
}
export const searchBomTargets = async (
q: string,
page: number = 1,
size: number = 20
): Promise<BomTarget[]> => {
const response = await api.get('/material/search', {
params: { q, page, size }
})
const data = response.data
return Array.isArray(data) ? data : (data?.items ?? [])
}
// ─── 齐套性推演 ──────────────────────────────────────────────────────────
export interface MaterialRequirement {
material_id: number
material_name: string
spec_model: string | null
unit: string | null
required_quantity: number
current_stock: number
shortage_quantity: number
is_shortage: boolean
}
export interface DeduceResult {
target_base_id: number
target_quantity: number
bom_no: string | null
version: string | null
is_shortage: boolean
total_shortage_count: number
material_requirements: MaterialRequirement[]
}
export const deduceBom = async (
target_base_id: number,
target_quantity: number,
bom_no?: string | null,
version?: string | null,
): Promise<DeduceResult> => {
const params: Record<string, unknown> = { target_base_id, target_quantity }
if (bom_no != null) params.bom_no = bom_no
if (version != null) params.version = version
const response = await api.get('/pms/deduce_bom', { params })
return response.data as DeduceResult
}
// ─── 用户偏好 ────────────────────────────────────────────────────────────
// favorite_target_ids 存储的是 bom_key 字符串
export interface UserPreference {
user_id: number
default_bom_target_id: number | null
favorite_target_ids: string[] // bom_key 列表
}
export const getUserPreference = async (user_id: number): Promise<UserPreference> => {
const response = await api.get('/pms/preference', { params: { user_id } })
return response.data as UserPreference
}
export const putUserPreference = async (
user_id: number,
data: { default_bom_target_id?: number | null; favorite_target_ids?: string[] | null }
): Promise<UserPreference> => {
const response = await api.put('/pms/preference', data, { params: { user_id } })
return response.data as UserPreference
}
// ─── 常看增删(精确路径)───────────────────────────────────────────────
export const addFavoriteTarget = async (user_id: number, bom_key: string): Promise<UserPreference> => {
const response = await api.post('/pms/preference/favorite/add', null, {
params: { user_id, target_id: bom_key }
})
return response.data as UserPreference
}
export const removeFavoriteTarget = async (user_id: number, bom_key: string): Promise<UserPreference> => {
const response = await api.post('/pms/preference/favorite/remove', null, {
params: { user_id, target_id: bom_key }
})
return response.data as UserPreference
}
// ─── 保存排序(替换全部顺序)────────────────────────────────────────────
export const reorderFavorites = (user_id: number, favorite_keys: string[]): Promise<UserPreference> => {
return api.post(`/pms/preference/favorite/reorder?user_id=${user_id}`, {
favorite_keys
}).then(res => res.data as UserPreference)
}
import { getMaterialList } from '@/api/material'
export { getMaterialList }

View File

@ -0,0 +1,41 @@
import axios from 'axios'
import type { ApprovalItem, ApprovalForm, MaterialBase } from '@/types'
// 统一使用 /api 作为 baseURL与 Vite proxy 配置匹配
const api = axios.create({
baseURL: '/api',
timeout: 10000
})
// 获取物料列表(从库存库读取,只读)
export const getMaterialList = async (): Promise<MaterialBase[]> => {
const response = await api.get('/material')
return response.data
}
// 获取待审批列表
export const getPendingApprovals = async (): Promise<ApprovalItem[]> => {
const response = await api.get('/approvals')
return response.data
}
// 提交缺料申请
export const createApproval = async (data: ApprovalForm): Promise<ApprovalItem> => {
const response = await api.post('/approvals', data)
return response.data
}
// 更新审批状态(主管操作)
export const updateApprovalStatus = async (
id: number,
status: 'APPROVED' | 'REJECTED'
): Promise<ApprovalItem> => {
const response = await api.put(`/approvals/${id}/status`, { status })
return response.data
}
// 获取审批详情
export const getApprovalDetail = async (id: number): Promise<ApprovalItem> => {
const response = await api.get(`/approvals/${id}`)
return response.data
}

145
frontend/src/api/project.ts Normal file
View File

@ -0,0 +1,145 @@
import axios from 'axios'
// 统一使用 /api 作为 baseURL与 Vite proxy 配置匹配
const api = axios.create({
baseURL: '/api',
timeout: 15000
})
// ─── 类型定义 ──────────────────────────────────────────────────────────
export enum ProjectStatus {
DRAFT = 'draft',
ACTIVE = 'active',
COMPLETED = 'completed',
CANCELLED = 'cancelled'
}
export interface ProjectStats {
project_id: number
project_no: string
project_name: string
status: ProjectStatus
start_date: string | null
end_date: string | null
// 进度相关
total_work_orders: number
completed_work_orders: number
in_progress_work_orders: number
pending_work_orders: number
progress_percentage: number
// 物料到位率
total_approvals: number
pending_approvals: number
approved_approvals: number
rejected_approvals: number
material_ready_rate: number
// 延期预警
is_overdue: boolean
overdue_days: number | null
created_at: string
}
export interface ProjectSummary {
total_projects: number
active_projects: number
completed_projects: number
overdue_projects: number
// 全局统计
total_work_orders: number
completed_work_orders: number
overall_progress: number
total_pending_approvals: number
overall_material_ready_rate: number
// 项目列表
projects: ProjectStats[]
}
// ─── API 接口 ──────────────────────────────────────────────────────────
/**
* 获取项目总览汇总数据
* 后端路径: GET /api/pms/projects/summary
* @param status 可选的项目状态筛选
*/
export const getProjectSummary = async (
status?: ProjectStatus
): Promise<ProjectSummary> => {
const params: Record<string, unknown> = {}
if (status) {
params.status = status
}
const response = await api.get('/pms/projects/summary', { params })
return response.data as ProjectSummary
}
/**
* 获取单个项目的详细统计信息
* 后端路径: GET /api/pms/projects/{project_id}/stats
* @param projectId 项目ID
*/
export const getProjectStats = async (projectId: number): Promise<ProjectStats> => {
const response = await api.get(`/pms/projects/${projectId}/stats`)
return response.data as ProjectStats
}
// ─── 类型定义 ──────────────────────────────────────────────────────────
export interface ProjectCreateParams {
project_no: string
project_name: string
start_date?: string | null
end_date?: string | null
status?: ProjectStatus
}
export interface ProjectResponse {
id: number
project_no: string
name: string
start_date: string | null
end_date: string | null
status: ProjectStatus
created_at: string
updated_at: string | null
}
/**
* 创建新项目
* 后端路径: POST /api/pms/projects
* @param data 项目创建参数
*/
export const createProject = async (
data: ProjectCreateParams
): Promise<ProjectResponse> => {
const payload = {
project_no: data.project_no,
name: data.project_name,
start_date: data.start_date ?? null,
end_date: data.end_date ?? null,
status: data.status ?? ProjectStatus.DRAFT,
}
const response = await api.post('/pms/projects', payload)
return response.data as ProjectResponse
}
// 状态显示映射
export const projectStatusMap: Record<ProjectStatus, { label: string; type: string }> = {
[ProjectStatus.DRAFT]: { label: '草稿', type: 'info' },
[ProjectStatus.ACTIVE]: { label: '进行中', type: 'primary' },
[ProjectStatus.COMPLETED]: { label: '已完成', type: 'success' },
[ProjectStatus.CANCELLED]: { label: '已取消', type: 'warning' },
}
// 延期状态显示
export const overdueStatusMap = {
normal: { label: '正常', type: 'success' },
warning: { label: '即将到期', type: 'warning' },
danger: { label: '已延期', type: 'danger' }
}

View File

@ -0,0 +1,123 @@
import axios from 'axios'
// 统一使用 /api 作为 baseURL与 Vite proxy 配置匹配
const api = axios.create({
baseURL: '/api',
timeout: 15000
})
// ─── 类型定义 ──────────────────────────────────────────────────────────
export enum WorkOrderStatus {
PENDING = 'pending',
IN_PROGRESS = 'in_progress',
COMPLETED = 'completed',
CANCELLED = 'cancelled'
}
export interface WorkOrderKanbanItem {
id: number
work_order_no: string
project_id: number
target_base_id: number
target_quantity: number
assignee_name: string | null
status: WorkOrderStatus
created_at: string
updated_at: string | null
// 关联信息
project_name: string | null
material_name: string | null
material_spec: string | null
}
export interface PaginatedWorkOrders {
items: WorkOrderKanbanItem[]
total: number
page: number
size: number
total_pages: number
}
export interface StatusCounts {
pending: number
in_progress: number
completed: number
cancelled: number
}
// ─── API 接口 ──────────────────────────────────────────────────────────
export interface WorkOrderKanbanParams {
status?: WorkOrderStatus
project_id?: number
assignee_name?: string
search?: string
page?: number
size?: number
}
/**
* 获取工单看板列表
*/
export const getWorkOrderKanban = async (
params: WorkOrderKanbanParams = {}
): Promise<PaginatedWorkOrders> => {
const response = await api.get('/pms/work_order_kanban', { params })
return response.data as PaginatedWorkOrders
}
/**
* 获取各状态工单数量统计
*/
export const getStatusCounts = async (): Promise<StatusCounts> => {
const response = await api.get('/pms/work_order_kanban/status-counts')
return response.data as StatusCounts
}
/**
* 更新工单状态
*/
export const updateWorkOrderStatus = async (
workOrderId: number,
status: WorkOrderStatus
): Promise<WorkOrderKanbanItem> => {
const response = await api.put(`/pms/work_order/${workOrderId}/status`, { status })
return response.data as WorkOrderKanbanItem
}
// ─── 状态配置映射 ──────────────────────────────────────────────────────
export const workOrderStatusConfig: Record<WorkOrderStatus, { label: string; type: string; color: string }> = {
[WorkOrderStatus.PENDING]: {
label: '待生产',
type: 'warning',
color: '#E6A23C'
},
[WorkOrderStatus.IN_PROGRESS]: {
label: '生产中',
type: 'primary',
color: '#409EFF'
},
[WorkOrderStatus.COMPLETED]: {
label: '已完成',
type: 'success',
color: '#67C23A'
},
[WorkOrderStatus.CANCELLED]: {
label: '已取消',
type: 'info',
color: '#909399'
}
}
// 看板列配置
export const kanbanColumns: Array<{
key: WorkOrderStatus
title: string
color: string
}> = [
{ key: WorkOrderStatus.PENDING, title: '待生产', color: '#E6A23C' },
{ key: WorkOrderStatus.IN_PROGRESS, title: '生产中', color: '#409EFF' },
{ key: WorkOrderStatus.COMPLETED, title: '已完成', color: '#67C23A' },
{ key: WorkOrderStatus.CANCELLED, title: '已取消', color: '#909399' }
]

View File

@ -0,0 +1,80 @@
<template>
<div class="app-layout">
<!-- 左侧菜单 -->
<aside class="sidebar">
<div class="logo">PMS 系统</div>
<el-menu
:default-active="activeMenu"
router
class="sidebar-menu"
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409EFF"
>
<el-menu-item index="/project">
<el-icon><Folder /></el-icon>
<span>项目总览</span>
</el-menu-item>
<el-menu-item index="/kanban">
<el-icon><Grid /></el-icon>
<span>工单看板</span>
</el-menu-item>
<el-menu-item index="/bom">
<el-icon><Document /></el-icon>
<span>BOM分析</span>
</el-menu-item>
<el-menu-item index="/approval">
<el-icon><List /></el-icon>
<span>缺料审批</span>
</el-menu-item>
</el-menu>
</aside>
<!-- 右侧内容 -->
<main class="main-content">
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { Folder, Grid, Document, List } from '@element-plus/icons-vue'
const route = useRoute()
const activeMenu = computed(() => route.path)
</script>
<style scoped>
.app-layout {
display: flex;
height: 100vh;
}
.sidebar {
width: 220px;
background-color: #304156;
flex-shrink: 0;
}
.logo {
height: 60px;
line-height: 60px;
text-align: center;
font-size: 18px;
font-weight: bold;
color: #fff;
background-color: #263445;
}
.sidebar-menu {
border-right: none;
}
.main-content {
flex: 1;
overflow-y: auto;
}
</style>

14
frontend/src/main.ts Normal file
View File

@ -0,0 +1,14 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.use(ElementPlus)
app.mount('#app')

View File

@ -0,0 +1,43 @@
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import AppLayout from '@/components/AppLayout.vue'
const routes: RouteRecordRaw[] = [
{
path: '/',
component: AppLayout,
children: [
{
path: '',
redirect: '/project'
},
{
path: 'project',
name: 'ProjectOverview',
component: () => import('@/views/ProjectOverview.vue')
},
{
path: 'kanban',
name: 'WorkOrderKanban',
component: () => import('@/views/WorkOrderKanban.vue')
},
{
path: 'bom',
name: 'BomAnalysis',
component: () => import('@/views/BomAnalysis.vue')
},
{
path: 'approval',
name: 'MaterialApproval',
component: () => import('@/views/MaterialApproval.vue')
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

View File

@ -0,0 +1,33 @@
export enum ApprovalStatus {
PENDING = 'PENDING',
APPROVED = 'APPROVED',
REJECTED = 'REJECTED'
}
export interface MaterialBase {
id: number
material_code: string
material_name: string
specification: string
unit: string
unit_price: number
}
export interface ApprovalItem {
id: number
work_order_id: number
work_order_no: string
missing_material_id: number
material_name: string
required_qty: number
reason: string
status: ApprovalStatus
created_at: string
}
export interface ApprovalForm {
work_order_id: number
missing_material_id: number
required_qty: number
reason: string
}

View File

@ -0,0 +1,767 @@
<template>
<div class="bom-page">
<h1 class="page-title">BOM 齐套性看板</h1>
<!-- 顶部搜索添加区 -->
<el-card class="search-card">
<div class="search-row">
<el-select
v-model="searchSelectedKey"
placeholder="搜索成品名称或 BOM 编号..."
filterable
remote
:remote-method="doSearch"
:loading="searching"
style="width: 420px"
clearable
@focus="onSelectFocus"
@clear="searchSelectedKey = null; searchResults = []"
>
<el-option
v-for="item in searchResults"
:key="item.bom_key"
:label="`${item.name} | ${item.bom_no || '无编号'} ${item.version ? `(${item.version})` : ''}`"
:value="item.bom_key"
:disabled="isTracked(item.bom_key)"
>
<div class="search-option">
<span class="opt-name">{{ item.name }}</span>
<span class="opt-meta">{{ item.bom_no || '无编号' }} {{ item.version ? `(${item.version})` : '' }}</span>
</div>
</el-option>
</el-select>
<el-button type="primary" :disabled="!searchSelectedKey" @click="addTracked">
添加到看板
</el-button>
<span class="tracked-count">已监控 {{ trackedProducts.length }} 个成品</span>
</div>
</el-card>
<!-- 单行全宽垂直卡片列表 -->
<div v-if="trackedProducts.length > 0" class="card-list">
<el-card
v-for="(card, index) in trackedProducts"
:key="card.bom_key"
class="product-card"
shadow="hover"
>
<!-- 卡片头部 -->
<template #header>
<div class="card-header">
<div class="card-header-left">
<span class="card-title">{{ card.name }}</span>
<span class="card-meta">
{{ card.bom_no || '无编号' }}
{{ card.version ? ` (${card.version})` : '' }}
</span>
</div>
<div class="card-header-actions">
<el-tag v-if="card.maxProduction === 0" type="danger" size="small">库存不足</el-tag>
<el-tag v-else type="success" size="small">可产 {{ card.maxProduction }} </el-tag>
<el-button
link
type="primary"
size="small"
:disabled="index === 0"
@click="moveCard(index, 'up')"
>
上移
</el-button>
<el-button
link
type="primary"
size="small"
:disabled="index === trackedProducts.length - 1"
@click="moveCard(index, 'down')"
>
下移
</el-button>
<el-button link type="warning" size="small" @click="removeCard(card, index)">
移除
</el-button>
</div>
</div>
</template>
<!-- Loading 骨架 -->
<div v-if="card.loading" class="loading-area">
<el-icon class="is-loading" :size="32"><Loading /></el-icon>
<span class="loading-text">正在分析中...</span>
</div>
<!-- 分析完成左侧大字 + 右侧排产模拟 -->
<div v-else-if="card.result" class="card-content">
<!-- 左侧最大产能大字 -->
<div class="left-max">
<div class="max-label">当前库存最大可产</div>
<div class="max-number" :class="card.maxProduction === 0 ? 'num-zero' : 'num-ok'">
{{ card.maxProduction }}
</div>
<div class="max-unit"></div>
</div>
<!-- 右侧排产模拟 -->
<div class="right-detail">
<!-- 计划排产模拟输入框 -->
<div class="simulate-header">
<span class="simulate-label">计划排产模拟</span>
<el-input-number
v-model="card.targetQty"
:min="1"
:max="99999"
size="small"
@change="recalculateCard(card, index)"
/>
<span class="simulate-unit"></span>
</div>
<!-- 目标量 库存最大产能 齐套 -->
<template v-if="card.targetQty <= card.maxProduction">
<div class="full-tip">
<el-icon color="#67c23a" :size="16"><CircleCheck /></el-icon>
物料齐套可直接下发生产
</div>
</template>
<!-- 目标量 > 库存最大产能 显示缺口 -->
<template v-else>
<div class="detail-tip danger">
<el-icon color="#f56c6c" :size="16"><WarningFilled /></el-icon>
存在缺口距离目标 {{ card.targetQty }} 套还缺以下物料
</div>
<el-table
v-if="shortageRows(card.result).length > 0"
:data="shortageRows(card.result)"
border
stripe
size="small"
class="shortage-table"
>
<el-table-column prop="material_name" label="物料名称" min-width="160" />
<el-table-column prop="spec_model" label="规格型号" min-width="120" show-overflow-tooltip />
<el-table-column prop="unit" label="单位" width="60" align="center" />
<el-table-column label="计划需求" width="90" align="right">
<template #default="{ row }">{{ Number(row.required_quantity).toLocaleString() }}</template>
</el-table-column>
<el-table-column label="当前库存" width="90" align="right">
<template #default="{ row }">{{ Number(row.current_stock).toLocaleString() }}</template>
</el-table-column>
<el-table-column label="缺口" width="90" align="right">
<template #default="{ row }">
<span class="shortage-val">-{{ Number(row.shortage_quantity).toLocaleString() }}</span>
</template>
</el-table-column>
</el-table>
<div v-else class="full-tip">
<el-icon color="#67c23a" :size="16"><CircleCheck /></el-icon>
物料齐套可直接下发生产
</div>
</template>
</div>
</div>
<!-- 分析失败 -->
<div v-else class="error-area">
<el-icon color="#f56c6c" :size="20"><CircleCloseFilled /></el-icon>
<span>分析失败请检查网络后重试</span>
<el-button type="primary" link size="small" @click="retryAnalyze(card, index)">
重试
</el-button>
</div>
</el-card>
</div>
<!-- 空状态 -->
<el-card v-else class="empty-card">
<el-empty description="监控列表为空,请在上方搜索并添加成品" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { CircleCheck, WarningFilled, Loading, CircleCloseFilled } from '@element-plus/icons-vue'
import {
searchBomTargets,
deduceBom,
getUserPreference,
addFavoriteTarget,
removeFavoriteTarget,
reorderFavorites,
type DeduceResult,
type MaterialRequirement,
} from '@/api/bom'
const CURRENT_USER_ID = 1
// ─── 路由 ─────────────────────────────────────────────────────────────
const route = useRoute()
const router = useRouter()
// ─── 类型 ─────────────────────────────────────────────────────────────
interface BomTarget {
id: number
name: string
spec_model: string | null
bom_no: string | null
version: string | null
bom_key: string
}
interface TrackedCard {
id: number
name: string
spec_model: string | null
bom_no: string | null
version: string | null
bom_key: string
targetQty: number
maxProduction: number
loading: boolean
result: DeduceResult | null
analyzeError: boolean
}
// ─── 状态 ─────────────────────────────────────────────────────────────
// 标记是否正在执行初始化(防止 watch 多次触发时重复执行)
const isInitializing = ref(false)
const searchSelectedKey = ref<string | null>(null)
const searchResults = ref<BomTarget[]>([])
const searching = ref(false)
const trackedProducts = ref<TrackedCard[]>([])
// ─── 辅助 ─────────────────────────────────────────────────────────────
const parseItems = (raw: unknown): BomTarget[] => {
if (!raw) return []
return Array.isArray(raw) ? raw : (Array.isArray((raw as any).items) ? (raw as any).items : [])
}
const shortageRows = (result: DeduceResult) =>
(result?.material_requirements ?? []).filter(r => Number(r.shortage_quantity) > 0)
const isTracked = (bom_key: string) =>
trackedProducts.value.some(c => c.bom_key === bom_key)
// ─── 搜索 ─────────────────────────────────────────────────────────────
let searchTimer: ReturnType<typeof setTimeout> | null = null
const doSearch = (q: string) => {
if (searchTimer) clearTimeout(searchTimer)
if (!q) {
searchBomTargets('', 1, 20)
.then(items => { searchResults.value = items })
.catch(() => {})
return
}
searchTimer = setTimeout(async () => {
searching.value = true
try {
searchResults.value = parseItems(await searchBomTargets(q, 1, 50))
} catch (e) {
console.error('[搜索失败]', e)
searchResults.value = []
} finally {
searching.value = false
}
}, 300)
}
const onSelectFocus = () => {
if (searchResults.value.length === 0) {
searchBomTargets('', 1, 20)
.then(items => { searchResults.value = items })
.catch(() => {})
}
}
// ─── 添加到看板 ──────────────────────────────────────────────────────
const addCardToKanban = async (
item: BomTarget,
options?: { skipFavorite?: boolean; autoAnalyze?: boolean }
) => {
if (isTracked(item.bom_key)) {
// 已存在则滚动到该卡片位置
ElMessage.warning(`${item.name}」已在看板中`)
return
}
if (!options?.skipFavorite) {
try {
await addFavoriteTarget(CURRENT_USER_ID, item.bom_key)
} catch (e) {
console.warn('[添加常看失败]', e)
}
}
const card: TrackedCard = {
id: item.id,
name: item.name,
spec_model: item.spec_model,
bom_no: item.bom_no,
version: item.version,
bom_key: item.bom_key,
targetQty: 1,
maxProduction: 0,
loading: true,
result: null,
analyzeError: false,
}
const index = trackedProducts.value.length
trackedProducts.value.push(card)
searchSelectedKey.value = null
searchResults.value = []
if (options?.autoAnalyze !== false) {
autoAnalyze(card, index)
}
}
// ─── 全自动推演(两阶段)──────────────────────────────────────────────
const autoAnalyze = async (card: TrackedCard, index: number) => {
// 确保卡片仍存在于看板中(可能被用户移除)
if (index >= trackedProducts.value.length ||
trackedProducts.value[index].bom_key !== card.bom_key) {
return
}
trackedProducts.value[index] = { ...card, loading: true, result: null }
try {
// 第一阶段:推演 1 套,计算最大产能
const res1 = await deduceBom(card.id, 1, card.bom_no, card.version)
const rows: MaterialRequirement[] = res1.material_requirements || []
let min = Infinity
for (const row of rows) {
if (Number(row.required_quantity) > 0) {
const possible = Math.floor(
Number(row.current_stock) / Number(row.required_quantity)
)
min = Math.min(min, possible)
}
}
const maxProd = min === Infinity ? 0 : Math.max(0, min)
const targetQty = maxProd === 0 ? 1 : maxProd + 1
// 第二阶段:用 targetQty 推演,获取缺料明细
const res2 = await deduceBom(card.id, targetQty, card.bom_no, card.version)
// 再次校验:卡片是否仍对应同一 bom_key避免竞态
if (index < trackedProducts.value.length &&
trackedProducts.value[index].bom_key === card.bom_key) {
trackedProducts.value[index] = {
...card,
targetQty,
maxProduction: maxProd,
loading: false,
result: res2,
analyzeError: false,
}
}
} catch (e) {
console.error('[自动分析失败]', card.name, e)
if (index < trackedProducts.value.length &&
trackedProducts.value[index].bom_key === card.bom_key) {
trackedProducts.value[index] = {
...card,
targetQty: 1,
maxProduction: 0,
loading: false,
result: null,
analyzeError: true,
}
}
}
}
// ─── 重试分析 ─────────────────────────────────────────────────────────
const retryAnalyze = (card: TrackedCard, index: number) => {
autoAnalyze(card, index)
}
// ─── 重新计算(用户修改数量时触发)───────────────────────────────────
const recalculateCard = async (card: TrackedCard, index: number) => {
trackedProducts.value[index] = { ...card, loading: true }
try {
const res = await deduceBom(card.id, card.targetQty, card.bom_no, card.version)
trackedProducts.value[index] = { ...card, loading: false, result: res }
} catch (e) {
console.error('[重新计算失败]', card.name, e)
ElMessage.error('重新计算失败')
trackedProducts.value[index] = { ...card, loading: false }
}
}
// ─── 上移 / 下移 ─────────────────────────────────────────────────────
const moveCard = async (index: number, direction: 'up' | 'down') => {
const arr = trackedProducts.value
const target = direction === 'up' ? index - 1 : index + 1
if (target < 0 || target >= arr.length) return
;[arr[index], arr[target]] = [arr[target], arr[index]]
reorderFavorites(CURRENT_USER_ID, arr.map(c => c.bom_key)).catch(err => {
console.warn('[保存排序失败]', err)
})
}
// ─── 添加(搜索下拉)─────────────────────────────────────────────────
const addTracked = async () => {
const item = searchResults.value.find(i => i.bom_key === searchSelectedKey.value)
if (!item) return
await addCardToKanban(item)
}
// ─── 移除 ─────────────────────────────────────────────────────────────
const removeCard = async (card: TrackedCard, index: number) => {
try {
await removeFavoriteTarget(CURRENT_USER_ID, card.bom_key)
} catch (e) {
console.warn('[移除常看失败]', e)
}
trackedProducts.value.splice(index, 1)
ElMessage.success(`已移除「${card.name}`)
}
// ═══════════════════════════════════════════════════════════════════════
// 统一入口watch route.query
// 负责:①加载收藏列表 ②处理路由参数(接力加载)
// ═══════════════════════════════════════════════════════════════════════
watch(
() => route.query,
async (query) => {
console.log('[BomAnalysis] route.query 变化:', query)
// 防止重复触发
if (isInitializing.value) {
console.log('[BomAnalysis] 正在初始化,跳过')
return
}
isInitializing.value = true
try {
// ── 步骤 1加载完整成品列表解析 bom_key 需要用到) ──
let allTargets: BomTarget[] = []
try {
allTargets = parseItems(await searchBomTargets('', 1, 100))
} catch (e) {
console.error('[BomAnalysis] 成品列表加载失败', e)
allTargets = []
}
const allMap = new Map<string, BomTarget>()
allTargets.forEach(t => allMap.set(t.bom_key, t))
// searchResults 同步更新(保障搜索下拉可用)
if (searchResults.value.length === 0) {
searchResults.value = allTargets
}
// ── 步骤 2从收藏加载已有卡片 ──
try {
const pref = await getUserPreference(CURRENT_USER_ID)
const favKeys: string[] = Array.isArray(pref?.favorite_target_ids)
? pref.favorite_target_ids
: []
for (const key of favKeys) {
if (allMap.has(key) && !isTracked(key)) {
const item = allMap.get(key)!
const card: TrackedCard = {
id: item.id,
name: item.name,
spec_model: item.spec_model,
bom_no: item.bom_no,
version: item.version,
bom_key: item.bom_key,
targetQty: 1,
maxProduction: 0,
loading: true,
result: null,
analyzeError: false,
}
const idx = trackedProducts.value.length
trackedProducts.value.push(card)
autoAnalyze(card, idx)
}
}
} catch (e) {
console.warn('[BomAnalysis] 收藏加载失败', e)
}
// ── 步骤 3处理路由参数接力加载 ──
// 期望参数格式:?target_id=123&bom_no=XXX&version=V1
// 或者直接传 bom_key?bom_key=123_XXX_V1
const targetId = query.target_id
const bomKeyFromQuery = query.bom_key
if (targetId || bomKeyFromQuery) {
let matchedItem: BomTarget | null = null
if (bomKeyFromQuery && typeof bomKeyFromQuery === 'string') {
// 优先使用完整的 bom_key 匹配
matchedItem = allMap.get(bomKeyFromQuery) ?? null
if (!matchedItem) {
console.warn(`[BomAnalysis] bom_key="${bomKeyFromQuery}" 在列表中未找到`)
}
} else if (targetId) {
// fallback用 target_id + bom_no + version 拼装 bom_key 尝试匹配
const qId = String(targetId)
const qNo = query.bom_no ? String(query.bom_no) : undefined
const qVer = query.version ? String(query.version) : undefined
matchedItem =
allTargets.find(t => {
const idMatch = String(t.id) === qId
const noMatch = !qNo || t.bom_no === qNo || t.bom_no == null
const verMatch = !qVer || t.version === qVer || t.version == null
return idMatch && noMatch && verMatch
}) ?? null
if (!matchedItem) {
console.warn(
`[BomAnalysis] target_id="${targetId}" 在列表中未找到,尝试匹配首项`
)
}
}
if (matchedItem) {
console.log('[BomAnalysis] 路由参数命中成品,自动添加到看板:', matchedItem.name)
await addCardToKanban(matchedItem)
} else if (allTargets.length > 0) {
// 兜底:默认选中列表第一项
ElMessage.warning(
`未找到指定成品ID=${targetId ?? bomKeyFromQuery}),已自动选中列表第一项`
)
await addCardToKanban(allTargets[0])
} else {
ElMessage.error('成品列表为空,无法加载路由指定的 BOM')
}
}
console.log('[BomAnalysis] 初始化完成')
} finally {
isInitializing.value = false
}
},
{ immediate: true } // 组件首次创建时立即执行一次
)
// ─── 生命周期 ─────────────────────────────────────────────────────────
onUnmounted(() => {
console.log('[BomAnalysis] onUnmounted - 清理状态')
if (searchTimer) {
clearTimeout(searchTimer)
searchTimer = null
}
isInitializing.value = false
})
</script>
<style scoped>
.bom-page {
padding: 24px;
background-color: #f5f7fa;
min-height: 100vh;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #303133;
margin-bottom: 24px;
}
/* 搜索区 */
.search-card { margin-bottom: 24px; }
.search-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.tracked-count {
margin-left: auto;
color: #909399;
font-size: 13px;
}
/* ── 单行全宽垂直卡片列表 ── */
.card-list {
display: flex;
flex-direction: column;
gap: 20px;
}
.product-card {
border-radius: 10px;
width: 100%;
box-sizing: border-box;
}
/* 卡片头部 */
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding-right: 8px;
}
.card-header-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #303133;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 280px;
}
.card-meta {
font-size: 12px;
color: #909399;
white-space: nowrap;
flex-shrink: 0;
}
.card-header-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
}
/* 卡片内容 */
.card-content {
display: flex;
gap: 40px;
padding: 8px 0;
}
/* 左侧:最大产能大字 */
.left-max {
flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: center;
background: #f0f9ff;
border: 2px solid #3370ff;
border-radius: 12px;
padding: 20px 28px;
min-width: 200px;
}
.max-label {
font-size: 13px;
color: #3370ff;
font-weight: 500;
text-align: center;
margin-bottom: 4px;
}
.max-number {
font-size: 60px;
font-weight: 900;
line-height: 1;
letter-spacing: -4px;
}
.num-zero { color: #f56c6c; }
.num-ok { color: #3370ff; }
.max-unit {
font-size: 14px;
color: #8c9dbe;
margin-top: 2px;
}
/* 右侧:排产模拟 */
.right-detail {
flex: 1;
min-width: 0;
}
/* 模拟输入框区域 */
.simulate-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.simulate-label {
font-weight: 600;
color: #303133;
font-size: 13px;
}
.simulate-unit {
font-size: 13px;
color: #606266;
}
/* 提示文字 */
.detail-tip {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 500;
margin-bottom: 12px;
}
.detail-tip.danger { color: #f56c6c; }
.detail-tip.warning { color: #e6a23c; }
.shortage-table { margin-top: 0; }
.shortage-val {
color: #f56c6c;
font-weight: bold;
}
.full-tip {
display: flex;
align-items: center;
gap: 8px;
padding: 14px 16px;
background: #f0f9eb;
border-radius: 6px;
color: #67c23a;
font-size: 14px;
font-weight: 500;
}
/* Loading / Error */
.loading-area {
display: flex;
align-items: center;
gap: 10px;
padding: 16px 0;
color: #909399;
}
.loading-text { font-size: 13px; }
.error-area {
display: flex;
align-items: center;
gap: 8px;
color: #f56c6c;
font-size: 13px;
padding: 8px 0;
}
/* 下拉选项 */
.search-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
gap: 16px;
}
.opt-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.opt-meta {
color: #8492a6;
font-size: 12px;
flex-shrink: 0;
}
.empty-card { margin-top: 20px; }
</style>

View File

@ -0,0 +1,321 @@
<template>
<div class="approval-container">
<h1 class="page-title">缺料审批</h1>
<el-row :gutter="20">
<!-- 左侧员工申请表单 -->
<el-col :span="8">
<el-card class="form-card">
<template #header>
<div class="card-header">
<el-icon><Plus /></el-icon>
<span>提交缺料申请</span>
</div>
</template>
<el-form
ref="formRef"
:model="form"
:rules="rules"
label-width="100px"
class="approval-form"
>
<el-form-item label="工单ID" prop="work_order_id">
<el-input
v-model.number="form.work_order_id"
placeholder="请输入工单ID"
clearable
/>
</el-form-item>
<el-form-item label="缺少物料" prop="missing_material_id">
<el-select
v-model="form.missing_material_id"
placeholder="请选择缺少的物料"
filterable
clearable
style="width: 100%"
>
<el-option
v-for="item in materialList"
:key="item.id"
:label="`${item.material_name} (${item.material_code})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="需求数量" prop="required_qty">
<el-input-number
v-model="form.required_qty"
:min="1"
:max="99999"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="缺料原因" prop="reason">
<el-input
v-model="form.reason"
type="textarea"
:rows="4"
placeholder="请描述缺料原因..."
maxlength="500"
show-word-limit
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="submitForm" :loading="submitting">
提交申请
</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<!-- 右侧主管审批列表 -->
<el-col :span="16">
<el-card class="table-card">
<template #header>
<div class="card-header">
<el-icon><List /></el-icon>
<span>待审批列表</span>
<el-button
type="primary"
link
@click="loadApprovals"
style="margin-left: auto"
>
<el-icon><Refresh /></el-icon>
刷新
</el-button>
</div>
</template>
<el-table
:data="approvalList"
border
stripe
v-loading="loading"
empty-text="暂无待审批数据"
style="width: 100%"
>
<el-table-column prop="id" label="ID" width="60" />
<el-table-column prop="work_order_no" label="工单编号" width="100" />
<el-table-column prop="material_name" label="缺少物料" min-width="150" />
<el-table-column prop="required_qty" label="需求数量" width="90" align="center" />
<el-table-column prop="reason" label="缺料原因" min-width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)" effect="dark" size="small">
{{ getStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center">
<template #default="{ row }">
<template v-if="row.status === 'PENDING'">
<el-button type="success" size="small" @click="handleApprove(row)" :loading="row.processing">
批准
</el-button>
<el-button type="danger" size="small" @click="handleReject(row)" :loading="row.processing">
拒绝
</el-button>
</template>
<span v-else class="processed-text">已处理</span>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Plus, List, Refresh } from '@element-plus/icons-vue'
import { getMaterialList, getPendingApprovals, createApproval, updateApprovalStatus } from '@/api/material'
import type { ApprovalItem, MaterialBase, ApprovalForm } from '@/types'
const formRef = ref<FormInstance>()
const loading = ref(false)
const submitting = ref(false)
const materialList = ref<MaterialBase[]>([])
const approvalList = ref<(ApprovalItem & { processing?: boolean })[]>([])
const form = reactive<ApprovalForm>({
work_order_id: 0,
missing_material_id: 0,
required_qty: 1,
reason: ''
})
const rules: FormRules = {
work_order_id: [
{ required: true, message: '请输入工单ID', trigger: 'blur' },
{ type: 'number', min: 1, message: '工单ID必须大于0', trigger: 'blur' }
],
missing_material_id: [
{ required: true, message: '请选择缺少的物料', trigger: 'change' }
],
required_qty: [
{ required: true, message: '请输入需求数量', trigger: 'blur' }
],
reason: [
{ required: true, message: '请描述缺料原因', trigger: 'blur' },
{ min: 5, message: '原因描述至少5个字符', trigger: 'blur' }
]
}
const getStatusType = (status: string) => {
const map: Record<string, string> = {
PENDING: 'warning',
APPROVED: 'success',
REJECTED: 'danger'
}
return map[status] || 'info'
}
const getStatusText = (status: string) => {
const map: Record<string, string> = {
PENDING: '待审批',
APPROVED: '已批准',
REJECTED: '已拒绝'
}
return map[status] || status
}
const loadMaterials = async () => {
try {
materialList.value = await getMaterialList()
} catch (error) {
console.error('加载物料列表失败:', error)
}
}
const loadApprovals = async () => {
loading.value = true
try {
approvalList.value = await getPendingApprovals()
} catch (error: any) {
ElMessage.error('加载审批列表失败')
console.error('加载审批列表失败:', error)
} finally {
loading.value = false
}
}
const submitForm = async () => {
if (!formRef.value) return
try {
await formRef.value.validate()
submitting.value = true
await createApproval(form)
ElMessage.success('申请提交成功')
resetForm()
loadApprovals()
} catch (error: any) {
if (error?.message) {
ElMessage.error(error.message)
}
} finally {
submitting.value = false
}
}
const resetForm = () => {
formRef.value?.resetFields()
form.required_qty = 1
}
const handleApprove = async (row: ApprovalItem & { processing?: boolean }) => {
try {
await ElMessageBox.confirm('确定批准该缺料申请吗?', '审批确认', {
confirmButtonText: '批准',
cancelButtonText: '取消',
type: 'success'
})
row.processing = true
await updateApprovalStatus(row.id, 'APPROVED')
ElMessage.success('审批通过')
loadApprovals()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error('操作失败')
}
} finally {
row.processing = false
}
}
const handleReject = async (row: ApprovalItem & { processing?: boolean }) => {
try {
await ElMessageBox.confirm('确定拒绝该缺料申请吗?', '审批确认', {
confirmButtonText: '拒绝',
cancelButtonText: '取消',
type: 'warning'
})
row.processing = true
await updateApprovalStatus(row.id, 'REJECTED')
ElMessage.warning('已拒绝')
loadApprovals()
} catch (error: any) {
if (error !== 'cancel') {
ElMessage.error('操作失败')
}
} finally {
row.processing = false
}
}
onMounted(() => {
loadMaterials()
loadApprovals()
})
</script>
<style scoped>
.approval-container {
padding: 24px;
background-color: #f5f7fa;
min-height: 100vh;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #303133;
margin-bottom: 24px;
}
.form-card,
.table-card {
height: 100%;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
font-size: 16px;
font-weight: 500;
}
.approval-form {
margin-top: 16px;
}
.processed-text {
color: #909399;
font-size: 12px;
}
:deep(.el-form-item) {
margin-bottom: 20px;
}
</style>

View File

@ -0,0 +1,672 @@
<template>
<div class="project-overview">
<h1 class="page-title">
项目总览
<el-button type="primary" size="default" @click="openCreateDialog">
+ 创建项目
</el-button>
</h1>
<!-- 全局统计卡片 -->
<el-row :gutter="20" class="stats-row">
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-value">{{ summary?.total_projects || 0 }}</div>
<div class="stat-label">项目总数</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-value warning">{{ summary?.active_projects || 0 }}</div>
<div class="stat-label">进行中</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-value danger">{{ summary?.overdue_projects || 0 }}</div>
<div class="stat-label">延期项目</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="stat-content">
<div class="stat-value success">{{ summary?.completed_projects || 0 }}</div>
<div class="stat-label">已完成</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 进度和物料到位率 -->
<el-row :gutter="20" class="stats-row">
<el-col :span="12">
<el-card shadow="hover">
<template #header>
<span class="card-header-title">全局生产进度</span>
</template>
<div class="progress-area">
<el-progress
:percentage="summary?.overall_progress || 0"
:stroke-width="20"
:color="progressColor"
:format="(val: number) => `${val}%`"
/>
<div class="progress-detail">
<span>已完成工单: {{ summary?.completed_work_orders || 0 }} / {{ summary?.total_work_orders || 0 }}</span>
</div>
</div>
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="hover">
<template #header>
<span class="card-header-title">物料到位率</span>
</template>
<div class="progress-area">
<el-progress
:percentage="summary?.overall_material_ready_rate || 0"
:stroke-width="20"
:color="materialReadyColor"
:format="(val: number) => `${val}%`"
/>
<div class="progress-detail">
<span>待审批缺料: {{ summary?.total_pending_approvals || 0 }} </span>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 项目列表 -->
<el-card shadow="hover" class="project-list-card">
<template #header>
<div class="list-header">
<span class="card-header-title">项目列表</span>
<el-select
v-model="statusFilter"
placeholder="筛选状态"
clearable
style="width: 140px"
@change="loadSummary"
>
<el-option label="全部" :value="undefined" />
<el-option label="草稿" value="draft" />
<el-option label="进行中" value="active" />
<el-option label="已完成" value="completed" />
<el-option label="已取消" value="cancelled" />
</el-select>
</div>
</template>
<el-table :data="summary?.projects || []" stripe style="width: 100%">
<el-table-column prop="project_no" label="项目编号" width="140" />
<el-table-column prop="project_name" label="项目名称" min-width="180" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="projectStatusMap[row.status]?.type || 'info'" size="small">
{{ projectStatusMap[row.status]?.label || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="生产进度" width="200" align="center">
<template #default="{ row }">
<el-progress
:percentage="row.progress_percentage"
:stroke-width="10"
:color="getProgressColor(row.progress_percentage)"
style="width: 120px; display: inline-block"
/>
<span style="margin-left: 8px; font-size: 12px; color: #909399">
{{ row.completed_work_orders }}/{{ row.total_work_orders }}
</span>
</template>
</el-table-column>
<el-table-column label="物料到位率" width="150" align="center">
<template #default="{ row }">
<span :style="{ color: getMaterialReadyColor(row.material_ready_rate) }">
{{ row.material_ready_rate }}%
</span>
<span v-if="row.pending_approvals > 0" style="font-size: 12px; color: #E6A23C">
({{ row.pending_approvals }}待审)
</span>
</template>
</el-table-column>
<el-table-column label="计划周期" width="180">
<template #default="{ row }">
<span v-if="row.start_date || row.end_date">
{{ row.start_date || '-' }} ~ {{ row.end_date || '-' }}
</span>
<span v-else style="color: #909399">未设置</span>
</template>
</el-table-column>
<el-table-column label="延期预警" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="row.is_overdue" type="danger" size="small">
延期 {{ row.overdue_days }}
</el-tag>
<span v-else style="color: #67C23A; font-size: 12px">正常</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right" align="center">
<template #default="{ row }">
<el-button type="primary" link size="small" @click="viewProjectDetail(row)">
查看详情
</el-button>
<el-button type="success" link size="small" @click="goToBomAnalysis(row)">
查看 BOM
</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && (!summary?.projects || summary.projects.length === 0)" description="暂无项目数据" />
</el-card>
<!-- 项目详情抽屉 -->
<el-drawer v-model="detailVisible" title="项目物料齐套情况" size="600px">
<div v-if="selectedProject" class="project-detail">
<h3>{{ selectedProject.project_name }}</h3>
<p class="project-meta">
项目编号: {{ selectedProject.project_no }} |
状态: {{ projectStatusMap[selectedProject.status]?.label }}
</p>
<el-divider />
<h4>工单进度</h4>
<el-row :gutter="16" class="detail-stats">
<el-col :span="6">
<div class="detail-stat-item">
<div class="detail-stat-value">{{ selectedProject.total_work_orders }}</div>
<div class="detail-stat-label">总工单</div>
</div>
</el-col>
<el-col :span="6">
<div class="detail-stat-item success">
<div class="detail-stat-value">{{ selectedProject.completed_work_orders }}</div>
<div class="detail-stat-label">已完成</div>
</div>
</el-col>
<el-col :span="6">
<div class="detail-stat-item warning">
<div class="detail-stat-value">{{ selectedProject.in_progress_work_orders }}</div>
<div class="detail-stat-label">进行中</div>
</div>
</el-col>
<el-col :span="6">
<div class="detail-stat-item info">
<div class="detail-stat-value">{{ selectedProject.pending_work_orders }}</div>
<div class="detail-stat-label">待生产</div>
</div>
</el-col>
</el-row>
<el-divider />
<h4>物料审批情况</h4>
<el-row :gutter="16" class="detail-stats">
<el-col :span="6">
<div class="detail-stat-item">
<div class="detail-stat-value">{{ selectedProject.total_approvals }}</div>
<div class="detail-stat-label">总申请</div>
</div>
</el-col>
<el-col :span="6">
<div class="detail-stat-item success">
<div class="detail-stat-value">{{ selectedProject.approved_approvals }}</div>
<div class="detail-stat-label">已批准</div>
</div>
</el-col>
<el-col :span="6">
<div class="detail-stat-item warning">
<div class="detail-stat-value">{{ selectedProject.pending_approvals }}</div>
<div class="detail-stat-label">待审批</div>
</div>
</el-col>
<el-col :span="6">
<div class="detail-stat-item danger">
<div class="detail-stat-value">{{ selectedProject.rejected_approvals }}</div>
<div class="detail-stat-label">已驳回</div>
</div>
</el-col>
</el-row>
<div class="material-rate">
<el-progress
:percentage="selectedProject.material_ready_rate"
:stroke-width="15"
:color="getMaterialReadyColor(selectedProject.material_ready_rate)"
/>
<span class="rate-label">物料到位率: {{ selectedProject.material_ready_rate }}%</span>
</div>
</div>
</el-drawer>
<!-- 创建项目弹窗 -->
<el-dialog
v-model="createDialogVisible"
title="创建项目"
width="500px"
:close-on-click-modal="false"
@close="resetCreateForm"
>
<el-form
ref="createFormRef"
:model="createForm"
:rules="createFormRules"
label-width="100px"
>
<el-form-item label="项目编号" prop="project_no">
<el-input
v-model="createForm.project_no"
placeholder="请输入项目编号,如 PRJ-001"
clearable
/>
</el-form-item>
<el-form-item label="项目名称" prop="project_name">
<el-input
v-model="createForm.project_name"
placeholder="请输入项目名称"
clearable
/>
</el-form-item>
<el-form-item label="计划开始日期" prop="start_date">
<el-date-picker
v-model="createForm.start_date"
type="date"
placeholder="选择开始日期"
value-format="YYYY-MM-DD"
:disabled-date="disabledStartDate"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="计划结束日期" prop="end_date">
<el-date-picker
v-model="createForm.end_date"
type="date"
placeholder="选择结束日期"
value-format="YYYY-MM-DD"
:disabled-date="disabledEndDate"
style="width: 100%"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="createLoading" @click="handleCreate">
确认创建
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import {
getProjectSummary,
projectStatusMap,
createProject,
ProjectStatus,
type ProjectSummary,
type ProjectStats,
} from '@/api/project'
const router = useRouter()
// ─── 状态 ─────────────────────────────────────────────────────────────
const loading = ref(false)
const statusFilter = ref<string | undefined>(undefined)
const summary = ref<ProjectSummary | null>(null)
const detailVisible = ref(false)
const selectedProject = ref<ProjectStats | null>(null)
// ─── 创建项目弹窗 ────────────────────────────────────────────────────
const createDialogVisible = ref(false)
const createLoading = ref(false)
const createFormRef = ref<FormInstance>()
interface CreateForm {
project_no: string
project_name: string
start_date: string | null
end_date: string | null
}
const createForm = ref<CreateForm>({
project_no: '',
project_name: '',
start_date: null,
end_date: null,
})
// 开始日期:不能小于今天(允许选今天)
const disabledStartDate = (time: Date): boolean => {
const today = new Date()
today.setHours(0, 0, 0, 0)
return time.getTime() < today.getTime()
}
// 结束日期:不能小于开始日期(允许同一天);未选开始日期时不能小于今天
const disabledEndDate = (time: Date): boolean => {
const today = new Date()
today.setHours(0, 0, 0, 0)
if (!createForm.value.start_date) {
return time.getTime() < today.getTime()
}
const start = new Date(createForm.value.start_date)
start.setHours(0, 0, 0, 0)
return time.getTime() < start.getTime()
}
const createFormRules: FormRules<CreateForm> = {
project_no: [
{ required: true, message: '请输入项目编号', trigger: 'blur' },
{ min: 1, max: 50, message: '长度不超过 50 个字符', trigger: 'blur' },
],
project_name: [
{ required: true, message: '请输入项目名称', trigger: 'blur' },
{ min: 1, max: 200, message: '长度不超过 200 个字符', trigger: 'blur' },
],
start_date: [
{
validator: (_rule, _value, callback) => {
if (createForm.value.start_date && createForm.value.end_date) {
if (createForm.value.start_date > createForm.value.end_date) {
callback(new Error('开始日期不能晚于结束日期'))
} else {
callback()
}
} else {
callback()
}
},
trigger: 'change',
},
],
end_date: [
{
validator: (_rule, _value, callback) => {
if (createForm.value.start_date && createForm.value.end_date) {
if (createForm.value.start_date > createForm.value.end_date) {
callback(new Error('开始日期不能晚于结束日期'))
} else {
callback()
}
} else {
callback()
}
},
trigger: 'change',
},
],
}
const openCreateDialog = () => {
resetCreateForm()
createDialogVisible.value = true
}
const resetCreateForm = () => {
createForm.value = {
project_no: '',
project_name: '',
start_date: null,
end_date: null,
}
createFormRef.value?.clearValidate()
}
const handleCreate = async () => {
if (!createFormRef.value) return
try {
await createFormRef.value.validate()
} catch {
return
}
createLoading.value = true
try {
await createProject({
project_no: createForm.value.project_no,
project_name: createForm.value.project_name,
start_date: createForm.value.start_date,
end_date: createForm.value.end_date,
})
ElMessage.success('项目创建成功')
createDialogVisible.value = false
await loadSummary()
} catch (e: any) {
console.error('[创建项目失败]', e)
ElMessage.error(e?.response?.data?.detail || '创建项目失败')
} finally {
createLoading.value = false
}
}
// ─── 计算属性 ─────────────────────────────────────────────────────────
const progressColor = computed(() => {
const progress = summary.value?.overall_progress || 0
if (progress >= 80) return '#67C23A'
if (progress >= 50) return '#409EFF'
return '#E6A23C'
})
const materialReadyColor = computed(() => {
const rate = summary.value?.overall_material_ready_rate || 0
if (rate >= 90) return '#67C23A'
if (rate >= 70) return '#409EFF'
return '#E6A23C'
})
// ─── 方法 ─────────────────────────────────────────────────────────────
const loadSummary = async () => {
loading.value = true
try {
const status = statusFilter.value as any
summary.value = await getProjectSummary(status || undefined)
} catch (e) {
console.error('[加载项目汇总失败]', e)
ElMessage.error('加载项目汇总失败')
} finally {
loading.value = false
}
}
const getProgressColor = (percentage: number): string => {
if (percentage >= 80) return '#67C23A'
if (percentage >= 50) return '#409EFF'
return '#E6A23C'
}
const getMaterialReadyColor = (rate: number): string => {
if (rate >= 90) return '#67C23A'
if (rate >= 70) return '#409EFF'
return '#E6A23C'
}
const viewProjectDetail = (project: ProjectStats) => {
selectedProject.value = project
detailVisible.value = true
}
const goToBomAnalysis = (project?: ProjectStats) => {
// 跳转到 BOM 分析页面,可选传递项目参数
if (project) {
router.push({
name: 'BomAnalysis',
query: {
project_id: String(project.project_id),
project_name: project.project_name
}
})
} else {
router.push({ name: 'BomAnalysis' })
}
}
// ─── 生命周期 ─────────────────────────────────────────────────────────
onMounted(() => {
loadSummary()
})
</script>
<style scoped>
.project-overview {
padding: 24px;
background-color: #f5f7fa;
min-height: 100vh;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #303133;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 16px;
}
.stats-row {
margin-bottom: 20px;
}
.stat-card {
border-radius: 8px;
}
.stat-content {
text-align: center;
padding: 10px 0;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: #303133;
}
.stat-value.warning {
color: #E6A23C;
}
.stat-value.danger {
color: #F56C6C;
}
.stat-value.success {
color: #67C23A;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-top: 4px;
}
.card-header-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.progress-area {
padding: 10px 0;
}
.progress-detail {
margin-top: 10px;
font-size: 13px;
color: #606266;
text-align: center;
}
.project-list-card {
border-radius: 8px;
}
/* 详情抽屉样式 */
.project-detail h3 {
margin: 0 0 8px;
color: #303133;
}
.project-meta {
color: #909399;
font-size: 13px;
margin: 0;
}
.project-detail h4 {
margin: 16px 0 12px;
color: #606266;
font-size: 14px;
}
.detail-stats {
margin-bottom: 8px;
}
.detail-stat-item {
text-align: center;
padding: 12px 8px;
background: #f5f7fa;
border-radius: 6px;
}
.detail-stat-item.success {
background: #f0f9eb;
}
.detail-stat-item.warning {
background: #fdf6ec;
}
.detail-stat-item.info {
background: #ecf5ff;
}
.detail-stat-item.danger {
background: #fef0f0;
}
.detail-stat-value {
font-size: 24px;
font-weight: 700;
color: #303133;
}
.detail-stat-label {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.material-rate {
margin-top: 20px;
}
.rate-label {
display: block;
text-align: center;
margin-top: 8px;
font-size: 14px;
color: #606266;
}
</style>

View File

@ -0,0 +1,481 @@
<template>
<div class="work-order-kanban">
<h1 class="page-title">工单看板</h1>
<!-- 搜索和筛选区域 -->
<el-card shadow="never" class="search-card">
<div class="search-row">
<el-input
v-model="searchKeyword"
placeholder="搜索工单号或产品名称..."
style="width: 280px"
clearable
@clear="handleSearch"
@keyup.enter="handleSearch"
>
<template #prefix>
<el-icon><Search /></el-icon>
</template>
</el-input>
<el-select
v-model="statusFilter"
placeholder="全部状态"
clearable
style="width: 140px"
>
<el-option label="全部状态" :value="undefined" />
<el-option
v-for="col in kanbanColumns"
:key="col.key"
:label="col.title"
:value="col.key"
/>
</el-select>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
<div class="kanban-summary">
<span> {{ paginatedData.total }} 个工单</span>
</div>
</div>
</el-card>
<!-- 看板视图 -->
<div v-loading="loading" class="kanban-board">
<div
v-for="column in kanbanColumns"
:key="column.key"
class="kanban-column"
>
<!-- 列头 -->
<div class="column-header" :style="{ borderTopColor: column.color }">
<span class="column-title">{{ column.title }}</span>
<el-badge
:value="getColumnCount(column.key)"
:type="workOrderStatusConfig[column.key]?.type as any"
:max="99"
/>
</div>
<!-- 卡片列表 -->
<div class="column-content">
<div
v-for="item in getColumnItems(column.key)"
:key="item.id"
class="kanban-card"
:style="{ borderLeftColor: column.color }"
shadow="hover"
@click="viewWorkOrderDetail(item)"
>
<div class="card-header">
<span class="work-order-no">{{ item.work_order_no }}</span>
<el-tag :type="workOrderStatusConfig[item.status]?.type as any" size="small">
{{ workOrderStatusConfig[item.status]?.label }}
</el-tag>
</div>
<div class="card-body">
<div class="product-name">
<el-icon><Box /></el-icon>
<span>{{ item.material_name || '未指定产品' }}</span>
</div>
<div v-if="item.material_spec" class="product-spec">
{{ item.material_spec }}
</div>
<div class="card-meta">
<div class="meta-item">
<el-icon><User /></el-icon>
<span>{{ item.assignee_name || '未分配' }}</span>
</div>
<div class="meta-item">
<el-icon><Collection /></el-icon>
<span>{{ item.target_quantity }} </span>
</div>
</div>
</div>
<div class="card-footer">
<span class="project-name">{{ item.project_name || '未知项目' }}</span>
</div>
</div>
<!-- 空列提示 -->
<div v-if="getColumnItems(column.key).length === 0" class="empty-column">
暂无工单
</div>
</div>
</div>
</div>
<!-- 分页 -->
<div class="pagination-wrapper">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:total="paginatedData.total"
:page-sizes="[20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
background
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</div>
<!-- 工单详情抽屉 -->
<el-drawer v-model="detailVisible" title="工单详情" size="500px">
<div v-if="selectedWorkOrder" class="work-order-detail">
<el-descriptions :column="1" border>
<el-descriptions-item label="工单编号">
{{ selectedWorkOrder.work_order_no }}
</el-descriptions-item>
<el-descriptions-item label="产品名称">
{{ selectedWorkOrder.material_name || '-' }}
</el-descriptions-item>
<el-descriptions-item label="规格型号">
{{ selectedWorkOrder.material_spec || '-' }}
</el-descriptions-item>
<el-descriptions-item label="目标数量">
{{ selectedWorkOrder.target_quantity }}
</el-descriptions-item>
<el-descriptions-item label="负责人">
{{ selectedWorkOrder.assignee_name || '未分配' }}
</el-descriptions-item>
<el-descriptions-item label="所属项目">
{{ selectedWorkOrder.project_name || '-' }}
</el-descriptions-item>
<el-descriptions-item label="工单状态">
<el-tag :type="workOrderStatusConfig[selectedWorkOrder.status]?.type as any">
{{ workOrderStatusConfig[selectedWorkOrder.status]?.label }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ selectedWorkOrder.created_at }}
</el-descriptions-item>
</el-descriptions>
<el-divider />
<div class="status-actions">
<span class="action-label">状态变更</span>
<el-button
v-for="status in validNextStatuses"
:key="status"
:type="workOrderStatusConfig[status]?.type"
size="small"
@click="handleStatusChange(selectedWorkOrder.id, status)"
>
{{ workOrderStatusConfig[status]?.label }}
</el-button>
</div>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, Box, User, Collection } from '@element-plus/icons-vue'
import {
getWorkOrderKanban,
updateWorkOrderStatus,
workOrderStatusConfig,
kanbanColumns,
WorkOrderStatus,
type WorkOrderKanbanItem,
type PaginatedWorkOrders
} from '@/api/workOrder'
// ─── 状态 ─────────────────────────────────────────────────────────────
const loading = ref(false)
const searchKeyword = ref('')
const statusFilter = ref<WorkOrderStatus | undefined>(undefined)
const currentPage = ref(1)
const pageSize = ref(20)
const paginatedData = ref<PaginatedWorkOrders>({
items: [],
total: 0,
page: 1,
size: 20,
total_pages: 1
})
const detailVisible = ref(false)
const selectedWorkOrder = ref<WorkOrderKanbanItem | null>(null)
// ─── 计算属性 ─────────────────────────────────────────────────────────
const validNextStatuses: WorkOrderStatus[] = [
WorkOrderStatus.PENDING,
WorkOrderStatus.IN_PROGRESS,
WorkOrderStatus.COMPLETED,
WorkOrderStatus.CANCELLED
]
// 按状态分组
const groupedItems = computed(() => {
const groups: Record<WorkOrderStatus, WorkOrderKanbanItem[]> = {
[WorkOrderStatus.PENDING]: [],
[WorkOrderStatus.IN_PROGRESS]: [],
[WorkOrderStatus.COMPLETED]: [],
[WorkOrderStatus.CANCELLED]: []
}
for (const item of paginatedData.value.items) {
if (!statusFilter.value || item.status === statusFilter.value) {
groups[item.status].push(item)
}
}
return groups
})
// ─── 方法 ─────────────────────────────────────────────────────────────
const getColumnItems = (status: WorkOrderStatus): WorkOrderKanbanItem[] => {
return groupedItems.value[status] || []
}
const getColumnCount = (status: WorkOrderStatus): number => {
return getColumnItems(status).length
}
const loadWorkOrders = async () => {
loading.value = true
try {
const data = await getWorkOrderKanban({
status: statusFilter.value,
search: searchKeyword.value || undefined,
page: currentPage.value,
size: pageSize.value
})
paginatedData.value = data
} catch (e) {
console.error('[加载工单失败]', e)
ElMessage.error('加载工单失败')
} finally {
loading.value = false
}
}
const handleSearch = () => {
currentPage.value = 1
loadWorkOrders()
}
const resetSearch = () => {
searchKeyword.value = ''
statusFilter.value = undefined
currentPage.value = 1
loadWorkOrders()
}
const handlePageChange = (page: number) => {
currentPage.value = page
loadWorkOrders()
}
const handleSizeChange = (size: number) => {
pageSize.value = size
currentPage.value = 1
loadWorkOrders()
}
const viewWorkOrderDetail = (item: WorkOrderKanbanItem) => {
selectedWorkOrder.value = item
detailVisible.value = true
}
const handleStatusChange = async (workOrderId: number, newStatus: WorkOrderStatus) => {
try {
await updateWorkOrderStatus(workOrderId, newStatus)
ElMessage.success('状态更新成功')
detailVisible.value = false
loadWorkOrders()
} catch (e: any) {
ElMessage.error(e?.detail || '状态更新失败')
}
}
// ─── 监听筛选变化 ─────────────────────────────────────────────────────
watch(statusFilter, () => {
currentPage.value = 1
loadWorkOrders()
})
// ─── 生命周期 ─────────────────────────────────────────────────────────
onMounted(() => {
loadWorkOrders()
})
</script>
<style scoped>
.work-order-kanban {
padding: 24px;
background-color: #f5f7fa;
min-height: 100vh;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #303133;
margin-bottom: 24px;
}
.search-card {
margin-bottom: 20px;
border-radius: 8px;
}
.search-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.kanban-summary {
margin-left: auto;
color: #606266;
font-size: 14px;
}
/* 看板样式 */
.kanban-board {
display: flex;
gap: 16px;
overflow-x: auto;
padding-bottom: 16px;
}
.kanban-column {
flex: 1;
min-width: 280px;
max-width: 340px;
background: #f5f7fa;
border-radius: 8px;
display: flex;
flex-direction: column;
}
.column-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: white;
border-radius: 8px 8px 0 0;
border-top: 3px solid;
}
.column-title {
font-size: 15px;
font-weight: 600;
color: #303133;
}
.column-content {
flex: 1;
padding: 12px;
overflow-y: auto;
max-height: calc(100vh - 380px);
}
.kanban-card {
background: white;
border-radius: 6px;
border-left: 3px solid;
padding: 12px;
margin-bottom: 12px;
cursor: pointer;
transition: all 0.2s;
}
.kanban-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.work-order-no {
font-size: 14px;
font-weight: 600;
color: #303133;
}
.card-body {
margin-bottom: 8px;
}
.product-name {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #606266;
margin-bottom: 4px;
}
.product-spec {
font-size: 12px;
color: #909399;
margin-left: 20px;
margin-bottom: 8px;
}
.card-meta {
display: flex;
gap: 16px;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #909399;
}
.card-footer {
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
.project-name {
font-size: 12px;
color: #909399;
}
.empty-column {
text-align: center;
padding: 40px 0;
color: #c0c4cc;
font-size: 14px;
}
/* 分页 */
.pagination-wrapper {
display: flex;
justify-content: center;
margin-top: 20px;
}
/* 工单详情 */
.work-order-detail {
padding: 0 16px;
}
.status-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.action-label {
font-size: 14px;
color: #606266;
}
</style>

25
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

22
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src')
}
},
server: {
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://backend:8001',
changeOrigin: true
}
}
}
})