Commit ed5de1a0 by 龚桂斌

Merge branch 'master' into 'tht-project'

# Conflicts:
#   tht_project/static/src/view/equipment_status.html
parents bb40424e 6f51b7e9
......@@ -3,3 +3,4 @@ from . import inherit_production_task
from . import inherit_workcenter
from . import work_log
from . import inherit_roke_routing
from . import inherit_ir_http
import base64
import hashlib
import os
import re
from odoo import models, fields, http, api, _
from odoo.modules.module import get_resource_path, get_module_path
from odoo.tools.mimetypes import guess_mimetype
from odoo.tools import consteq, pycompat
class InheritIrHttp(models.AbstractModel):
_inherit = 'ir.http'
@classmethod
def _binary_ir_attachment_redirect_content(cls, record, default_mimetype='application/octet-stream'):
# mainly used for theme images attachemnts
base = http.request.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
if record.type == 'url' and record.url and base.startswith("https") and "http://121.37.69.162:9000" in record.url:
filename = filehash = None
mimetype = getattr(record, 'mimetype', False)
url = record.url.replace("http://121.37.69.162:9000", "https://minio.xbg.rokeris.com")
status = 301
content = url
else:
status = content = filename = filehash = None
mimetype = getattr(record, 'mimetype', False)
if record.type == 'url' and record.url:
# if url in in the form /somehint server locally
url_match = re.match("^/(\w+)/(.+)$", record.url)
if url_match:
module = url_match.group(1)
module_path = get_module_path(module)
module_resource_path = get_resource_path(module, url_match.group(2))
if module_path and module_resource_path:
module_path = os.path.join(os.path.normpath(module_path),
'') # join ensures the path ends with '/'
module_resource_path = os.path.normpath(module_resource_path)
if module_resource_path.startswith(module_path):
with open(module_resource_path, 'rb') as f:
content = base64.b64encode(f.read())
status = 200
filename = os.path.basename(module_resource_path)
mimetype = guess_mimetype(base64.b64decode(content), default=default_mimetype)
filehash = '"%s"' % hashlib.md5(pycompat.to_text(content).encode('utf-8')).hexdigest()
if not content:
status = 301
content = record.url
return status, content, filename, mimetype, filehash
......@@ -9,9 +9,10 @@
<div id="jzjx_work_center_state" class="row" style="display:flex;justify-content: center;align-items: center;font-size: 30px;">
<p style="color:#dee2e6;"><field name="deviceState"/></p>
</div>
<div id="work_center_wo"/>
</xpath>
<xpath expr="//div[@id='work_center_empty']" position="replace">
<div/>
<div id="work_center_empty"/>
</xpath>
</field>
</record>
......
# -*- coding: utf-8 -*-
from . import controllers
from . import models
\ No newline at end of file
# -*- coding: utf-8 -*-
{
'name': "融科-荏原",
'summary': """
Short (1 phrase/line) summary of the module's purpose, used as
subtitle on modules listing or apps.openerp.com""",
'description': """
Long description of module's purpose
""",
'author': "My Company",
'website': "http://www.yourcompany.com",
# Categories can be used to filter modules in modules listing
# Check https://github.com/odoo/odoo/blob/14.0/odoo/addons/base/data/ir_module_category_data.xml
# for the full list
'category': 'Uncategorized',
'version': '0.1',
# any module necessary for this one to work correctly
'depends': ['base'],
# always loaded
'data': [
# 'security/ir.model.access.csv',
'views/views.xml',
'views/templates.xml',
],
# only loaded in demonstration mode
'demo': [
'demo/demo.xml',
],
}
# -*- coding: utf-8 -*-
from . import controllers
\ No newline at end of file
import os
import datetime
import logging
import requests
from odoo.addons.roke_mes_client.controller import login as mes_login
from odoo import http, tools, SUPERUSER_ID
from jinja2 import FileSystemLoader, Environment
import pytz
from dateutil.relativedelta import relativedelta
_logger = logging.getLogger(__name__)
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
templateloader = FileSystemLoader(searchpath=BASE_DIR + "/static/src/view")
env = Environment(loader=templateloader)
class RokeMesThreeColourLight(http.Controller):
@http.route("/roke/three_color_light/device_state_list", type="http", auth='none', cors='*', csrf=False)
def device_state_list(self, **kwargs):
_self = http.request
factory_code = http.request.env(user=SUPERUSER_ID)['ir.config_parameter'].get_param('database.uuid', default="")
data = {"code": 1, "message": "请求通过", "data": {"factory_code": factory_code}}
template = env.get_template('equipment_status_qdry.html')
return template.render(data)
@http.route('/roke/workstation/plant/tree', type='json', auth='none', csrf=False, cors="*")
def get_roke_workstation_plant(self):
_self = http.request
no_icon = _self.jsonrequest.get("no_icon", False)
data = []
workshop_ids = http.request.env(user=SUPERUSER_ID)['roke.workshop'].search([
("plant_id", "=", False)
], order="name asc")
if len(workshop_ids) > 0:
data.append({
"id": 0,
"name": "未分配至车间",
"type": "plant",
"file_list": [],
"workshops": [
{
"id": workshop_id.id,
"name": workshop_id.name or "",
"type": "workshop",
"workshop_icon": workshop_id.workshop_icon or "",
"classes_id": workshop_id.classes_id.id,
"classes_name": workshop_id.classes_id.name or "",
"center_count": len(workshop_id.center_ids),
"file_list": [{
"name": attachment.name,
"file_type": attachment.mimetype,
"data": f"data:{attachment.mimetype};base64,{attachment.datas.decode('utf-8')}" if
not no_icon else "",
"url": self._get_attachment_file_url(attachment)
} for attachment in workshop_id.attachment_ids]
} for workshop_id in workshop_ids
]
})
plant_ids = http.request.env(user=SUPERUSER_ID)['roke.plant'].search([
], order="name asc")
for plant_id in plant_ids:
# print("plant_id", plant_id.company_ids)
data.append({
"id": plant_id.id,
"name": plant_id.name or "未分配至车间",
"type": "plant",
"file_list": [{
"name": attachment.name,
"file_type": attachment.mimetype,
"data": f"data:{attachment.mimetype};base64,{attachment.datas.decode('utf-8')}" if not no_icon
else "",
"url": self._get_attachment_file_url(attachment)
} for attachment in plant_id.attachment_ids],
"workshops": [
{
"id": workshop_id.id,
"name": workshop_id.name or "",
"type": "workshop",
"workshop_icon": workshop_id.workshop_icon or "",
"classes_id": workshop_id.classes_id.id,
"classes_name": workshop_id.classes_id.name or "",
"center_count": len(workshop_id.center_ids),
"file_list": [{
"name": attachment.name,
"file_type": attachment.mimetype,
"data": f"data:{attachment.mimetype};base64,{attachment.datas.decode('utf-8')}" if
not no_icon else "",
"url": self._get_attachment_file_url(attachment)
} for attachment in workshop_id.attachment_ids]
} for workshop_id in plant_id.workshop_ids
]
})
return {"code": 0, "message": "获取车间列表成功", "data": data}
\ No newline at end of file
<odoo>
<data>
<!--
<record id="object0" model="qdry_project.qdry_project">
<field name="name">Object 0</field>
<field name="value">0</field>
</record>
<record id="object1" model="qdry_project.qdry_project">
<field name="name">Object 1</field>
<field name="value">10</field>
</record>
<record id="object2" model="qdry_project.qdry_project">
<field name="name">Object 2</field>
<field name="value">20</field>
</record>
<record id="object3" model="qdry_project.qdry_project">
<field name="name">Object 3</field>
<field name="value">30</field>
</record>
<record id="object4" model="qdry_project.qdry_project">
<field name="name">Object 4</field>
<field name="value">40</field>
</record>
-->
</data>
</odoo>
\ No newline at end of file
# -*- coding: utf-8 -*-
from . import models
\ No newline at end of file
# -*- coding: utf-8 -*-
# from odoo import models, fields, api
# class qdry_project(models.Model):
# _name = 'qdry_project.qdry_project'
# _description = 'qdry_project.qdry_project'
# name = fields.Char()
# value = fields.Integer()
# value2 = fields.Float(compute="_value_pc", store=True)
# description = fields.Text()
#
# @api.depends('value')
# def _value_pc(self):
# for record in self:
# record.value2 = float(record.value) / 100
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_qdry_project_qdry_project,qdry_project.qdry_project,model_qdry_project_qdry_project,base.group_user,1,1,1,1
\ No newline at end of file
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>设备实时看板</title>
<meta content="width=device-width,initial-scale=1.0, maximum-scale=1.0,user-scalable=0" name="viewport" />
<!-- /roke_workstation_api/static/html/routing -->
<link rel="stylesheet" href="/roke_workstation_api/static/html/routing/element-ui/index.css" />
<script src="/roke_workstation_api/static/html/routing/js/echarts.min.js"></script>
<script src="/roke_workstation_api/static/html/routing/js/moment.min.js"></script>
<script src="/roke_workstation_api/static/html/routing/js/vue.js"></script>
<script src="/roke_workstation_api/static/html/routing/js/axios.min.js"></script>
<script src="/roke_workstation_api/static/html/routing/element-ui/index.js"></script>
</head>
<body id="bodyId" style="display: none">
<div id="app" v-loading.body.fullscreen.lock="loading" ref="fullScreenElement">
<!-- 页面标题 -->
<div class="dashboard-header">
<div class="header-content">
<img src="/roke_workstation_api/static/html/routing/image/header_bg.png" class="header-bg"
alt="header background" />
<span class="header-text">设备实时看板</span>
<span class="hintText">
<i style="font-size: 30px" @click="toggleFullscreen"
:class="isFullscreen ? 'el-icon-close' : 'el-icon-full-screen'"></i>
<span> [[currentTime ]]</span>
</span>
</div>
</div>
<!-- 设备状态卡片区域 - 新设计 -->
<div v-for="category in deviceList" :key="category.category_name">
<div class="device-category-name-container">[[category.category_name]]</div>
<div class="device-cards-container">
<div v-for="(device, index) in category.data" :key="index" class="device-card new-design"
:class="getCardClass(device.status)">
<!-- 设备名称 - 左上角 -->
<el-tooltip :disabled="device.name.length<16" effect="dark" :content="device.name" placement="top">
<div class="device-name">
[[truncateText(device.name, 16)]]
</div>
</el-tooltip>
<!-- 设备状态 - 右上角 -->
<div class="device-status-tag" :class="getStatusClass(device.status)">
[[getStatusText(device.status)]]
</div>
<!-- 设备状态水波纹 - 中间 -->
<div class="device-wave-container">
<!-- v-if="device.status === 'running' || device.status === 'waiting'" -->
<div class="oee-text">
OEE
</div>
<!-- <div class="err-text" v-if="device.status === 'error'" :class="getErrClass(device.status)">
ERR
</div>
<div class="off-text" v-if="device.status === 'off'" :class="getOffClass(device.status)">
OFF
</div> -->
<!-- v-if="device.status === 'running' || device.status === 'waiting'" -->
<div class="percentage-text">
[[ device.percentage > 100 ? 100 : device.percentage ]]%
</div>
<!-- 圆形容器 -->
<div class="circle-container" :class="getBorderClass(device.status)">
<!-- 水波纹效果 - 通过内联样式直接控制高度百分比 -->
<div class="water-wave" :class="getWaveClass(device.status)" :style="{
'height': getWaveHeight(device.status, device.percentage) + '%',
'bottom': '0',
'left': '0',
'position': 'absolute',
'width': '100%',
'overflow': 'hidden'
}">
<div class="water-wave-content">
<div class="water-wave-ripple"></div>
</div>
</div>
</div>
</div>
<!-- 设备状态信息 - 底部 -->
<div class="device-status-info">
<span>已持续 [[device.duration]]</span>
</div>
<div style="width: 100%; display: flex;margin-left: 13px;">
<div class="flxe_sty">
<div class="flxe_label_sty" style=" background-color: #00aa00;">开机</div>
[[ device.run_seconds ]]
</div>
<div class="flxe_sty">
<div class="flxe_label_sty" style=" background-color: #ffaa00;">加工</div>
[[ device.green_seconds ]]
</div>
</div>
<div style="width: 100%;display: flex;margin-left: 13px;">
<div class="flxe_sty">
<div class="flxe_label_sty" style=" background-color: #797979;">空闲</div>
[[ device.yellow_seconds ]]
</div>
<div class="flxe_sty">
<div class="flxe_label_sty" style=" background-color: #f87171;">故障</div>
[[ device.red_seconds ]]
</div>
</div>
</div>
</div>
</div>
<!-- 底部状态图 -->
<div class="status-chart glass-effect" v-if="false">
<!-- 这部分保留但不显示,如果将来需要可以启用 -->
<div class="section-title">
<span class="title-text">设备运行状态统计</span>
<div class="title-decoration"></div>
</div>
<div class="chart-header">
<el-select v-model="selectedDevice" placeholder="请选择设备" size="medium" @change="selDeviceChange">
<el-option v-for="item in deviceList" :label="item.name" :value="item.id" :key="item.id">
</el-option>
</el-select>
<h4>近10天设备运行状态统计</h4>
</div>
<div class="chart-container">
<!-- 图例区域 -->
<div class="flex_legend">
<div class="single_sty">
<div style="background-color: red"></div>
<span>故障</span>
</div>
<div class="single_sty">
<div style="background-color: yellow"></div>
<span>等待</span>
</div>
<div class="single_sty">
<div style="background-color: green"></div>
<span>运行</span>
</div>
<div class="single_sty">
<div style="background-color: gray"></div>
<span>关机</span>
</div>
</div>
<!-- Y轴刻度 -->
<div class="y-axis">
<span v-for="(value, index) in yAxisValues" :key="index" class="y-axis-label">
[[value]]
<span class="unit">h</span>
</span>
</div>
<!-- 图表主体区域 -->
<div class="chart-content">
<!-- 网格区域 -->
<div class="grid-area">
<!-- 背景网格 -->
<div class="">
<div class="horizontal-lines">
<div class="horizontal-line" v-for="(value, index) in yAxisValues" :key="index"></div>
</div>
</div>
<!-- 柱状图组 -->
<div class="columns-container">
<div class="column-group" v-for="(item, dayIndex) in pickingOrderList" :key="dayIndex">
<div class="column-stack">
<div v-for="(segment, stackIndex) in item.data" :key="stackIndex"
class="column-segment" :style="{
'height': calculateHeight(segment.value) + '%',
'background-color': segment.color,
'margin-top': '0px'
}"></div>
</div>
</div>
</div>
</div>
</div>
<!-- X轴 -->
<div class="x-axis">
<span v-for="(item, index) in pickingOrderList" :key="index" class="x-axis-label">
[[item.name_format]]
</span>
</div>
</div>
</div>
</div>
</body>
<script>
// 发送消息给父页面(关闭odoo的菜单弹窗)
document.addEventListener("click", () => {
window.parent.postMessage("hidePopover", "*");
});
let vue = new Vue({
el: "#app",
delimiters: ["[[", "]]"], // 替换原本vue的[[ key ]]取值方式(与odoo使用的jinja2冲突问题)
data() {
return {
isFullscreen: false, // 全屏状态
currentTime: null, // 当前时间
timer: null, // 定时器
windowHeight: window.innerHeight, // 窗口高度
// dwsURL: "https://workstation.rokeris.com", // 基地址
dwsURL: "", // 基地址
baseURL: "https://dws-platform.xbg.rokeris.com/dev-api", // 基地址
loading: false, // 全局加载效果
deviceList: [], // 设备列表
selectedDevice: null, // 选中的设备
yAxisValues: ["24", "20", "16", "12", "8", "4", "0"], // Y轴刻度值
pickingOrderList: [], // 拣选单列表
dateList: [], // 日期列表
start_time: "", // 开始时间
end_time: "", // 结束时间
refreshInterval: null, // 定时刷新计时器
factoryCode: "", // 公司CODE
allEquipmentData: [], // 所有已绑定设备数据
};
},
created() {
if (this.getUrlSearch("factory_code")) {
this.factoryCode = this.getUrlSearch("factory_code"); //截取url后面的参数
}
this.initCurrentTimeFn()
},
computed: {
// 选中设备的信息
selDeviceInfo() {
return this.deviceList.find((item) => item.id == this.selectedDevice);
},
},
async mounted() {
window.addEventListener("resize", this.handleResize);
this.$nextTick(() => {
document.getElementById("bodyId").style.display = "block";
});
// // 先加载测试数据以便查看效果
// this.initMockData();
// 初始化数据 - 实际使用时取消这行的注释
await this.initData();
// 获取最后指定天数的日期
this.dateList = this.getLastAssignDays();
// 设置定时刷新(每分钟刷新一次,静默模式)
this.refreshInterval = setInterval(() => {
this.initData(true); // 传入true表示静默刷新,不显示加载提示
}, 60000);
// 设置设备信息的title属性
this.$nextTick(() => {
document.querySelectorAll(".device-info").forEach((el) => {
el.title = el.dataset.fullTitle;
});
});
},
updated() {
// 在数据更新后也设置title属性
this.$nextTick(() => {
document.querySelectorAll(".device-info").forEach((el) => {
el.title = el.dataset.fullTitle;
});
});
},
beforeDestroy() {
// 清除定时器
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
if (this.timer) {
clearInterval(this.timer)
}
// 移除事件监听
window.removeEventListener("resize", this.handleResize);
},
methods: {
// 全屏icon点击事件
toggleFullscreen: function () {
if (!this.isFullscreen) {
this.enterFullScreen();
} else {
this.exitFullScreen();
}
},
// 全屏方法
enterFullScreen: function () {
// 获取需要全屏的元素
var elem = this.$refs.fullScreenElement;
if (elem && elem.requestFullscreen) {
elem.requestFullscreen();
} else if (elem && elem.mozRequestFullScreen) {
// Firefox
elem.mozRequestFullScreen();
} else if (elem && elem.webkitRequestFullscreen) {
// Chrome, Safari & Opera
elem.webkitRequestFullscreen();
} else if (elem && elem.msRequestFullscreen) {
// IE/Edge
elem.msRequestFullscreen();
}
this.isFullscreen = true;
},
exitFullScreen: function () {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.mozCancelFullScreen) {
// Firefox
document.mozCancelFullScreen();
} else if (document.webkitExitFullscreen) {
// Chrome, Safari and Opera
document.webkitExitFullscreen();
} else if (document.msExitFullscreen) {
// IE/Edge
document.msExitFullscreen();
}
this.isFullscreen = false;
},
// 初始化当前时间
initCurrentTimeFn() {
this.timer = setInterval(() => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
this.currentTime = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}, 1000)
},
// 通过网址跳转过来的页面,截取后面的参数
getUrlSearch(name) {
// 未传参,返回空
if (!name) return "";
// 查询参数:先通过search取值,如果取不到就通过hash来取
var after = window.location.search;
after = after.substr(1) || window.location.hash.split("?")[1];
// 地址栏URL没有查询参数,返回空
if (!after) return null;
// 如果查询参数中没有"name",返回空
if (after.indexOf(name) === -1) return null;
var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)");
// 当地址栏参数存在中文时,需要解码,不然会乱码
var r = decodeURI(after).match(reg);
// 如果url中"name"没有值,返回空
if (!r) return null;
return r[2];
},
// 初始化数据
async initData(silent = false) {
try {
// 只有在非静默模式下才显示加载提示
if (!silent) {
this.loading = true;
}
// 并行请求设备计划运行时间和所有已绑定设备数据
const [planTimeResult, allEquipmentResult] = await Promise.all([
this.getDevicePlanTime(),
this.getAllEquipmentData(),
]);
} catch (error) {
// console.error("初始化数据出错:", error);
// 只有在非静默模式下才显示错误提示
if (!silent) {
this.$message.error("初始化数据出错: " + (error.message || "未知错误"));
}
// 如果接口请求失败,使用模拟数据
this.initMockData();
} finally {
// 关闭加载提示
if (!silent) {
this.loading = false;
}
}
},
// 获取设备计划运行时间
async getDevicePlanTime() {
try {
// 发送请求获取计划运行时间
const response = await axios({
method: "post",
url: this.dwsURL + "/roke/workstation/equipment/get_plan_time",
data: {},
headers: { "Content-Type": "application/json" },
});
// 处理JSON-RPC格式的响应
if (response.data && response.data.result && response.data.result.code === 0) {
// 获取计划运行时间数据
const planTimeList = response.data.result.data || {}
if (this.factoryCode) {
// 调用设备状态接口
await this.getDeviceStateList(planTimeList);
}
else {
await this.getFactoryCode(planTimeList);
}
return planTimeList;
} else {
const errorMsg = response.data.result
? response.data.result.message
: "获取计划运行时间失败";
throw new Error(errorMsg);
}
} catch (error) {
// console.error("获取计划运行时间出错:", error);
throw error;
}
},
// 获取所有已绑定设备数据
async getAllEquipmentData() {
try {
// 发送请求获取所有已绑定设备数据
const response = await axios({
method: "post",
url: this.dwsURL + "/roke/workstation/equipment/get_equipment_data",
data: {},
headers: {
"Content-Type": "application/json",
},
});
// 处理JSON-RPC格式的响应
if (response.data && response.data.result && response.data.result.code === 0) {
// 存储所有设备数据
this.allEquipmentData = response.data.result.data || []
return this.allEquipmentData;
} else {
const errorMsg = response.data.result
? response.data.result.message
: "获取所有已绑定设备数据失败";
throw new Error(errorMsg);
}
} catch (error) {
// console.error("获取所有已绑定设备数据出错:", error);
throw error;
}
},
// 获取设备状态列表
async getDeviceStateList(planTimeList) {
try {
// 使用CORS代理
// 发送请求获取设备状态
const response = await axios({
method: "POST",
url: this.baseURL + "/public/device_efficiency/device_state_list",
data: {
factory_code: this.factoryCode,
plan_time_list: planTimeList,
},
headers: {
"Content-Type": "application/json",
},
});
// 处理响应
if (response.data && response.data.success) {
// 处理设备状态数据
this.processDeviceStateData(response.data.data);
} else {
throw new Error(response.data.msg || "获取设备状态失败");
}
} catch (error) {
// console.error("获取设备状态出错:", error);
throw error;
}
},
// 获取factoryCode
async getFactoryCode(planTimeList) {
try {
// 使用CORS代理
// 发送请求获取设备状态
const response = await axios({
method: "post",
url: this.dwsURL + "/roke/workstation/db_uuid/get",
data: {},
headers: { "Content-Type": "application/json" },
});
// 处理JSON-RPC格式的响应
if (response.data && response.data.result && response.data.result.code === 0) {
// 获取计划运行时间数据
this.factoryCode = response.data.result.data || "";
// 调用设备状态接口
await this.getDeviceStateList(planTimeList);
} else {
const errorMsg = response.data.result
? response.data.result.message
: "获取账套db_uuid失败";
throw new Error(errorMsg);
}
} catch (error) {
// console.error("获取账套db_uuid失败:", error);
throw error;
}
},
// 处理设备状态数据
processDeviceStateData(deviceStateData) {
if (!deviceStateData || !Array.isArray(deviceStateData)) {
return;
}
// 将API返回的数据转换为页面所需的格式
let list = deviceStateData.map((device) => {
// 根据API返回的状态确定前端显示的状态
let status = "off";
if (device.state === "green") {
status = "running";
} else if (device.state === "yellow") {
status = "waiting";
} else if (device.state === "red") {
status = "error";
} else if (device.state === "gray") {
status = "off";
}
// 计算持续时间的显示格式
let durationText = "0"
if (device.duration_hours !== undefined) { durationText = this.formatTime(Number(device.duration_hours * 3600)) }
let run_seconds = "0"
if (device.run_seconds !== undefined) run_seconds = this.formatTime(Number(device.run_seconds))
let green_seconds = "0"
if (device.green_seconds !== undefined) green_seconds = this.formatTime(Number(device.green_seconds))
let yellow_seconds = "0"
if (device.yellow_seconds !== undefined) yellow_seconds = this.formatTime(Number(device.yellow_seconds))
let red_seconds = "0"
if (device.red_seconds !== undefined) yellow_seconds = this.formatTime(Number(device.yellow_seconds))
// 计算利用率百分比,确保有效值
const percentage = device.utilization_rate !== undefined ? Math.round(device
.utilization_rate) : 0
// 从所有设备列表中获取准确的设备名称
let deviceName = device.name || device.code // 默认使用API返回的名称或编码
let categoryName = null
let categorySequence = null
// 在所有设备列表中查找匹配的设备
if (this.allEquipmentData && this.allEquipmentData.length > 0) {
const matchedDevice = this.allEquipmentData.find(
(equip) => equip.code === device.code
)
// 如果找到匹配的设备,使用其名称
if (matchedDevice && matchedDevice.name) {
deviceName = device.name ? matchedDevice.name : device.code
categoryName = matchedDevice.category_name
categorySequence = matchedDevice.category_sequence
}
}
return {
id: device.code,
code: device.code,
name: deviceName,
status: status,
percentage: percentage,
duration: durationText,
run_seconds: run_seconds,
green_seconds: green_seconds,
yellow_seconds: yellow_seconds,
red_seconds: red_seconds,
category_name: categoryName,
category_sequence: categorySequence
}
});
// 按category_name分组并组装成指定结构
const grouped = {};
list.forEach(item => {
const cname = item.category_name || '未分类';
if (!grouped[cname]) grouped[cname] = [];
grouped[cname].push(item);
});
// 组装成 [{category_name:'', data:[]}, ...] 并排序
const result = Object.keys(grouped).map(category_name => {
// 每组内部按percentage降序排序
const data = grouped[category_name].sort((a, b) => b.percentage - a.percentage);
// 取第一个的category_sequence作为分组的category_sequence
const category_sequence = data[0]?.category_sequence ?? 9999;
return { category_name, category_sequence, data };
});
// 最终按category_sequence从小到大排序
result.sort((a, b) => (a.category_sequence ?? 9999) - (b.category_sequence ?? 9999));
this.deviceList = result
},
formatTime(seconds) {
if (seconds < 60) {
return `0min`; // 不足 1 分钟显示 0min
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60); // 转换为分钟
return `${minutes}min`; // 显示分钟
} else if (seconds < 86400) { // 小于 1 天
const hours = Math.floor(seconds / 3600); // 转换为小时
return `${hours}h`; // 只返回小时
} else {
const days = Math.floor(seconds / 86400); // 转换为天数
return `${days}d`; // 只返回天
}
},
// 初始化模拟数据 (保留原有的模拟数据方法,作为备用)
initMockData() {
// 模拟设备数据 - 添加不同状态和明显不同百分比的测试数据
this.deviceList = [
// {
// id: "device001",
// code: "device001",
// name: "设备名称1",
// status: "running", // 运行中
// percentage: 90, // 非常高的百分比
// duration: "2h15m",
// },
// {
// id: "device002",
// code: "device002",
// name: "设备名称2",
// status: "error", // 故障中
// percentage: 50, // 中等百分比
// duration: "4h30m",
// },
// {
// id: "device003",
// code: "device003",
// name: "设备名称3",
// status: "waiting", // 等待中
// percentage: 20, // 非常低的百分比
// duration: "1h45m",
// },
// {
// id: "device004",
// code: "device004",
// name: "设备名称4",
// status: "off", // 停机中
// percentage: 50, // 中等百分比
// duration: "8h20m",
// },
];
// 添加测试日志
console.log("测试数据初始化完成,设备列表:", this.deviceList);
},
// 获取卡片的CSS类名
getCardClass(status) {
switch (status) {
case "running":
return "card-running";
case "waiting":
return "card-waiting";
case "error":
return "card-error";
case "off":
default:
return "card-off";
}
},
// 获取边框的CSS类名
getBorderClass(status) {
switch (status) {
case "running":
return "border-running";
case "waiting":
return "border-waiting";
case "error":
return "border-error";
case "off":
default:
return "border-off";
}
},
// 获取设备状态对应的CSS类名
getStatusClass(status) {
switch (status) {
case "running":
return "status-running";
case "waiting":
return "status-waiting";
case "error":
return "status-error";
case "off":
default:
return "status-off";
}
},
// 获取波浪效果的类名
getWaveClass(status) {
switch (status) {
case "running":
return "wave-running";
case "waiting":
return "wave-waiting";
case "error":
return "wave-error";
case "off":
default:
return "wave-off";
}
},
// 获取波浪高度
getWaveHeight(status, percentage) {
// 将百分比限制在10%-100%之间,确保即使是低百分比也有一定的水位可见
let height = percentage;
// 如果是故障或停机状态,固定显示50%
// if (status === "error" || status === "off") {
// height = 50;
// }
// 确保最小高度为10%,最大为100%
height = Math.min(Math.max(height, 10), 100);
return height;
},
// 获取设备状态对应的文本
getStatusText(status) {
switch (status) {
case "running":
return "运行中";
case "waiting":
return "等待中";
case "error":
return "故障中";
case "off":
default:
return "停机中";
}
},
// 处理小数位数方法
toFixedHandle(value, num = 4) {
if (value) {
let strValue = String(value);
if (strValue.split(".").length > 1 || strValue.split(".")[1]?.length > num) {
strValue = Number(strValue).toFixed(num);
}
return Number(strValue);
} else {
return 0;
}
},
// 计算高度百分比
calculateHeight(hours) {
// 24小时对应整个高度(100%)
// 每4小时对应一个刻度区间,总共6个区间
// 计算每小时占的百分比:100% / 24 ≈ 4.167%
const heightPerHour = 100 / 24;
const percentage = hours * heightPerHour;
// 确保高度在0-100%之间
return Math.min(Math.max(percentage, 0), 100);
},
// 获取最后指定天数的日期
getLastAssignDays(num = 10) {
const dates = [];
for (let i = num - 1; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
dates.push(`${year}-${month}-${day}`);
}
this.start_time = dates[0]; // 第一天
this.end_time = dates[dates.length - 1]; // 最后一天
return dates;
},
// 处理窗口大小变化
handleResize() {
// 窗口大小变化时的处理逻辑
},
// 文本截断方法
truncateText(text, maxLength) {
if (!text) return "";
if (text.length <= maxLength) return text;
return text.substring(0, maxLength);
},
// 显示完整标题
showFullTitle(event, device) {
const fullTitle = device.name + " | " + device.code;
event.target.setAttribute("title", fullTitle);
},
// 获取ERR文字的CSS类名
getErrClass(status) {
if (status === "error") {
return "err-error";
}
return "";
},
// 获取OFF文字的CSS类名
getOffClass(status) {
if (status === "off") {
return "off-status";
}
return "";
},
},
});
</script>
<style>
/* 基础样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
color: #fff;
overflow: hidden;
}
#app {
width: 100vw;
height: 100vh;
padding: 20px;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
background: url("/roke_workstation_api/static/html/routing/image/bg-ok.png") no-repeat center center fixed;
background-size: cover;
}
/* 玻璃态效果 */
.glass-effect {
background: rgba(22, 41, 60, 0.8);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 15px;
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
}
/* 标题样式 */
.dashboard-header {
width: 100vw;
margin: -20px -20px 10px -20px;
display: flex;
justify-content: center;
align-items: center;
}
.header-content {
width: 100%;
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.header-bg {
width: 100%;
height: auto;
}
.header-text {
position: absolute;
font-size: 28px;
font-weight: 450;
color: #fff;
text-shadow: 0 0 10px rgba(0, 195, 255, 0.5);
z-index: 1;
}
.hintText {
position: absolute;
font-size: 14px;
bottom: -5px;
right: 30px;
color: rgba(255, 255, 255, 0.85);
display: -webkit-box;
display: -webkit-flex;
display: -moz-box;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-webkit-align-items: center;
-moz-align-items: center;
-ms-flex-align: center;
align-items: center;
}
.hintText>* {
margin-left: 10px;
}
.hintText>*:first-child {
margin-left: 0;
}
.device-category-name-container {
width: 100%;
padding: 0px 15px;
font-weight: bold;
font-size: 20px;
}
/* 设备卡片容器 */
.device-cards-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px;
padding: 15px;
}
/* 新设计设备卡片样式 */
.device-card.new-design {
position: relative;
padding: 5px 10px 10px;
display: flex;
flex-direction: column;
align-items: center;
min-height: 180px;
border-radius: 12px;
transition: all 0.3s ease;
background-color: rgba(20, 22, 28, 0.95);
/* 更深的背景色 */
}
/* 卡片颜色和阴影 - 调整为适度柔和的阴影效果 */
.card-running {
border: none;
box-shadow: 0 0 0 1px rgba(78, 198, 138, 0.5), 5px 5px 2px 0 rgba(78, 198, 138, 0.7),
5px 5px 15px 2px rgba(78, 198, 138, 0.5);
}
.card-waiting {
border: none;
box-shadow: 0 0 0 1px rgba(235, 186, 22, 0.5), 5px 5px 2px 0 rgba(235, 186, 22, 0.7),
5px 5px 15px 2px rgba(235, 186, 22, 0.5);
}
.card-error {
border: none;
box-shadow: 0 0 0 1px rgba(235, 86, 86, 0.5), 5px 5px 2px 0 rgba(235, 86, 86, 0.7),
5px 5px 15px 2px rgba(235, 86, 86, 0.5);
}
.card-off {
border: none;
box-shadow: 0 0 0 1px rgba(144, 147, 153, 0.5), 5px 5px 2px 0 rgba(144, 147, 153, 0.7),
5px 5px 15px 2px rgba(144, 147, 153, 0.5);
}
.device-card.new-design:hover {
transform: translateY(-3px);
box-shadow: 0 0 0 1px rgba(78, 198, 138, 0.5), 7px 7px 4px 0 rgba(78, 198, 138, 0.7),
7px 7px 20px 2px rgba(78, 198, 138, 0.6);
}
/* 设备名称 - 左上角 */
.device-name {
align-self: flex-start;
font-size: 14px;
font-weight: bold;
color: #fff;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
/* text-overflow: ellipsis; */
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
margin-bottom: 5px;
}
/* 设备状态标签 - 右上角 */
.device-status-tag {
position: absolute;
top: 25px;
right: 5px;
padding: 2px 5px 5px;
border-radius: 4px;
font-size: 12px;
font-weight: bold;
color: #fff;
min-width: 50px;
text-align: center;
}
/* 移除状态标签的阴影 */
.status-running {
background-color: rgba(78, 198, 138, 0.9);
box-shadow: none;
}
.status-waiting {
background-color: rgba(235, 186, 22, 0.9);
box-shadow: none;
}
.status-error {
background-color: rgba(235, 86, 86, 0.9);
box-shadow: none;
}
.status-off {
background-color: rgba(144, 147, 153, 0.9);
box-shadow: none;
}
/* 设备波纹容器 */
.device-wave-container {
width: 100%;
height: 120px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
}
/* OEE文字 */
.oee-text {
position: absolute;
top: 30px;
font-size: 16px;
font-weight: bold;
color: #fff;
z-index: 3;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
/* ERR文字 */
.err-text {
position: absolute;
top: 30px;
font-size: 16px;
font-weight: bold;
z-index: 3;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
left: 50%;
transform: translateX(-50%);
}
/* ERR颜色 */
.err-error {
color: rgba(235, 86, 86, 0.9);
}
/* OFF文字 */
.off-text {
position: absolute;
top: 30px;
font-size: 16px;
font-weight: bold;
z-index: 3;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
left: 50%;
transform: translateX(-50%);
}
/* OFF颜色 */
.off-status {
color: rgba(144, 147, 153, 0.9);
}
/* 百分比文字 */
.percentage-text {
position: absolute;
top: 50px;
font-size: 25px;
font-weight: bold;
color: #fff;
z-index: 3;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
}
/* 圆形容器 */
.circle-container {
width: 100px;
height: 100px;
border-radius: 50%;
overflow: hidden;
position: relative;
background-color: rgba(10, 12, 15, 0.8);
}
/* 移除圆形容器的阴影 */
.border-running {
border: 2px solid rgba(78, 198, 138, 0.9);
box-shadow: none;
}
.border-waiting {
border: 2px solid rgba(235, 186, 22, 0.9);
box-shadow: none;
}
.border-error {
border: 2px solid rgba(235, 86, 86, 0.9);
box-shadow: none;
}
.border-off {
border: 2px solid rgba(144, 147, 153, 0.9);
box-shadow: none;
}
/* 水波纹 */
.water-wave {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
transition: height 0.7s ease;
overflow: hidden;
border-radius: 0 0 50px 50px;
}
/* 水波纹颜色 */
.wave-running {
background-color: rgba(78, 198, 138, 0.7);
}
.wave-waiting {
background-color: rgba(235, 186, 22, 0.7);
}
.wave-error {
background-color: rgba(235, 86, 86, 0.7);
}
.wave-off {
background-color: rgba(144, 147, 153, 0.7);
}
/* 水波纹内容 - 设置相对定位以包含波浪元素 */
.water-wave-content {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
/* 波浪容器 - 水平波浪效果 */
.water-wave-ripple {
width: 200%;
height: 100%;
position: absolute;
bottom: 0;
left: 0;
background: transparent;
z-index: 2;
}
/* 波浪图形 - 使用SVG背景实现波浪形状 */
.water-wave-ripple::before {
content: "";
position: absolute;
top: -12px;
left: 0;
width: 100%;
height: 25px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 120' preserveAspectRatio='none'%3E%3Cpath fill='white' opacity='0.5' d='M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.84,104.45-29.34C989.49,25,1113,-14.29,1200,52.47V0Z'%3E%3C/path%3E%3C/svg%3E");
background-size: 100% 100%;
-webkit-animation: wave-horizontal 6s linear infinite;
animation: wave-horizontal 6s linear infinite;
}
/* 第二层波浪 - 与第一层错开,增强效果 */
.water-wave-ripple::after {
content: "";
position: absolute;
top: -14px;
left: 0;
width: 100%;
height: 28px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 120' preserveAspectRatio='none'%3E%3Cpath fill='white' opacity='0.3' d='M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.84,104.45-29.34C989.49,25,1113,-14.29,1200,52.47V0Z'%3E%3C/path%3E%3C/svg%3E");
background-size: 100% 100%;
-webkit-animation: wave-horizontal 8s linear infinite;
animation: wave-horizontal 8s linear infinite;
animation-delay: -2s;
}
/* 为不同状态设置不同波浪颜色 */
.wave-running .water-wave-ripple::before,
.wave-running .water-wave-ripple::after {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 120' preserveAspectRatio='none'%3E%3Cpath fill='%234EC68A' opacity='0.5' d='M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.84,104.45-29.34C989.49,25,1113,-14.29,1200,52.47V0Z'%3E%3C/path%3E%3C/svg%3E"),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 120' preserveAspectRatio='none'%3E%3Cpath fill='%234EC68A' opacity='0.3' d='M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.84,104.45-29.34C989.49,25,1113,-14.29,1200,52.47V0Z'%3E%3C/path%3E%3C/svg%3E");
}
.wave-waiting .water-wave-ripple::before,
.wave-waiting .water-wave-ripple::after {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 120' preserveAspectRatio='none'%3E%3Cpath fill='%23EBBA16' opacity='0.5' d='M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.84,104.45-29.34C989.49,25,1113,-14.29,1200,52.47V0Z'%3E%3C/path%3E%3C/svg%3E"),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 120' preserveAspectRatio='none'%3E%3Cpath fill='%23EBBA16' opacity='0.3' d='M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.84,104.45-29.34C989.49,25,1113,-14.29,1200,52.47V0Z'%3E%3C/path%3E%3C/svg%3E");
}
.wave-error .water-wave-ripple::before,
.wave-error .water-wave-ripple::after {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 120' preserveAspectRatio='none'%3E%3Cpath fill='%23EB5656' opacity='0.5' d='M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.84,104.45-29.34C989.49,25,1113,-14.29,1200,52.47V0Z'%3E%3C/path%3E%3C/svg%3E"),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 120' preserveAspectRatio='none'%3E%3Cpath fill='%23EB5656' opacity='0.3' d='M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.84,104.45-29.34C989.49,25,1113,-14.29,1200,52.47V0Z'%3E%3C/path%3E%3C/svg%3E");
}
.wave-off .water-wave-ripple::before,
.wave-off .water-wave-ripple::after {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 120' preserveAspectRatio='none'%3E%3Cpath fill='%23909399' opacity='0.5' d='M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.84,104.45-29.34C989.49,25,1113,-14.29,1200,52.47V0Z'%3E%3C/path%3E%3C/svg%3E"),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1200 120' preserveAspectRatio='none'%3E%3Cpath fill='%23909399' opacity='0.3' d='M0,0V46.29c47.79,22.2,103.59,32.17,158,28,70.36-5.37,136.33-33.31,206.8-37.5C438.64,32.43,512.34,53.67,583,72.05c69.27,18,138.3,24.88,209.4,13.08,36.15-6,69.85-17.84,104.45-29.34C989.49,25,1113,-14.29,1200,52.47V0Z'%3E%3C/path%3E%3C/svg%3E");
}
/* 顶部发光效果 */
.water-wave::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 8px;
background: linear-gradient(to bottom, rgba(255, 255, 255, 0.7), transparent);
z-index: 10;
}
/* 设备状态信息 */
.device-status-info {
font-size: 12px;
color: rgba(255, 255, 255, 0.85);
text-align: center;
line-height: 1.5;
}
/* 响应式调整 */
@media (max-width: 768px) {
.device-cards-container {
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
.device-card.new-design {
min-height: 160px;
padding: 10px;
}
.circle-container {
width: 80px;
height: 80px;
}
.oee-text,
.err-text,
.off-text {
top: 20px;
font-size: 14px;
}
.percentage-text {
top: 40px;
font-size: 20px;
}
}
/* 为Safari和iOS设备的特别修复 */
@supports (-webkit-appearance: none) {
.water-wave-ripple::before,
.water-wave-ripple::after {
-webkit-animation-play-state: running;
animation-play-state: running;
}
}
/* 波浪水平移动动画 */
@-webkit-keyframes wave-horizontal {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
@keyframes wave-horizontal {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
.flxe_sty {
width: 50%;
display: flex;
align-items: center;
font-size: 10px;
color: #fff;
margin-top: 5px;
.flxe_label_sty {
margin-right: 2px;
font-weight: bold;
color: #000;
display: flex;
justify-content: center;
align-items: center;
font-size: 10px;
width: 30px;
height: auto;
border-radius: 2px;
margin-left: 10px;
}
}
</style>
</html>
\ No newline at end of file
<odoo>
<data>
<!--
<template id="listing">
<ul>
<li t-foreach="objects" t-as="object">
<a t-attf-href="#{ root }/objects/#{ object.id }">
<t t-esc="object.display_name"/>
</a>
</li>
</ul>
</template>
<template id="object">
<h1><t t-esc="object.display_name"/></h1>
<dl>
<t t-foreach="object._fields" t-as="field">
<dt><t t-esc="field"/></dt>
<dd><t t-esc="object[field]"/></dd>
</t>
</dl>
</template>
-->
</data>
</odoo>
\ No newline at end of file
<odoo>
<data>
<!-- explicit list view definition -->
<!--
<record model="ir.ui.view" id="qdry_project.list">
<field name="name">qdry_project list</field>
<field name="model">qdry_project.qdry_project</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
<field name="value"/>
<field name="value2"/>
</tree>
</field>
</record>
-->
<!-- actions opening views on models -->
<!--
<record model="ir.actions.act_window" id="qdry_project.action_window">
<field name="name">qdry_project window</field>
<field name="res_model">qdry_project.qdry_project</field>
<field name="view_mode">tree,form</field>
</record>
-->
<!-- server action to the one above -->
<!--
<record model="ir.actions.server" id="qdry_project.action_server">
<field name="name">qdry_project server</field>
<field name="model_id" ref="model_qdry_project_qdry_project"/>
<field name="state">code</field>
<field name="code">
action = {
"type": "ir.actions.act_window",
"view_mode": "tree,form",
"res_model": model._name,
}
</field>
</record>
-->
<!-- Top menu item -->
<!--
<menuitem name="qdry_project" id="qdry_project.menu_root"/>
-->
<!-- menu categories -->
<!--
<menuitem name="Menu 1" id="qdry_project.menu_1" parent="qdry_project.menu_root"/>
<menuitem name="Menu 2" id="qdry_project.menu_2" parent="qdry_project.menu_root"/>
-->
<!-- actions -->
<!--
<menuitem name="List" id="qdry_project.menu_1_list" parent="qdry_project.menu_1"
action="qdry_project.action_window"/>
<menuitem name="Server to list" id="qdry_project" parent="qdry_project.menu_2"
action="qdry_project.action_server"/>
-->
</data>
</odoo>
\ No newline at end of file
import datetime
import pandas as pd
import xlsxwriter
from odoo import http, tools
from odoo.http import content_disposition, request
import os
import io
from jinja2 import FileSystemLoader, Environment
import logging
_logger = logging.getLogger(__name__)
......@@ -32,10 +35,19 @@ class ProductIncomeExpenseIframe(http.Controller):
"business_date": v.get("business_date", False),
"abstract": v.get("abstract", False),
"income": v.get("income", False),
"expenditure": v.get("expenditure", False)
"machinery_type": v.get("machinery_type", "其他"),
"expenditure": v.get("expenditure", False),
"customer": v.get("customer", False)
}
_self.env(user=v.get("user_id"))["roke.product.income.expense"].create(data)
return {"code": 0, "message": "创建成功!"}
# 看是否有id,如果有的话就说明是更新,没有的话就说明是创建
if v.get("id", False):
expense_obj = _self.env["roke.product.income.expense"].sudo().search([("id", "=", v.get("id"))])
if not expense_obj:
return {"code": 1, "message": "更新失败,没找到对应数据。"}
expense_obj.write(data)
else:
_self.env(user=v.get("user_id"))["roke.product.income.expense"].create(data)
return {"code": 0, "message": "操作成功!"}
@http.route("/roke/product/product_income_expense/get", type="json", auth='none', cors='*', csrf=False)
def product_income_expense_get_list(self):
......@@ -44,10 +56,22 @@ class ProductIncomeExpenseIframe(http.Controller):
page = _self.jsonrequest.get("page", 1)
start_date = _self.jsonrequest.get("start_date", "")
end_date = _self.jsonrequest.get("end_date", "")
type_str = _self.jsonrequest.get("type_str", False) # income收入/expenditure支出
machinery_type = _self.jsonrequest.get("machinery_type", False)
customer = _self.jsonrequest.get("customer", False)
abstract = _self.jsonrequest.get("abstract", False)
domain = []
if start_date and end_date:
domain.append(("business_date", ">=", start_date))
domain.append(("business_date", "<=", end_date))
if type_str:
domain.append((type_str, ">", 0))
if machinery_type:
domain.append(("machinery_type", "=", machinery_type))
if customer:
domain.append(("customer", "ilike", customer))
if abstract:
domain.append(("abstract", "ilike", abstract))
data_list = _self.env["roke.product.income.expense"].sudo().search(domain, limit=limit,
offset=(page - 1) * limit,
order="business_date desc, create_date desc")
......@@ -58,9 +82,11 @@ class ProductIncomeExpenseIframe(http.Controller):
"id": v.id,
"business_date": v.business_date and v.business_date.strftime('%Y-%m-%d'),
"abstract": v.abstract or "",
"income": v.income or 0,
"expenditure": v.expenditure or 0,
"balance": v.balance or 0,
"customer": v.customer or "",
"income": round(v.income, 2) or 0,
"machinery_type": v.machinery_type or "其他",
"expenditure": round(v.expenditure) or 0,
"balance": round(v.balance, 2) or 0,
"user_name": v.create_uid.name or "",
"create_date": (v.create_date + datetime.timedelta(hours=8)).strftime('%Y-%m-%d %H:%M'),
})
......@@ -77,3 +103,88 @@ class ProductIncomeExpenseIframe(http.Controller):
return {"code": 1, "message": "删除失败,没找到对应数据。"}
data.unlink()
return {"code": 0, "message": "删除成功!"}
@http.route("/roke/product/product_income_expense/export", type="http", auth='none', cors='*', csrf=False)
def get_product_income_expense_export(self, **kwargs):
_self = http.request
start_date = kwargs.get("start_date", "")
end_date = kwargs.get("end_date", "")
type_str = kwargs.get("type_str", False) # income收入/expenditure支出
machinery_type = kwargs.get("machinery_type", False)
customer = kwargs.get("customer", False)
abstract = kwargs.get("abstract", False)
domain = []
if start_date and end_date:
domain.append(("business_date", ">=", start_date))
domain.append(("business_date", "<=", end_date))
if type_str:
domain.append((type_str, ">", 0.0))
if machinery_type:
domain.append(("machinery_type", "=", machinery_type))
if customer:
domain.append(("customer", "ilike", customer))
if abstract:
domain.append(("abstract", "ilike", abstract))
data_list = _self.env["roke.product.income.expense"].sudo().search(domain, order="business_date desc, create_date desc")
data = []
for v in data_list:
data.append({
"business_date": v.business_date,
"abstract": v.abstract or "",
"customer": v.customer or "",
"income": v.income or 0,
"expenditure": v.expenditure or 0,
"balance": v.balance or 0,
"machinery_type": v.machinery_type or '其他',
"user_name": v.create_uid.name or "",
"create_date": v.create_date + datetime.timedelta(hours=8),
})
output = io.BytesIO()
workbook = xlsxwriter.Workbook(output, {'in_memory': True})
worksheet = workbook.add_worksheet('Sheet1')
header_format = workbook.add_format({
'bold': True, 'border': 1,
'fg_color': '#17a2b8', 'font_color': '#FFFFFF',
'align': 'center', 'valign': 'vcenter'
})
date_format = workbook.add_format({'num_format': 'YYYY-MM-DD'})
datetime_format = workbook.add_format({'num_format': 'YYYY-MM-DD HH:MM'})
currency_format = workbook.add_format({'num_format': '#,##0.00'}) # 逗号作为千分位分隔符
worksheet.write(0, 0, "业务日期", header_format)
worksheet.write(0, 1, "摘要", header_format)
worksheet.write(0, 2, "客户", header_format)
worksheet.write(0, 3, "收入", header_format)
worksheet.write(0, 4, "支出", header_format)
worksheet.write(0, 5, "结余", header_format)
worksheet.write(0, 6, "类型", header_format)
worksheet.write(0, 7, "创建人", header_format)
worksheet.write(0, 8, "创建时间", header_format)
for row_num, row_data in enumerate(data):
worksheet.write_datetime(row_num + 1, 0, row_data.get("business_date"), date_format)
worksheet.write(row_num + 1, 1, row_data.get("abstract"))
worksheet.write(row_num + 1, 2, row_data.get("customer"))
worksheet.write_number(row_num + 1, 3, row_data.get("income"), currency_format)
worksheet.write_number(row_num + 1, 4, row_data.get("expenditure"), currency_format)
worksheet.write_number(row_num + 1, 5, row_data.get("balance"), currency_format)
worksheet.write(row_num + 1, 6, row_data.get("machinery_type"))
worksheet.write(row_num + 1, 7, row_data.get("user_name"))
worksheet.write_datetime(row_num + 1, 8, row_data.get("create_date"), datetime_format)
workbook.close()
output.seek(0)
file_name = '财务收支记录.xlsx'
response = request.make_response(
None,
headers=[
('Content-Type', 'application/vnd.ms-excel'),
('Content-Disposition', content_disposition(file_name))
]
)
response.stream.write(output.read())
output.close()
return response
......@@ -14,10 +14,13 @@ class ProductIncomeExpense(models.Model):
income = fields.Float(string="收入")
expenditure = fields.Float(string="支出")
balance = fields.Float(string="结余", compute="_compute_balance")
machinery_type = fields.Selection([("烘干桶", "烘干桶"), ("钣金", "钣金"), ("颗粒机", "颗粒机"), ("其他", "其他")],
default="其他", string="类型")
customer = fields.Char(string="客户")
@api.depends("income", "expenditure")
def _compute_balance(self):
data = self.search([("id", "in", self.ids)], order="business_date asc, create_date asc")
data = self.search([], order="business_date asc, create_date asc")
for v in data:
last_data = self.search([
"|",
......@@ -28,6 +31,6 @@ class ProductIncomeExpense(models.Model):
("id", "!=", v.id)
], limit=1, order="business_date desc, create_date desc")
if not last_data:
v.balance = 0 + v.income - v.expenditure
v.balance = round(0 + v.income - v.expenditure, 2)
else:
v.balance = last_data.balance + v.income - v.expenditure
v.balance = round(last_data.balance + v.income - v.expenditure, 2)
......@@ -34,6 +34,11 @@
<el-input v-model="scope.row.abstract" placeholder="请输入"></el-input>
</template>
</el-table-column>
<el-table-column label="客户" align="center" width="180">
<template slot-scope="scope">
<el-input v-model="scope.row.customer" placeholder="请输入"></el-input>
</template>
</el-table-column>
<el-table-column label="收入" align="center" width="120">
<template slot-scope="scope">
<el-input v-model="scope.row.income" type="number" placeholder="请输入"></el-input>
......@@ -44,56 +49,101 @@
<el-input v-model="scope.row.expenditure" type="number" placeholder="请输入"></el-input>
</template>
</el-table-column>
<!-- <el-table-column label="创建人" align="center" width="110">-->
<!-- <template slot-scope="scope">-->
<!-- <div>-->
<!-- [[ scope.row.user_name ]]-->
<!-- </div>-->
<!-- </template>-->
<!-- </el-table-column>-->
<el-table-column label="类型" align="center" width="180">
<template slot-scope="scope">
<!-- <el-input v-model="scope.row.machinery_type" type="number" placeholder="请输入"></el-input> -->
<el-select v-model="scope.row.machinery_type" clearable placeholder="请选择类型">
<el-option v-for="item in machinery_type_options" :key="item.value" :label="item.label"
:value="item.value">
</el-option>
</el-select>
</template>
</el-table-column>
<!-- <el-table-column label="创建人" align="center" width="110">
<template slot-scope="scope">
<div>
[[ scope.row.user_name ]]
</div>
</template>
</el-table-column> -->
<el-table-column label="操作" align="center" width="95">
<template slot-scope="scope">
<el-button type="primary" @click="saveListData">保存</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div style="text-align: right; margin-bottom: 5px;">
<el-date-picker v-model="datePickerValue" type="daterange" range-separator="至" start-placeholder="开始日期"
end-placeholder="结束日期" format="yyyy-MM-dd" value-format="yyyy-MM-dd"
@change="datePickerChange">
<div style="display: flex;justify-content: flex-end; text-align: right; margin-bottom: 5px;">
<el-input style="width: 15%;" v-model="abstract_value" placeholder="请填写摘要信息" @change="select_change"></el-input>
<el-select v-model="select_value" clearable placeholder="请选择收支类型" @change="select_change">
<el-option v-for="item in select_options" :key="item.value" :label="item.label" :value="item.value">
</el-option>
</el-select>
<el-select class="margin_sty" v-model="machinery_type_value" clearable placeholder="请选择类型"
@change="machinery_type_change">
<el-option v-for="item in machinery_type_options" :key="item.value" :label="item.label"
:value="item.value">
</el-option>
</el-select>
<el-input style="width: 15%;" v-model="customer_value" placeholder="请填写客户名称" @change="select_change"></el-input>
<el-date-picker class="margin_sty" v-model="datePickerValue" type="daterange" range-separator="至" start-placeholder="开始日期"
end-placeholder="结束日期" format="yyyy-MM-dd" value-format="yyyy-MM-dd" @change="datePickerChange">
</el-date-picker>
<el-button type="primary" @click="export_click">导出</el-button>
<el-button v-if="edit_state" type="primary" @click="save_modifications">保存修改</el-button>
</div>
<el-table :data="dataList" border height="500" :row-style="{height: '0'}"
<el-table :data="dataList" border height="500" :row-style="{height: '0'}" :key="table_key"
style="width: 100%;border: 1px solid black;border-color: black" :cell-style="tableCellStyle"
:header-cell-style="tableHeaderCellStyle">
<el-table-column label="业务日期" align="center" width="166">
<el-table-column label="业务日期" align="center" width="150">
<template slot-scope="scope">
<div>
<el-date-picker v-if="scope.row.whether_edit" v-model="scope.row.business_date" type="date"
format="yyyy-MM-dd" value-format="yyyy-MM-dd" placeholder="选择日期">
</el-date-picker>
<div v-else>
[[ scope.row.business_date ]]
</div>
</template>
</el-table-column>
<el-table-column label="摘要" align="center">
<template slot-scope="scope">
<div style="text-align: left;">
<el-input v-if="scope.row.whether_edit" type="textarea" v-model="scope.row.abstract"
placeholder="请输入"></el-input>
<div style="text-align: left;" v-else>
[[ scope.row.abstract ]]
</div>
</template>
</el-table-column>
<el-table-column label="收入" align="center" width="120">
<el-table-column label="客户" align="center">
<template slot-scope="scope">
<div style="text-align: right;">
<el-input v-if="scope.row.whether_edit" v-model="scope.row.customer" placeholder="请输入"></el-input>
<div style="text-align: left;" v-else>
[[ scope.row.customer ]]
</div>
</template>
</el-table-column>
<el-table-column label="收入" align="center" width="100">
<template slot-scope="scope">
<el-input v-if="scope.row.whether_edit" v-model="scope.row.income" type="number"
placeholder="请输入"></el-input>
<div style="text-align: right;" v-else>
[[ scope.row.income ]]
</div>
</template>
</el-table-column>
<el-table-column label="支出" align="center" width="120">
<el-table-column label="支出" align="center" width="100">
<template slot-scope="scope">
<div style="text-align: right;">
<el-input v-if="scope.row.whether_edit" v-model="scope.row.expenditure" type="number"
placeholder="请输入"></el-input>
<div style="text-align: right;" v-else>
[[ scope.row.expenditure ]]
</div>
</template>
......@@ -105,7 +155,20 @@
</div>
</template>
</el-table-column>
<el-table-column label="创建人" align="center" width="110">
<el-table-column label="类型" align="center" width="120">
<template slot-scope="scope">
<el-select v-if="scope.row.whether_edit" v-model="scope.row.machinery_type" clearable
placeholder="请选择类型">
<el-option v-for="item in machinery_type_options" :key="item.value" :label="item.label"
:value="item.value">
</el-option>
</el-select>
<div v-else>
[[ scope.row.machinery_type ]]
</div>
</template>
</el-table-column>
<el-table-column label="创建人" align="center" width="100">
<template slot-scope="scope">
<div>
[[ scope.row.user_name ]]
......@@ -119,8 +182,11 @@
</div>
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="80">
<el-table-column label="操作" align="center" width="120">
<template slot-scope="scope">
<!-- <el-button v-if="scope.row.whether_edit" type="primary">保存</el-button> -->
<el-button v-if="!scope.row.whether_edit" type="primary" icon="el-icon-edit" circle
@click="item_edit(scope)"></el-button>
<el-button type="danger" icon="el-icon-delete" circle @click="deleteItem(scope)"></el-button>
</template>
</el-table-column>
......@@ -144,19 +210,48 @@
paginationTotal: 10,
loading: false,
dataList: [],
table_key: false,
user_name: '',
user_id: null,
createList: [
{
business_date: '',
abstract: '',
customer: '',
income: '',
expenditure: '',
user_name: '',
user_id: null,
machinery_type: ''
}
],
datePickerValue: ['', '']
datePickerValue: ['', ''],
select_options: [{
value: 'income',
label: '收入'
}, {
value: 'expenditure',
label: '支出'
}],
machinery_type_options: [{
value: '烘干桶',
label: '烘干桶'
}, {
value: '钣金',
label: '钣金'
}, {
value: '颗粒机',
label: '颗粒机'
}, {
value: '其他',
label: '其他'
}],
select_value: '',
machinery_type_value: '',
customer_value: '',
abstract_value: '',
edit_state: false,
edit_list: []
};
},
......@@ -170,9 +265,84 @@
this.getDataList()
},
methods: {
// 保存修改
save_modifications() {
for (let i = 0; i < this.edit_list.length; i++) {
if (!this.edit_list[i].business_date) {
return this.$message({
type: "warning",
message: "请选择业务日期",
});
} else if (this.edit_list[i].machinery_type == '') {
return this.$message({
type: "warning",
message: "请选择类型",
});
} else if ((this.edit_list[i].income == '' && this.edit_list[i].income != 0) || (this.edit_list[i].expenditure == '' && this.edit_list[i].expenditure != 0)) {
return this.$message({
type: "warning",
message: "请填写收入或支出后保存",
});
}
this.edit_list[i].user_id = this.user_id
}
this.loading = true;
axios.request({
url: "/roke/product/product_income_expense/create",
method: "post",
headers: {
"Content-Type": "application/json",
},
data: {
data_list: this.edit_list
}
}).then((res) => {
if (res.data.result.code === 0) {
this.$message({
type: "success",
message: res.data.result.message || "修改成功",
});
this.currentPageNo = 1
this.createList = [
{
business_date: moment().format("YYYY-MM-DD"),
abstract: '',
income: '',
customer: '',
expenditure: '',
user_name: this.user_name,
user_id: this.user_id,
machinery_type: ''
}
]
this.select_value = ''
this.machinery_type_value = ''
this.customer_value = ''
this.datePickerValue = ''
this.edit_list = []
this.edit_state = false
this.getDataList()
} else {
this.$message({
type: "error",
message: res.data.result.message || "修改失败",
});
}
this.loading = false;
});
},
machinery_type_change(e) {
this.getDataList()
},
select_change(e) {
console.log(e);
console.log(this.select_value);
this.getDataList()
},
// 列表 时间过滤
datePickerChange(e) {
console.log(e);
if (!e) {
this.datePickerValue = ['', '']
}
......@@ -180,17 +350,38 @@
},
// 分页请求数据
handleCurrentChangee(val) {
this.currentPageNo = val;
this.getDataList()
if (this.edit_list.length > 0) {
this.$confirm('有编辑的内容未保存, 是否保存?', '提示', {
confirmButtonText: '确认保存',
cancelButtonText: '取消保存',
type: 'warning'
}).then(() => {
this.save_modifications()
}).catch(() => {
this.edit_list = []
this.currentPageNo = val;
this.getDataList()
});
} else {
this.currentPageNo = val;
this.getDataList()
}
},
// 获取列表数据
getDataList() {
if (!this.datePickerValue) {
this.datePickerValue = ['', '']
}
this.loading = true;
let parameter = {
limit: 10, //每页数量 非必填
page: this.currentPageNo, //当前页码
start_date: this.datePickerValue[0],
end_date: this.datePickerValue[1]
end_date: this.datePickerValue[1],
type_str: this.select_value,
machinery_type: this.machinery_type_value,
abstract: this.abstract_value,
customer: this.customer_value
}
axios.request({
url: "/roke/product/product_income_expense/get",
......@@ -202,6 +393,9 @@
}).then((res) => {
if (res.data.result.code === 0) {
this.paginationTotal = res.data.result.count
res.data.result.data.forEach(item => {
item.whether_edit = false
});
this.dataList = res.data.result.data
} else {
this.$message({
......@@ -210,7 +404,20 @@
});
}
this.loading = false;
});
}).catch(err => {
console.log(err);
this.$message({
type: "error",
message: "接口报错,获取列表数据失败",
});
})
},
item_edit(scope) {
console.log(scope);
this.edit_list.push(scope.row)
this.edit_state = true
scope.row.whether_edit = true
this.table_key = !this.table_key
},
// 删除
deleteItem(item, index) {
......@@ -243,53 +450,118 @@
} else {
this.dataList.splice(item.$index, 1)
}
if (this.edit_list.length > 0) {
this.edit_list.forEach((res_itm, res_idx) => {
if (res_itm.id == item.row.id) {
this.edit_list.splice(res_idx, 1)
}
})
}
},
// 导出
export_click() {
if (this.dataList.length > 0) {
this.loading = true;
let formData = new FormData();
let _config = { responseType: 'blob' }
if (!this.datePickerValue) {
this.datePickerValue = ['', '']
}
formData.append("start_date", this.datePickerValue[0]);
formData.append("end_date", this.datePickerValue[1]);
formData.append("type_str", this.select_value);
formData.append("machinery_type", this.machinery_type_value);
formData.append("abstract", this.abstract_value);
formData.append("customer", this.customer_value);
axios.post("/roke/product/product_income_expense/export",
formData,
_config,
).then(res => {
this.loading = false
let blob = new Blob([res.data], { type: res.data.type });
let url = window.URL.createObjectURL(blob);
let link = document.createElement("a");
link.style.display = "none";
link.href = url;
link.setAttribute("download", `财务收入支出表.xlsx`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
})
} else {
this.$message({
type: 'error',
message: '请根据配置的筛选条件查询数据后在导出报表!'
});
}
},
// 保存
saveListData() {
if (this.createList[0].income != '' || this.createList[0].expenditure != '') {
this.loading = true;
axios.request({
url: "/roke/product/product_income_expense/create",
method: "post",
headers: {
"Content-Type": "application/json",
},
data: {
data_list: this.createList
}
}).then((res) => {
if (res.data.result.code === 0) {
this.$message({
type: "success",
message: res.data.result.message || "创建成功",
});
this.currentPageNo = 1
this.createList = [
{
business_date: moment().format("YYYY-MM-DD"),
abstract: '',
income: '',
expenditure: '',
user_name: this.user_name,
user_id: this.user_id
}
]
this.getDataList()
} else {
this.$message({
type: "error",
message: res.data.result.message || "创建失败",
});
}
this.loading = false;
if (!this.createList[0].business_date) {
return this.$message({
type: "warning",
message: "请选择业务日期",
});
} else {
}
if (this.createList[0].machinery_type == '') {
this.$message({
type: "warning",
message: "请填写收入或支出后保存",
message: "请选择类型",
});
} else {
if (this.createList[0].income != '' || this.createList[0].expenditure != '') {
this.loading = true;
axios.request({
url: "/roke/product/product_income_expense/create",
method: "post",
headers: {
"Content-Type": "application/json",
},
data: {
data_list: this.createList
}
}).then((res) => {
if (res.data.result.code === 0) {
this.$message({
type: "success",
message: res.data.result.message || "创建成功",
});
this.currentPageNo = 1
this.createList = [
{
business_date: moment().format("YYYY-MM-DD"),
abstract: '',
income: '',
customer: '',
expenditure: '',
user_name: this.user_name,
user_id: this.user_id,
machinery_type: ''
}
]
this.select_value = ''
this.machinery_type_value = ''
this.customer_value = ''
this.abstract_value = ''
this.datePickerValue = ''
this.getDataList()
} else {
this.$message({
type: "error",
message: res.data.result.message || "创建失败",
});
}
this.loading = false;
});
} else {
this.$message({
type: "warning",
message: "请填写收入或支出后保存",
});
}
}
},
// table样式
tableCellStyle() {
......@@ -328,7 +600,7 @@
}
body {
padding: 15px 35px 30px 35px;
padding: 15px 25px 25px 25px;
}
.el-table .el-table__cell {
......@@ -338,6 +610,15 @@
.el-date-editor.el-input {
width: 140px;
}
.margin_sty {
margin: 0 10px;
}
.el-textarea__inner {
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB",
"Microsoft YaHei", "微软雅黑", Arial, sans-serif;
}
</style>
</html>
\ No newline at end of file
......@@ -589,6 +589,8 @@
// 如果找到匹配的设备,使用其名称
if (matchedDevice && matchedDevice.name) {
deviceName = device.name ? matchedDevice.name : device.code
} else {
return false
}
}
return {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment