Initial commit: 智能项目定价模型
This commit is contained in:
49
前端应用/Dockerfile
Normal file
49
前端应用/Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
||||
# 智能项目定价模型 - 前端 Dockerfile
|
||||
# 遵循瑞小美部署规范:使用具体版本号,配置阿里云镜像源
|
||||
|
||||
# 构建阶段
|
||||
FROM node:20.11-alpine AS builder
|
||||
|
||||
# 设置工作目录
|
||||
WORKDIR /app
|
||||
|
||||
# 配置阿里云 npm 源
|
||||
RUN npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 安装 pnpm
|
||||
RUN npm install -g pnpm
|
||||
RUN pnpm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 复制依赖文件
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
|
||||
# 安装依赖
|
||||
RUN pnpm install
|
||||
|
||||
# 复制源代码
|
||||
COPY . .
|
||||
|
||||
# 构建
|
||||
RUN pnpm build
|
||||
|
||||
# 运行阶段
|
||||
FROM nginx:1.25.3-alpine AS runner
|
||||
|
||||
# 复制构建产物
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# 复制 nginx 配置
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 设置时区
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 80
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost/ || exit 1
|
||||
|
||||
# 启动 nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
23
前端应用/Dockerfile.dev
Normal file
23
前端应用/Dockerfile.dev
Normal file
@@ -0,0 +1,23 @@
|
||||
# 开发环境 Dockerfile
|
||||
|
||||
FROM node:20.11-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 配置阿里云 npm 源
|
||||
RUN npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 安装 pnpm
|
||||
RUN npm install -g pnpm
|
||||
RUN pnpm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 安装依赖
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN pnpm install
|
||||
|
||||
# 设置时区
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["pnpm", "dev", "--host"]
|
||||
73
前端应用/eslint.config.js
Normal file
73
前端应用/eslint.config.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* ESLint 配置
|
||||
* 遵循瑞小美代码规范(必须配置)
|
||||
*/
|
||||
|
||||
import js from '@eslint/js'
|
||||
import vue from 'eslint-plugin-vue'
|
||||
import typescript from '@typescript-eslint/eslint-plugin'
|
||||
import typescriptParser from '@typescript-eslint/parser'
|
||||
import vueParser from 'vue-eslint-parser'
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...vue.configs['flat/recommended'],
|
||||
{
|
||||
files: ['**/*.{ts,tsx,vue}'],
|
||||
languageOptions: {
|
||||
parser: vueParser,
|
||||
parserOptions: {
|
||||
parser: typescriptParser,
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
},
|
||||
globals: {
|
||||
// Vue 3 编译器宏
|
||||
defineProps: 'readonly',
|
||||
defineEmits: 'readonly',
|
||||
defineExpose: 'readonly',
|
||||
withDefaults: 'readonly',
|
||||
// 浏览器全局变量
|
||||
window: 'readonly',
|
||||
document: 'readonly',
|
||||
console: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
fetch: 'readonly',
|
||||
FormData: 'readonly',
|
||||
File: 'readonly',
|
||||
Blob: 'readonly',
|
||||
URL: 'readonly',
|
||||
URLSearchParams: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'@typescript-eslint': typescript,
|
||||
},
|
||||
rules: {
|
||||
// Vue 规则
|
||||
'vue/multi-word-component-names': 'off',
|
||||
'vue/no-v-html': 'off',
|
||||
'vue/require-default-prop': 'off',
|
||||
'vue/require-explicit-emits': 'error',
|
||||
'vue/v-on-event-hyphenation': ['error', 'always'],
|
||||
|
||||
// TypeScript 规则
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
|
||||
// 通用规则
|
||||
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
|
||||
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
|
||||
'no-unused-vars': 'off', // 使用 @typescript-eslint/no-unused-vars
|
||||
'prefer-const': 'error',
|
||||
'no-var': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**', '*.d.ts'],
|
||||
},
|
||||
]
|
||||
13
前端应用/index.html
Normal file
13
前端应用/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>智能项目定价模型</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
80
前端应用/nginx.conf
Normal file
80
前端应用/nginx.conf
Normal file
@@ -0,0 +1,80 @@
|
||||
# 前端容器内部 nginx 配置
|
||||
# 遵循瑞小美系统技术栈标准 - 性能优化版
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# 启用 gzip 压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_buffers 16 8k;
|
||||
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml;
|
||||
|
||||
# 启用预压缩文件(.gz)
|
||||
gzip_static on;
|
||||
|
||||
# 静态资源缓存 - 带哈希的文件长期缓存
|
||||
location /assets {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
|
||||
# 安全头
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
}
|
||||
|
||||
# JS/CSS 文件缓存
|
||||
location ~* \.(js|css)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# 图片资源缓存
|
||||
location ~* \.(ico|gif|jpg|jpeg|png|webp|svg)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000";
|
||||
}
|
||||
|
||||
# 字体文件缓存
|
||||
location ~* \.(woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, max-age=31536000";
|
||||
add_header Access-Control-Allow-Origin "*";
|
||||
}
|
||||
|
||||
# HTML 文件不缓存(SPA 入口)
|
||||
location = /index.html {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";
|
||||
}
|
||||
|
||||
# SPA 路由支持
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# 安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# 禁止访问隐藏文件
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
43
前端应用/package.json
Normal file
43
前端应用/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "pricing-model-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx",
|
||||
"lint:fix": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx --fix",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.15",
|
||||
"vue-router": "^4.2.5",
|
||||
"pinia": "^2.1.7",
|
||||
"axios": "^1.6.7",
|
||||
"element-plus": "^2.5.5",
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"echarts": "^5.4.3",
|
||||
"vue-echarts": "^6.6.8",
|
||||
"dayjs": "^1.11.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"vite": "^5.0.12",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vue-tsc": "^1.8.27",
|
||||
"typescript": "^5.3.3",
|
||||
"@types/node": "^20.11.16",
|
||||
"eslint": "^8.56.0",
|
||||
"@eslint/js": "^8.56.0",
|
||||
"eslint-plugin-vue": "^9.21.1",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"postcss": "^8.4.35",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"unplugin-auto-import": "^0.17.5",
|
||||
"unplugin-vue-components": "^0.26.0"
|
||||
}
|
||||
}
|
||||
6
前端应用/postcss.config.js
Normal file
6
前端应用/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
16
前端应用/src/App.vue
Normal file
16
前端应用/src/App.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 应用根组件
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
</style>
|
||||
91
前端应用/src/api/benchmark-prices.ts
Normal file
91
前端应用/src/api/benchmark-prices.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 标杆价格管理 API
|
||||
*/
|
||||
|
||||
import { request, PaginatedData } from './request'
|
||||
|
||||
// 价格带枚举
|
||||
export type PriceTier = 'low' | 'medium' | 'high' | 'premium'
|
||||
|
||||
// 标杆价格接口类型
|
||||
export interface BenchmarkPrice {
|
||||
id: number
|
||||
benchmark_name: string
|
||||
category_id: number | null
|
||||
category_name: string | null
|
||||
min_price: number
|
||||
max_price: number
|
||||
avg_price: number
|
||||
price_tier: PriceTier
|
||||
effective_date: string
|
||||
remark: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface BenchmarkPriceCreate {
|
||||
benchmark_name: string
|
||||
category_id?: number | null
|
||||
min_price: number
|
||||
max_price: number
|
||||
avg_price: number
|
||||
price_tier?: PriceTier
|
||||
effective_date: string
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
export interface BenchmarkPriceUpdate {
|
||||
benchmark_name?: string
|
||||
category_id?: number | null
|
||||
min_price?: number
|
||||
max_price?: number
|
||||
avg_price?: number
|
||||
price_tier?: PriceTier
|
||||
effective_date?: string
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
export interface BenchmarkPriceQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
category_id?: number
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const benchmarkPriceApi = {
|
||||
/**
|
||||
* 获取标杆价格列表
|
||||
*/
|
||||
getList(params?: BenchmarkPriceQuery) {
|
||||
return request.get<PaginatedData<BenchmarkPrice>>('/benchmark-prices', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建标杆价格
|
||||
*/
|
||||
create(data: BenchmarkPriceCreate) {
|
||||
return request.post<BenchmarkPrice>('/benchmark-prices', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新标杆价格
|
||||
*/
|
||||
update(id: number, data: BenchmarkPriceUpdate) {
|
||||
return request.put<BenchmarkPrice>(`/benchmark-prices/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除标杆价格
|
||||
*/
|
||||
delete(id: number) {
|
||||
return request.delete(`/benchmark-prices/${id}`)
|
||||
},
|
||||
}
|
||||
|
||||
// 价格带选项
|
||||
export const priceTierOptions = [
|
||||
{ value: 'low', label: '低端' },
|
||||
{ value: 'medium', label: '中端' },
|
||||
{ value: 'high', label: '高端' },
|
||||
{ value: 'premium', label: '奢华' },
|
||||
]
|
||||
83
前端应用/src/api/categories.ts
Normal file
83
前端应用/src/api/categories.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 项目分类 API
|
||||
*/
|
||||
|
||||
import { request, PaginatedData } from './request'
|
||||
|
||||
// 分类接口类型
|
||||
export interface Category {
|
||||
id: number
|
||||
category_name: string
|
||||
parent_id: number | null
|
||||
sort_order: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
children?: Category[]
|
||||
}
|
||||
|
||||
export interface CategoryCreate {
|
||||
category_name: string
|
||||
parent_id?: number | null
|
||||
sort_order?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface CategoryUpdate {
|
||||
category_name?: string
|
||||
parent_id?: number | null
|
||||
sort_order?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface CategoryQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
parent_id?: number | null
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const categoryApi = {
|
||||
/**
|
||||
* 获取分类列表
|
||||
*/
|
||||
getList(params?: CategoryQuery) {
|
||||
return request.get<PaginatedData<Category>>('/categories', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取分类树
|
||||
*/
|
||||
getTree(isActive?: boolean) {
|
||||
return request.get<Category[]>('/categories/tree', { params: { is_active: isActive } })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取单个分类
|
||||
*/
|
||||
getById(id: number) {
|
||||
return request.get<Category>(`/categories/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建分类
|
||||
*/
|
||||
create(data: CategoryCreate) {
|
||||
return request.post<Category>('/categories', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新分类
|
||||
*/
|
||||
update(id: number, data: CategoryUpdate) {
|
||||
return request.put<Category>(`/categories/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除分类
|
||||
*/
|
||||
delete(id: number) {
|
||||
return request.delete(`/categories/${id}`)
|
||||
},
|
||||
}
|
||||
178
前端应用/src/api/competitors.ts
Normal file
178
前端应用/src/api/competitors.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 竞品机构管理 API
|
||||
*/
|
||||
|
||||
import { request, PaginatedData } from './request'
|
||||
|
||||
// 机构定位枚举
|
||||
export type Positioning = 'high' | 'medium' | 'budget'
|
||||
|
||||
// 价格来源枚举
|
||||
export type PriceSource = 'official' | 'meituan' | 'dianping' | 'survey'
|
||||
|
||||
// 竞品机构接口类型
|
||||
export interface Competitor {
|
||||
id: number
|
||||
competitor_name: string
|
||||
address: string | null
|
||||
distance_km: number | null
|
||||
positioning: Positioning
|
||||
contact: string | null
|
||||
is_key_competitor: boolean
|
||||
is_active: boolean
|
||||
price_count: number
|
||||
last_price_update: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CompetitorCreate {
|
||||
competitor_name: string
|
||||
address?: string | null
|
||||
distance_km?: number | null
|
||||
positioning?: Positioning
|
||||
contact?: string | null
|
||||
is_key_competitor?: boolean
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface CompetitorUpdate {
|
||||
competitor_name?: string
|
||||
address?: string | null
|
||||
distance_km?: number | null
|
||||
positioning?: Positioning
|
||||
contact?: string | null
|
||||
is_key_competitor?: boolean
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface CompetitorQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
positioning?: Positioning
|
||||
is_key_competitor?: boolean
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
// 竞品价格
|
||||
export interface CompetitorPrice {
|
||||
id: number
|
||||
competitor_id: number
|
||||
competitor_name: string | null
|
||||
project_id: number | null
|
||||
project_name: string
|
||||
original_price: number
|
||||
promo_price: number | null
|
||||
member_price: number | null
|
||||
price_source: PriceSource
|
||||
collected_at: string
|
||||
remark: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CompetitorPriceCreate {
|
||||
project_id?: number | null
|
||||
project_name: string
|
||||
original_price: number
|
||||
promo_price?: number | null
|
||||
member_price?: number | null
|
||||
price_source: PriceSource
|
||||
collected_at: string
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
export interface CompetitorPriceUpdate {
|
||||
project_id?: number | null
|
||||
project_name?: string
|
||||
original_price?: number
|
||||
promo_price?: number | null
|
||||
member_price?: number | null
|
||||
price_source?: PriceSource
|
||||
collected_at?: string
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const competitorApi = {
|
||||
/**
|
||||
* 获取竞品机构列表
|
||||
*/
|
||||
getList(params?: CompetitorQuery) {
|
||||
return request.get<PaginatedData<Competitor>>('/competitors', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取单个竞品机构
|
||||
*/
|
||||
getById(id: number) {
|
||||
return request.get<Competitor>(`/competitors/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建竞品机构
|
||||
*/
|
||||
create(data: CompetitorCreate) {
|
||||
return request.post<Competitor>('/competitors', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新竞品机构
|
||||
*/
|
||||
update(id: number, data: CompetitorUpdate) {
|
||||
return request.put<Competitor>(`/competitors/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除竞品机构
|
||||
*/
|
||||
delete(id: number) {
|
||||
return request.delete(`/competitors/${id}`)
|
||||
},
|
||||
|
||||
// ============ 竞品价格 ============
|
||||
|
||||
/**
|
||||
* 获取竞品价格列表
|
||||
*/
|
||||
getPrices(competitorId: number, projectId?: number) {
|
||||
const params = projectId ? { project_id: projectId } : undefined
|
||||
return request.get<CompetitorPrice[]>(`/competitors/${competitorId}/prices`, { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加竞品价格
|
||||
*/
|
||||
addPrice(competitorId: number, data: CompetitorPriceCreate) {
|
||||
return request.post<CompetitorPrice>(`/competitors/${competitorId}/prices`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新竞品价格
|
||||
*/
|
||||
updatePrice(priceId: number, data: CompetitorPriceUpdate) {
|
||||
return request.put<CompetitorPrice>(`/competitor-prices/${priceId}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除竞品价格
|
||||
*/
|
||||
deletePrice(priceId: number) {
|
||||
return request.delete(`/competitor-prices/${priceId}`)
|
||||
},
|
||||
}
|
||||
|
||||
// 定位选项
|
||||
export const positioningOptions = [
|
||||
{ value: 'high', label: '高端' },
|
||||
{ value: 'medium', label: '中端' },
|
||||
{ value: 'budget', label: '大众' },
|
||||
]
|
||||
|
||||
// 价格来源选项
|
||||
export const priceSourceOptions = [
|
||||
{ value: 'official', label: '官网' },
|
||||
{ value: 'meituan', label: '美团' },
|
||||
{ value: 'dianping', label: '大众点评' },
|
||||
{ value: 'survey', label: '实地调研' },
|
||||
]
|
||||
121
前端应用/src/api/dashboard.ts
Normal file
121
前端应用/src/api/dashboard.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 仪表盘 API
|
||||
*/
|
||||
|
||||
import { request } from './request'
|
||||
|
||||
// 项目概览
|
||||
export interface ProjectOverview {
|
||||
total_projects: number
|
||||
active_projects: number
|
||||
projects_with_pricing: number
|
||||
}
|
||||
|
||||
// 成本项目信息
|
||||
export interface CostProjectInfo {
|
||||
id: number
|
||||
name: string
|
||||
cost: number
|
||||
}
|
||||
|
||||
// 成本概览
|
||||
export interface CostOverview {
|
||||
avg_project_cost: number
|
||||
highest_cost_project: CostProjectInfo | null
|
||||
lowest_cost_project: CostProjectInfo | null
|
||||
}
|
||||
|
||||
// 市场概览
|
||||
export interface MarketOverview {
|
||||
competitors_tracked: number
|
||||
price_records_this_month: number
|
||||
avg_market_price: number | null
|
||||
}
|
||||
|
||||
// 策略分布
|
||||
export interface StrategiesDistribution {
|
||||
traffic: number
|
||||
profit: number
|
||||
premium: number
|
||||
}
|
||||
|
||||
// 定价概览
|
||||
export interface PricingOverview {
|
||||
pricing_plans_count: number
|
||||
avg_target_margin: number | null
|
||||
strategies_distribution: StrategiesDistribution
|
||||
}
|
||||
|
||||
// AI 使用概览
|
||||
export interface AIUsageOverview {
|
||||
total_calls: number
|
||||
total_tokens: number
|
||||
total_cost_usd: number
|
||||
provider_distribution: Record<string, number>
|
||||
}
|
||||
|
||||
// 最近活动
|
||||
export interface RecentActivity {
|
||||
type: string
|
||||
project_name: string
|
||||
user: string | null
|
||||
time: string
|
||||
}
|
||||
|
||||
// 仪表盘概览响应
|
||||
export interface DashboardSummaryResponse {
|
||||
project_overview: ProjectOverview
|
||||
cost_overview: CostOverview
|
||||
market_overview: MarketOverview
|
||||
pricing_overview: PricingOverview
|
||||
ai_usage_this_month: AIUsageOverview | null
|
||||
recent_activities: RecentActivity[]
|
||||
}
|
||||
|
||||
// 趋势数据点
|
||||
export interface TrendDataPoint {
|
||||
date: string
|
||||
value: number
|
||||
}
|
||||
|
||||
// 成本趋势响应
|
||||
export interface CostTrendResponse {
|
||||
period: string
|
||||
data: TrendDataPoint[]
|
||||
avg_cost: number
|
||||
}
|
||||
|
||||
// 市场趋势响应
|
||||
export interface MarketTrendResponse {
|
||||
period: string
|
||||
data: TrendDataPoint[]
|
||||
avg_price: number
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const dashboardApi = {
|
||||
/**
|
||||
* 获取仪表盘概览数据
|
||||
*/
|
||||
getSummary() {
|
||||
return request.get<DashboardSummaryResponse>('/dashboard/summary')
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取成本趋势
|
||||
*/
|
||||
getCostTrend(period: 'week' | 'month' | 'quarter' = 'month') {
|
||||
return request.get<CostTrendResponse>('/dashboard/cost-trend', {
|
||||
params: { period },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取市场价格趋势
|
||||
*/
|
||||
getMarketTrend(period: 'week' | 'month' | 'quarter' = 'month') {
|
||||
return request.get<MarketTrendResponse>('/dashboard/market-trend', {
|
||||
params: { period },
|
||||
})
|
||||
},
|
||||
}
|
||||
88
前端应用/src/api/equipments.ts
Normal file
88
前端应用/src/api/equipments.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 设备管理 API
|
||||
*/
|
||||
|
||||
import { request, PaginatedData } from './request'
|
||||
|
||||
// 设备接口类型
|
||||
export interface Equipment {
|
||||
id: number
|
||||
equipment_code: string
|
||||
equipment_name: string
|
||||
original_value: number
|
||||
residual_rate: number
|
||||
service_years: number
|
||||
estimated_uses: number
|
||||
depreciation_per_use: number
|
||||
purchase_date: string | null
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface EquipmentCreate {
|
||||
equipment_code: string
|
||||
equipment_name: string
|
||||
original_value: number
|
||||
residual_rate?: number
|
||||
service_years: number
|
||||
estimated_uses: number
|
||||
purchase_date?: string | null
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface EquipmentUpdate {
|
||||
equipment_code?: string
|
||||
equipment_name?: string
|
||||
original_value?: number
|
||||
residual_rate?: number
|
||||
service_years?: number
|
||||
estimated_uses?: number
|
||||
purchase_date?: string | null
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface EquipmentQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
keyword?: string
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const equipmentApi = {
|
||||
/**
|
||||
* 获取设备列表
|
||||
*/
|
||||
getList(params?: EquipmentQuery) {
|
||||
return request.get<PaginatedData<Equipment>>('/equipments', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取单个设备
|
||||
*/
|
||||
getById(id: number) {
|
||||
return request.get<Equipment>(`/equipments/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建设备
|
||||
*/
|
||||
create(data: EquipmentCreate) {
|
||||
return request.post<Equipment>('/equipments', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新设备
|
||||
*/
|
||||
update(id: number, data: EquipmentUpdate) {
|
||||
return request.put<Equipment>(`/equipments/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除设备
|
||||
*/
|
||||
delete(id: number) {
|
||||
return request.delete(`/equipments/${id}`)
|
||||
},
|
||||
}
|
||||
117
前端应用/src/api/fixed-costs.ts
Normal file
117
前端应用/src/api/fixed-costs.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* 固定成本 API
|
||||
*/
|
||||
|
||||
import { request, PaginatedData } from './request'
|
||||
|
||||
// 成本类型枚举
|
||||
export type CostType = 'rent' | 'utilities' | 'property' | 'other'
|
||||
|
||||
// 分摊方式枚举
|
||||
export type AllocationMethod = 'count' | 'revenue' | 'duration'
|
||||
|
||||
// 固定成本接口类型
|
||||
export interface FixedCost {
|
||||
id: number
|
||||
cost_name: string
|
||||
cost_type: CostType
|
||||
monthly_amount: number
|
||||
year_month: string
|
||||
allocation_method: AllocationMethod
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface FixedCostCreate {
|
||||
cost_name: string
|
||||
cost_type: CostType
|
||||
monthly_amount: number
|
||||
year_month: string
|
||||
allocation_method?: AllocationMethod
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface FixedCostUpdate {
|
||||
cost_name?: string
|
||||
cost_type?: CostType
|
||||
monthly_amount?: number
|
||||
year_month?: string
|
||||
allocation_method?: AllocationMethod
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface FixedCostQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
year_month?: string
|
||||
cost_type?: CostType
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface FixedCostSummary {
|
||||
year_month: string
|
||||
total_amount: number
|
||||
by_type: Record<string, number>
|
||||
count: number
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const fixedCostApi = {
|
||||
/**
|
||||
* 获取固定成本列表
|
||||
*/
|
||||
getList(params?: FixedCostQuery) {
|
||||
return request.get<PaginatedData<FixedCost>>('/fixed-costs', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取月度汇总
|
||||
*/
|
||||
getSummary(yearMonth: string) {
|
||||
return request.get<FixedCostSummary>('/fixed-costs/summary', { params: { year_month: yearMonth } })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取单个固定成本
|
||||
*/
|
||||
getById(id: number) {
|
||||
return request.get<FixedCost>(`/fixed-costs/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建固定成本
|
||||
*/
|
||||
create(data: FixedCostCreate) {
|
||||
return request.post<FixedCost>('/fixed-costs', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新固定成本
|
||||
*/
|
||||
update(id: number, data: FixedCostUpdate) {
|
||||
return request.put<FixedCost>(`/fixed-costs/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除固定成本
|
||||
*/
|
||||
delete(id: number) {
|
||||
return request.delete(`/fixed-costs/${id}`)
|
||||
},
|
||||
}
|
||||
|
||||
// 成本类型选项
|
||||
export const costTypeOptions = [
|
||||
{ value: 'rent', label: '房租' },
|
||||
{ value: 'utilities', label: '水电' },
|
||||
{ value: 'property', label: '物业' },
|
||||
{ value: 'other', label: '其他' },
|
||||
]
|
||||
|
||||
// 分摊方式选项
|
||||
export const allocationMethodOptions = [
|
||||
{ value: 'count', label: '按项目数量' },
|
||||
{ value: 'revenue', label: '按营收占比' },
|
||||
{ value: 'duration', label: '按时长占比' },
|
||||
]
|
||||
17
前端应用/src/api/index.ts
Normal file
17
前端应用/src/api/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* API 统一导出
|
||||
*/
|
||||
|
||||
export * from './request'
|
||||
export * from './categories'
|
||||
export * from './materials'
|
||||
export * from './equipments'
|
||||
export * from './staff-levels'
|
||||
export * from './fixed-costs'
|
||||
export * from './projects'
|
||||
export * from './competitors'
|
||||
export * from './benchmark-prices'
|
||||
export * from './market-analysis'
|
||||
export * from './pricing'
|
||||
export * from './dashboard'
|
||||
export * from './profit'
|
||||
103
前端应用/src/api/market-analysis.ts
Normal file
103
前端应用/src/api/market-analysis.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 市场分析 API
|
||||
*/
|
||||
|
||||
import { request } from './request'
|
||||
|
||||
// 价格统计
|
||||
export interface PriceStatistics {
|
||||
min_price: number
|
||||
max_price: number
|
||||
avg_price: number
|
||||
median_price: number
|
||||
std_deviation: number | null
|
||||
}
|
||||
|
||||
// 价格分布项
|
||||
export interface PriceDistributionItem {
|
||||
range: string
|
||||
count: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
// 价格分布
|
||||
export interface PriceDistribution {
|
||||
low: PriceDistributionItem
|
||||
medium: PriceDistributionItem
|
||||
high: PriceDistributionItem
|
||||
}
|
||||
|
||||
// 竞品价格摘要
|
||||
export interface CompetitorPriceSummary {
|
||||
competitor_name: string
|
||||
positioning: string
|
||||
original_price: number
|
||||
promo_price: number | null
|
||||
collected_at: string
|
||||
}
|
||||
|
||||
// 标杆参考
|
||||
export interface BenchmarkReference {
|
||||
tier: string
|
||||
min_price: number
|
||||
max_price: number
|
||||
avg_price: number
|
||||
}
|
||||
|
||||
// 建议定价区间
|
||||
export interface SuggestedRange {
|
||||
min: number
|
||||
max: number
|
||||
recommended: number
|
||||
}
|
||||
|
||||
// 市场分析结果
|
||||
export interface MarketAnalysisResult {
|
||||
project_id: number
|
||||
project_name: string
|
||||
analysis_date: string
|
||||
competitor_count: number
|
||||
price_statistics: PriceStatistics
|
||||
price_distribution: PriceDistribution | null
|
||||
competitor_prices: CompetitorPriceSummary[]
|
||||
benchmark_reference: BenchmarkReference | null
|
||||
suggested_range: SuggestedRange
|
||||
}
|
||||
|
||||
// 市场分析响应(数据库记录)
|
||||
export interface MarketAnalysisResponse {
|
||||
id: number
|
||||
project_id: number
|
||||
analysis_date: string
|
||||
competitor_count: number
|
||||
market_min_price: number
|
||||
market_max_price: number
|
||||
market_avg_price: number
|
||||
market_median_price: number
|
||||
suggested_range_min: number
|
||||
suggested_range_max: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 市场分析请求
|
||||
export interface MarketAnalysisRequest {
|
||||
competitor_ids?: number[]
|
||||
include_benchmark?: boolean
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const marketAnalysisApi = {
|
||||
/**
|
||||
* 执行市场分析
|
||||
*/
|
||||
analyze(projectId: number, data?: MarketAnalysisRequest) {
|
||||
return request.post<MarketAnalysisResult>(`/projects/${projectId}/market-analysis`, data || {})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取最新市场分析结果
|
||||
*/
|
||||
getLatest(projectId: number) {
|
||||
return request.get<MarketAnalysisResponse>(`/projects/${projectId}/market-analysis`)
|
||||
},
|
||||
}
|
||||
112
前端应用/src/api/materials.ts
Normal file
112
前端应用/src/api/materials.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 耗材管理 API
|
||||
*/
|
||||
|
||||
import { request, PaginatedData } from './request'
|
||||
|
||||
// 耗材类型枚举
|
||||
export type MaterialType = 'consumable' | 'injectable' | 'product'
|
||||
|
||||
// 耗材接口类型
|
||||
export interface Material {
|
||||
id: number
|
||||
material_code: string
|
||||
material_name: string
|
||||
unit: string
|
||||
unit_price: number
|
||||
supplier: string | null
|
||||
material_type: MaterialType
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface MaterialCreate {
|
||||
material_code: string
|
||||
material_name: string
|
||||
unit: string
|
||||
unit_price: number
|
||||
supplier?: string | null
|
||||
material_type: MaterialType
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface MaterialUpdate {
|
||||
material_code?: string
|
||||
material_name?: string
|
||||
unit?: string
|
||||
unit_price?: number
|
||||
supplier?: string | null
|
||||
material_type?: MaterialType
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface MaterialQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
keyword?: string
|
||||
material_type?: MaterialType
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface MaterialImportResult {
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
errors: { row: number; error: string }[]
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const materialApi = {
|
||||
/**
|
||||
* 获取耗材列表
|
||||
*/
|
||||
getList(params?: MaterialQuery) {
|
||||
return request.get<PaginatedData<Material>>('/materials', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取单个耗材
|
||||
*/
|
||||
getById(id: number) {
|
||||
return request.get<Material>(`/materials/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建耗材
|
||||
*/
|
||||
create(data: MaterialCreate) {
|
||||
return request.post<Material>('/materials', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新耗材
|
||||
*/
|
||||
update(id: number, data: MaterialUpdate) {
|
||||
return request.put<Material>(`/materials/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除耗材
|
||||
*/
|
||||
delete(id: number) {
|
||||
return request.delete(`/materials/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 批量导入耗材
|
||||
*/
|
||||
import(file: File, updateExisting = false) {
|
||||
return request.upload<MaterialImportResult>(
|
||||
`/materials/import?update_existing=${updateExisting}`,
|
||||
file
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
// 耗材类型选项
|
||||
export const materialTypeOptions = [
|
||||
{ value: 'consumable', label: '一般耗材' },
|
||||
{ value: 'injectable', label: '针剂' },
|
||||
{ value: 'product', label: '产品' },
|
||||
]
|
||||
205
前端应用/src/api/pricing.ts
Normal file
205
前端应用/src/api/pricing.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 智能定价 API
|
||||
*/
|
||||
|
||||
import { request, PaginatedData } from './request'
|
||||
|
||||
// 策略类型
|
||||
export type StrategyType = 'traffic' | 'profit' | 'premium'
|
||||
|
||||
// 策略类型选项
|
||||
export const strategyTypeOptions = [
|
||||
{ value: 'traffic', label: '引流款' },
|
||||
{ value: 'profit', label: '利润款' },
|
||||
{ value: 'premium', label: '高端款' },
|
||||
]
|
||||
|
||||
// 定价方案
|
||||
export interface PricingPlan {
|
||||
id: number
|
||||
project_id: number
|
||||
project_name: string | null
|
||||
plan_name: string
|
||||
strategy_type: StrategyType
|
||||
base_cost: number
|
||||
target_margin: number
|
||||
suggested_price: number
|
||||
final_price: number | null
|
||||
ai_advice: string | null
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
created_by_name: string | null
|
||||
}
|
||||
|
||||
export interface PricingPlanCreate {
|
||||
project_id: number
|
||||
plan_name: string
|
||||
strategy_type: StrategyType
|
||||
target_margin: number
|
||||
}
|
||||
|
||||
export interface PricingPlanUpdate {
|
||||
plan_name?: string
|
||||
strategy_type?: StrategyType
|
||||
target_margin?: number
|
||||
final_price?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface PricingPlanQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
project_id?: number
|
||||
strategy_type?: StrategyType
|
||||
is_active?: boolean
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
// AI 定价建议
|
||||
export interface GeneratePricingRequest {
|
||||
target_margin?: number
|
||||
strategies?: StrategyType[]
|
||||
stream?: boolean
|
||||
}
|
||||
|
||||
export interface StrategySuggestion {
|
||||
strategy: string
|
||||
suggested_price: number
|
||||
margin: number
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface MarketReference {
|
||||
min: number
|
||||
max: number
|
||||
avg: number
|
||||
}
|
||||
|
||||
export interface PricingSuggestions {
|
||||
traffic?: StrategySuggestion
|
||||
profit?: StrategySuggestion
|
||||
premium?: StrategySuggestion
|
||||
}
|
||||
|
||||
export interface AIAdvice {
|
||||
summary: string
|
||||
cost_analysis: string
|
||||
market_analysis: string
|
||||
risk_notes: string
|
||||
recommendations: string[]
|
||||
}
|
||||
|
||||
export interface AIUsage {
|
||||
provider: string
|
||||
model: string
|
||||
tokens: number
|
||||
latency_ms: number
|
||||
}
|
||||
|
||||
export interface GeneratePricingResponse {
|
||||
project_id: number
|
||||
project_name: string
|
||||
cost_base: number
|
||||
market_reference: MarketReference | null
|
||||
pricing_suggestions: PricingSuggestions
|
||||
ai_advice: AIAdvice | null
|
||||
ai_usage: AIUsage | null
|
||||
}
|
||||
|
||||
// 策略模拟
|
||||
export interface SimulateStrategyRequest {
|
||||
strategies: StrategyType[]
|
||||
target_margin?: number
|
||||
}
|
||||
|
||||
export interface StrategySimulationResult {
|
||||
strategy_type: string
|
||||
strategy_name: string
|
||||
suggested_price: number
|
||||
margin: number
|
||||
profit_per_unit: number
|
||||
market_position: string
|
||||
}
|
||||
|
||||
export interface SimulateStrategyResponse {
|
||||
project_id: number
|
||||
project_name: string
|
||||
base_cost: number
|
||||
results: StrategySimulationResult[]
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const pricingApi = {
|
||||
/**
|
||||
* 获取定价方案列表
|
||||
*/
|
||||
getList(params?: PricingPlanQuery) {
|
||||
return request.get<PaginatedData<PricingPlan>>('/pricing-plans', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取定价方案详情
|
||||
*/
|
||||
getById(id: number) {
|
||||
return request.get<PricingPlan>(`/pricing-plans/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建定价方案
|
||||
*/
|
||||
create(data: PricingPlanCreate) {
|
||||
return request.post<PricingPlan>('/pricing-plans', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新定价方案
|
||||
*/
|
||||
update(id: number, data: PricingPlanUpdate) {
|
||||
return request.put<PricingPlan>(`/pricing-plans/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除定价方案
|
||||
*/
|
||||
delete(id: number) {
|
||||
return request.delete(`/pricing-plans/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* AI 生成定价建议(非流式)
|
||||
*/
|
||||
generatePricing(projectId: number, data?: GeneratePricingRequest) {
|
||||
return request.post<GeneratePricingResponse>(
|
||||
`/projects/${projectId}/generate-pricing`,
|
||||
{ ...data, stream: false }
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* AI 生成定价建议(流式)
|
||||
* 返回 EventSource URL,由组件处理 SSE
|
||||
*/
|
||||
generatePricingStreamUrl(projectId: number, targetMargin: number = 50): string {
|
||||
return `/api/v1/projects/${projectId}/generate-pricing`
|
||||
},
|
||||
|
||||
/**
|
||||
* 模拟定价策略
|
||||
*/
|
||||
simulateStrategy(projectId: number, data: SimulateStrategyRequest) {
|
||||
return request.post<SimulateStrategyResponse>(
|
||||
`/projects/${projectId}/simulate-strategy`,
|
||||
data
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* 导出定价报告
|
||||
*/
|
||||
exportReport(planId: number, format: 'pdf' | 'excel' = 'pdf') {
|
||||
// 返回下载 URL
|
||||
return `/api/v1/pricing-plans/${planId}/export?format=${format}`
|
||||
},
|
||||
}
|
||||
190
前端应用/src/api/profit.ts
Normal file
190
前端应用/src/api/profit.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* 利润模拟 API
|
||||
*/
|
||||
|
||||
import { request, PaginatedData } from './request'
|
||||
|
||||
// 周期类型
|
||||
export type PeriodType = 'daily' | 'weekly' | 'monthly'
|
||||
|
||||
// 周期类型选项
|
||||
export const periodTypeOptions = [
|
||||
{ value: 'daily', label: '日' },
|
||||
{ value: 'weekly', label: '周' },
|
||||
{ value: 'monthly', label: '月' },
|
||||
]
|
||||
|
||||
// 利润模拟
|
||||
export interface ProfitSimulation {
|
||||
id: number
|
||||
pricing_plan_id: number
|
||||
plan_name: string | null
|
||||
project_name: string | null
|
||||
simulation_name: string
|
||||
price: number
|
||||
estimated_volume: number
|
||||
period_type: PeriodType
|
||||
estimated_revenue: number
|
||||
estimated_cost: number
|
||||
estimated_profit: number
|
||||
profit_margin: number
|
||||
breakeven_volume: number
|
||||
created_at: string
|
||||
created_by_name: string | null
|
||||
}
|
||||
|
||||
export interface ProfitSimulationQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
pricing_plan_id?: number
|
||||
period_type?: PeriodType
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
// 执行模拟
|
||||
export interface SimulateProfitRequest {
|
||||
price: number
|
||||
estimated_volume: number
|
||||
period_type?: PeriodType
|
||||
}
|
||||
|
||||
export interface SimulationInput {
|
||||
price: number
|
||||
cost_per_unit: number
|
||||
estimated_volume: number
|
||||
period_type: string
|
||||
}
|
||||
|
||||
export interface SimulationResult {
|
||||
estimated_revenue: number
|
||||
estimated_cost: number
|
||||
estimated_profit: number
|
||||
profit_margin: number
|
||||
profit_per_unit: number
|
||||
}
|
||||
|
||||
export interface BreakevenAnalysis {
|
||||
breakeven_volume: number
|
||||
current_volume: number
|
||||
safety_margin: number
|
||||
safety_margin_percentage: number
|
||||
}
|
||||
|
||||
export interface SimulateProfitResponse {
|
||||
simulation_id: number
|
||||
pricing_plan_id: number
|
||||
project_name: string
|
||||
input: SimulationInput
|
||||
result: SimulationResult
|
||||
breakeven_analysis: BreakevenAnalysis
|
||||
created_at: string
|
||||
}
|
||||
|
||||
// 敏感性分析
|
||||
export interface SensitivityAnalysisRequest {
|
||||
price_change_rates?: number[]
|
||||
}
|
||||
|
||||
export interface SensitivityResultItem {
|
||||
price_change_rate: number
|
||||
adjusted_price: number
|
||||
adjusted_profit: number
|
||||
profit_change_rate: number
|
||||
}
|
||||
|
||||
export interface SensitivityInsights {
|
||||
price_elasticity: string
|
||||
risk_level: string
|
||||
recommendation: string
|
||||
}
|
||||
|
||||
export interface SensitivityAnalysisResponse {
|
||||
simulation_id: number
|
||||
base_price: number
|
||||
base_profit: number
|
||||
sensitivity_results: SensitivityResultItem[]
|
||||
insights?: SensitivityInsights
|
||||
}
|
||||
|
||||
// 盈亏平衡分析
|
||||
export interface BreakevenResponse {
|
||||
pricing_plan_id: number
|
||||
project_name: string
|
||||
price: number
|
||||
unit_cost: number
|
||||
fixed_cost_monthly: number
|
||||
breakeven_volume: number
|
||||
current_margin: number
|
||||
target_profit_volume: number | null
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const profitApi = {
|
||||
/**
|
||||
* 获取模拟列表
|
||||
*/
|
||||
getList(params?: ProfitSimulationQuery) {
|
||||
return request.get<PaginatedData<ProfitSimulation>>('/profit-simulations', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取模拟详情
|
||||
*/
|
||||
getById(id: number) {
|
||||
return request.get<ProfitSimulation>(`/profit-simulations/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除模拟记录
|
||||
*/
|
||||
delete(id: number) {
|
||||
return request.delete(`/profit-simulations/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 执行利润模拟
|
||||
*/
|
||||
simulate(planId: number, data: SimulateProfitRequest) {
|
||||
return request.post<SimulateProfitResponse>(
|
||||
`/pricing-plans/${planId}/simulate-profit`,
|
||||
data
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* 执行敏感性分析
|
||||
*/
|
||||
sensitivityAnalysis(simulationId: number, data?: SensitivityAnalysisRequest) {
|
||||
return request.post<SensitivityAnalysisResponse>(
|
||||
`/profit-simulations/${simulationId}/sensitivity`,
|
||||
data || {}
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取敏感性分析结果
|
||||
*/
|
||||
getSensitivityAnalysis(simulationId: number) {
|
||||
return request.get<SensitivityAnalysisResponse>(
|
||||
`/profit-simulations/${simulationId}/sensitivity`
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取盈亏平衡分析
|
||||
*/
|
||||
getBreakevenAnalysis(planId: number, targetProfit?: number) {
|
||||
const params = targetProfit ? { target_profit: targetProfit } : undefined
|
||||
return request.get<BreakevenResponse>(`/pricing-plans/${planId}/breakeven`, { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* AI 生成利润预测分析
|
||||
*/
|
||||
generateForecast(simulationId: number) {
|
||||
return request.post<{ content: string }>(
|
||||
`/profit-simulations/${simulationId}/forecast`
|
||||
)
|
||||
},
|
||||
}
|
||||
278
前端应用/src/api/projects.ts
Normal file
278
前端应用/src/api/projects.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* 服务项目管理 API
|
||||
*/
|
||||
|
||||
import { request, PaginatedData } from './request'
|
||||
|
||||
// 成本汇总简要
|
||||
export interface CostSummaryBrief {
|
||||
total_cost: number
|
||||
material_cost: number
|
||||
equipment_cost: number
|
||||
labor_cost: number
|
||||
fixed_cost_allocation: number
|
||||
}
|
||||
|
||||
// 项目接口类型
|
||||
export interface Project {
|
||||
id: number
|
||||
project_code: string
|
||||
project_name: string
|
||||
category_id: number | null
|
||||
category_name: string | null
|
||||
description: string | null
|
||||
duration_minutes: number
|
||||
is_active: boolean
|
||||
cost_summary: CostSummaryBrief | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ProjectCreate {
|
||||
project_code: string
|
||||
project_name: string
|
||||
category_id?: number | null
|
||||
description?: string | null
|
||||
duration_minutes?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface ProjectUpdate {
|
||||
project_code?: string
|
||||
project_name?: string
|
||||
category_id?: number | null
|
||||
description?: string | null
|
||||
duration_minutes?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface ProjectQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
category_id?: number
|
||||
keyword?: string
|
||||
is_active?: boolean
|
||||
sort_by?: string
|
||||
sort_order?: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
// 成本明细类型
|
||||
export type CostItemType = 'material' | 'equipment'
|
||||
|
||||
export interface CostItem {
|
||||
id: number
|
||||
item_type: CostItemType
|
||||
item_id: number
|
||||
item_name: string | null
|
||||
quantity: number
|
||||
unit: string | null
|
||||
unit_cost: number
|
||||
total_cost: number
|
||||
remark: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CostItemCreate {
|
||||
item_type: CostItemType
|
||||
item_id: number
|
||||
quantity: number
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
export interface CostItemUpdate {
|
||||
quantity?: number
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
// 人工成本
|
||||
export interface LaborCost {
|
||||
id: number
|
||||
staff_level_id: number
|
||||
level_name: string | null
|
||||
duration_minutes: number
|
||||
hourly_rate: number
|
||||
labor_cost: number
|
||||
remark: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface LaborCostCreate {
|
||||
staff_level_id: number
|
||||
duration_minutes: number
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
export interface LaborCostUpdate {
|
||||
staff_level_id?: number
|
||||
duration_minutes?: number
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
// 成本计算请求
|
||||
export type AllocationMethod = 'count' | 'revenue' | 'duration'
|
||||
|
||||
export interface CalculateCostRequest {
|
||||
fixed_cost_allocation_method?: AllocationMethod
|
||||
}
|
||||
|
||||
// 成本计算结果
|
||||
export interface CostCalculationResult {
|
||||
project_id: number
|
||||
project_name: string
|
||||
cost_breakdown: {
|
||||
material_cost: { items: any[]; subtotal: number }
|
||||
equipment_cost: { items: any[]; subtotal: number }
|
||||
labor_cost: { items: any[]; subtotal: number }
|
||||
fixed_cost_allocation: {
|
||||
method: string
|
||||
total_fixed_cost: number
|
||||
project_count?: number
|
||||
allocation: number
|
||||
}
|
||||
}
|
||||
total_cost: number
|
||||
min_price_suggestion: number
|
||||
calculated_at: string
|
||||
}
|
||||
|
||||
// 成本汇总响应
|
||||
export interface CostSummary {
|
||||
project_id: number
|
||||
material_cost: number
|
||||
equipment_cost: number
|
||||
labor_cost: number
|
||||
fixed_cost_allocation: number
|
||||
total_cost: number
|
||||
calculated_at: string
|
||||
}
|
||||
|
||||
// 项目详情(含成本)
|
||||
export interface ProjectDetail extends Omit<Project, 'cost_summary'> {
|
||||
cost_items: CostItem[]
|
||||
labor_costs: LaborCost[]
|
||||
cost_summary: CostSummary | null
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const projectApi = {
|
||||
/**
|
||||
* 获取项目列表
|
||||
*/
|
||||
getList(params?: ProjectQuery) {
|
||||
return request.get<PaginatedData<Project>>('/projects', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取项目详情
|
||||
*/
|
||||
getById(id: number) {
|
||||
return request.get<ProjectDetail>(`/projects/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建项目
|
||||
*/
|
||||
create(data: ProjectCreate) {
|
||||
return request.post<Project>('/projects', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新项目
|
||||
*/
|
||||
update(id: number, data: ProjectUpdate) {
|
||||
return request.put<Project>(`/projects/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除项目
|
||||
*/
|
||||
delete(id: number) {
|
||||
return request.delete(`/projects/${id}`)
|
||||
},
|
||||
|
||||
// ============ 成本明细 ============
|
||||
|
||||
/**
|
||||
* 获取成本明细列表
|
||||
*/
|
||||
getCostItems(projectId: number) {
|
||||
return request.get<CostItem[]>(`/projects/${projectId}/cost-items`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加成本明细
|
||||
*/
|
||||
addCostItem(projectId: number, data: CostItemCreate) {
|
||||
return request.post<CostItem>(`/projects/${projectId}/cost-items`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新成本明细
|
||||
*/
|
||||
updateCostItem(projectId: number, itemId: number, data: CostItemUpdate) {
|
||||
return request.put<CostItem>(`/projects/${projectId}/cost-items/${itemId}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除成本明细
|
||||
*/
|
||||
deleteCostItem(projectId: number, itemId: number) {
|
||||
return request.delete(`/projects/${projectId}/cost-items/${itemId}`)
|
||||
},
|
||||
|
||||
// ============ 人工成本 ============
|
||||
|
||||
/**
|
||||
* 获取人工成本列表
|
||||
*/
|
||||
getLaborCosts(projectId: number) {
|
||||
return request.get<LaborCost[]>(`/projects/${projectId}/labor-costs`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 添加人工成本
|
||||
*/
|
||||
addLaborCost(projectId: number, data: LaborCostCreate) {
|
||||
return request.post<LaborCost>(`/projects/${projectId}/labor-costs`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新人工成本
|
||||
*/
|
||||
updateLaborCost(projectId: number, itemId: number, data: LaborCostUpdate) {
|
||||
return request.put<LaborCost>(`/projects/${projectId}/labor-costs/${itemId}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除人工成本
|
||||
*/
|
||||
deleteLaborCost(projectId: number, itemId: number) {
|
||||
return request.delete(`/projects/${projectId}/labor-costs/${itemId}`)
|
||||
},
|
||||
|
||||
// ============ 成本计算 ============
|
||||
|
||||
/**
|
||||
* 计算项目总成本
|
||||
*/
|
||||
calculateCost(projectId: number, data?: CalculateCostRequest) {
|
||||
return request.post<CostCalculationResult>(`/projects/${projectId}/calculate-cost`, data || {})
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取成本汇总
|
||||
*/
|
||||
getCostSummary(projectId: number) {
|
||||
return request.get<CostSummary>(`/projects/${projectId}/cost-summary`)
|
||||
},
|
||||
}
|
||||
|
||||
// 成本明细类型选项
|
||||
export const costItemTypeOptions = [
|
||||
{ value: 'material', label: '耗材' },
|
||||
{ value: 'equipment', label: '设备' },
|
||||
]
|
||||
|
||||
// 固定成本分摊方式选项 - 使用 fixed-costs.ts 中的 allocationMethodOptions
|
||||
147
前端应用/src/api/request.ts
Normal file
147
前端应用/src/api/request.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Axios 请求封装
|
||||
* 遵循瑞小美技术栈标准:统一使用 Axios
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
// API 响应格式
|
||||
export interface ApiResponse<T = any> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
// 分页数据格式
|
||||
export interface PaginatedData<T = any> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
// 错误码
|
||||
export const ErrorCode = {
|
||||
SUCCESS: 0,
|
||||
PARAM_ERROR: 10001,
|
||||
NOT_FOUND: 10002,
|
||||
ALREADY_EXISTS: 10003,
|
||||
NOT_ALLOWED: 10004,
|
||||
AUTH_FAILED: 20001,
|
||||
PERMISSION_DENIED: 20002,
|
||||
TOKEN_EXPIRED: 20003,
|
||||
INTERNAL_ERROR: 30001,
|
||||
SERVICE_UNAVAILABLE: 30002,
|
||||
AI_SERVICE_ERROR: 40001,
|
||||
AI_SERVICE_TIMEOUT: 40002,
|
||||
}
|
||||
|
||||
// 创建 Axios 实例
|
||||
const instance: AxiosInstance = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// 请求拦截器
|
||||
instance.interceptors.request.use(
|
||||
(config) => {
|
||||
// 添加 Token(如果有)
|
||||
const token = localStorage.getItem('token')
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 响应拦截器
|
||||
instance.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse>) => {
|
||||
const { data } = response
|
||||
|
||||
// 业务错误处理
|
||||
if (data.code !== ErrorCode.SUCCESS) {
|
||||
ElMessage.error(data.message || '请求失败')
|
||||
return Promise.reject(new Error(data.message))
|
||||
}
|
||||
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
// HTTP 错误处理
|
||||
let message = '网络错误,请稍后重试'
|
||||
|
||||
if (error.response) {
|
||||
const { status, data } = error.response
|
||||
// 获取错误信息:支持 data.message 和 data.detail.message 两种格式
|
||||
const errorMsg = data?.message || data?.detail?.message || (typeof data?.detail === 'string' ? data.detail : null)
|
||||
|
||||
switch (status) {
|
||||
case 400:
|
||||
message = errorMsg || '请求参数错误'
|
||||
break
|
||||
case 401:
|
||||
message = '登录已过期,请重新登录'
|
||||
// TODO: 跳转登录页
|
||||
break
|
||||
case 403:
|
||||
message = '没有权限访问'
|
||||
break
|
||||
case 404:
|
||||
message = errorMsg || '请求的资源不存在'
|
||||
break
|
||||
case 422:
|
||||
// Pydantic 验证错误
|
||||
message = errorMsg || '请求参数验证失败'
|
||||
break
|
||||
case 500:
|
||||
message = '服务器内部错误'
|
||||
break
|
||||
default:
|
||||
message = errorMsg || `请求失败 (${status})`
|
||||
}
|
||||
} else if (error.code === 'ECONNABORTED') {
|
||||
message = '请求超时,请稍后重试'
|
||||
}
|
||||
|
||||
ElMessage.error(message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 封装请求方法
|
||||
export const request = {
|
||||
get<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
return instance.get(url, config).then((res) => res.data)
|
||||
},
|
||||
|
||||
post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
return instance.post(url, data, config).then((res) => res.data)
|
||||
},
|
||||
|
||||
put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
return instance.put(url, data, config).then((res) => res.data)
|
||||
},
|
||||
|
||||
delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
return instance.delete(url, config).then((res) => res.data)
|
||||
},
|
||||
|
||||
upload<T = any>(url: string, file: File, fieldName = 'file'): Promise<ApiResponse<T>> {
|
||||
const formData = new FormData()
|
||||
formData.append(fieldName, file)
|
||||
return instance.post(url, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
}).then((res) => res.data)
|
||||
},
|
||||
}
|
||||
|
||||
export default instance
|
||||
75
前端应用/src/api/staff-levels.ts
Normal file
75
前端应用/src/api/staff-levels.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 人员级别 API
|
||||
*/
|
||||
|
||||
import { request, PaginatedData } from './request'
|
||||
|
||||
// 人员级别接口类型
|
||||
export interface StaffLevel {
|
||||
id: number
|
||||
level_code: string
|
||||
level_name: string
|
||||
hourly_rate: number
|
||||
is_active: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface StaffLevelCreate {
|
||||
level_code: string
|
||||
level_name: string
|
||||
hourly_rate: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface StaffLevelUpdate {
|
||||
level_code?: string
|
||||
level_name?: string
|
||||
hourly_rate?: number
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
export interface StaffLevelQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
keyword?: string
|
||||
is_active?: boolean
|
||||
}
|
||||
|
||||
// API 方法
|
||||
export const staffLevelApi = {
|
||||
/**
|
||||
* 获取人员级别列表
|
||||
*/
|
||||
getList(params?: StaffLevelQuery) {
|
||||
return request.get<PaginatedData<StaffLevel>>('/staff-levels', { params })
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取单个人员级别
|
||||
*/
|
||||
getById(id: number) {
|
||||
return request.get<StaffLevel>(`/staff-levels/${id}`)
|
||||
},
|
||||
|
||||
/**
|
||||
* 创建人员级别
|
||||
*/
|
||||
create(data: StaffLevelCreate) {
|
||||
return request.post<StaffLevel>('/staff-levels', data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 更新人员级别
|
||||
*/
|
||||
update(id: number, data: StaffLevelUpdate) {
|
||||
return request.put<StaffLevel>(`/staff-levels/${id}`, data)
|
||||
},
|
||||
|
||||
/**
|
||||
* 删除人员级别
|
||||
*/
|
||||
delete(id: number) {
|
||||
return request.delete(`/staff-levels/${id}`)
|
||||
},
|
||||
}
|
||||
10
前端应用/src/assets/logo.svg
Normal file
10
前端应用/src/assets/logo.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<defs>
|
||||
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#409eff;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#67c23a;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="6" fill="url(#grad1)"/>
|
||||
<text x="16" y="22" text-anchor="middle" fill="white" font-size="16" font-weight="bold" font-family="Arial">¥</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 495 B |
33
前端应用/src/main.ts
Normal file
33
前端应用/src/main.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 智能项目定价模型 - 前端入口
|
||||
* 遵循瑞小美技术栈标准:Vue 3 + TypeScript + Vite + pnpm
|
||||
*/
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
// 样式
|
||||
import 'element-plus/dist/index.css'
|
||||
import './styles/index.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// 注册 Element Plus 图标
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
// 使用插件
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(ElementPlus, {
|
||||
locale: zhCn,
|
||||
})
|
||||
|
||||
app.mount('#app')
|
||||
153
前端应用/src/router/index.ts
Normal file
153
前端应用/src/router/index.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Vue Router 配置
|
||||
*/
|
||||
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/views/layout/MainLayout.vue'),
|
||||
redirect: '/dashboard',
|
||||
children: [
|
||||
{
|
||||
path: 'dashboard',
|
||||
name: 'Dashboard',
|
||||
component: () => import('@/views/dashboard/index.vue'),
|
||||
meta: { title: '仪表盘', icon: 'Odometer' },
|
||||
},
|
||||
// 成本核算模块
|
||||
{
|
||||
path: 'cost',
|
||||
name: 'Cost',
|
||||
redirect: '/cost/projects',
|
||||
meta: { title: '成本核算', icon: 'Coin' },
|
||||
children: [
|
||||
{
|
||||
path: 'projects',
|
||||
name: 'CostProjects',
|
||||
component: () => import('@/views/cost/projects/index.vue'),
|
||||
meta: { title: '服务项目', icon: 'List' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// 市场行情模块
|
||||
{
|
||||
path: 'market',
|
||||
name: 'Market',
|
||||
redirect: '/market/competitors',
|
||||
meta: { title: '市场行情', icon: 'TrendCharts' },
|
||||
children: [
|
||||
{
|
||||
path: 'competitors',
|
||||
name: 'Competitors',
|
||||
component: () => import('@/views/market/competitors/index.vue'),
|
||||
meta: { title: '竞品机构', icon: 'OfficeBuilding' },
|
||||
},
|
||||
{
|
||||
path: 'benchmarks',
|
||||
name: 'Benchmarks',
|
||||
component: () => import('@/views/market/benchmarks/index.vue'),
|
||||
meta: { title: '标杆价格', icon: 'PriceTag' },
|
||||
},
|
||||
{
|
||||
path: 'analysis',
|
||||
name: 'MarketAnalysis',
|
||||
component: () => import('@/views/market/analysis/index.vue'),
|
||||
meta: { title: '市场分析', icon: 'DataAnalysis' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// 智能定价模块
|
||||
{
|
||||
path: 'pricing',
|
||||
name: 'Pricing',
|
||||
redirect: '/pricing/plans',
|
||||
meta: { title: '智能定价', icon: 'Money' },
|
||||
children: [
|
||||
{
|
||||
path: 'plans',
|
||||
name: 'PricingPlans',
|
||||
component: () => import('@/views/pricing/index.vue'),
|
||||
meta: { title: '定价方案', icon: 'List' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// 利润模拟模块
|
||||
{
|
||||
path: 'profit',
|
||||
name: 'Profit',
|
||||
redirect: '/profit/simulations',
|
||||
meta: { title: '利润模拟', icon: 'DataLine' },
|
||||
children: [
|
||||
{
|
||||
path: 'simulations',
|
||||
name: 'ProfitSimulations',
|
||||
component: () => import('@/views/profit/index.vue'),
|
||||
meta: { title: '模拟测算', icon: 'DataAnalysis' },
|
||||
},
|
||||
],
|
||||
},
|
||||
// 基础数据管理
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'Settings',
|
||||
redirect: '/settings/categories',
|
||||
meta: { title: '基础数据', icon: 'Setting' },
|
||||
children: [
|
||||
{
|
||||
path: 'categories',
|
||||
name: 'Categories',
|
||||
component: () => import('@/views/settings/categories/index.vue'),
|
||||
meta: { title: '项目分类', icon: 'Menu' },
|
||||
},
|
||||
{
|
||||
path: 'materials',
|
||||
name: 'Materials',
|
||||
component: () => import('@/views/settings/materials/index.vue'),
|
||||
meta: { title: '耗材管理', icon: 'Box' },
|
||||
},
|
||||
{
|
||||
path: 'equipments',
|
||||
name: 'Equipments',
|
||||
component: () => import('@/views/settings/equipments/index.vue'),
|
||||
meta: { title: '设备管理', icon: 'Monitor' },
|
||||
},
|
||||
{
|
||||
path: 'staff-levels',
|
||||
name: 'StaffLevels',
|
||||
component: () => import('@/views/settings/staff-levels/index.vue'),
|
||||
meta: { title: '人员级别', icon: 'User' },
|
||||
},
|
||||
{
|
||||
path: 'fixed-costs',
|
||||
name: 'FixedCosts',
|
||||
component: () => import('@/views/settings/fixed-costs/index.vue'),
|
||||
meta: { title: '固定成本', icon: 'Wallet' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// 404 页面
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import('@/views/error/404.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach((to, _from, next) => {
|
||||
// 设置页面标题
|
||||
const title = to.meta.title as string
|
||||
document.title = title ? `${title} - 智能项目定价模型` : '智能项目定价模型'
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
45
前端应用/src/stores/app.ts
Normal file
45
前端应用/src/stores/app.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* 应用全局状态
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
// 侧边栏折叠状态
|
||||
const sidebarCollapsed = ref(false)
|
||||
|
||||
// 当前激活的菜单
|
||||
const activeMenu = ref('')
|
||||
|
||||
// 面包屑
|
||||
const breadcrumbs = ref<{ title: string; path?: string }[]>([])
|
||||
|
||||
// 切换侧边栏
|
||||
const toggleSidebar = () => {
|
||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||
}
|
||||
|
||||
// 设置激活菜单
|
||||
const setActiveMenu = (menu: string) => {
|
||||
activeMenu.value = menu
|
||||
}
|
||||
|
||||
// 设置面包屑
|
||||
const setBreadcrumbs = (items: { title: string; path?: string }[]) => {
|
||||
breadcrumbs.value = items
|
||||
}
|
||||
|
||||
// 侧边栏宽度
|
||||
const sidebarWidth = computed(() => sidebarCollapsed.value ? '64px' : '220px')
|
||||
|
||||
return {
|
||||
sidebarCollapsed,
|
||||
activeMenu,
|
||||
breadcrumbs,
|
||||
toggleSidebar,
|
||||
setActiveMenu,
|
||||
setBreadcrumbs,
|
||||
sidebarWidth,
|
||||
}
|
||||
})
|
||||
5
前端应用/src/stores/index.ts
Normal file
5
前端应用/src/stores/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Pinia Store 统一导出
|
||||
*/
|
||||
|
||||
export * from './app'
|
||||
73
前端应用/src/styles/index.css
Normal file
73
前端应用/src/styles/index.css
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* 全局样式
|
||||
* Tailwind CSS + Element Plus
|
||||
*/
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 基础样式 */
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font-family: 'PingFang SC', 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* Element Plus 样式覆盖 */
|
||||
.el-menu {
|
||||
border-right: none !important;
|
||||
}
|
||||
|
||||
.el-card {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.el-table {
|
||||
--el-table-border-color: #ebeef5;
|
||||
}
|
||||
|
||||
/* 页面容器 */
|
||||
.page-container {
|
||||
@apply p-6 bg-gray-50 min-h-full;
|
||||
}
|
||||
|
||||
/* 卡片样式 */
|
||||
.card-container {
|
||||
@apply bg-white rounded-lg shadow-sm p-6;
|
||||
}
|
||||
|
||||
/* 表格操作按钮 */
|
||||
.table-actions {
|
||||
@apply flex items-center gap-2;
|
||||
}
|
||||
|
||||
/* 表单标签 */
|
||||
.form-label-required::before {
|
||||
content: '*';
|
||||
color: #f56c6c;
|
||||
margin-right: 4px;
|
||||
}
|
||||
87
前端应用/src/types/auto-imports.d.ts
vendored
Normal file
87
前端应用/src/types/auto-imports.d.ts
vendored
Normal file
@@ -0,0 +1,87 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
// Generated by unplugin-auto-import
|
||||
export {}
|
||||
declare global {
|
||||
const EffectScope: typeof import('vue')['EffectScope']
|
||||
const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate']
|
||||
const computed: typeof import('vue')['computed']
|
||||
const createApp: typeof import('vue')['createApp']
|
||||
const createPinia: typeof import('pinia')['createPinia']
|
||||
const customRef: typeof import('vue')['customRef']
|
||||
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
|
||||
const defineComponent: typeof import('vue')['defineComponent']
|
||||
const defineStore: typeof import('pinia')['defineStore']
|
||||
const effectScope: typeof import('vue')['effectScope']
|
||||
const getActivePinia: typeof import('pinia')['getActivePinia']
|
||||
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
|
||||
const getCurrentScope: typeof import('vue')['getCurrentScope']
|
||||
const h: typeof import('vue')['h']
|
||||
const inject: typeof import('vue')['inject']
|
||||
const isProxy: typeof import('vue')['isProxy']
|
||||
const isReactive: typeof import('vue')['isReactive']
|
||||
const isReadonly: typeof import('vue')['isReadonly']
|
||||
const isRef: typeof import('vue')['isRef']
|
||||
const mapActions: typeof import('pinia')['mapActions']
|
||||
const mapGetters: typeof import('pinia')['mapGetters']
|
||||
const mapState: typeof import('pinia')['mapState']
|
||||
const mapStores: typeof import('pinia')['mapStores']
|
||||
const mapWritableState: typeof import('pinia')['mapWritableState']
|
||||
const markRaw: typeof import('vue')['markRaw']
|
||||
const nextTick: typeof import('vue')['nextTick']
|
||||
const onActivated: typeof import('vue')['onActivated']
|
||||
const onBeforeMount: typeof import('vue')['onBeforeMount']
|
||||
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
|
||||
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
|
||||
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
|
||||
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
|
||||
const onDeactivated: typeof import('vue')['onDeactivated']
|
||||
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
|
||||
const onMounted: typeof import('vue')['onMounted']
|
||||
const onRenderTracked: typeof import('vue')['onRenderTracked']
|
||||
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
|
||||
const onScopeDispose: typeof import('vue')['onScopeDispose']
|
||||
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
|
||||
const onUnmounted: typeof import('vue')['onUnmounted']
|
||||
const onUpdated: typeof import('vue')['onUpdated']
|
||||
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
|
||||
const provide: typeof import('vue')['provide']
|
||||
const reactive: typeof import('vue')['reactive']
|
||||
const readonly: typeof import('vue')['readonly']
|
||||
const ref: typeof import('vue')['ref']
|
||||
const resolveComponent: typeof import('vue')['resolveComponent']
|
||||
const setActivePinia: typeof import('pinia')['setActivePinia']
|
||||
const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix']
|
||||
const shallowReactive: typeof import('vue')['shallowReactive']
|
||||
const shallowReadonly: typeof import('vue')['shallowReadonly']
|
||||
const shallowRef: typeof import('vue')['shallowRef']
|
||||
const storeToRefs: typeof import('pinia')['storeToRefs']
|
||||
const toRaw: typeof import('vue')['toRaw']
|
||||
const toRef: typeof import('vue')['toRef']
|
||||
const toRefs: typeof import('vue')['toRefs']
|
||||
const toValue: typeof import('vue')['toValue']
|
||||
const triggerRef: typeof import('vue')['triggerRef']
|
||||
const unref: typeof import('vue')['unref']
|
||||
const useAttrs: typeof import('vue')['useAttrs']
|
||||
const useCssModule: typeof import('vue')['useCssModule']
|
||||
const useCssVars: typeof import('vue')['useCssVars']
|
||||
const useId: typeof import('vue')['useId']
|
||||
const useLink: typeof import('vue-router')['useLink']
|
||||
const useModel: typeof import('vue')['useModel']
|
||||
const useRoute: typeof import('vue-router')['useRoute']
|
||||
const useRouter: typeof import('vue-router')['useRouter']
|
||||
const useSlots: typeof import('vue')['useSlots']
|
||||
const useTemplateRef: typeof import('vue')['useTemplateRef']
|
||||
const watch: typeof import('vue')['watch']
|
||||
const watchEffect: typeof import('vue')['watchEffect']
|
||||
const watchPostEffect: typeof import('vue')['watchPostEffect']
|
||||
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
|
||||
}
|
||||
// for type re-export
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
|
||||
import('vue')
|
||||
}
|
||||
13
前端应用/src/types/components.d.ts
vendored
Normal file
13
前端应用/src/types/components.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
export {}
|
||||
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
512
前端应用/src/views/cost/projects/CostDetailDialog.vue
Normal file
512
前端应用/src/views/cost/projects/CostDetailDialog.vue
Normal file
@@ -0,0 +1,512 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 成本详情对话框
|
||||
* 显示和编辑项目的成本明细(耗材、设备、人工)
|
||||
*/
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, RefreshRight } from '@element-plus/icons-vue'
|
||||
import {
|
||||
projectApi,
|
||||
Project,
|
||||
ProjectDetail,
|
||||
CostItem,
|
||||
CostItemCreate,
|
||||
LaborCost,
|
||||
LaborCostCreate,
|
||||
CostCalculationResult,
|
||||
AllocationMethod,
|
||||
} from '@/api/projects'
|
||||
import { allocationMethodOptions } from '@/api/fixed-costs'
|
||||
import { materialApi, Material } from '@/api/materials'
|
||||
import { equipmentApi, Equipment } from '@/api/equipments'
|
||||
import { staffLevelApi, StaffLevel } from '@/api/staff-levels'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
project: Project
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:visible', 'close'])
|
||||
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
const projectDetail = ref<ProjectDetail | null>(null)
|
||||
const calculationResult = ref<CostCalculationResult | null>(null)
|
||||
|
||||
// 物料和设备列表
|
||||
const materials = ref<Material[]>([])
|
||||
const equipments = ref<Equipment[]>([])
|
||||
const staffLevels = ref<StaffLevel[]>([])
|
||||
|
||||
// 成本明细表单
|
||||
const costItemDialogVisible = ref(false)
|
||||
const costItemForm = ref<CostItemCreate>({
|
||||
item_type: 'material',
|
||||
item_id: 0,
|
||||
quantity: 1,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
// 人工成本表单
|
||||
const laborCostDialogVisible = ref(false)
|
||||
const laborCostForm = ref<LaborCostCreate>({
|
||||
staff_level_id: 0,
|
||||
duration_minutes: 30,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
// 分摊方式
|
||||
const allocationMethod = ref<AllocationMethod>('count')
|
||||
|
||||
// 计算成本构成数据
|
||||
const costBreakdown = computed(() => {
|
||||
if (!projectDetail.value?.cost_summary) return null
|
||||
const { material_cost, equipment_cost, labor_cost, fixed_cost_allocation, total_cost } = projectDetail.value.cost_summary
|
||||
return [
|
||||
{ name: '耗材成本', value: material_cost },
|
||||
{ name: '设备折旧', value: equipment_cost },
|
||||
{ name: '人工成本', value: labor_cost },
|
||||
{ name: '固定成本', value: fixed_cost_allocation },
|
||||
]
|
||||
})
|
||||
|
||||
// 获取基础数据
|
||||
const fetchBasicData = async () => {
|
||||
try {
|
||||
const [matRes, equipRes, levelRes] = await Promise.all([
|
||||
materialApi.getList({ page_size: 100, is_active: true }),
|
||||
equipmentApi.getList({ page_size: 100, is_active: true }),
|
||||
staffLevelApi.getList({ page_size: 100, is_active: true }),
|
||||
])
|
||||
materials.value = matRes.data?.items || []
|
||||
equipments.value = equipRes.data?.items || []
|
||||
staffLevels.value = levelRes.data?.items || []
|
||||
} catch (error) {
|
||||
console.error('获取基础数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取项目详情
|
||||
const fetchProjectDetail = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await projectApi.getById(props.project.id)
|
||||
projectDetail.value = res.data
|
||||
} catch (error) {
|
||||
console.error('获取项目详情失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 添加成本明细
|
||||
const handleAddCostItem = () => {
|
||||
costItemForm.value = {
|
||||
item_type: 'material',
|
||||
item_id: 0,
|
||||
quantity: 1,
|
||||
remark: '',
|
||||
}
|
||||
costItemDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交成本明细
|
||||
const handleSubmitCostItem = async () => {
|
||||
if (!costItemForm.value.item_id) {
|
||||
ElMessage.warning('请选择耗材或设备')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await projectApi.addCostItem(props.project.id, costItemForm.value)
|
||||
ElMessage.success('添加成功')
|
||||
costItemDialogVisible.value = false
|
||||
fetchProjectDetail()
|
||||
} catch (error) {
|
||||
console.error('添加失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除成本明细
|
||||
const handleDeleteCostItem = async (item: CostItem) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除该成本项吗?`, '提示', { type: 'warning' })
|
||||
await projectApi.deleteCostItem(props.project.id, item.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchProjectDetail()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 添加人工成本
|
||||
const handleAddLaborCost = () => {
|
||||
laborCostForm.value = {
|
||||
staff_level_id: 0,
|
||||
duration_minutes: 30,
|
||||
remark: '',
|
||||
}
|
||||
laborCostDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交人工成本
|
||||
const handleSubmitLaborCost = async () => {
|
||||
if (!laborCostForm.value.staff_level_id) {
|
||||
ElMessage.warning('请选择人员级别')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await projectApi.addLaborCost(props.project.id, laborCostForm.value)
|
||||
ElMessage.success('添加成功')
|
||||
laborCostDialogVisible.value = false
|
||||
fetchProjectDetail()
|
||||
} catch (error) {
|
||||
console.error('添加失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除人工成本
|
||||
const handleDeleteLaborCost = async (item: LaborCost) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除该人工成本项吗?`, '提示', { type: 'warning' })
|
||||
await projectApi.deleteLaborCost(props.project.id, item.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchProjectDetail()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算成本
|
||||
const handleCalculateCost = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await projectApi.calculateCost(props.project.id, {
|
||||
fixed_cost_allocation_method: allocationMethod.value,
|
||||
})
|
||||
calculationResult.value = res.data
|
||||
ElMessage.success('成本计算完成')
|
||||
fetchProjectDetail()
|
||||
} catch (error) {
|
||||
console.error('计算失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 格式化金额
|
||||
const formatMoney = (val: number) => `¥${val.toFixed(2)}`
|
||||
|
||||
// 监听显示状态
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
fetchBasicData()
|
||||
fetchProjectDetail()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 可选物料列表(根据类型筛选)
|
||||
const selectableItems = computed(() => {
|
||||
if (costItemForm.value.item_type === 'material') {
|
||||
return materials.value.map(m => ({ id: m.id, name: m.material_name, unit: m.unit, price: m.unit_price }))
|
||||
} else {
|
||||
return equipments.value.map(e => ({ id: e.id, name: e.equipment_name, unit: '次', price: e.depreciation_per_use }))
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
title="成本详情"
|
||||
width="900px"
|
||||
@update:model-value="$emit('update:visible', $event)"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div v-loading="loading" class="cost-detail">
|
||||
<!-- 项目信息 -->
|
||||
<div class="section">
|
||||
<h4>{{ project.project_name }}</h4>
|
||||
<el-descriptions :column="3" border size="small">
|
||||
<el-descriptions-item label="编码">{{ project.project_code }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分类">{{ project.category_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="时长">{{ project.duration_minutes }}分钟</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 成本明细 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span>成本明细(耗材/设备)</span>
|
||||
<el-button type="primary" size="small" @click="handleAddCostItem">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="projectDetail?.cost_items || []" border size="small">
|
||||
<el-table-column prop="item_type" label="类型" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.item_type === 'material' ? '耗材' : '设备' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="item_name" label="名称" min-width="120" />
|
||||
<el-table-column prop="quantity" label="数量" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.quantity }}{{ row.unit }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit_cost" label="单价" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.unit_cost) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_cost" label="小计" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.total_cost) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link size="small" @click="handleDeleteCostItem(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 人工成本 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span>人工成本</span>
|
||||
<el-button type="primary" size="small" @click="handleAddLaborCost">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
<el-table :data="projectDetail?.labor_costs || []" border size="small">
|
||||
<el-table-column prop="level_name" label="人员级别" min-width="120" />
|
||||
<el-table-column prop="duration_minutes" label="时长" width="100" align="center">
|
||||
<template #default="{ row }">{{ row.duration_minutes }}分钟</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="hourly_rate" label="时薪" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.hourly_rate) }}/小时</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="labor_cost" label="人工成本" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.labor_cost) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link size="small" @click="handleDeleteLaborCost(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- 成本计算 -->
|
||||
<div class="section">
|
||||
<div class="section-header">
|
||||
<span>成本汇总</span>
|
||||
<div class="calc-options">
|
||||
<span>固定成本分摊:</span>
|
||||
<el-select v-model="allocationMethod" size="small" style="width: 120px">
|
||||
<el-option
|
||||
v-for="item in allocationMethodOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" size="small" @click="handleCalculateCost">
|
||||
<el-icon><RefreshRight /></el-icon>
|
||||
计算成本
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="projectDetail?.cost_summary" class="cost-summary">
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<div class="summary-item">
|
||||
<span class="label">耗材成本</span>
|
||||
<span class="value">{{ formatMoney(projectDetail.cost_summary.material_cost) }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-item">
|
||||
<span class="label">设备折旧</span>
|
||||
<span class="value">{{ formatMoney(projectDetail.cost_summary.equipment_cost) }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-item">
|
||||
<span class="label">人工成本</span>
|
||||
<span class="value">{{ formatMoney(projectDetail.cost_summary.labor_cost) }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-item">
|
||||
<span class="label">固定成本分摊</span>
|
||||
<span class="value">{{ formatMoney(projectDetail.cost_summary.fixed_cost_allocation) }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
<div class="total-cost">
|
||||
<span>最低成本线:</span>
|
||||
<span class="total-value">{{ formatMoney(projectDetail.cost_summary.total_cost) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无成本数据,请先添加成本明细并点击「计算成本」" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 添加成本明细对话框 -->
|
||||
<el-dialog v-model="costItemDialogVisible" title="添加成本明细" width="400px" append-to-body>
|
||||
<el-form :model="costItemForm" label-width="80px">
|
||||
<el-form-item label="类型">
|
||||
<el-radio-group v-model="costItemForm.item_type">
|
||||
<el-radio value="material">耗材</el-radio>
|
||||
<el-radio value="equipment">设备</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="选择">
|
||||
<el-select v-model="costItemForm.item_id" placeholder="请选择" filterable>
|
||||
<el-option
|
||||
v-for="item in selectableItems"
|
||||
:key="item.id"
|
||||
:label="`${item.name} (${formatMoney(item.price)}/${item.unit})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="数量">
|
||||
<el-input-number v-model="costItemForm.quantity" :min="0.01" :precision="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="costItemForm.remark" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="costItemDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmitCostItem">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 添加人工成本对话框 -->
|
||||
<el-dialog v-model="laborCostDialogVisible" title="添加人工成本" width="400px" append-to-body>
|
||||
<el-form :model="laborCostForm" label-width="80px">
|
||||
<el-form-item label="人员级别">
|
||||
<el-select v-model="laborCostForm.staff_level_id" placeholder="请选择" filterable>
|
||||
<el-option
|
||||
v-for="item in staffLevels"
|
||||
:key="item.id"
|
||||
:label="`${item.level_name} (${formatMoney(item.hourly_rate)}/小时)`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="时长">
|
||||
<el-input-number v-model="laborCostForm.duration_minutes" :min="1" />
|
||||
<span style="margin-left: 8px; color: #909399;">分钟</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="laborCostForm.remark" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="laborCostDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmitLaborCost">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cost-detail {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.calc-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: normal;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.cost-summary {
|
||||
background: #f5f7fa;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.summary-item .label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.summary-item .value {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.total-cost {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px dashed #dcdfe6;
|
||||
text-align: right;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.total-value {
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #e6a23c;
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
375
前端应用/src/views/cost/projects/index.vue
Normal file
375
前端应用/src/views/cost/projects/index.vue
Normal file
@@ -0,0 +1,375 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 服务项目管理页面(含成本核算)
|
||||
*/
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { View, Plus, Money } from '@element-plus/icons-vue'
|
||||
import { projectApi, Project, ProjectCreate, ProjectUpdate } from '@/api/projects'
|
||||
import { categoryApi } from '@/api/categories'
|
||||
import CostDetailDialog from './CostDetailDialog.vue'
|
||||
|
||||
// 分类列表
|
||||
const categories = ref<{ id: number; category_name: string }[]>([])
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
keyword: '',
|
||||
category_id: undefined as number | undefined,
|
||||
is_active: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
const tableData = ref<Project[]>([])
|
||||
const total = ref(0)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增项目')
|
||||
const formRef = ref()
|
||||
const form = ref<ProjectCreate>({
|
||||
project_code: '',
|
||||
project_name: '',
|
||||
category_id: null,
|
||||
description: '',
|
||||
duration_minutes: 30,
|
||||
is_active: true,
|
||||
})
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
// 成本详情对话框
|
||||
const costDetailVisible = ref(false)
|
||||
const currentProject = ref<Project | null>(null)
|
||||
|
||||
// 表单规则
|
||||
const rules = {
|
||||
project_code: [
|
||||
{ required: true, message: '请输入项目编码', trigger: 'blur' },
|
||||
],
|
||||
project_name: [
|
||||
{ required: true, message: '请输入项目名称', trigger: 'blur' },
|
||||
],
|
||||
duration_minutes: [
|
||||
{ required: true, message: '请输入操作时长', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
// 获取分类
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const res = await categoryApi.getList({ page_size: 100 })
|
||||
categories.value = res.data?.items || []
|
||||
} catch (error) {
|
||||
console.error('获取分类失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await projectApi.getList(queryParams)
|
||||
tableData.value = res.data?.items || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch (error) {
|
||||
console.error('获取项目失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
queryParams.keyword = ''
|
||||
queryParams.category_id = undefined
|
||||
queryParams.is_active = undefined
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 新增
|
||||
const handleAdd = () => {
|
||||
editingId.value = null
|
||||
dialogTitle.value = '新增项目'
|
||||
form.value = {
|
||||
project_code: '',
|
||||
project_name: '',
|
||||
category_id: null,
|
||||
description: '',
|
||||
duration_minutes: 30,
|
||||
is_active: true,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row: Project) => {
|
||||
editingId.value = row.id
|
||||
dialogTitle.value = '编辑项目'
|
||||
form.value = {
|
||||
project_code: row.project_code,
|
||||
project_name: row.project_name,
|
||||
category_id: row.category_id,
|
||||
description: row.description || '',
|
||||
duration_minutes: row.duration_minutes,
|
||||
is_active: row.is_active,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row: Project) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除项目「${row.project_name}」吗?`, '提示', {
|
||||
type: 'warning',
|
||||
})
|
||||
await projectApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查看成本详情
|
||||
const handleViewCost = (row: Project) => {
|
||||
currentProject.value = row
|
||||
costDetailVisible.value = true
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
|
||||
if (editingId.value) {
|
||||
await projectApi.update(editingId.value, form.value as ProjectUpdate)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await projectApi.create(form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handleCurrentChange = (page: number) => {
|
||||
queryParams.page = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
queryParams.page_size = size
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 格式化成本
|
||||
const formatCost = (cost: number | null | undefined) => {
|
||||
if (cost === null || cost === undefined) return '--'
|
||||
return `¥${cost.toFixed(2)}`
|
||||
}
|
||||
|
||||
// 成本详情关闭
|
||||
const handleCostDetailClose = () => {
|
||||
costDetailVisible.value = false
|
||||
currentProject.value = null
|
||||
fetchData() // 刷新列表
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<!-- 搜索栏 -->
|
||||
<el-form :inline="true" class="search-form">
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="编码/名称"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="queryParams.category_id" placeholder="全部" clearable>
|
||||
<el-option
|
||||
v-for="item in categories"
|
||||
:key="item.id"
|
||||
:label="item.category_name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.is_active" placeholder="全部" clearable>
|
||||
<el-option label="启用" :value="true" />
|
||||
<el-option label="禁用" :value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增项目
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table v-loading="loading" :data="tableData" border>
|
||||
<el-table-column prop="project_code" label="编码" width="120" />
|
||||
<el-table-column prop="project_name" label="名称" min-width="150" />
|
||||
<el-table-column prop="category_name" label="分类" width="100" />
|
||||
<el-table-column prop="duration_minutes" label="时长" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.duration_minutes }}分钟
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总成本" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ 'cost-value': row.cost_summary }">
|
||||
{{ formatCost(row.cost_summary?.total_cost) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_active" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
|
||||
{{ row.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleViewCost(row)">
|
||||
<el-icon><Money /></el-icon>
|
||||
成本
|
||||
</el-button>
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 表单对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
|
||||
<el-form-item label="编码" prop="project_code">
|
||||
<el-input v-model="form.project_code" placeholder="请输入编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" prop="project_name">
|
||||
<el-input v-model="form.project_name" placeholder="请输入名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类" prop="category_id">
|
||||
<el-select v-model="form.category_id" placeholder="请选择分类" clearable>
|
||||
<el-option
|
||||
v-for="item in categories"
|
||||
:key="item.id"
|
||||
:label="item.category_name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="时长" prop="duration_minutes">
|
||||
<el-input-number v-model="form.duration_minutes" :min="0" />
|
||||
<span class="unit-label">分钟</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="描述" prop="description">
|
||||
<el-input
|
||||
v-model="form.description"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="请输入描述"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="is_active">
|
||||
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 成本详情对话框 -->
|
||||
<CostDetailDialog
|
||||
v-if="currentProject"
|
||||
v-model:visible="costDetailVisible"
|
||||
:project="currentProject"
|
||||
@close="handleCostDetailClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.cost-value {
|
||||
color: #e6a23c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.unit-label {
|
||||
margin-left: 8px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
608
前端应用/src/views/dashboard/index.vue
Normal file
608
前端应用/src/views/dashboard/index.vue
Normal file
@@ -0,0 +1,608 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 仪表盘页面
|
||||
*/
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import * as echarts from 'echarts'
|
||||
import { dashboardApi, type DashboardSummaryResponse, type CostTrendResponse, type MarketTrendResponse } from '@/api/dashboard'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 统计数据
|
||||
const loading = ref(false)
|
||||
const summary = ref<DashboardSummaryResponse | null>(null)
|
||||
|
||||
// 图表
|
||||
const costChartRef = ref<HTMLElement | null>(null)
|
||||
const marketChartRef = ref<HTMLElement | null>(null)
|
||||
let costChart: echarts.ECharts | null = null
|
||||
let marketChart: echarts.ECharts | null = null
|
||||
|
||||
// 图表加载状态和数据状态
|
||||
const costChartLoading = ref(false)
|
||||
const marketChartLoading = ref(false)
|
||||
const costChartEmpty = ref(false)
|
||||
const marketChartEmpty = ref(false)
|
||||
|
||||
// 加载仪表盘数据
|
||||
const loadDashboard = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await dashboardApi.getSummary()
|
||||
summary.value = res.data
|
||||
} catch (error) {
|
||||
console.error('加载仪表盘数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载成本趋势图表
|
||||
const loadCostTrend = async () => {
|
||||
costChartLoading.value = true
|
||||
costChartEmpty.value = false
|
||||
try {
|
||||
const res = await dashboardApi.getCostTrend('month')
|
||||
if (!res.data?.data || res.data.data.length === 0) {
|
||||
costChartEmpty.value = true
|
||||
} else {
|
||||
renderCostChart(res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载成本趋势失败:', error)
|
||||
costChartEmpty.value = true
|
||||
} finally {
|
||||
costChartLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载市场趋势图表
|
||||
const loadMarketTrend = async () => {
|
||||
marketChartLoading.value = true
|
||||
marketChartEmpty.value = false
|
||||
try {
|
||||
const res = await dashboardApi.getMarketTrend('month')
|
||||
if (!res.data?.data || res.data.data.length === 0) {
|
||||
marketChartEmpty.value = true
|
||||
} else {
|
||||
renderMarketChart(res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载市场趋势失败:', error)
|
||||
marketChartEmpty.value = true
|
||||
} finally {
|
||||
marketChartLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染成本趋势图表
|
||||
const renderCostChart = (data: CostTrendResponse) => {
|
||||
if (!costChartRef.value) return
|
||||
|
||||
if (costChart) costChart.dispose()
|
||||
costChart = echarts.init(costChartRef.value)
|
||||
|
||||
const option: echarts.EChartsOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: '{b}<br/>平均成本: ¥{c}',
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
top: '10%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.data.map((d) => d.date),
|
||||
axisLabel: {
|
||||
rotate: 45,
|
||||
fontSize: 10,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '成本 (元)',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: data.data.map((d) => d.value),
|
||||
smooth: true,
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(64, 158, 255, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(64, 158, 255, 0.05)' },
|
||||
]),
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#409EFF',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
costChart.setOption(option)
|
||||
}
|
||||
|
||||
// 渲染市场趋势图表
|
||||
const renderMarketChart = (data: MarketTrendResponse) => {
|
||||
if (!marketChartRef.value) return
|
||||
|
||||
if (marketChart) marketChart.dispose()
|
||||
marketChart = echarts.init(marketChartRef.value)
|
||||
|
||||
const option: echarts.EChartsOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: '{b}<br/>市场均价: ¥{c}',
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
top: '10%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: data.data.map((d) => d.date),
|
||||
axisLabel: {
|
||||
rotate: 45,
|
||||
fontSize: 10,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
name: '价格 (元)',
|
||||
},
|
||||
series: [
|
||||
{
|
||||
type: 'line',
|
||||
data: data.data.map((d) => d.value),
|
||||
smooth: true,
|
||||
areaStyle: {
|
||||
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{ offset: 0, color: 'rgba(103, 194, 58, 0.3)' },
|
||||
{ offset: 1, color: 'rgba(103, 194, 58, 0.05)' },
|
||||
]),
|
||||
},
|
||||
itemStyle: {
|
||||
color: '#67C23A',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
marketChart.setOption(option)
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
|
||||
return date.toLocaleDateString('zh-CN')
|
||||
}
|
||||
|
||||
// 获取活动类型标签
|
||||
const getActivityLabel = (type: string) => {
|
||||
const labels: Record<string, string> = {
|
||||
'pricing_created': '创建定价方案',
|
||||
'cost_calculated': '计算成本',
|
||||
'market_analysis': '市场分析',
|
||||
'profit_simulated': '利润模拟',
|
||||
}
|
||||
return labels[type] || type
|
||||
}
|
||||
|
||||
// 计算策略百分比
|
||||
const getStrategyPercentage = (type: 'traffic' | 'profit' | 'premium') => {
|
||||
if (!summary.value?.pricing_overview?.strategies_distribution) return 0
|
||||
const dist = summary.value.pricing_overview.strategies_distribution
|
||||
const total = dist.traffic + dist.profit + dist.premium
|
||||
if (total === 0) return 0
|
||||
return (dist[type] / total) * 100
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadDashboard()
|
||||
loadCostTrend()
|
||||
loadMarketTrend()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard" v-loading="loading">
|
||||
<!-- 统计卡片 -->
|
||||
<el-row :gutter="20" class="stat-cards">
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-info">
|
||||
<div class="stat-title">项目总数</div>
|
||||
<div class="stat-value">{{ summary?.project_overview.total_projects || 0 }}</div>
|
||||
<div class="stat-sub">
|
||||
启用: {{ summary?.project_overview.active_projects || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon" style="background-color: #409eff">
|
||||
<el-icon :size="24"><Document /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-info">
|
||||
<div class="stat-title">平均项目成本</div>
|
||||
<div class="stat-value">¥{{ summary?.cost_overview.avg_project_cost?.toFixed(0) || 0 }}</div>
|
||||
<div class="stat-sub">
|
||||
最高: ¥{{ summary?.cost_overview.highest_cost_project?.cost?.toFixed(0) || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon" style="background-color: #67c23a">
|
||||
<el-icon :size="24"><Coin /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-info">
|
||||
<div class="stat-title">跟踪竞品数</div>
|
||||
<div class="stat-value">{{ summary?.market_overview.competitors_tracked || 0 }}</div>
|
||||
<div class="stat-sub">
|
||||
本月记录: {{ summary?.market_overview.price_records_this_month || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon" style="background-color: #e6a23c">
|
||||
<el-icon :size="24"><OfficeBuilding /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :lg="6">
|
||||
<el-card shadow="hover" class="stat-card">
|
||||
<div class="stat-content">
|
||||
<div class="stat-info">
|
||||
<div class="stat-title">定价方案数</div>
|
||||
<div class="stat-value">{{ summary?.pricing_overview.pricing_plans_count || 0 }}</div>
|
||||
<div class="stat-sub">
|
||||
平均毛利率: {{ summary?.pricing_overview.avg_target_margin?.toFixed(1) || 0 }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-icon" style="background-color: #f56c6c">
|
||||
<el-icon :size="24"><Money /></el-icon>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 图表区域 -->
|
||||
<el-row :gutter="20" class="chart-row">
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card shadow="hover" v-loading="costChartLoading">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>成本趋势</span>
|
||||
<el-tag size="small">近30天</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="costChartEmpty" class="chart-empty">
|
||||
<el-empty description="暂无成本数据" :image-size="80" />
|
||||
</div>
|
||||
<div v-else ref="costChartRef" style="height: 280px;"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card shadow="hover" v-loading="marketChartLoading">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>市场价格趋势</span>
|
||||
<el-tag size="small">近30天</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="marketChartEmpty" class="chart-empty">
|
||||
<el-empty description="暂无市场数据" :image-size="80" />
|
||||
</div>
|
||||
<div v-else ref="marketChartRef" style="height: 280px;"></div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 策略分布和快捷操作 -->
|
||||
<el-row :gutter="20" class="info-row">
|
||||
<el-col :xs="24" :lg="8">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>定价策略分布</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="strategy-distribution">
|
||||
<div class="strategy-item">
|
||||
<div class="strategy-label">
|
||||
<el-tag type="warning" size="small">引流款</el-tag>
|
||||
</div>
|
||||
<div class="strategy-bar">
|
||||
<div
|
||||
class="bar-fill traffic"
|
||||
:style="{ width: getStrategyPercentage('traffic') + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="strategy-count">{{ summary?.pricing_overview.strategies_distribution.traffic || 0 }}</div>
|
||||
</div>
|
||||
<div class="strategy-item">
|
||||
<div class="strategy-label">
|
||||
<el-tag size="small">利润款</el-tag>
|
||||
</div>
|
||||
<div class="strategy-bar">
|
||||
<div
|
||||
class="bar-fill profit"
|
||||
:style="{ width: getStrategyPercentage('profit') + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="strategy-count">{{ summary?.pricing_overview.strategies_distribution.profit || 0 }}</div>
|
||||
</div>
|
||||
<div class="strategy-item">
|
||||
<div class="strategy-label">
|
||||
<el-tag type="success" size="small">高端款</el-tag>
|
||||
</div>
|
||||
<div class="strategy-bar">
|
||||
<div
|
||||
class="bar-fill premium"
|
||||
:style="{ width: getStrategyPercentage('premium') + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="strategy-count">{{ summary?.pricing_overview.strategies_distribution.premium || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="8">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>快捷操作</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="quick-actions">
|
||||
<el-button type="primary" @click="router.push('/pricing/plans')">
|
||||
<el-icon><MagicStick /></el-icon>
|
||||
AI 智能定价
|
||||
</el-button>
|
||||
<el-button @click="router.push('/profit/simulations')">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
利润模拟
|
||||
</el-button>
|
||||
<el-button @click="router.push('/market/analysis')">
|
||||
<el-icon><TrendCharts /></el-icon>
|
||||
市场分析
|
||||
</el-button>
|
||||
<el-button @click="router.push('/cost/projects')">
|
||||
<el-icon><Coin /></el-icon>
|
||||
成本核算
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="8">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>最近活动</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="recent-activities">
|
||||
<template v-if="summary?.recent_activities?.length">
|
||||
<div
|
||||
v-for="(activity, idx) in summary.recent_activities.slice(0, 5)"
|
||||
:key="idx"
|
||||
class="activity-item"
|
||||
>
|
||||
<div class="activity-content">
|
||||
<span class="activity-type">{{ getActivityLabel(activity.type) }}</span>
|
||||
<span class="activity-project">{{ activity.project_name }}</span>
|
||||
</div>
|
||||
<div class="activity-time">{{ formatTime(activity.time) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-empty v-else description="暂无活动记录" :image-size="60" />
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.dashboard {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stat-cards {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stat-sub {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.chart-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-row .el-card {
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.chart-empty {
|
||||
height: 280px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.info-row .el-card {
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.strategy-distribution {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.strategy-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.strategy-label {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.strategy-bar {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.bar-fill.traffic {
|
||||
background-color: #e6a23c;
|
||||
}
|
||||
|
||||
.bar-fill.profit {
|
||||
background-color: #409eff;
|
||||
}
|
||||
|
||||
.bar-fill.premium {
|
||||
background-color: #67c23a;
|
||||
}
|
||||
|
||||
.strategy-count {
|
||||
width: 30px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.quick-actions .el-button {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.recent-activities {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.activity-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.activity-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.activity-type {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.activity-project {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
52
前端应用/src/views/error/404.vue
Normal file
52
前端应用/src/views/error/404.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 404 页面
|
||||
*/
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const goHome = () => {
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="error-page">
|
||||
<div class="error-content">
|
||||
<div class="error-code">404</div>
|
||||
<div class="error-message">抱歉,您访问的页面不存在</div>
|
||||
<el-button type="primary" @click="goHome">
|
||||
返回首页
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.error-page {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 120px;
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
line-height: 1;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 18px;
|
||||
color: #606266;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
</style>
|
||||
222
前端应用/src/views/layout/MainLayout.vue
Normal file
222
前端应用/src/views/layout/MainLayout.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 主布局组件
|
||||
* 包含侧边栏导航、顶部栏和主内容区
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import SideMenu from './components/SideMenu.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 当前路由信息
|
||||
const currentRoute = computed(() => route.path)
|
||||
|
||||
// 面包屑导航
|
||||
const breadcrumbs = computed(() => {
|
||||
const matched = route.matched.filter((item) => item.meta?.title)
|
||||
return matched.map((item) => ({
|
||||
title: item.meta?.title as string,
|
||||
path: item.path,
|
||||
}))
|
||||
})
|
||||
|
||||
// 跳转面包屑
|
||||
const handleBreadcrumbClick = (path: string) => {
|
||||
if (path && path !== route.path) {
|
||||
router.push(path)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="main-layout">
|
||||
<!-- 侧边栏 -->
|
||||
<el-aside :width="appStore.sidebarWidth" class="sidebar">
|
||||
<div class="logo">
|
||||
<img src="@/assets/logo.svg" alt="logo" class="logo-img" />
|
||||
<span v-if="!appStore.sidebarCollapsed" class="logo-text">智能定价模型</span>
|
||||
</div>
|
||||
<SideMenu :collapsed="appStore.sidebarCollapsed" />
|
||||
</el-aside>
|
||||
|
||||
<el-container class="main-container">
|
||||
<!-- 顶部栏 -->
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<!-- 折叠按钮 -->
|
||||
<el-icon
|
||||
class="collapse-btn"
|
||||
@click="appStore.toggleSidebar"
|
||||
>
|
||||
<Fold v-if="!appStore.sidebarCollapsed" />
|
||||
<Expand v-else />
|
||||
</el-icon>
|
||||
|
||||
<!-- 面包屑 -->
|
||||
<el-breadcrumb separator="/">
|
||||
<el-breadcrumb-item
|
||||
v-for="(item, index) in breadcrumbs"
|
||||
:key="index"
|
||||
>
|
||||
<span
|
||||
:class="{ 'breadcrumb-link': index < breadcrumbs.length - 1 }"
|
||||
@click="handleBreadcrumbClick(item.path || '')"
|
||||
>
|
||||
{{ item.title }}
|
||||
</span>
|
||||
</el-breadcrumb-item>
|
||||
</el-breadcrumb>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<!-- 用户信息 -->
|
||||
<el-dropdown trigger="click">
|
||||
<div class="user-info">
|
||||
<el-avatar :size="32" icon="User" />
|
||||
<span class="username">管理员</span>
|
||||
<el-icon><ArrowDown /></el-icon>
|
||||
</div>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item>个人设置</el-dropdown-item>
|
||||
<el-dropdown-item divided>退出登录</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<el-main class="main-content">
|
||||
<router-view v-slot="{ Component }">
|
||||
<transition name="fade" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main-layout {
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: #304156;
|
||||
transition: width 0.3s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
background-color: #263445;
|
||||
}
|
||||
|
||||
.logo-img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-left: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.main-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
height: 60px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
box-shadow: 0 1px 4px rgba(0, 21, 41, 0.08);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: #606266;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.collapse-btn:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.breadcrumb-link {
|
||||
cursor: pointer;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.breadcrumb-link:hover {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.user-info:hover {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background-color: #f5f7fa;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 页面切换动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
146
前端应用/src/views/layout/components/SideMenu.vue
Normal file
146
前端应用/src/views/layout/components/SideMenu.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 侧边栏菜单组件
|
||||
*/
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
interface Props {
|
||||
collapsed: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// 当前激活的菜单
|
||||
const activeMenu = computed(() => {
|
||||
const { path } = route
|
||||
return path
|
||||
})
|
||||
|
||||
// 菜单数据
|
||||
const menuItems = [
|
||||
{
|
||||
index: '/dashboard',
|
||||
icon: 'Odometer',
|
||||
title: '仪表盘',
|
||||
},
|
||||
{
|
||||
index: '/cost',
|
||||
icon: 'Coin',
|
||||
title: '成本核算',
|
||||
children: [
|
||||
{ index: '/cost/projects', icon: 'List', title: '服务项目' },
|
||||
],
|
||||
},
|
||||
{
|
||||
index: '/market',
|
||||
icon: 'TrendCharts',
|
||||
title: '市场行情',
|
||||
children: [
|
||||
{ index: '/market/competitors', icon: 'OfficeBuilding', title: '竞品机构' },
|
||||
{ index: '/market/benchmarks', icon: 'PriceTag', title: '标杆价格' },
|
||||
{ index: '/market/analysis', icon: 'DataAnalysis', title: '市场分析' },
|
||||
],
|
||||
},
|
||||
{
|
||||
index: '/pricing',
|
||||
icon: 'Money',
|
||||
title: '智能定价',
|
||||
children: [
|
||||
{ index: '/pricing/plans', icon: 'List', title: '定价方案' },
|
||||
],
|
||||
},
|
||||
{
|
||||
index: '/profit',
|
||||
icon: 'DataLine',
|
||||
title: '利润模拟',
|
||||
children: [
|
||||
{ index: '/profit/simulations', icon: 'DataAnalysis', title: '模拟测算' },
|
||||
],
|
||||
},
|
||||
{
|
||||
index: '/settings',
|
||||
icon: 'Setting',
|
||||
title: '基础数据',
|
||||
children: [
|
||||
{ index: '/settings/categories', icon: 'Menu', title: '项目分类' },
|
||||
{ index: '/settings/materials', icon: 'Box', title: '耗材管理' },
|
||||
{ index: '/settings/equipments', icon: 'Monitor', title: '设备管理' },
|
||||
{ index: '/settings/staff-levels', icon: 'User', title: '人员级别' },
|
||||
{ index: '/settings/fixed-costs', icon: 'Wallet', title: '固定成本' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// 菜单点击
|
||||
const handleMenuSelect = (index: string) => {
|
||||
router.push(index)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
:collapse="collapsed"
|
||||
:collapse-transition="false"
|
||||
background-color="#304156"
|
||||
text-color="#bfcbd9"
|
||||
active-text-color="#409eff"
|
||||
class="side-menu"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<template v-for="item in menuItems" :key="item.index">
|
||||
<!-- 有子菜单 -->
|
||||
<el-sub-menu v-if="item.children" :index="item.index">
|
||||
<template #title>
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
</template>
|
||||
<el-menu-item
|
||||
v-for="child in item.children"
|
||||
:key="child.index"
|
||||
:index="child.index"
|
||||
>
|
||||
<el-icon><component :is="child.icon" /></el-icon>
|
||||
<span>{{ child.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<!-- 无子菜单 -->
|
||||
<el-menu-item v-else :index="item.index">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.side-menu {
|
||||
border-right: none;
|
||||
height: calc(100vh - 60px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.side-menu:not(.el-menu--collapse) {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.side-menu::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.side-menu::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.side-menu::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
381
前端应用/src/views/market/analysis/index.vue
Normal file
381
前端应用/src/views/market/analysis/index.vue
Normal file
@@ -0,0 +1,381 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 市场分析页面
|
||||
*/
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { DataAnalysis, Refresh } from '@element-plus/icons-vue'
|
||||
import { marketAnalysisApi, MarketAnalysisResult } from '@/api/market-analysis'
|
||||
import { projectApi, Project } from '@/api/projects'
|
||||
|
||||
// 项目列表
|
||||
const projects = ref<Project[]>([])
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
|
||||
// 分析结果
|
||||
const loading = ref(false)
|
||||
const analysisResult = ref<MarketAnalysisResult | null>(null)
|
||||
|
||||
// 获取项目列表
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const res = await projectApi.getList({ page_size: 100, is_active: true })
|
||||
projects.value = res.data?.items || []
|
||||
} catch (error) {
|
||||
console.error('获取项目失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 执行分析
|
||||
const handleAnalyze = async () => {
|
||||
if (!selectedProjectId.value) {
|
||||
ElMessage.warning('请选择项目')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await marketAnalysisApi.analyze(selectedProjectId.value)
|
||||
analysisResult.value = res.data
|
||||
ElMessage.success('分析完成')
|
||||
} catch (error) {
|
||||
console.error('分析失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化金额
|
||||
const formatMoney = (val: number) => `¥${val.toFixed(2)}`
|
||||
|
||||
// 获取定位标签颜色
|
||||
const getPositioningType = (positioning: string) => {
|
||||
const map: Record<string, string> = {
|
||||
high: 'danger',
|
||||
medium: '',
|
||||
budget: 'success',
|
||||
}
|
||||
return map[positioning] || ''
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchProjects()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<!-- 选择项目 -->
|
||||
<div class="select-section">
|
||||
<span>选择项目:</span>
|
||||
<el-select
|
||||
v-model="selectedProjectId"
|
||||
placeholder="请选择要分析的项目"
|
||||
filterable
|
||||
style="width: 300px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in projects"
|
||||
:key="item.id"
|
||||
:label="item.project_name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button type="primary" :loading="loading" @click="handleAnalyze">
|
||||
<el-icon><DataAnalysis /></el-icon>
|
||||
执行分析
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 分析结果 -->
|
||||
<template v-if="analysisResult">
|
||||
<el-divider />
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<div class="section">
|
||||
<h4>{{ analysisResult.project_name }}</h4>
|
||||
<el-descriptions :column="3" border size="small">
|
||||
<el-descriptions-item label="分析日期">{{ analysisResult.analysis_date }}</el-descriptions-item>
|
||||
<el-descriptions-item label="样本数量">{{ analysisResult.competitor_count }}条</el-descriptions-item>
|
||||
<el-descriptions-item label="建议价格">
|
||||
<span class="recommend-price">{{ formatMoney(analysisResult.suggested_range.recommended) }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 价格统计 -->
|
||||
<div class="section">
|
||||
<h4>价格统计</h4>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<span class="label">市场最低价</span>
|
||||
<span class="value">{{ formatMoney(analysisResult.price_statistics.min_price) }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<span class="label">市场最高价</span>
|
||||
<span class="value">{{ formatMoney(analysisResult.price_statistics.max_price) }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<span class="label">市场均价</span>
|
||||
<span class="value highlight">{{ formatMoney(analysisResult.price_statistics.avg_price) }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="stat-card">
|
||||
<span class="label">市场中位价</span>
|
||||
<span class="value">{{ formatMoney(analysisResult.price_statistics.median_price) }}</span>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 价格分布 -->
|
||||
<div v-if="analysisResult.price_distribution" class="section">
|
||||
<h4>价格分布</h4>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="8">
|
||||
<div class="distribution-item low">
|
||||
<div class="dist-label">低价位 {{ analysisResult.price_distribution.low.range }}</div>
|
||||
<div class="dist-bar">
|
||||
<div
|
||||
class="dist-fill"
|
||||
:style="{ width: `${analysisResult.price_distribution.low.percentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="dist-value">{{ analysisResult.price_distribution.low.count }}条 ({{ analysisResult.price_distribution.low.percentage }}%)</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="distribution-item medium">
|
||||
<div class="dist-label">中价位 {{ analysisResult.price_distribution.medium.range }}</div>
|
||||
<div class="dist-bar">
|
||||
<div
|
||||
class="dist-fill"
|
||||
:style="{ width: `${analysisResult.price_distribution.medium.percentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="dist-value">{{ analysisResult.price_distribution.medium.count }}条 ({{ analysisResult.price_distribution.medium.percentage }}%)</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<div class="distribution-item high">
|
||||
<div class="dist-label">高价位 {{ analysisResult.price_distribution.high.range }}</div>
|
||||
<div class="dist-bar">
|
||||
<div
|
||||
class="dist-fill"
|
||||
:style="{ width: `${analysisResult.price_distribution.high.percentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="dist-value">{{ analysisResult.price_distribution.high.count }}条 ({{ analysisResult.price_distribution.high.percentage }}%)</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 建议定价区间 -->
|
||||
<div class="section">
|
||||
<h4>建议定价区间</h4>
|
||||
<div class="price-range">
|
||||
<div class="range-bar">
|
||||
<div class="range-min">{{ formatMoney(analysisResult.suggested_range.min) }}</div>
|
||||
<div class="range-fill"></div>
|
||||
<div class="range-recommend">
|
||||
<div class="recommend-marker"></div>
|
||||
<span>推荐: {{ formatMoney(analysisResult.suggested_range.recommended) }}</span>
|
||||
</div>
|
||||
<div class="range-max">{{ formatMoney(analysisResult.suggested_range.max) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标杆参考 -->
|
||||
<div v-if="analysisResult.benchmark_reference" class="section">
|
||||
<h4>标杆参考</h4>
|
||||
<el-descriptions :column="4" border size="small">
|
||||
<el-descriptions-item label="价格带">
|
||||
<el-tag size="small">{{ analysisResult.benchmark_reference.tier }}</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最低价">{{ formatMoney(analysisResult.benchmark_reference.min_price) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最高价">{{ formatMoney(analysisResult.benchmark_reference.max_price) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="均价">{{ formatMoney(analysisResult.benchmark_reference.avg_price) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<!-- 竞品价格列表 -->
|
||||
<div class="section">
|
||||
<h4>竞品价格明细</h4>
|
||||
<el-table :data="analysisResult.competitor_prices" border size="small" max-height="300">
|
||||
<el-table-column prop="competitor_name" label="竞品机构" min-width="120" />
|
||||
<el-table-column prop="positioning" label="定位" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getPositioningType(row.positioning)" size="small">
|
||||
{{ row.positioning === 'high' ? '高端' : row.positioning === 'medium' ? '中端' : '大众' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="original_price" label="原价" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.original_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="promo_price" label="促销价" width="100" align="right">
|
||||
<template #default="{ row }">{{ row.promo_price ? formatMoney(row.promo_price) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="collected_at" label="采集日期" width="110" align="center" />
|
||||
</el-table>
|
||||
<el-empty v-if="analysisResult.competitor_prices.length === 0" description="暂无竞品价格数据" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-empty v-else-if="!loading" description="请选择项目并执行分析" />
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.select-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.section h4 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 15px;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #f5f7fa;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-card .label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stat-card .value.highlight {
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.recommend-price {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #e6a23c;
|
||||
}
|
||||
|
||||
.distribution-item {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
.distribution-item .dist-label {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.distribution-item .dist-bar {
|
||||
height: 8px;
|
||||
background: #e4e7ed;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.distribution-item .dist-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.distribution-item.low .dist-fill {
|
||||
background: #67c23a;
|
||||
}
|
||||
|
||||
.distribution-item.medium .dist-fill {
|
||||
background: #409eff;
|
||||
}
|
||||
|
||||
.distribution-item.high .dist-fill {
|
||||
background: #e6a23c;
|
||||
}
|
||||
|
||||
.distribution-item .dist-value {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.price-range {
|
||||
background: #f5f7fa;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.range-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.range-fill {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: linear-gradient(to right, #67c23a, #409eff, #e6a23c);
|
||||
margin: 0 12px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.range-min,
|
||||
.range-max {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.range-recommend {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
top: -24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.recommend-marker {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #e6a23c;
|
||||
border-radius: 50%;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.range-recommend span {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #e6a23c;
|
||||
}
|
||||
</style>
|
||||
350
前端应用/src/views/market/benchmarks/index.vue
Normal file
350
前端应用/src/views/market/benchmarks/index.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 标杆价格管理页面
|
||||
*/
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { benchmarkPriceApi, BenchmarkPrice, BenchmarkPriceCreate, BenchmarkPriceUpdate, priceTierOptions } from '@/api/benchmark-prices'
|
||||
import { categoryApi } from '@/api/categories'
|
||||
|
||||
// 分类列表
|
||||
const categories = ref<{ id: number; category_name: string }[]>([])
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
category_id: undefined as number | undefined,
|
||||
})
|
||||
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
const tableData = ref<BenchmarkPrice[]>([])
|
||||
const total = ref(0)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增标杆价格')
|
||||
const formRef = ref()
|
||||
const form = ref<BenchmarkPriceCreate>({
|
||||
benchmark_name: '',
|
||||
category_id: null,
|
||||
min_price: 0,
|
||||
max_price: 0,
|
||||
avg_price: 0,
|
||||
price_tier: 'medium',
|
||||
effective_date: new Date().toISOString().split('T')[0],
|
||||
remark: '',
|
||||
})
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
// 表单规则
|
||||
const rules = {
|
||||
benchmark_name: [
|
||||
{ required: true, message: '请输入标杆机构名称', trigger: 'blur' },
|
||||
],
|
||||
min_price: [
|
||||
{ required: true, message: '请输入最低价', trigger: 'blur' },
|
||||
],
|
||||
max_price: [
|
||||
{ required: true, message: '请输入最高价', trigger: 'blur' },
|
||||
],
|
||||
avg_price: [
|
||||
{ required: true, message: '请输入均价', trigger: 'blur' },
|
||||
],
|
||||
effective_date: [
|
||||
{ required: true, message: '请选择生效日期', trigger: 'change' },
|
||||
],
|
||||
}
|
||||
|
||||
// 获取分类
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const res = await categoryApi.getList({ page_size: 100 })
|
||||
categories.value = res.data?.items || []
|
||||
} catch (error) {
|
||||
console.error('获取分类失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await benchmarkPriceApi.getList(queryParams)
|
||||
tableData.value = res.data?.items || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch (error) {
|
||||
console.error('获取标杆价格失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
queryParams.category_id = undefined
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 新增
|
||||
const handleAdd = () => {
|
||||
editingId.value = null
|
||||
dialogTitle.value = '新增标杆价格'
|
||||
form.value = {
|
||||
benchmark_name: '',
|
||||
category_id: null,
|
||||
min_price: 0,
|
||||
max_price: 0,
|
||||
avg_price: 0,
|
||||
price_tier: 'medium',
|
||||
effective_date: new Date().toISOString().split('T')[0],
|
||||
remark: '',
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row: BenchmarkPrice) => {
|
||||
editingId.value = row.id
|
||||
dialogTitle.value = '编辑标杆价格'
|
||||
form.value = {
|
||||
benchmark_name: row.benchmark_name,
|
||||
category_id: row.category_id,
|
||||
min_price: row.min_price,
|
||||
max_price: row.max_price,
|
||||
avg_price: row.avg_price,
|
||||
price_tier: row.price_tier,
|
||||
effective_date: row.effective_date,
|
||||
remark: row.remark || '',
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row: BenchmarkPrice) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除该标杆价格吗?`, '提示', {
|
||||
type: 'warning',
|
||||
})
|
||||
await benchmarkPriceApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
|
||||
if (editingId.value) {
|
||||
await benchmarkPriceApi.update(editingId.value, form.value as BenchmarkPriceUpdate)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await benchmarkPriceApi.create(form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handleCurrentChange = (page: number) => {
|
||||
queryParams.page = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
queryParams.page_size = size
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 获取价格带标签
|
||||
const getTierLabel = (tier: string) => {
|
||||
return priceTierOptions.find(item => item.value === tier)?.label || tier
|
||||
}
|
||||
|
||||
const getTierType = (tier: string) => {
|
||||
const map: Record<string, string> = {
|
||||
low: 'success',
|
||||
medium: '',
|
||||
high: 'warning',
|
||||
premium: 'danger',
|
||||
}
|
||||
return map[tier] || ''
|
||||
}
|
||||
|
||||
// 格式化金额
|
||||
const formatMoney = (val: number) => `¥${val.toFixed(2)}`
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchCategories()
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<!-- 搜索栏 -->
|
||||
<el-form :inline="true" class="search-form">
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="queryParams.category_id" placeholder="全部" clearable>
|
||||
<el-option
|
||||
v-for="item in categories"
|
||||
:key="item.id"
|
||||
:label="item.category_name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增标杆价格
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table v-loading="loading" :data="tableData" border>
|
||||
<el-table-column prop="benchmark_name" label="标杆机构" min-width="150" />
|
||||
<el-table-column prop="category_name" label="分类" width="100" />
|
||||
<el-table-column prop="price_tier" label="价格带" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getTierType(row.price_tier)" size="small">
|
||||
{{ getTierLabel(row.price_tier) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="min_price" label="最低价" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.min_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="max_price" label="最高价" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.max_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="avg_price" label="均价" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.avg_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="effective_date" label="生效日期" width="110" align="center" />
|
||||
<el-table-column label="操作" width="140" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 表单对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
|
||||
<el-form-item label="标杆机构" prop="benchmark_name">
|
||||
<el-input v-model="form.benchmark_name" placeholder="请输入标杆机构名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类" prop="category_id">
|
||||
<el-select v-model="form.category_id" placeholder="请选择分类" clearable>
|
||||
<el-option
|
||||
v-for="item in categories"
|
||||
:key="item.id"
|
||||
:label="item.category_name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="价格带" prop="price_tier">
|
||||
<el-select v-model="form.price_tier">
|
||||
<el-option
|
||||
v-for="item in priceTierOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="最低价" prop="min_price">
|
||||
<el-input-number v-model="form.min_price" :min="0" :precision="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最高价" prop="max_price">
|
||||
<el-input-number v-model="form.max_price" :min="0" :precision="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="均价" prop="avg_price">
|
||||
<el-input-number v-model="form.avg_price" :min="0" :precision="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="生效日期" prop="effective_date">
|
||||
<el-date-picker
|
||||
v-model="form.effective_date"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="选择日期"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注" prop="remark">
|
||||
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
268
前端应用/src/views/market/competitors/PricesDialog.vue
Normal file
268
前端应用/src/views/market/competitors/PricesDialog.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 竞品价格管理对话框
|
||||
*/
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import {
|
||||
competitorApi,
|
||||
Competitor,
|
||||
CompetitorPrice,
|
||||
CompetitorPriceCreate,
|
||||
priceSourceOptions,
|
||||
} from '@/api/competitors'
|
||||
import { projectApi, Project } from '@/api/projects'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
competitor: Competitor
|
||||
}>()
|
||||
|
||||
const emit = defineEmits(['update:visible', 'close'])
|
||||
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
const prices = ref<CompetitorPrice[]>([])
|
||||
const projects = ref<Project[]>([])
|
||||
|
||||
// 表单
|
||||
const addDialogVisible = ref(false)
|
||||
const form = ref<CompetitorPriceCreate>({
|
||||
project_id: null,
|
||||
project_name: '',
|
||||
original_price: 0,
|
||||
promo_price: null,
|
||||
member_price: null,
|
||||
price_source: 'survey',
|
||||
collected_at: new Date().toISOString().split('T')[0],
|
||||
remark: '',
|
||||
})
|
||||
|
||||
// 获取项目列表
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
const res = await projectApi.getList({ page_size: 100, is_active: true })
|
||||
projects.value = res.data?.items || []
|
||||
} catch (error) {
|
||||
console.error('获取项目失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取价格列表
|
||||
const fetchPrices = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await competitorApi.getPrices(props.competitor.id)
|
||||
prices.value = res.data || []
|
||||
} catch (error) {
|
||||
console.error('获取价格失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 添加价格
|
||||
const handleAdd = () => {
|
||||
form.value = {
|
||||
project_id: null,
|
||||
project_name: '',
|
||||
original_price: 0,
|
||||
promo_price: null,
|
||||
member_price: null,
|
||||
price_source: 'survey',
|
||||
collected_at: new Date().toISOString().split('T')[0],
|
||||
remark: '',
|
||||
}
|
||||
addDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交价格
|
||||
const handleSubmit = async () => {
|
||||
if (!form.value.project_name) {
|
||||
ElMessage.warning('请输入项目名称')
|
||||
return
|
||||
}
|
||||
if (!form.value.original_price) {
|
||||
ElMessage.warning('请输入原价')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await competitorApi.addPrice(props.competitor.id, form.value)
|
||||
ElMessage.success('添加成功')
|
||||
addDialogVisible.value = false
|
||||
fetchPrices()
|
||||
} catch (error) {
|
||||
console.error('添加失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除价格
|
||||
const handleDelete = async (row: CompetitorPrice) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除该价格记录吗?`, '提示', { type: 'warning' })
|
||||
await competitorApi.deletePrice(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchPrices()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// 格式化金额
|
||||
const formatMoney = (val: number | null) => {
|
||||
if (val === null) return '-'
|
||||
return `¥${val.toFixed(2)}`
|
||||
}
|
||||
|
||||
// 获取来源标签
|
||||
const getSourceLabel = (source: string) => {
|
||||
return priceSourceOptions.find(item => item.value === source)?.label || source
|
||||
}
|
||||
|
||||
// 选择项目后自动填充名称
|
||||
const handleProjectSelect = (projectId: number | null) => {
|
||||
if (projectId) {
|
||||
const project = projects.value.find(p => p.id === projectId)
|
||||
if (project) {
|
||||
form.value.project_name = project.project_name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听显示
|
||||
watch(
|
||||
() => props.visible,
|
||||
(val) => {
|
||||
if (val) {
|
||||
fetchProjects()
|
||||
fetchPrices()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="`竞品价格 - ${competitor.competitor_name}`"
|
||||
width="800px"
|
||||
@update:model-value="$emit('update:visible', $event)"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div v-loading="loading">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
添加价格
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-table :data="prices" border size="small" max-height="400">
|
||||
<el-table-column prop="project_name" label="项目名称" min-width="120" />
|
||||
<el-table-column prop="original_price" label="原价" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.original_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="promo_price" label="促销价" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.promo_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="member_price" label="会员价" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.member_price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="price_source" label="来源" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ getSourceLabel(row.price_source) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="collected_at" label="采集日期" width="110" align="center" />
|
||||
<el-table-column label="操作" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="prices.length === 0" description="暂无价格记录" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">关闭</el-button>
|
||||
</template>
|
||||
|
||||
<!-- 添加价格对话框 -->
|
||||
<el-dialog v-model="addDialogVisible" title="添加价格" width="450px" append-to-body>
|
||||
<el-form :model="form" label-width="80px">
|
||||
<el-form-item label="关联项目">
|
||||
<el-select
|
||||
v-model="form.project_id"
|
||||
placeholder="选择本店项目(可选)"
|
||||
clearable
|
||||
filterable
|
||||
@change="handleProjectSelect"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in projects"
|
||||
:key="item.id"
|
||||
:label="item.project_name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目名称" required>
|
||||
<el-input v-model="form.project_name" placeholder="竞品项目名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="原价" required>
|
||||
<el-input-number v-model="form.original_price" :min="0" :precision="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="促销价">
|
||||
<el-input-number v-model="form.promo_price" :min="0" :precision="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="会员价">
|
||||
<el-input-number v-model="form.member_price" :min="0" :precision="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="来源" required>
|
||||
<el-select v-model="form.price_source">
|
||||
<el-option
|
||||
v-for="item in priceSourceOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="采集日期" required>
|
||||
<el-date-picker
|
||||
v-model="form.collected_at"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="选择日期"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.remark" placeholder="选填" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="addDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
369
前端应用/src/views/market/competitors/index.vue
Normal file
369
前端应用/src/views/market/competitors/index.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 竞品机构管理页面
|
||||
*/
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Plus, PriceTag } from '@element-plus/icons-vue'
|
||||
import { competitorApi, Competitor, CompetitorCreate, CompetitorUpdate, positioningOptions } from '@/api/competitors'
|
||||
import PricesDialog from './PricesDialog.vue'
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
keyword: '',
|
||||
positioning: undefined as string | undefined,
|
||||
is_key_competitor: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
const tableData = ref<Competitor[]>([])
|
||||
const total = ref(0)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增竞品')
|
||||
const formRef = ref()
|
||||
const form = ref<CompetitorCreate>({
|
||||
competitor_name: '',
|
||||
address: '',
|
||||
distance_km: null,
|
||||
positioning: 'medium',
|
||||
contact: '',
|
||||
is_key_competitor: false,
|
||||
is_active: true,
|
||||
})
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
// 竞品价格对话框
|
||||
const pricesDialogVisible = ref(false)
|
||||
const currentCompetitor = ref<Competitor | null>(null)
|
||||
|
||||
// 表单规则
|
||||
const rules = {
|
||||
competitor_name: [
|
||||
{ required: true, message: '请输入机构名称', trigger: 'blur' },
|
||||
],
|
||||
positioning: [
|
||||
{ required: true, message: '请选择定位', trigger: 'change' },
|
||||
],
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await competitorApi.getList(queryParams)
|
||||
tableData.value = res.data?.items || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch (error) {
|
||||
console.error('获取竞品失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
queryParams.keyword = ''
|
||||
queryParams.positioning = undefined
|
||||
queryParams.is_key_competitor = undefined
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 新增
|
||||
const handleAdd = () => {
|
||||
editingId.value = null
|
||||
dialogTitle.value = '新增竞品'
|
||||
form.value = {
|
||||
competitor_name: '',
|
||||
address: '',
|
||||
distance_km: null,
|
||||
positioning: 'medium',
|
||||
contact: '',
|
||||
is_key_competitor: false,
|
||||
is_active: true,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row: Competitor) => {
|
||||
editingId.value = row.id
|
||||
dialogTitle.value = '编辑竞品'
|
||||
form.value = {
|
||||
competitor_name: row.competitor_name,
|
||||
address: row.address || '',
|
||||
distance_km: row.distance_km,
|
||||
positioning: row.positioning,
|
||||
contact: row.contact || '',
|
||||
is_key_competitor: row.is_key_competitor,
|
||||
is_active: row.is_active,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row: Competitor) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除竞品「${row.competitor_name}」吗?`, '提示', {
|
||||
type: 'warning',
|
||||
})
|
||||
await competitorApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 管理价格
|
||||
const handleManagePrices = (row: Competitor) => {
|
||||
currentCompetitor.value = row
|
||||
pricesDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
|
||||
if (editingId.value) {
|
||||
await competitorApi.update(editingId.value, form.value as CompetitorUpdate)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await competitorApi.create(form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handleCurrentChange = (page: number) => {
|
||||
queryParams.page = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
queryParams.page_size = size
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 获取定位标签
|
||||
const getPositioningLabel = (positioning: string) => {
|
||||
return positioningOptions.find(item => item.value === positioning)?.label || positioning
|
||||
}
|
||||
|
||||
const getPositioningType = (positioning: string) => {
|
||||
const map: Record<string, string> = {
|
||||
high: 'danger',
|
||||
medium: '',
|
||||
budget: 'success',
|
||||
}
|
||||
return map[positioning] || ''
|
||||
}
|
||||
|
||||
// 关闭价格对话框
|
||||
const handlePricesClose = () => {
|
||||
pricesDialogVisible.value = false
|
||||
currentCompetitor.value = null
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<!-- 搜索栏 -->
|
||||
<el-form :inline="true" class="search-form">
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="名称/地址"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="定位">
|
||||
<el-select v-model="queryParams.positioning" placeholder="全部" clearable>
|
||||
<el-option
|
||||
v-for="item in positioningOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="重点关注">
|
||||
<el-select v-model="queryParams.is_key_competitor" placeholder="全部" clearable>
|
||||
<el-option label="是" :value="true" />
|
||||
<el-option label="否" :value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增竞品
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table v-loading="loading" :data="tableData" border>
|
||||
<el-table-column prop="competitor_name" label="机构名称" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.competitor_name }}</span>
|
||||
<el-tag v-if="row.is_key_competitor" type="warning" size="small" class="ml-2">
|
||||
重点
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="positioning" label="定位" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getPositioningType(row.positioning)" size="small">
|
||||
{{ getPositioningLabel(row.positioning) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="distance_km" label="距离" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.distance_km ? `${row.distance_km}km` : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="address" label="地址" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="price_count" label="价格记录" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.price_count }}条
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="last_price_update" label="最近更新" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.last_price_update || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleManagePrices(row)">
|
||||
<el-icon><PriceTag /></el-icon>
|
||||
价格
|
||||
</el-button>
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 表单对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
|
||||
<el-form-item label="名称" prop="competitor_name">
|
||||
<el-input v-model="form.competitor_name" placeholder="请输入机构名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="定位" prop="positioning">
|
||||
<el-select v-model="form.positioning">
|
||||
<el-option
|
||||
v-for="item in positioningOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="地址" prop="address">
|
||||
<el-input v-model="form.address" placeholder="请输入地址" />
|
||||
</el-form-item>
|
||||
<el-form-item label="距离" prop="distance_km">
|
||||
<el-input-number v-model="form.distance_km" :min="0" :precision="1" />
|
||||
<span style="margin-left: 8px;">公里</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="联系方式" prop="contact">
|
||||
<el-input v-model="form.contact" placeholder="请输入联系方式" />
|
||||
</el-form-item>
|
||||
<el-form-item label="重点关注" prop="is_key_competitor">
|
||||
<el-switch v-model="form.is_key_competitor" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="is_active">
|
||||
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 价格管理对话框 -->
|
||||
<PricesDialog
|
||||
v-if="currentCompetitor"
|
||||
v-model:visible="pricesDialogVisible"
|
||||
:competitor="currentCompetitor"
|
||||
@close="handlePricesClose"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
289
前端应用/src/views/pricing/AIAdviceDialog.vue
Normal file
289
前端应用/src/views/pricing/AIAdviceDialog.vue
Normal file
@@ -0,0 +1,289 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="AI 智能定价建议"
|
||||
width="800px"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
@close="handleClose"
|
||||
>
|
||||
<!-- 步骤一:选择项目 -->
|
||||
<div v-if="step === 1" class="step-content">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="选择项目" required>
|
||||
<el-select
|
||||
v-model="selectedProjectId"
|
||||
placeholder="请选择项目"
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in projectList"
|
||||
:key="item.id"
|
||||
:label="item.project_name"
|
||||
:value="item.id"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<span>{{ item.project_name }}</span>
|
||||
<span v-if="item.cost_summary" class="text-gray-400 text-sm">
|
||||
成本: ¥{{ item.cost_summary.total_cost.toFixed(2) }}
|
||||
</span>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标毛利率">
|
||||
<el-slider
|
||||
v-model="targetMargin"
|
||||
:min="10"
|
||||
:max="90"
|
||||
:step="5"
|
||||
show-input
|
||||
:format-tooltip="(val: number) => `${val}%`"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 步骤二:AI 分析中 / 结果展示 -->
|
||||
<div v-else class="step-content">
|
||||
<!-- 加载中 -->
|
||||
<div v-if="loading" class="text-center py-10">
|
||||
<el-icon class="is-loading text-4xl text-primary mb-4"><Loading /></el-icon>
|
||||
<p class="text-gray-500">AI 正在分析中,请稍候...</p>
|
||||
<p v-if="streamContent" class="mt-4 text-left p-4 bg-gray-50 rounded max-h-60 overflow-auto whitespace-pre-wrap text-sm">
|
||||
{{ streamContent }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 结果展示 -->
|
||||
<div v-else-if="result">
|
||||
<!-- 基础信息 -->
|
||||
<el-descriptions :column="3" border class="mb-4">
|
||||
<el-descriptions-item label="项目">{{ result.project_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="基础成本">
|
||||
<span class="font-medium">¥{{ result.cost_base.toFixed(2) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="市场均价">
|
||||
<span v-if="result.market_reference">
|
||||
¥{{ result.market_reference.avg.toFixed(2) }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400">暂无</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 策略建议对比 -->
|
||||
<h4 class="font-medium mb-3">定价策略建议</h4>
|
||||
<div class="grid grid-cols-3 gap-4 mb-4">
|
||||
<template v-for="(suggestion, key) in result.pricing_suggestions" :key="key">
|
||||
<el-card
|
||||
v-if="suggestion"
|
||||
:class="['strategy-card', { active: selectedStrategy === key }]"
|
||||
shadow="hover"
|
||||
@click="selectedStrategy = key as any"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex justify-between items-center">
|
||||
<el-tag :type="getStrategyTagType(key)">{{ suggestion.strategy }}</el-tag>
|
||||
<el-radio v-model="selectedStrategy" :value="key" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="text-center">
|
||||
<div class="text-2xl font-bold text-primary mb-2">
|
||||
¥{{ suggestion.suggested_price.toFixed(0) }}
|
||||
</div>
|
||||
<div class="text-gray-500 text-sm mb-2">
|
||||
毛利率 {{ suggestion.margin.toFixed(1) }}%
|
||||
</div>
|
||||
<div class="text-gray-400 text-xs">
|
||||
{{ suggestion.description }}
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- AI 详细建议 -->
|
||||
<div v-if="result.ai_advice">
|
||||
<h4 class="font-medium mb-3">AI 分析建议</h4>
|
||||
<el-collapse>
|
||||
<el-collapse-item title="综合建议" name="summary">
|
||||
<p class="text-gray-600">{{ result.ai_advice.summary }}</p>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="成本分析" name="cost">
|
||||
<p class="text-gray-600">{{ result.ai_advice.cost_analysis }}</p>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="市场分析" name="market">
|
||||
<p class="text-gray-600">{{ result.ai_advice.market_analysis }}</p>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item title="风险提示" name="risk">
|
||||
<p class="text-gray-600">{{ result.ai_advice.risk_notes }}</p>
|
||||
</el-collapse-item>
|
||||
<el-collapse-item v-if="result.ai_advice.recommendations?.length" title="具体建议" name="recommendations">
|
||||
<ul class="list-disc pl-5">
|
||||
<li v-for="(item, idx) in result.ai_advice.recommendations" :key="idx" class="text-gray-600">
|
||||
{{ item }}
|
||||
</li>
|
||||
</ul>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</div>
|
||||
|
||||
<!-- AI 使用统计 -->
|
||||
<div v-if="result.ai_usage" class="mt-4 text-right text-gray-400 text-xs">
|
||||
服务商: {{ result.ai_usage.provider }} |
|
||||
Token: {{ result.ai_usage.tokens }} |
|
||||
耗时: {{ result.ai_usage.latency_ms }}ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button v-if="step === 1" type="primary" :disabled="!selectedProjectId" @click="generateAdvice">
|
||||
生成建议
|
||||
</el-button>
|
||||
<el-button v-if="step === 2 && !loading" @click="step = 1">
|
||||
重新选择
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="step === 2 && result && selectedStrategy"
|
||||
type="primary"
|
||||
@click="createPlan"
|
||||
>
|
||||
创建定价方案
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { pricingApi, type GeneratePricingResponse, type StrategyType } from '@/api/pricing'
|
||||
import type { Project } from '@/api/projects'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
projectList: Project[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'createPlan', data: any): void
|
||||
}>()
|
||||
|
||||
// 状态
|
||||
const step = ref(1)
|
||||
const loading = ref(false)
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
const targetMargin = ref(50)
|
||||
const result = ref<GeneratePricingResponse | null>(null)
|
||||
const selectedStrategy = ref<StrategyType | null>(null)
|
||||
const streamContent = ref('')
|
||||
|
||||
// 生成建议
|
||||
const generateAdvice = async () => {
|
||||
if (!selectedProjectId.value) return
|
||||
|
||||
step.value = 2
|
||||
loading.value = true
|
||||
result.value = null
|
||||
streamContent.value = ''
|
||||
|
||||
try {
|
||||
const res = await pricingApi.generatePricing(selectedProjectId.value, {
|
||||
target_margin: targetMargin.value,
|
||||
stream: false,
|
||||
})
|
||||
result.value = res.data
|
||||
|
||||
// 默认选中利润款
|
||||
if (result.value.pricing_suggestions.profit) {
|
||||
selectedStrategy.value = 'profit'
|
||||
} else if (result.value.pricing_suggestions.traffic) {
|
||||
selectedStrategy.value = 'traffic'
|
||||
} else if (result.value.pricing_suggestions.premium) {
|
||||
selectedStrategy.value = 'premium'
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('生成建议失败:', error)
|
||||
step.value = 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 创建定价方案
|
||||
const createPlan = () => {
|
||||
if (!result.value || !selectedStrategy.value) return
|
||||
|
||||
const suggestion = result.value.pricing_suggestions[selectedStrategy.value]
|
||||
if (!suggestion) return
|
||||
|
||||
emit('createPlan', {
|
||||
project_id: result.value.project_id,
|
||||
plan_name: `${result.value.project_name}-${suggestion.strategy}`,
|
||||
strategy_type: selectedStrategy.value,
|
||||
target_margin: suggestion.margin,
|
||||
suggested_price: suggestion.suggested_price,
|
||||
ai_advice: result.value.ai_advice?.summary,
|
||||
})
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
emit('update:modelValue', false)
|
||||
resetState()
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
const resetState = () => {
|
||||
step.value = 1
|
||||
loading.value = false
|
||||
selectedProjectId.value = null
|
||||
targetMargin.value = 50
|
||||
result.value = null
|
||||
selectedStrategy.value = null
|
||||
streamContent.value = ''
|
||||
}
|
||||
|
||||
// 监听对话框关闭
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(val) => {
|
||||
if (!val) {
|
||||
resetState()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 工具函数
|
||||
const getStrategyTagType = (key: string) => {
|
||||
const types: Record<string, string> = {
|
||||
traffic: 'warning',
|
||||
profit: '',
|
||||
premium: 'success',
|
||||
}
|
||||
return types[key] || ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.step-content {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.strategy-card {
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.strategy-card.active {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 0 10px rgba(var(--el-color-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
</style>
|
||||
211
前端应用/src/views/pricing/PricingDialog.vue
Normal file
211
前端应用/src/views/pricing/PricingDialog.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
:title="isEdit ? '编辑定价方案' : '新建定价方案'"
|
||||
width="500px"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="项目" prop="project_id">
|
||||
<el-select
|
||||
v-model="formData.project_id"
|
||||
placeholder="请选择项目"
|
||||
filterable
|
||||
style="width: 100%"
|
||||
:disabled="isEdit"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in projectList"
|
||||
:key="item.id"
|
||||
:label="item.project_name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="方案名称" prop="plan_name">
|
||||
<el-input v-model="formData.plan_name" placeholder="请输入方案名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="策略类型" prop="strategy_type">
|
||||
<el-radio-group v-model="formData.strategy_type">
|
||||
<el-radio-button
|
||||
v-for="item in strategyTypeOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标毛利率" prop="target_margin">
|
||||
<el-input-number
|
||||
v-model="formData.target_margin"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:precision="1"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<span class="ml-2">%</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isEdit" label="最终定价" prop="final_price">
|
||||
<el-input-number
|
||||
v-model="formData.final_price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<span class="ml-2">元</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="isEdit" label="状态" prop="is_active">
|
||||
<el-switch v-model="formData.is_active" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleSubmit">
|
||||
{{ isEdit ? '保存' : '创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import {
|
||||
pricingApi,
|
||||
strategyTypeOptions,
|
||||
type PricingPlan,
|
||||
type PricingPlanCreate,
|
||||
type PricingPlanUpdate,
|
||||
type StrategyType,
|
||||
} from '@/api/pricing'
|
||||
import type { Project } from '@/api/projects'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
editData: PricingPlan | null
|
||||
projectList: Project[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
const isEdit = computed(() => !!props.editData?.id)
|
||||
|
||||
// 表单数据
|
||||
const formData = ref<{
|
||||
project_id: number | null
|
||||
plan_name: string
|
||||
strategy_type: StrategyType
|
||||
target_margin: number
|
||||
final_price: number | null
|
||||
is_active: boolean
|
||||
}>({
|
||||
project_id: null,
|
||||
plan_name: '',
|
||||
strategy_type: 'profit',
|
||||
target_margin: 50,
|
||||
final_price: null,
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules: FormRules = {
|
||||
project_id: [{ required: true, message: '请选择项目', trigger: 'change' }],
|
||||
plan_name: [
|
||||
{ required: true, message: '请输入方案名称', trigger: 'blur' },
|
||||
{ min: 1, max: 100, message: '长度在 1 到 100 个字符', trigger: 'blur' },
|
||||
],
|
||||
strategy_type: [{ required: true, message: '请选择策略类型', trigger: 'change' }],
|
||||
target_margin: [{ required: true, message: '请输入目标毛利率', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
// 监听编辑数据
|
||||
watch(
|
||||
() => props.editData,
|
||||
(val) => {
|
||||
if (val) {
|
||||
formData.value = {
|
||||
project_id: val.project_id,
|
||||
plan_name: val.plan_name,
|
||||
strategy_type: val.strategy_type as StrategyType,
|
||||
target_margin: val.target_margin,
|
||||
final_price: val.final_price,
|
||||
is_active: val.is_active,
|
||||
}
|
||||
} else {
|
||||
resetForm()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
project_id: null,
|
||||
plan_name: '',
|
||||
strategy_type: 'profit',
|
||||
target_margin: 50,
|
||||
final_price: null,
|
||||
is_active: true,
|
||||
}
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
emit('update:modelValue', false)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
const valid = await formRef.value?.validate()
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
if (isEdit.value) {
|
||||
const updateData: PricingPlanUpdate = {
|
||||
plan_name: formData.value.plan_name,
|
||||
strategy_type: formData.value.strategy_type,
|
||||
target_margin: formData.value.target_margin,
|
||||
final_price: formData.value.final_price || undefined,
|
||||
is_active: formData.value.is_active,
|
||||
}
|
||||
await pricingApi.update(props.editData!.id, updateData)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
const createData: PricingPlanCreate = {
|
||||
project_id: formData.value.project_id!,
|
||||
plan_name: formData.value.plan_name,
|
||||
strategy_type: formData.value.strategy_type,
|
||||
target_margin: formData.value.target_margin,
|
||||
}
|
||||
await pricingApi.create(createData)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
emit('success')
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
376
前端应用/src/views/pricing/index.vue
Normal file
376
前端应用/src/views/pricing/index.vue
Normal file
@@ -0,0 +1,376 @@
|
||||
<template>
|
||||
<div class="pricing-page">
|
||||
<!-- 页面标题和操作 -->
|
||||
<div class="page-header flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">智能定价</h2>
|
||||
<div class="flex gap-2">
|
||||
<el-button type="primary" @click="openAIDialog">
|
||||
<el-icon class="mr-1"><MagicStick /></el-icon>
|
||||
AI 生成建议
|
||||
</el-button>
|
||||
<el-button @click="openCreateDialog">
|
||||
<el-icon class="mr-1"><Plus /></el-icon>
|
||||
新建方案
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<el-card class="mb-4" shadow="never">
|
||||
<el-form :inline="true" :model="queryParams" class="filter-form">
|
||||
<el-form-item label="项目">
|
||||
<el-select
|
||||
v-model="queryParams.project_id"
|
||||
placeholder="全部项目"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 200px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in projectList"
|
||||
:key="item.id"
|
||||
:label="item.project_name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="策略类型">
|
||||
<el-select
|
||||
v-model="queryParams.strategy_type"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
style="width: 120px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in strategyTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select
|
||||
v-model="queryParams.is_active"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
style="width: 100px"
|
||||
>
|
||||
<el-option label="启用" :value="true" />
|
||||
<el-option label="禁用" :value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-card shadow="never">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="tableData"
|
||||
border
|
||||
stripe
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="plan_name" label="方案名称" min-width="150" />
|
||||
<el-table-column prop="project_name" label="项目" min-width="120" />
|
||||
<el-table-column prop="strategy_type" label="策略" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStrategyTagType(row.strategy_type)">
|
||||
{{ getStrategyLabel(row.strategy_type) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="base_cost" label="基础成本" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
¥{{ row.base_cost.toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="target_margin" label="目标毛利率" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ row.target_margin }}%
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="suggested_price" label="建议价格" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="text-primary font-medium">¥{{ row.suggested_price.toFixed(2) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="final_price" label="最终定价" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.final_price" class="text-success font-medium">
|
||||
¥{{ row.final_price.toFixed(2) }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400">未设置</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_active" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
|
||||
{{ row.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleView(row)">
|
||||
查看
|
||||
</el-button>
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="primary" link size="small" @click="handleSimulate(row)">
|
||||
模拟
|
||||
</el-button>
|
||||
<el-popconfirm
|
||||
title="确定删除该方案?"
|
||||
@confirm="handleDelete(row)"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button type="danger" link size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="flex justify-end mt-4">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSearch"
|
||||
@current-change="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 新建/编辑对话框 -->
|
||||
<PricingDialog
|
||||
v-model="dialogVisible"
|
||||
:edit-data="editData"
|
||||
:project-list="projectList"
|
||||
@success="handleSearch"
|
||||
/>
|
||||
|
||||
<!-- AI 建议对话框 -->
|
||||
<AIAdviceDialog
|
||||
v-model="aiDialogVisible"
|
||||
:project-list="projectList"
|
||||
@create-plan="handleCreateFromAI"
|
||||
/>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<el-dialog v-model="detailVisible" title="定价方案详情" width="600px">
|
||||
<div v-if="detailData">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="方案名称">{{ detailData.plan_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目">{{ detailData.project_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="策略类型">
|
||||
<el-tag :type="getStrategyTagType(detailData.strategy_type)">
|
||||
{{ getStrategyLabel(detailData.strategy_type) }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">
|
||||
<el-tag :type="detailData.is_active ? 'success' : 'info'">
|
||||
{{ detailData.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="基础成本">¥{{ detailData.base_cost.toFixed(2) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="目标毛利率">{{ detailData.target_margin }}%</el-descriptions-item>
|
||||
<el-descriptions-item label="建议价格">
|
||||
<span class="text-primary font-medium">¥{{ detailData.suggested_price.toFixed(2) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最终定价">
|
||||
<span v-if="detailData.final_price" class="text-success font-medium">
|
||||
¥{{ detailData.final_price.toFixed(2) }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400">未设置</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div v-if="detailData.ai_advice" class="mt-4">
|
||||
<h4 class="font-medium mb-2">AI 建议</h4>
|
||||
<el-card shadow="never" class="bg-gray-50">
|
||||
<div class="whitespace-pre-wrap text-sm">{{ detailData.ai_advice }}</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, MagicStick } from '@element-plus/icons-vue'
|
||||
import { pricingApi, strategyTypeOptions, type PricingPlan, type PricingPlanQuery } from '@/api/pricing'
|
||||
import { projectApi, type Project } from '@/api/projects'
|
||||
import PricingDialog from './PricingDialog.vue'
|
||||
import AIAdviceDialog from './AIAdviceDialog.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const tableData = ref<PricingPlan[]>([])
|
||||
const total = ref(0)
|
||||
const projectList = ref<Project[]>([])
|
||||
|
||||
const queryParams = ref<PricingPlanQuery>({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
|
||||
// 对话框
|
||||
const dialogVisible = ref(false)
|
||||
const editData = ref<PricingPlan | null>(null)
|
||||
const aiDialogVisible = ref(false)
|
||||
const detailVisible = ref(false)
|
||||
const detailData = ref<PricingPlan | null>(null)
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await pricingApi.getList(queryParams.value)
|
||||
tableData.value = res.data.items
|
||||
total.value = res.data.total
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载项目列表
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const res = await projectApi.getList({ page_size: 100 })
|
||||
projectList.value = res.data.items
|
||||
} catch (error) {
|
||||
console.error('加载项目列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
queryParams.value.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
queryParams.value = {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
}
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 打开新建对话框
|
||||
const openCreateDialog = () => {
|
||||
editData.value = null
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 打开 AI 对话框
|
||||
const openAIDialog = () => {
|
||||
aiDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 从 AI 建议创建方案
|
||||
const handleCreateFromAI = (data: any) => {
|
||||
editData.value = data
|
||||
dialogVisible.value = true
|
||||
aiDialogVisible.value = false
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleView = async (row: PricingPlan) => {
|
||||
try {
|
||||
const res = await pricingApi.getById(row.id)
|
||||
detailData.value = res.data
|
||||
detailVisible.value = true
|
||||
} catch (error) {
|
||||
console.error('获取详情失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row: PricingPlan) => {
|
||||
editData.value = row
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 模拟
|
||||
const handleSimulate = (row: PricingPlan) => {
|
||||
router.push({
|
||||
path: '/profit/simulations',
|
||||
query: { plan_id: row.id },
|
||||
})
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row: PricingPlan) => {
|
||||
try {
|
||||
await pricingApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
const getStrategyLabel = (type: string) => {
|
||||
const option = strategyTypeOptions.find((o) => o.value === type)
|
||||
return option?.label || type
|
||||
}
|
||||
|
||||
const getStrategyTagType = (type: string) => {
|
||||
const types: Record<string, string> = {
|
||||
traffic: 'warning',
|
||||
profit: '',
|
||||
premium: 'success',
|
||||
}
|
||||
return types[type] || ''
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
loadProjects()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pricing-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
</style>
|
||||
152
前端应用/src/views/profit/DetailDialog.vue
Normal file
152
前端应用/src/views/profit/DetailDialog.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="模拟详情"
|
||||
width="700px"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<div v-if="loading" class="text-center py-10">
|
||||
<el-icon class="is-loading text-2xl"><Loading /></el-icon>
|
||||
</div>
|
||||
|
||||
<div v-else-if="detail">
|
||||
<!-- 基本信息 -->
|
||||
<el-descriptions :column="2" border class="mb-4">
|
||||
<el-descriptions-item label="模拟名称">{{ detail.simulation_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="项目">{{ detail.project_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="定价方案">{{ detail.plan_name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="周期">{{ getPeriodLabel(detail.period_type) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 输入参数 -->
|
||||
<h4 class="font-medium mb-2">输入参数</h4>
|
||||
<el-descriptions :column="2" border class="mb-4">
|
||||
<el-descriptions-item label="模拟价格">
|
||||
¥{{ detail.price.toFixed(2) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="预估客量">
|
||||
{{ detail.estimated_volume }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 模拟结果 -->
|
||||
<h4 class="font-medium mb-2">模拟结果</h4>
|
||||
<el-row :gutter="20" class="mb-4">
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" class="text-center">
|
||||
<div class="text-gray-500 text-sm mb-1">预估收入</div>
|
||||
<div class="text-xl font-bold">¥{{ detail.estimated_revenue.toFixed(2) }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" class="text-center">
|
||||
<div class="text-gray-500 text-sm mb-1">预估成本</div>
|
||||
<div class="text-xl font-bold">¥{{ detail.estimated_cost.toFixed(2) }}</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :span="8">
|
||||
<el-card shadow="never" class="text-center">
|
||||
<div class="text-gray-500 text-sm mb-1">预估利润</div>
|
||||
<div class="text-xl font-bold" :class="detail.estimated_profit >= 0 ? 'text-success' : 'text-danger'">
|
||||
¥{{ detail.estimated_profit.toFixed(2) }}
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="利润率">
|
||||
<span :class="detail.profit_margin >= 30 ? 'text-success' : 'text-warning'" class="font-medium">
|
||||
{{ detail.profit_margin.toFixed(1) }}%
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="盈亏平衡客量">
|
||||
{{ detail.breakeven_volume }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="安全边际">
|
||||
{{ detail.estimated_volume - detail.breakeven_volume }} ({{ safetyMarginPercentage }}%)
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ formatDate(detail.created_at) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="$emit('update:modelValue', false)">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { profitApi, periodTypeOptions, type ProfitSimulation } from '@/api/profit'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
simulationId: number | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const detail = ref<ProfitSimulation | null>(null)
|
||||
|
||||
const safetyMarginPercentage = computed(() => {
|
||||
if (!detail.value) return 0
|
||||
const margin = detail.value.estimated_volume - detail.value.breakeven_volume
|
||||
return ((margin / detail.value.estimated_volume) * 100).toFixed(1)
|
||||
})
|
||||
|
||||
// 加载详情
|
||||
const loadDetail = async () => {
|
||||
if (!props.simulationId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await profitApi.getById(props.simulationId)
|
||||
detail.value = res.data
|
||||
} catch (error) {
|
||||
console.error('加载详情失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听
|
||||
watch(
|
||||
() => [props.modelValue, props.simulationId],
|
||||
([visible, id]) => {
|
||||
if (visible && id) {
|
||||
loadDetail()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 工具函数
|
||||
const getPeriodLabel = (type: string) => {
|
||||
const option = periodTypeOptions.find((o) => o.value === type)
|
||||
return option?.label || type
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
</style>
|
||||
279
前端应用/src/views/profit/SensitivityDialog.vue
Normal file
279
前端应用/src/views/profit/SensitivityDialog.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="敏感性分析"
|
||||
width="900px"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
>
|
||||
<div v-if="loading" class="text-center py-10">
|
||||
<el-icon class="is-loading text-2xl"><Loading /></el-icon>
|
||||
<p class="text-gray-500 mt-2">正在分析中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="analysisResult">
|
||||
<!-- 基准数据 -->
|
||||
<el-descriptions :column="3" border class="mb-4">
|
||||
<el-descriptions-item label="基准价格">
|
||||
¥{{ analysisResult.base_price.toFixed(2) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="基准利润">
|
||||
¥{{ analysisResult.base_profit.toFixed(2) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="数据点数">
|
||||
{{ analysisResult.sensitivity_results.length }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<!-- 图表 -->
|
||||
<div ref="chartRef" style="width: 100%; height: 350px;"></div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<h4 class="font-medium mb-2 mt-4">详细数据</h4>
|
||||
<el-table :data="analysisResult.sensitivity_results" border size="small">
|
||||
<el-table-column prop="price_change_rate" label="价格变动" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="row.price_change_rate >= 0 ? 'text-success' : 'text-danger'">
|
||||
{{ row.price_change_rate >= 0 ? '+' : '' }}{{ row.price_change_rate }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="adjusted_price" label="调整后价格" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
¥{{ row.adjusted_price.toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="adjusted_profit" label="调整后利润" width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="row.adjusted_profit >= 0 ? 'text-success' : 'text-danger'">
|
||||
¥{{ row.adjusted_profit.toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="profit_change_rate" label="利润变动" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span :class="row.profit_change_rate >= 0 ? 'text-success' : 'text-danger'">
|
||||
{{ row.profit_change_rate >= 0 ? '+' : '' }}{{ row.profit_change_rate.toFixed(1) }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 洞察 -->
|
||||
<el-card v-if="analysisResult.insights" class="mt-4" shadow="never">
|
||||
<template #header>
|
||||
<span class="font-medium">分析洞察</span>
|
||||
</template>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="价格弹性">
|
||||
{{ analysisResult.insights.price_elasticity }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="风险等级">
|
||||
<el-tag :type="getRiskTagType(analysisResult.insights.risk_level)">
|
||||
{{ analysisResult.insights.risk_level }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="建议">
|
||||
{{ analysisResult.insights.recommendation }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 未分析状态 -->
|
||||
<div v-else class="text-center py-10">
|
||||
<p class="text-gray-500 mb-4">尚未执行敏感性分析</p>
|
||||
<el-button type="primary" @click="runAnalysis">开始分析</el-button>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button v-if="analysisResult" @click="runAnalysis">重新分析</el-button>
|
||||
<el-button @click="$emit('update:modelValue', false)">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { profitApi, type SensitivityAnalysisResponse } from '@/api/profit'
|
||||
import * as echarts from 'echarts'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
simulationId: number | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const analysisResult = ref<SensitivityAnalysisResponse | null>(null)
|
||||
const chartRef = ref<HTMLElement | null>(null)
|
||||
let chartInstance: echarts.ECharts | null = null
|
||||
|
||||
// 执行分析
|
||||
const runAnalysis = async () => {
|
||||
if (!props.simulationId) return
|
||||
|
||||
loading.value = true
|
||||
analysisResult.value = null
|
||||
|
||||
try {
|
||||
const res = await profitApi.sensitivityAnalysis(props.simulationId, {
|
||||
price_change_rates: [-20, -15, -10, -5, 0, 5, 10, 15, 20],
|
||||
})
|
||||
analysisResult.value = res.data
|
||||
|
||||
// 渲染图表
|
||||
await nextTick()
|
||||
renderChart()
|
||||
} catch (error) {
|
||||
console.error('分析失败:', error)
|
||||
// 尝试获取已有结果
|
||||
try {
|
||||
const res = await profitApi.getSensitivityAnalysis(props.simulationId)
|
||||
analysisResult.value = res.data
|
||||
await nextTick()
|
||||
renderChart()
|
||||
} catch {
|
||||
// 无已有结果
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载已有分析
|
||||
const loadExistingAnalysis = async () => {
|
||||
if (!props.simulationId) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await profitApi.getSensitivityAnalysis(props.simulationId)
|
||||
analysisResult.value = res.data
|
||||
await nextTick()
|
||||
renderChart()
|
||||
} catch {
|
||||
// 无已有结果,不显示错误
|
||||
analysisResult.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染图表
|
||||
const renderChart = () => {
|
||||
if (!chartRef.value || !analysisResult.value) return
|
||||
|
||||
if (chartInstance) {
|
||||
chartInstance.dispose()
|
||||
}
|
||||
|
||||
chartInstance = echarts.init(chartRef.value)
|
||||
|
||||
const data = analysisResult.value.sensitivity_results
|
||||
const xData = data.map((d) => `${d.price_change_rate >= 0 ? '+' : ''}${d.price_change_rate}%`)
|
||||
const priceData = data.map((d) => d.adjusted_price)
|
||||
const profitData = data.map((d) => d.adjusted_profit)
|
||||
|
||||
const option: echarts.EChartsOption = {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'cross',
|
||||
},
|
||||
},
|
||||
legend: {
|
||||
data: ['调整后价格', '调整后利润'],
|
||||
},
|
||||
grid: {
|
||||
left: '3%',
|
||||
right: '4%',
|
||||
bottom: '3%',
|
||||
containLabel: true,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: xData,
|
||||
name: '价格变动',
|
||||
},
|
||||
yAxis: [
|
||||
{
|
||||
type: 'value',
|
||||
name: '价格 (元)',
|
||||
position: 'left',
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: '利润 (元)',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
series: [
|
||||
{
|
||||
name: '调整后价格',
|
||||
type: 'bar',
|
||||
data: priceData,
|
||||
itemStyle: {
|
||||
color: '#409EFF',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: '调整后利润',
|
||||
type: 'line',
|
||||
yAxisIndex: 1,
|
||||
data: profitData,
|
||||
smooth: true,
|
||||
itemStyle: {
|
||||
color: '#67C23A',
|
||||
},
|
||||
markLine: {
|
||||
data: [
|
||||
{
|
||||
yAxis: 0,
|
||||
lineStyle: { color: '#F56C6C' },
|
||||
label: { formatter: '盈亏平衡' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
chartInstance.setOption(option)
|
||||
}
|
||||
|
||||
// 监听
|
||||
watch(
|
||||
() => [props.modelValue, props.simulationId],
|
||||
([visible, id]) => {
|
||||
if (visible && id) {
|
||||
loadExistingAnalysis()
|
||||
} else if (!visible && chartInstance) {
|
||||
chartInstance.dispose()
|
||||
chartInstance = null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 工具函数
|
||||
const getRiskTagType = (level: string) => {
|
||||
const types: Record<string, string> = {
|
||||
'低': 'success',
|
||||
'中等': 'warning',
|
||||
'高': 'danger',
|
||||
}
|
||||
return types[level] || ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
</style>
|
||||
264
前端应用/src/views/profit/SimulateDialog.vue
Normal file
264
前端应用/src/views/profit/SimulateDialog.vue
Normal file
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
:model-value="modelValue"
|
||||
title="新建利润模拟"
|
||||
width="600px"
|
||||
@update:model-value="$emit('update:modelValue', $event)"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="formData"
|
||||
:rules="rules"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="定价方案" prop="pricing_plan_id">
|
||||
<el-select
|
||||
v-model="formData.pricing_plan_id"
|
||||
placeholder="请选择定价方案"
|
||||
filterable
|
||||
style="width: 100%"
|
||||
@change="handlePlanChange"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in pricingPlanList"
|
||||
:key="item.id"
|
||||
:label="`${item.plan_name} (${item.project_name})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 方案信息 -->
|
||||
<el-card v-if="selectedPlan" class="mb-4 bg-gray-50" shadow="never">
|
||||
<el-descriptions :column="2" size="small">
|
||||
<el-descriptions-item label="基础成本">
|
||||
¥{{ selectedPlan.base_cost.toFixed(2) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="建议价格">
|
||||
¥{{ selectedPlan.suggested_price.toFixed(2) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="目标毛利率">
|
||||
{{ selectedPlan.target_margin }}%
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最终定价">
|
||||
{{ selectedPlan.final_price ? `¥${selectedPlan.final_price.toFixed(2)}` : '未设置' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-form-item label="模拟价格" prop="price">
|
||||
<el-input-number
|
||||
v-model="formData.price"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
<span class="ml-2">元</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="预估客量" prop="estimated_volume">
|
||||
<el-input-number
|
||||
v-model="formData.estimated_volume"
|
||||
:min="1"
|
||||
controls-position="right"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="周期" prop="period_type">
|
||||
<el-radio-group v-model="formData.period_type">
|
||||
<el-radio-button
|
||||
v-for="item in periodTypeOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
>
|
||||
{{ item.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 预估结果 -->
|
||||
<el-card v-if="previewResult" class="mt-4" shadow="never">
|
||||
<template #header>
|
||||
<span class="font-medium">预估结果</span>
|
||||
</template>
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="预估收入">
|
||||
<span class="font-medium">¥{{ previewResult.revenue.toFixed(2) }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="预估成本">
|
||||
¥{{ previewResult.cost.toFixed(2) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="预估利润">
|
||||
<span :class="previewResult.profit >= 0 ? 'text-success' : 'text-danger'" class="font-medium">
|
||||
¥{{ previewResult.profit.toFixed(2) }}
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="利润率">
|
||||
<span :class="previewResult.margin >= 30 ? 'text-success' : 'text-warning'" class="font-medium">
|
||||
{{ previewResult.margin.toFixed(1) }}%
|
||||
</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="单位利润">
|
||||
¥{{ previewResult.profitPerUnit.toFixed(2) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="盈亏平衡客量">
|
||||
{{ previewResult.breakevenVolume }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="handleClose">取消</el-button>
|
||||
<el-button type="primary" :loading="loading" @click="handleSubmit">
|
||||
保存模拟
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { profitApi, periodTypeOptions, type PeriodType } from '@/api/profit'
|
||||
import type { PricingPlan } from '@/api/pricing'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
pricingPlanList: PricingPlan[]
|
||||
initialPlanId: number | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
(e: 'success'): void
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = ref({
|
||||
pricing_plan_id: null as number | null,
|
||||
price: 0,
|
||||
estimated_volume: 100,
|
||||
period_type: 'monthly' as PeriodType,
|
||||
})
|
||||
|
||||
// 选中的定价方案
|
||||
const selectedPlan = computed(() => {
|
||||
if (!formData.value.pricing_plan_id) return null
|
||||
return props.pricingPlanList.find((p) => p.id === formData.value.pricing_plan_id)
|
||||
})
|
||||
|
||||
// 预估结果
|
||||
const previewResult = computed(() => {
|
||||
if (!selectedPlan.value || !formData.value.price || !formData.value.estimated_volume) {
|
||||
return null
|
||||
}
|
||||
|
||||
const price = formData.value.price
|
||||
const volume = formData.value.estimated_volume
|
||||
const cost = selectedPlan.value.base_cost
|
||||
|
||||
const revenue = price * volume
|
||||
const totalCost = cost * volume
|
||||
const profit = revenue - totalCost
|
||||
const margin = revenue > 0 ? (profit / revenue) * 100 : 0
|
||||
const profitPerUnit = price - cost
|
||||
const breakevenVolume = profitPerUnit > 0 ? Math.ceil(1) : 999999
|
||||
|
||||
return {
|
||||
revenue,
|
||||
cost: totalCost,
|
||||
profit,
|
||||
margin,
|
||||
profitPerUnit,
|
||||
breakevenVolume,
|
||||
}
|
||||
})
|
||||
|
||||
// 表单验证规则
|
||||
const rules: FormRules = {
|
||||
pricing_plan_id: [{ required: true, message: '请选择定价方案', trigger: 'change' }],
|
||||
price: [{ required: true, message: '请输入模拟价格', trigger: 'blur' }],
|
||||
estimated_volume: [{ required: true, message: '请输入预估客量', trigger: 'blur' }],
|
||||
period_type: [{ required: true, message: '请选择周期', trigger: 'change' }],
|
||||
}
|
||||
|
||||
// 监听初始方案 ID
|
||||
watch(
|
||||
() => props.initialPlanId,
|
||||
(val) => {
|
||||
if (val && props.modelValue) {
|
||||
formData.value.pricing_plan_id = val
|
||||
handlePlanChange(val)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 方案变更
|
||||
const handlePlanChange = (planId: number) => {
|
||||
const plan = props.pricingPlanList.find((p) => p.id === planId)
|
||||
if (plan) {
|
||||
formData.value.price = plan.final_price || plan.suggested_price
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
pricing_plan_id: null,
|
||||
price: 0,
|
||||
estimated_volume: 100,
|
||||
period_type: 'monthly',
|
||||
}
|
||||
formRef.value?.clearValidate()
|
||||
}
|
||||
|
||||
// 关闭对话框
|
||||
const handleClose = () => {
|
||||
emit('update:modelValue', false)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
// 提交
|
||||
const handleSubmit = async () => {
|
||||
const valid = await formRef.value?.validate()
|
||||
if (!valid) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await profitApi.simulate(formData.value.pricing_plan_id!, {
|
||||
price: formData.value.price,
|
||||
estimated_volume: formData.value.estimated_volume,
|
||||
period_type: formData.value.period_type,
|
||||
})
|
||||
ElMessage.success('模拟创建成功')
|
||||
emit('success')
|
||||
handleClose()
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
</style>
|
||||
294
前端应用/src/views/profit/index.vue
Normal file
294
前端应用/src/views/profit/index.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div class="profit-page">
|
||||
<!-- 页面标题和操作 -->
|
||||
<div class="page-header flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-semibold">利润模拟</h2>
|
||||
<el-button type="primary" @click="openSimulateDialog">
|
||||
<el-icon class="mr-1"><DataAnalysis /></el-icon>
|
||||
新建模拟
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<el-card class="mb-4" shadow="never">
|
||||
<el-form :inline="true" :model="queryParams" class="filter-form">
|
||||
<el-form-item label="定价方案">
|
||||
<el-select
|
||||
v-model="queryParams.pricing_plan_id"
|
||||
placeholder="全部方案"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 200px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in pricingPlanList"
|
||||
:key="item.id"
|
||||
:label="item.plan_name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="周期">
|
||||
<el-select
|
||||
v-model="queryParams.period_type"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
style="width: 100px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in periodTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<el-card shadow="never">
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="tableData"
|
||||
border
|
||||
stripe
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-table-column prop="simulation_name" label="模拟名称" min-width="150" />
|
||||
<el-table-column prop="project_name" label="项目" min-width="120" />
|
||||
<el-table-column prop="plan_name" label="定价方案" min-width="120" />
|
||||
<el-table-column prop="price" label="模拟价格" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
¥{{ row.price.toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="estimated_volume" label="预估客量" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ row.estimated_volume }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="period_type" label="周期" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ getPeriodLabel(row.period_type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="estimated_profit" label="预估利润" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="row.estimated_profit >= 0 ? 'text-success' : 'text-danger'">
|
||||
¥{{ row.estimated_profit.toFixed(2) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="profit_margin" label="利润率" width="90" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="row.profit_margin >= 30 ? 'text-success' : 'text-warning'">
|
||||
{{ row.profit_margin.toFixed(1) }}%
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleView(row)">
|
||||
详情
|
||||
</el-button>
|
||||
<el-button type="primary" link size="small" @click="handleSensitivity(row)">
|
||||
敏感性分析
|
||||
</el-button>
|
||||
<el-popconfirm
|
||||
title="确定删除该模拟记录?"
|
||||
@confirm="handleDelete(row)"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button type="danger" link size="small">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="flex justify-end mt-4">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:total="total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSearch"
|
||||
@current-change="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 新建模拟对话框 -->
|
||||
<SimulateDialog
|
||||
v-model="simulateDialogVisible"
|
||||
:pricing-plan-list="pricingPlanList"
|
||||
:initial-plan-id="initialPlanId"
|
||||
@success="handleSearch"
|
||||
/>
|
||||
|
||||
<!-- 详情对话框 -->
|
||||
<DetailDialog
|
||||
v-model="detailDialogVisible"
|
||||
:simulation-id="selectedSimulationId"
|
||||
/>
|
||||
|
||||
<!-- 敏感性分析对话框 -->
|
||||
<SensitivityDialog
|
||||
v-model="sensitivityDialogVisible"
|
||||
:simulation-id="selectedSimulationId"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { DataAnalysis } from '@element-plus/icons-vue'
|
||||
import { profitApi, periodTypeOptions, type ProfitSimulation, type ProfitSimulationQuery } from '@/api/profit'
|
||||
import { pricingApi, type PricingPlan } from '@/api/pricing'
|
||||
import SimulateDialog from './SimulateDialog.vue'
|
||||
import DetailDialog from './DetailDialog.vue'
|
||||
import SensitivityDialog from './SensitivityDialog.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 状态
|
||||
const loading = ref(false)
|
||||
const tableData = ref<ProfitSimulation[]>([])
|
||||
const total = ref(0)
|
||||
const pricingPlanList = ref<PricingPlan[]>([])
|
||||
|
||||
const queryParams = ref<ProfitSimulationQuery>({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
})
|
||||
|
||||
// 对话框状态
|
||||
const simulateDialogVisible = ref(false)
|
||||
const detailDialogVisible = ref(false)
|
||||
const sensitivityDialogVisible = ref(false)
|
||||
const selectedSimulationId = ref<number | null>(null)
|
||||
const initialPlanId = ref<number | null>(null)
|
||||
|
||||
// 加载数据
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await profitApi.getList(queryParams.value)
|
||||
tableData.value = res.data.items
|
||||
total.value = res.data.total
|
||||
} catch (error) {
|
||||
console.error('加载数据失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 加载定价方案列表
|
||||
const loadPricingPlans = async () => {
|
||||
try {
|
||||
const res = await pricingApi.getList({ page_size: 100, is_active: true })
|
||||
pricingPlanList.value = res.data.items
|
||||
} catch (error) {
|
||||
console.error('加载定价方案列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
queryParams.value.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
queryParams.value = {
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
}
|
||||
loadData()
|
||||
}
|
||||
|
||||
// 打开模拟对话框
|
||||
const openSimulateDialog = () => {
|
||||
initialPlanId.value = null
|
||||
simulateDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 查看详情
|
||||
const handleView = (row: ProfitSimulation) => {
|
||||
selectedSimulationId.value = row.id
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 敏感性分析
|
||||
const handleSensitivity = (row: ProfitSimulation) => {
|
||||
selectedSimulationId.value = row.id
|
||||
sensitivityDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row: ProfitSimulation) => {
|
||||
try {
|
||||
await profitApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 工具函数
|
||||
const getPeriodLabel = (type: string) => {
|
||||
const option = periodTypeOptions.find((o) => o.value === type)
|
||||
return option?.label || type
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
// 检查是否从定价页面跳转过来
|
||||
const planId = route.query.plan_id
|
||||
if (planId) {
|
||||
initialPlanId.value = Number(planId)
|
||||
simulateDialogVisible.value = true
|
||||
}
|
||||
|
||||
loadData()
|
||||
loadPricingPlans()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profit-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.text-success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
</style>
|
||||
188
前端应用/src/views/settings/categories/index.vue
Normal file
188
前端应用/src/views/settings/categories/index.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 项目分类管理页面
|
||||
*/
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { categoryApi, Category, CategoryCreate, CategoryUpdate } from '@/api'
|
||||
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
const tableData = ref<Category[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增分类')
|
||||
const formRef = ref()
|
||||
const form = ref<CategoryCreate>({
|
||||
category_name: '',
|
||||
parent_id: null,
|
||||
sort_order: 0,
|
||||
is_active: true,
|
||||
})
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
// 表单规则
|
||||
const rules = {
|
||||
category_name: [
|
||||
{ required: true, message: '请输入分类名称', trigger: 'blur' },
|
||||
{ max: 50, message: '名称不能超过50个字符', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await categoryApi.getTree()
|
||||
tableData.value = res.data || []
|
||||
} catch (error) {
|
||||
console.error('获取分类失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 新增
|
||||
const handleAdd = (parent?: Category) => {
|
||||
editingId.value = null
|
||||
dialogTitle.value = parent ? `新增子分类 - ${parent.category_name}` : '新增分类'
|
||||
form.value = {
|
||||
category_name: '',
|
||||
parent_id: parent?.id || null,
|
||||
sort_order: 0,
|
||||
is_active: true,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row: Category) => {
|
||||
editingId.value = row.id
|
||||
dialogTitle.value = '编辑分类'
|
||||
form.value = {
|
||||
category_name: row.category_name,
|
||||
parent_id: row.parent_id,
|
||||
sort_order: row.sort_order,
|
||||
is_active: row.is_active,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row: Category) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除分类「${row.category_name}」吗?`, '提示', {
|
||||
type: 'warning',
|
||||
})
|
||||
await categoryApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
|
||||
if (editingId.value) {
|
||||
await categoryApi.update(editingId.value, form.value as CategoryUpdate)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await categoryApi.create(form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<!-- 操作栏 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAdd()">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增分类
|
||||
</el-button>
|
||||
<el-button @click="fetchData">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="tableData"
|
||||
row-key="id"
|
||||
border
|
||||
default-expand-all
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||
>
|
||||
<el-table-column prop="category_name" label="分类名称" min-width="200" />
|
||||
<el-table-column prop="sort_order" label="排序" width="80" align="center" />
|
||||
<el-table-column prop="is_active" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
|
||||
{{ row.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleAdd(row)">
|
||||
添加子分类
|
||||
</el-button>
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 表单对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
|
||||
<el-form-item label="分类名称" prop="category_name">
|
||||
<el-input v-model="form.category_name" placeholder="请输入分类名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序" prop="sort_order">
|
||||
<el-input-number v-model="form.sort_order" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="is_active">
|
||||
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
311
前端应用/src/views/settings/equipments/index.vue
Normal file
311
前端应用/src/views/settings/equipments/index.vue
Normal file
@@ -0,0 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 设备管理页面
|
||||
*/
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { equipmentApi, Equipment, EquipmentCreate, EquipmentUpdate } from '@/api'
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
keyword: '',
|
||||
is_active: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
const tableData = ref<Equipment[]>([])
|
||||
const total = ref(0)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增设备')
|
||||
const formRef = ref()
|
||||
const form = ref<EquipmentCreate>({
|
||||
equipment_code: '',
|
||||
equipment_name: '',
|
||||
original_value: 0,
|
||||
residual_rate: 5,
|
||||
service_years: 5,
|
||||
estimated_uses: 1000,
|
||||
purchase_date: null,
|
||||
is_active: true,
|
||||
})
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
// 表单规则
|
||||
const rules = {
|
||||
equipment_code: [
|
||||
{ required: true, message: '请输入设备编码', trigger: 'blur' },
|
||||
],
|
||||
equipment_name: [
|
||||
{ required: true, message: '请输入设备名称', trigger: 'blur' },
|
||||
],
|
||||
original_value: [
|
||||
{ required: true, message: '请输入设备原值', trigger: 'blur' },
|
||||
],
|
||||
service_years: [
|
||||
{ required: true, message: '请输入使用年限', trigger: 'blur' },
|
||||
],
|
||||
estimated_uses: [
|
||||
{ required: true, message: '请输入预计使用次数', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await equipmentApi.getList(queryParams)
|
||||
tableData.value = res.data?.items || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch (error) {
|
||||
console.error('获取设备失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
queryParams.keyword = ''
|
||||
queryParams.is_active = undefined
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 新增
|
||||
const handleAdd = () => {
|
||||
editingId.value = null
|
||||
dialogTitle.value = '新增设备'
|
||||
form.value = {
|
||||
equipment_code: '',
|
||||
equipment_name: '',
|
||||
original_value: 0,
|
||||
residual_rate: 5,
|
||||
service_years: 5,
|
||||
estimated_uses: 1000,
|
||||
purchase_date: null,
|
||||
is_active: true,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row: Equipment) => {
|
||||
editingId.value = row.id
|
||||
dialogTitle.value = '编辑设备'
|
||||
form.value = { ...row }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row: Equipment) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除设备「${row.equipment_name}」吗?`, '提示', {
|
||||
type: 'warning',
|
||||
})
|
||||
await equipmentApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
|
||||
if (editingId.value) {
|
||||
await equipmentApi.update(editingId.value, form.value as EquipmentUpdate)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await equipmentApi.create(form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handleCurrentChange = (page: number) => {
|
||||
queryParams.page = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
queryParams.page_size = size
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<!-- 搜索栏 -->
|
||||
<el-form :inline="true" class="search-form">
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="编码/名称"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.is_active" placeholder="全部" clearable>
|
||||
<el-option label="启用" :value="true" />
|
||||
<el-option label="禁用" :value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增设备
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table v-loading="loading" :data="tableData" border>
|
||||
<el-table-column prop="equipment_code" label="编码" width="120" />
|
||||
<el-table-column prop="equipment_name" label="名称" min-width="150" />
|
||||
<el-table-column prop="original_value" label="原值" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
¥{{ row.original_value.toLocaleString() }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="residual_rate" label="残值率" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.residual_rate }}%
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="service_years" label="使用年限" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ row.service_years }}年
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="estimated_uses" label="预计次数" width="100" align="center" />
|
||||
<el-table-column prop="depreciation_per_use" label="单次折旧" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
¥{{ row.depreciation_per_use.toFixed(4) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_active" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
|
||||
{{ row.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 表单对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="设备编码" prop="equipment_code">
|
||||
<el-input v-model="form.equipment_code" placeholder="请输入编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备名称" prop="equipment_name">
|
||||
<el-input v-model="form.equipment_name" placeholder="请输入名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="设备原值" prop="original_value">
|
||||
<el-input-number v-model="form.original_value" :min="0" :precision="2" style="width: 100%;" />
|
||||
</el-form-item>
|
||||
<el-form-item label="残值率(%)" prop="residual_rate">
|
||||
<el-input-number v-model="form.residual_rate" :min="0" :max="100" :precision="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="使用年限" prop="service_years">
|
||||
<el-input-number v-model="form.service_years" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="预计使用次数" prop="estimated_uses">
|
||||
<el-input-number v-model="form.estimated_uses" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="购入日期" prop="purchase_date">
|
||||
<el-date-picker
|
||||
v-model="form.purchase_date"
|
||||
type="date"
|
||||
placeholder="选择日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="is_active">
|
||||
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
351
前端应用/src/views/settings/fixed-costs/index.vue
Normal file
351
前端应用/src/views/settings/fixed-costs/index.vue
Normal file
@@ -0,0 +1,351 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 固定成本管理页面
|
||||
*/
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { fixedCostApi, FixedCost, FixedCostCreate, FixedCostUpdate, costTypeOptions, allocationMethodOptions } from '@/api'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
year_month: dayjs().format('YYYY-MM'),
|
||||
cost_type: undefined as string | undefined,
|
||||
is_active: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
const tableData = ref<FixedCost[]>([])
|
||||
const total = ref(0)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增固定成本')
|
||||
const formRef = ref()
|
||||
const form = ref<FixedCostCreate>({
|
||||
cost_name: '',
|
||||
cost_type: 'rent',
|
||||
monthly_amount: 0,
|
||||
year_month: dayjs().format('YYYY-MM'),
|
||||
allocation_method: 'count',
|
||||
is_active: true,
|
||||
})
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
// 表单规则
|
||||
const rules = {
|
||||
cost_name: [
|
||||
{ required: true, message: '请输入成本名称', trigger: 'blur' },
|
||||
],
|
||||
cost_type: [
|
||||
{ required: true, message: '请选择类型', trigger: 'change' },
|
||||
],
|
||||
monthly_amount: [
|
||||
{ required: true, message: '请输入月度金额', trigger: 'blur' },
|
||||
],
|
||||
year_month: [
|
||||
{ required: true, message: '请选择年月', trigger: 'change' },
|
||||
],
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await fixedCostApi.getList(queryParams)
|
||||
tableData.value = res.data?.items || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch (error) {
|
||||
console.error('获取固定成本失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
queryParams.year_month = dayjs().format('YYYY-MM')
|
||||
queryParams.cost_type = undefined
|
||||
queryParams.is_active = undefined
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 新增
|
||||
const handleAdd = () => {
|
||||
editingId.value = null
|
||||
dialogTitle.value = '新增固定成本'
|
||||
form.value = {
|
||||
cost_name: '',
|
||||
cost_type: 'rent',
|
||||
monthly_amount: 0,
|
||||
year_month: queryParams.year_month,
|
||||
allocation_method: 'count',
|
||||
is_active: true,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row: FixedCost) => {
|
||||
editingId.value = row.id
|
||||
dialogTitle.value = '编辑固定成本'
|
||||
form.value = { ...row }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row: FixedCost) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除固定成本「${row.cost_name}」吗?`, '提示', {
|
||||
type: 'warning',
|
||||
})
|
||||
await fixedCostApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
|
||||
if (editingId.value) {
|
||||
await fixedCostApi.update(editingId.value, form.value as FixedCostUpdate)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await fixedCostApi.create(form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handleCurrentChange = (page: number) => {
|
||||
queryParams.page = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
queryParams.page_size = size
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 获取类型标签
|
||||
const getTypeLabel = (type: string) => {
|
||||
return costTypeOptions.find(item => item.value === type)?.label || type
|
||||
}
|
||||
|
||||
// 获取分摊方式标签
|
||||
const getMethodLabel = (method: string) => {
|
||||
return allocationMethodOptions.find(item => item.value === method)?.label || method
|
||||
}
|
||||
|
||||
// 计算合计
|
||||
const totalAmount = computed(() => {
|
||||
return tableData.value.reduce((sum, item) => sum + item.monthly_amount, 0)
|
||||
})
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<!-- 搜索栏 -->
|
||||
<el-form :inline="true" class="search-form">
|
||||
<el-form-item label="年月">
|
||||
<el-date-picker
|
||||
v-model="queryParams.year_month"
|
||||
type="month"
|
||||
placeholder="选择月份"
|
||||
value-format="YYYY-MM"
|
||||
@change="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="queryParams.cost_type" placeholder="全部" clearable>
|
||||
<el-option
|
||||
v-for="item in costTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.is_active" placeholder="全部" clearable>
|
||||
<el-option label="启用" :value="true" />
|
||||
<el-option label="禁用" :value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增固定成本
|
||||
</el-button>
|
||||
<div class="total-info">
|
||||
当月合计:<span class="amount">¥{{ totalAmount.toLocaleString() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table v-loading="loading" :data="tableData" border>
|
||||
<el-table-column prop="cost_name" label="名称" min-width="150" />
|
||||
<el-table-column prop="cost_type" label="类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ getTypeLabel(row.cost_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="monthly_amount" label="月度金额" width="140" align="right">
|
||||
<template #default="{ row }">
|
||||
¥{{ row.monthly_amount.toLocaleString() }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="year_month" label="年月" width="100" align="center" />
|
||||
<el-table-column prop="allocation_method" label="分摊方式" width="120" align="center">
|
||||
<template #default="{ row }">
|
||||
{{ getMethodLabel(row.allocation_method) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_active" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
|
||||
{{ row.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 表单对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="成本名称" prop="cost_name">
|
||||
<el-input v-model="form.cost_name" placeholder="如:门店租金" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="cost_type">
|
||||
<el-select v-model="form.cost_type">
|
||||
<el-option
|
||||
v-for="item in costTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="月度金额" prop="monthly_amount">
|
||||
<el-input-number v-model="form.monthly_amount" :min="0" :precision="2" style="width: 100%;" />
|
||||
</el-form-item>
|
||||
<el-form-item label="年月" prop="year_month">
|
||||
<el-date-picker
|
||||
v-model="form.year_month"
|
||||
type="month"
|
||||
placeholder="选择月份"
|
||||
value-format="YYYY-MM"
|
||||
style="width: 100%;"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="分摊方式" prop="allocation_method">
|
||||
<el-select v-model="form.allocation_method">
|
||||
<el-option
|
||||
v-for="item in allocationMethodOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="is_active">
|
||||
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.total-info {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.total-info .amount {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
315
前端应用/src/views/settings/materials/index.vue
Normal file
315
前端应用/src/views/settings/materials/index.vue
Normal file
@@ -0,0 +1,315 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 耗材管理页面
|
||||
*/
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { materialApi, Material, MaterialCreate, MaterialUpdate, materialTypeOptions } from '@/api'
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
keyword: '',
|
||||
material_type: undefined as string | undefined,
|
||||
is_active: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
const tableData = ref<Material[]>([])
|
||||
const total = ref(0)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增耗材')
|
||||
const formRef = ref()
|
||||
const form = ref<MaterialCreate>({
|
||||
material_code: '',
|
||||
material_name: '',
|
||||
unit: '',
|
||||
unit_price: 0,
|
||||
supplier: null,
|
||||
material_type: 'consumable',
|
||||
is_active: true,
|
||||
})
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
// 表单规则
|
||||
const rules = {
|
||||
material_code: [
|
||||
{ required: true, message: '请输入耗材编码', trigger: 'blur' },
|
||||
],
|
||||
material_name: [
|
||||
{ required: true, message: '请输入耗材名称', trigger: 'blur' },
|
||||
],
|
||||
unit: [
|
||||
{ required: true, message: '请输入单位', trigger: 'blur' },
|
||||
],
|
||||
unit_price: [
|
||||
{ required: true, message: '请输入单价', trigger: 'blur' },
|
||||
],
|
||||
material_type: [
|
||||
{ required: true, message: '请选择类型', trigger: 'change' },
|
||||
],
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await materialApi.getList(queryParams)
|
||||
tableData.value = res.data?.items || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch (error) {
|
||||
console.error('获取耗材失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
queryParams.keyword = ''
|
||||
queryParams.material_type = undefined
|
||||
queryParams.is_active = undefined
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 新增
|
||||
const handleAdd = () => {
|
||||
editingId.value = null
|
||||
dialogTitle.value = '新增耗材'
|
||||
form.value = {
|
||||
material_code: '',
|
||||
material_name: '',
|
||||
unit: '',
|
||||
unit_price: 0,
|
||||
supplier: null,
|
||||
material_type: 'consumable',
|
||||
is_active: true,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row: Material) => {
|
||||
editingId.value = row.id
|
||||
dialogTitle.value = '编辑耗材'
|
||||
form.value = { ...row }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row: Material) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除耗材「${row.material_name}」吗?`, '提示', {
|
||||
type: 'warning',
|
||||
})
|
||||
await materialApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
|
||||
if (editingId.value) {
|
||||
await materialApi.update(editingId.value, form.value as MaterialUpdate)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await materialApi.create(form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handleCurrentChange = (page: number) => {
|
||||
queryParams.page = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
queryParams.page_size = size
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 获取类型标签
|
||||
const getTypeLabel = (type: string) => {
|
||||
return materialTypeOptions.find(item => item.value === type)?.label || type
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<!-- 搜索栏 -->
|
||||
<el-form :inline="true" class="search-form">
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="编码/名称"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-select v-model="queryParams.material_type" placeholder="全部" clearable>
|
||||
<el-option
|
||||
v-for="item in materialTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.is_active" placeholder="全部" clearable>
|
||||
<el-option label="启用" :value="true" />
|
||||
<el-option label="禁用" :value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增耗材
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table v-loading="loading" :data="tableData" border>
|
||||
<el-table-column prop="material_code" label="编码" width="120" />
|
||||
<el-table-column prop="material_name" label="名称" min-width="150" />
|
||||
<el-table-column prop="unit" label="单位" width="80" align="center" />
|
||||
<el-table-column prop="unit_price" label="单价" width="100" align="right">
|
||||
<template #default="{ row }">
|
||||
¥{{ row.unit_price.toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="material_type" label="类型" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small">{{ getTypeLabel(row.material_type) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="supplier" label="供应商" min-width="120" />
|
||||
<el-table-column prop="is_active" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
|
||||
{{ row.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 表单对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
|
||||
<el-form-item label="编码" prop="material_code">
|
||||
<el-input v-model="form.material_code" placeholder="请输入编码" />
|
||||
</el-form-item>
|
||||
<el-form-item label="名称" prop="material_name">
|
||||
<el-input v-model="form.material_name" placeholder="请输入名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单位" prop="unit">
|
||||
<el-input v-model="form.unit" placeholder="如:支、ml、个" />
|
||||
</el-form-item>
|
||||
<el-form-item label="单价" prop="unit_price">
|
||||
<el-input-number v-model="form.unit_price" :min="0" :precision="2" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="material_type">
|
||||
<el-select v-model="form.material_type">
|
||||
<el-option
|
||||
v-for="item in materialTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="供应商" prop="supplier">
|
||||
<el-input v-model="form.supplier" placeholder="请输入供应商" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="is_active">
|
||||
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
269
前端应用/src/views/settings/staff-levels/index.vue
Normal file
269
前端应用/src/views/settings/staff-levels/index.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 人员级别管理页面
|
||||
*/
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { staffLevelApi, StaffLevel, StaffLevelCreate, StaffLevelUpdate } from '@/api'
|
||||
|
||||
// 查询参数
|
||||
const queryParams = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
keyword: '',
|
||||
is_active: undefined as boolean | undefined,
|
||||
})
|
||||
|
||||
// 数据
|
||||
const loading = ref(false)
|
||||
const tableData = ref<StaffLevel[]>([])
|
||||
const total = ref(0)
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('新增人员级别')
|
||||
const formRef = ref()
|
||||
const form = ref<StaffLevelCreate>({
|
||||
level_code: '',
|
||||
level_name: '',
|
||||
hourly_rate: 0,
|
||||
is_active: true,
|
||||
})
|
||||
const editingId = ref<number | null>(null)
|
||||
|
||||
// 表单规则
|
||||
const rules = {
|
||||
level_code: [
|
||||
{ required: true, message: '请输入级别编码', trigger: 'blur' },
|
||||
],
|
||||
level_name: [
|
||||
{ required: true, message: '请输入级别名称', trigger: 'blur' },
|
||||
],
|
||||
hourly_rate: [
|
||||
{ required: true, message: '请输入时薪', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
// 获取数据
|
||||
const fetchData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await staffLevelApi.getList(queryParams)
|
||||
tableData.value = res.data?.items || []
|
||||
total.value = res.data?.total || 0
|
||||
} catch (error) {
|
||||
console.error('获取人员级别失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
queryParams.keyword = ''
|
||||
queryParams.is_active = undefined
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 新增
|
||||
const handleAdd = () => {
|
||||
editingId.value = null
|
||||
dialogTitle.value = '新增人员级别'
|
||||
form.value = {
|
||||
level_code: '',
|
||||
level_name: '',
|
||||
hourly_rate: 0,
|
||||
is_active: true,
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const handleEdit = (row: StaffLevel) => {
|
||||
editingId.value = row.id
|
||||
dialogTitle.value = '编辑人员级别'
|
||||
form.value = { ...row }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 删除
|
||||
const handleDelete = async (row: StaffLevel) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除人员级别「${row.level_name}」吗?`, '提示', {
|
||||
type: 'warning',
|
||||
})
|
||||
await staffLevelApi.delete(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
fetchData()
|
||||
} catch (error: any) {
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
await formRef.value?.validate()
|
||||
|
||||
if (editingId.value) {
|
||||
await staffLevelApi.update(editingId.value, form.value as StaffLevelUpdate)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await staffLevelApi.create(form.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
fetchData()
|
||||
} catch (error) {
|
||||
console.error('提交失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 分页
|
||||
const handleCurrentChange = (page: number) => {
|
||||
queryParams.page = page
|
||||
fetchData()
|
||||
}
|
||||
|
||||
const handleSizeChange = (size: number) => {
|
||||
queryParams.page_size = size
|
||||
queryParams.page = 1
|
||||
fetchData()
|
||||
}
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<el-card shadow="never">
|
||||
<!-- 搜索栏 -->
|
||||
<el-form :inline="true" class="search-form">
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
v-model="queryParams.keyword"
|
||||
placeholder="编码/名称"
|
||||
clearable
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="queryParams.is_active" placeholder="全部" clearable>
|
||||
<el-option label="启用" :value="true" />
|
||||
<el-option label="禁用" :value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="handleReset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<!-- 操作栏 -->
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="handleAdd">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新增人员级别
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table v-loading="loading" :data="tableData" border>
|
||||
<el-table-column prop="level_code" label="编码" width="120" />
|
||||
<el-table-column prop="level_name" label="名称" min-width="150" />
|
||||
<el-table-column prop="hourly_rate" label="时薪(元/小时)" width="140" align="right">
|
||||
<template #default="{ row }">
|
||||
¥{{ row.hourly_rate.toFixed(2) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="is_active" label="状态" width="80" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
|
||||
{{ row.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link size="small" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="queryParams.page"
|
||||
v-model:page-size="queryParams.page_size"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 表单对话框 -->
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="500px">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="级别编码" prop="level_code">
|
||||
<el-input v-model="form.level_code" placeholder="如:L1, D1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="级别名称" prop="level_name">
|
||||
<el-input v-model="form.level_name" placeholder="如:初级美容师" />
|
||||
</el-form-item>
|
||||
<el-form-item label="时薪" prop="hourly_rate">
|
||||
<el-input-number v-model="form.hourly_rate" :min="0" :precision="2" />
|
||||
<span class="unit">元/小时</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态" prop="is_active">
|
||||
<el-switch v-model="form.is_active" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.unit {
|
||||
margin-left: 8px;
|
||||
color: #909399;
|
||||
}
|
||||
</style>
|
||||
7
前端应用/src/vite-env.d.ts
vendored
Normal file
7
前端应用/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
41
前端应用/tailwind.config.js
Normal file
41
前端应用/tailwind.config.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
'./index.html',
|
||||
'./src/**/*.{vue,js,ts,jsx,tsx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// 主色调(与 Element Plus 协调)
|
||||
primary: {
|
||||
50: '#ecf5ff',
|
||||
100: '#d9ecff',
|
||||
200: '#b3d8ff',
|
||||
300: '#8cc5ff',
|
||||
400: '#66b1ff',
|
||||
500: '#409eff', // Element Plus 主色
|
||||
600: '#3a8ee6',
|
||||
700: '#337ecc',
|
||||
800: '#2d6eb3',
|
||||
900: '#265e99',
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [
|
||||
'PingFang SC',
|
||||
'Microsoft YaHei',
|
||||
'Helvetica Neue',
|
||||
'Helvetica',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
// 与 Element Plus 共存,禁用冲突的样式
|
||||
corePlugins: {
|
||||
preflight: false,
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
34
前端应用/tsconfig.json
Normal file
34
前端应用/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Path alias */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Types */
|
||||
"types": ["vite/client", "element-plus/global"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
前端应用/tsconfig.node.json
Normal file
11
前端应用/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
98
前端应用/vite.config.ts
Normal file
98
前端应用/vite.config.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
import AutoImport from 'unplugin-auto-import/vite'
|
||||
import Components from 'unplugin-vue-components/vite'
|
||||
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
|
||||
import compression from 'vite-plugin-compression'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
// Element Plus 自动导入
|
||||
AutoImport({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
imports: ['vue', 'vue-router', 'pinia'],
|
||||
dts: 'src/types/auto-imports.d.ts',
|
||||
}),
|
||||
Components({
|
||||
resolvers: [ElementPlusResolver()],
|
||||
dts: 'src/types/components.d.ts',
|
||||
}),
|
||||
// Gzip 压缩
|
||||
compression({
|
||||
algorithm: 'gzip',
|
||||
ext: '.gz',
|
||||
threshold: 10240, // 大于 10KB 才压缩
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
minify: 'esbuild',
|
||||
// 块大小警告阈值
|
||||
chunkSizeWarningLimit: 500,
|
||||
rollupOptions: {
|
||||
output: {
|
||||
chunkFileNames: 'assets/js/[name]-[hash].js',
|
||||
entryFileNames: 'assets/js/[name]-[hash].js',
|
||||
assetFileNames: 'assets/[ext]/[name]-[hash].[ext]',
|
||||
// 优化的代码分割策略
|
||||
manualChunks(id) {
|
||||
// Vue 核心库
|
||||
if (id.includes('node_modules/vue') ||
|
||||
id.includes('node_modules/vue-router') ||
|
||||
id.includes('node_modules/pinia')) {
|
||||
return 'vue-vendor'
|
||||
}
|
||||
// Element Plus
|
||||
if (id.includes('node_modules/element-plus')) {
|
||||
return 'element-plus'
|
||||
}
|
||||
// ECharts
|
||||
if (id.includes('node_modules/echarts') ||
|
||||
id.includes('node_modules/vue-echarts') ||
|
||||
id.includes('node_modules/zrender')) {
|
||||
return 'echarts'
|
||||
}
|
||||
// 其他第三方库
|
||||
if (id.includes('node_modules')) {
|
||||
return 'vendor'
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
// 资源内联阈值
|
||||
assetsInlineLimit: 4096,
|
||||
// CSS 代码分割
|
||||
cssCodeSplit: true,
|
||||
},
|
||||
// 预构建优化
|
||||
optimizeDeps: {
|
||||
include: [
|
||||
'vue',
|
||||
'vue-router',
|
||||
'pinia',
|
||||
'axios',
|
||||
'element-plus',
|
||||
'echarts',
|
||||
'vue-echarts',
|
||||
],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user