This commit is contained in:
pengyuthon
2025-12-04 00:12:38 +08:00
parent 2e3cca1e4c
commit 85e4274e84
18 changed files with 1078 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import controllers
from . import models
+24
View File
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
{
'name': 'Yuthon Catering',
'summary': 'Yuthon Catering',
'description': 'Yuthon Catering',
'author': 'XSK',
'website': '',
'category': 'Tools',
'version': '17.0',
'depends': ['base', 'hr'],
'data': [
'security/ir.model.access.csv',
'views/root_menu.xml',
'views/yuthon_catering.xml',
'views/yuthon_catering_conf.xml',
'templates/templates.xml',
],
# 'assets': {
# 'web.assets_backend': [
# 'base_dynamic_columns/static/src/**/*',
# ],
# },
}
+2
View File
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import controllers
@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
from odoo import http
from odoo.http import request
class CateringController(http.Controller):
@http.route(['/catering/portal/<int:employee_id>'], type='http', auth='public', website=True)
def catering_portal(self, employee_id, **kw):
"""
渲染 catering_portal_page 这个 QWeb 模板。
页面内部包含一个挂载 OWL 的区域。
"""
employee = request.env['hr.employee'].sudo().browse(employee_id)
# 渲染 QWeb 模板
return request.render('yuthon_catering.yuthon_catering_template', {'employee': employee})
@http.route(['/catering/create'], type='http', auth='public', methods=['POST'], csrf=False)
def catering_create(self, **post):
"""
前端 OWL 调用此接口创建 yuthon.catering 记录。
接受字段:date, employee_id, breakfast, lunch, amount, note
"""
qcontext = post
try:
employee_id = int(post.get('employee_id'))
vals = {
'date': post.get('date'),
'employee_id': employee_id,
'breakfast': post.get('breakfast') == 'true',
'lunch': post.get('lunch') == 'true',
'amount': float(post.get('amount') or 0),
'note': post.get('note') or '',
}
request.env['yuthon.catering'].sudo().create(vals)
employee = request.env['hr.employee'].sudo().browse(employee_id)
qcontext['employee'] = employee
qcontext['tips'] = '提交成功!'
return request.render('yuthon_catering.yuthon_catering_template', qcontext)
except Exception as e:
qcontext['error'] = str(e)
return request.render('yuthon_catering.yuthon_catering_template', qcontext)
+3
View File
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import yuthon_catering
from . import yuthon_catering_conf
+17
View File
@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class YuthonCatering(models.Model):
_name = 'yuthon.catering'
date = fields.Date(string='日期', required=True)
employee_id = fields.Many2one(string='员工', comodel_name='hr.employee', required=True)
breakfast = fields.Boolean(string='早餐', default=False)
lunch = fields.Boolean(string='午餐', default=False)
amount = fields.Float(string='结算费用')
note = fields.Text(string='备注')
def cron_send_message(self):
url = f'/catering/portal/<int:employee_id>'
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class YuthonCateringConf(models.Model):
_name = 'yuthon.catering.conf'
company_id = fields.Many2one(string='公司', comodel_name='res.company', required=True, default=lambda self: self.env.company)
employee_ids = fields.Many2many(string='员工', comodel_name='hr.employee')
@@ -0,0 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_yuthon_catering,yuthon_catering,model_yuthon_catering,,1,1,1,1
access_yuthon_catering_conf,yuthon_catering_conf,model_yuthon_catering_conf,,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_yuthon_catering yuthon_catering model_yuthon_catering 1 1 1 1
3 access_yuthon_catering_conf yuthon_catering_conf model_yuthon_catering_conf 1 1 1 1
@@ -0,0 +1,148 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { FileInput } from "@web/core/file_input/file_input";
import { useSelectCreate } from "@web/views/fields/relational_utils";
import { many2OneField, Many2OneField } from "@web/views/fields/many2one/many2one_field";
export class PreviewMany2OneField extends Many2OneField {
static template = "preview_easy.Many2OneField";
static components = {
...Many2OneField.components,
FileInput
};
static props = {
...Many2OneField.props,
acceptedFileExtensions: { type: String, optional: true },
showSelectBtn: { type: Boolean, optional: true },
previewType: { type: String, optional: true },
previewString: { type: String, optional: true },
className: { type: String, optional: true },
};
setup() {
super.setup();
const isToMany = false;
this.selectCreate = useSelectCreate({
resModel: this.relation,
activeActions: {create: false, },
onSelected: (resId) => {
const resIds = Array.isArray(resId) ? resId : [resId];
const values = resIds.map((id) => ({ id }));
return this.update(values);
},
onCreateEdit: ({ context }) => this.openMany2X({ context }),
onUnselect: isToMany ? undefined : () => this.update(),
});
}
async onFileUploaded(files) {
for (const file of files) {
if (file.error) {
return this.notification.add(file.error, {
title: _t("Uploading error"),
type: "danger",
});
}
await this.update([{id: file.id, display_name: file.filename}]);
}
}
async onFileRemove(deleteId) {
await this.update(false);
}
get uploadText() {
return this.props.record.fields[this.props.name].string;
}
// 获取文件
getFile() {
const jsonData = {
id: Math.floor(Math.random() * 1000 * 1000 * 1000),
jsonrpc: "2.0",
method: "call",
params: {
args: [
[this.resId],
["id", "name", "mimetype"]
],
kwargs: {
context: this.context,
},
method: "read",
model: this.relation,
}
};
let file;
$.ajax({
url: "/web/dataset/call_kw/ir.attachment/read",
method: "POST",
dataType: 'json',
async: false, // 设置为同步请求
beforeSend: (xhr) => {xhr.setRequestHeader('Content-Type', 'application/json');},
data : JSON.stringify(jsonData),
success: (result) => {
file = result.result[0];
}
})
return file;
}
getUrl(id) {
return "/web/content/" + id + "?download=true";
}
preview_Obj(id, name) {
var winStr = 'height=600,width=1000,top=' + (screen.height - 600) / 2 + ',left=' + (screen.width - 1000) / 2 + ',toolbar=no,directories=no,status=no,menubar=no,scrollbars=yes,resizable=no,location=no';
var newWin = window.open('/web/content/preview/'+ id , name, winStr);
newWin.focus();
}
getExtension(file) {
return file.name.replace(/^.*\./, "");
}
async onExternalBtnClick() {
const domain = this.getDomain();
this.selectCreate({
domain,
context: this.props.record.context,
filters: [],
title: "Search",
});
}
get documentsInstalled() {
try {
const result = $.ajax({
url: "/web/document_module/installed",
type: "POST",
async: false, // 设置为 false 表示同步请求
contentType: "application/json",
data: "{}"
});
return result?.responseJSON?.result?.installed;
} catch (error) {
console.error('Error fetching document installed status:', error);
return false; // 或者其他默认值
}
}
}
export const previewMany2OneField = {
...many2OneField,
component: PreviewMany2OneField,
extractProps({ attrs, context, decorations, options, string }, dynamicInfo) {
const result = many2OneField.extractProps.apply(this, arguments);
return {
...result,
acceptedFileExtensions: options.accepted_file_extensions,
showSelectBtn: options.show_select_btn === undefined ? true : options.show_select_btn,
previewType: options.preview_type,
previewString: options.preview_string,
className: attrs.class,
};
},
};
registry.category("fields").add("preview_many2one", previewMany2OneField);
+213
View File
@@ -0,0 +1,213 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import {standardFieldProps} from "@web/views/fields/standard_field_props";
import { FileInput } from "@web/core/file_input/file_input";
import { useX2ManyCrud, useSelectCreate, useOpenMany2XRecord } from "@web/views/fields/relational_utils";
import { Component } from "@odoo/owl";
import { TagsList } from "@web/core/tags_list/tags_list";
import { Dialog } from "@web/core/dialog/dialog";
import {
Many2XAutocomplete,
useActiveActions,
} from "@web/views/fields/relational_utils";
import { Many2ManyTagsField, many2ManyTagsField } from "@web/views/fields/many2many_tags/many2many_tags_field";
export class Many2ManyBinaryField extends Many2ManyTagsField {
static template = "web.Many2ManyBinaryField";
static components = {
FileInput,
TagsList,
Many2XAutocomplete,
};
static props = {
...standardFieldProps,
acceptedFileExtensions: { type: String, optional: true },
className: { type: String, optional: true },
canCreate: { type: Boolean, optional: true },
canQuickCreate: { type: Boolean, optional: true },
canCreateEdit: { type: Boolean, optional: true },
colorField: { type: String, optional: true },
createDomain: { type: [Array, Boolean], optional: true },
domain: { type: [Array, Function], optional: true },
context: { type: Object, optional: true },
placeholder: { type: String, optional: true },
nameCreateField: { type: String, optional: true },
string: { type: String, optional: true },
noSearchMore: { type: Boolean, optional: true },
showSelectBtn: { type: Boolean, optional: true },
previewType: { type: String, optional: true },
previewString: { type: String, optional: true },
};
setup() {
super.setup()
this.orm = useService("orm");
this.notification = useService("notification");
this.operations = useX2ManyCrud(() => this.props.record.data[this.props.name], true);
const isToMany = true;
this.canCreate = false;
this.quickCreate = false;
this.openMany2X = useOpenMany2XRecord({
resModel: this.relation,
activeActions: this.activeActions,
isToMany: isToMany,
onRecordSaved: (record) => {
return this.update([{ ...record.data, id: record.resId }]);
},
onRecordDiscarded: () => {
if (!isToMany) {
this.update(false);
}
},
fieldString: this.string,
onClose: () => {
const autoCompleteInput = this.autoCompleteContainer?.el.querySelector("input");
if (!autoCompleteInput) {
return;
}
// There are two cases:
// 1. Value is the same as the input: it means the autocomplete has re-rendered with the right value
// This is in case we saved the record, triggering all the interface to update.
// 2. Value is different from the input: it means the input has a manually entered value and nothing
// happened, that is, we discarded the changes
if (this.props.value !== autoCompleteInput.value) {
autoCompleteInput.value = "";
}
autoCompleteInput.focus();
},
});
this.selectCreate = useSelectCreate({
resModel: this.relation,
activeActions: this.activeActions,
onSelected: (resId) => {
const resIds = Array.isArray(resId) ? resId : [resId];
const values = resIds.map((id) => ({ id }));
return this.update(values);
},
onCreateEdit: ({ context }) => this.openMany2X({ context }),
onUnselect: isToMany ? undefined : () => this.update(),
});
}
get uploadText() {
return this.props.record.fields[this.props.name].string;
}
get files() {
return this.props.record.data[this.props.name].records.map((record) => {
return {
...record.data,
id: record.resId,
};
});
}
getUrl(id) {
return "/web/content/" + id + "?download=true";
}
preview_Obj(id, name) {
var winStr = 'height=600,width=1000,top=' + (screen.height - 600) / 2 + ',left=' + (screen.width - 1000) / 2 + ',toolbar=no,directories=no,status=no,menubar=no,scrollbars=yes,resizable=no,location=no';
var newWin = window.open('/web/content/preview/'+ id , name, winStr);
newWin.focus();
}
getExtension(file) {
return file.name.replace(/^.*\./, "");
}
async onFileUploaded(files) {
for (const file of files) {
if (file.error) {
return this.notification.add(file.error, {
title: _t("Uploading error"),
type: "danger",
});
}
await this.operations.saveRecord([file.id]);
}
}
async onFileRemove(deleteId) {
const record = this.props.record.data[this.props.name].records.find(
(record) => record.resId === deleteId
);
this.operations.removeRecord(record);
}
async onExternalBtnClick() {
const domain = this.getDomain();
let dynamicFilters = [];
const nameGets = await this.orm.call(this.relation, "name_search", [], {
name: '',
args: domain,
operator: "ilike",
limit: this.props.searchMoreLimit,
context: this.props.context,
});
dynamicFilters = [
{
description: _t("Quick search"),
domain: [["id", "in", nameGets.map((nameGet) => nameGet[0])]],
},
];
const title = _t("Search");
this.selectCreate({
domain,
context: this.props.context,
filters: dynamicFilters,
title,
});
}
get documentsInstalled() {
try {
const result = $.ajax({
url: "/web/document_module/installed",
type: "POST",
async: false, // 设置为 false 表示同步请求
contentType: "application/json",
data: "{}"
});
return result?.responseJSON?.result?.installed;
} catch (error) {
console.error('Error fetching document installed status:', error);
return false; // 或者其他默认值
}
}
}
export const many2ManyBinaryField = {
component: Many2ManyBinaryField,
supportedOptions: [
{
label: _t("Accepted file extensions"),
name: "accepted_file_extensions",
type: "string",
},
],
supportedTypes: ["many2many"],
isEmpty: () => false,
relatedFields: [
{ name: "name", type: "char" },
{ name: "mimetype", type: "char" },
],
extractProps: ({ attrs, options }) => ({
acceptedFileExtensions: options.accepted_file_extensions,
showSelectBtn: options.show_select_btn === undefined ? true : options.show_select_btn,
previewType: options.preview_type,
previewString: options.preview_string,
className: attrs.class,
}),
};
registry.category("fields").add("preview_many2many", many2ManyBinaryField);
@@ -0,0 +1,5 @@
.o_field_preview_many2many {
.oe_add {
}
}
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=utf-8">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate"/>
<meta http-equiv="Pragma" content="no-cache"/>
<meta http-equiv="Expires" content="0"/>
<title>preview</title>
<script>
</script>
</head>
<body>
{{html}}
</body>
</html>
@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="preview_easy.Many2OneField">
<t t-if="props.readonly">
<t t-call="preview_easy.Many2OneFieldPreview"/>
</t>
<t t-else="">
<!-- 如果存在文件显示预览按钮,如果不存在文件,显示上传选择按钮 -->
<t t-if="resId">
<div class="oe_add">
<t t-call="preview_easy.Many2OneFieldPreview"/>
<FileInput
acceptedFileExtensions="props.acceptedFileExtensions"
multiUpload="true"
onUpload.bind="onFileUploaded"
resModel="props.record.resModel"
resId="props.record.resId or 0"
>
<button class="btn btn-secondary o_attach" data-tooltip="Attach">
<span class="fa fa-paperclip" aria-label="Attach"/>
<t t-esc="uploadText"/>
</button>
</FileInput>
<button t-if="documentsInstalled and props.showSelectBtn" class="btn btn-secondary o_attach" t-on-click="onExternalBtnClick" data-tooltip="Attach">
<i class="fa fa-folder-open-o"/>
选择文件
</button>
</div>
</t>
<t t-else="">
<div class="oe_add">
<FileInput
acceptedFileExtensions="props.acceptedFileExtensions"
multiUpload="true"
onUpload.bind="onFileUploaded"
resModel="props.record.resModel"
resId="props.record.resId or 0"
>
<button class="btn btn-secondary o_attach" data-tooltip="Attach">
<span class="fa fa-paperclip" aria-label="Attach"/>
<t t-esc="uploadText"/>
</button>
</FileInput>
<button t-if="documentsInstalled and props.showSelectBtn" class="btn btn-secondary o_attach" t-on-click="onExternalBtnClick" data-tooltip="Attach">
<i class="fa fa-folder-open-o"/>
选择文件
</button>
</div>
</t>
</t>
</t>
<t t-name="preview_easy.Many2OneFieldPreview" owl="1">
<t t-set="editable" t-value="!props.readonly"/>
<t t-set="file" t-value="getFile()"/>
<!-- 预览按钮 -->
<t t-if="props.previewType and props.previewType === 'button'">
<button class="btn btn-secondary o_attach" t-on-click.stop="() => this.preview_Obj()" data-tooltip="Attach">
<i class="fa fa-eye"/>
<span t-if="props.previewString" t-out="props.previewString"/>
<span t-else="" t-out="'预览文件'"/>
</button>
</t>
<t t-else="">
<div t-attf-class="o_attachment o_attachment_many2many #{ editable ? 'o_attachment_editable' : '' } #{upload ? 'o_attachment_uploading' : ''}" t-att-title="file.name">
<div class="o_attachment_wrap">
<t t-set="ext" t-value="getExtension(file)"/>
<t t-set="url" t-value="getUrl(file.id)"/>
<div class="o_image_box float-start" t-att-data-tooltip="'Preview ' + file.name">
<span class="o_image o_hover" t-on-click.stop="() => this.preview_Obj(file.id,file.name)" t-att-data-mimetype="file.mimetype" t-att-data-ext="ext" role="img"/>
</div>
<div class="caption">
<a class="ml4" t-att-data-tooltip="'Download ' + file.name" t-att-href="getUrl(file.id)"><t t-esc='file.name'/></a>
</div>
<div class="caption small">
<a class="ml4 small text-uppercase" t-att-href="getUrl(file.id)"><b><t t-esc='ext'/></b></a>
<div t-if="editable" class="progress o_attachment_progress_bar">
<div class="progress-bar progress-bar-striped active" style="width: 100%">Uploading</div>
</div>
</div>
<div class="o_attachment_uploaded"><i class="text-success fa fa-check" role="img" aria-label="Uploaded" title="Uploaded"/></div>
<div t-if="editable" class="o_attachment_delete" t-on-click.stop="() => this.onFileRemove(file.id)"><span class="text-white" role="img" aria-label="Delete" title="Delete">×</span></div>
</div>
</div>
</t>
</t>
</templates>
+65
View File
@@ -0,0 +1,65 @@
<templates xml:space="preserve">
<t t-inherit="web.Many2ManyBinaryField" t-inherit-mode="extension" owl="1">
<xpath expr="//div[hasclass('o_attachments')]" position="replace">
<t t-foreach="files" t-as="file" t-key="file_index">
<t t-call="Many2ManyBinaryField.attachment_preview1"/>
</t>
</xpath>
<xpath expr="//div[hasclass('oe_add')]" position="inside">
<button t-if="documentsInstalled and props.showSelectBtn" class="btn btn-secondary o_attach" t-on-click="onExternalBtnClick" data-tooltip="Attach">
<i class="fa fa-folder-open-o"/>
选择文件
<!-- <Many2XAutocomplete -->
<!-- id="props.id" -->
<!-- resModel="relation" -->
<!-- autoSelect="true" -->
<!-- fieldString="string" -->
<!-- activeActions="activeActions" -->
<!-- update="update" -->
<!-- quickCreate="activeActions.create ? quickCreate : null" -->
<!-- context="props.context" -->
<!-- getDomain.bind="getDomain" -->
<!-- isToMany="true" -->
<!-- nameCreateField="props.nameCreateField" -->
<!-- noSearchMore="props.noSearchMore" -->
<!-- /> -->
</button>
</xpath>
</t>
<t t-name="Many2ManyBinaryField.attachment_preview1" owl="1">
<t t-set="editable" t-value="!props.readonly"/>
<t t-if="props.previewType and props.previewType === 'button'">
<button class="btn btn-secondary o_attach" t-on-click.stop="() => this.preview_Obj(file.id,file.name)" data-tooltip="Attach">
<i class="fa fa-eye"/>
<span t-if="props.previewString" t-out="props.previewString"/>
<span t-else="" t-out="'预览文件'"/>
</button>
</t>
<t t-else="">
<div t-attf-class="o_attachment o_attachment_many2many #{ editable ? 'o_attachment_editable' : '' } #{upload ? 'o_attachment_uploading' : ''}" t-att-title="file.name">
<div class="o_attachment_wrap">
<t t-set="ext" t-value="getExtension(file)"/>
<t t-set="url" t-value="getUrl(file.id)"/>
<div class="o_image_box float-start" t-att-data-tooltip="'Preview ' + file.name">
<span class="o_image o_hover" t-on-click.stop="() => this.preview_Obj(file.id,file.name)" t-att-data-mimetype="file.mimetype" t-att-data-ext="ext" role="img"/>
</div>
<div class="caption">
<a class="ml4" t-att-data-tooltip="'Download ' + file.name" t-att-href="getUrl(file.id)"><t t-esc='file.name'/></a>
</div>
<div class="caption small">
<a class="ml4 small text-uppercase" t-att-href="getUrl(file.id)"><b><t t-esc='ext'/></b></a>
<div t-if="editable" class="progress o_attachment_progress_bar">
<div class="progress-bar progress-bar-striped active" style="width: 100%">Uploading</div>
</div>
</div>
<div class="o_attachment_uploaded"><i class="text-success fa fa-check" role="img" aria-label="Uploaded" title="Uploaded"/></div>
<div t-if="editable" class="o_attachment_delete" t-on-click.stop="() => this.onFileRemove(file.id)"><span class="text-white" role="img" aria-label="Delete" title="Delete">×</span></div>
</div>
</div>
</t>
</t>
</templates>
+348
View File
@@ -0,0 +1,348 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<template id="yuthon_catering_template" name="Catering Form Page">
<!-- 自定义CSS样式 -->
<style>
:root {
--primary-color: #4f46e5;
--primary-light: #e0e7ff;
--success-color: #10b981;
--warning-color: #f59e0b;
--info-color: #3b82f6;
--light-bg: #f8fafc;
--card-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
--card-radius: 5px;
--transition: all 0.3s ease;
}
.catering-container {
max-width: 700px;
margin: 2rem auto;
padding: 0 1rem;
}
.page-header {
text-align: center;
margin-bottom: 2.5rem;
padding: 1.5rem;
background: linear-gradient(135deg, var(--primary-color) 0%, #7c3aed 100%);
border-radius: var(--card-radius);
color: white;
box-shadow: var(--card-shadow);
position: relative;
overflow: hidden;
}
.page-header::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px);
background-size: 30px 30px;
opacity: 0.3;
animation: float 20s linear infinite;
}
@keyframes float {
0% { transform: translate(0, 0) rotate(0deg); }
100% { transform: translate(-30px, -30px) rotate(360deg); }
}
.page-title {
font-size: 2.2rem;
font-weight: 800;
margin-bottom: 0.5rem;
position: relative;
display: inline-block;
}
.page-subtitle {
font-size: 1rem;
opacity: 0.9;
font-weight: 400;
}
.form-card {
background: white;
border-radius: var(--card-radius);
padding: 0.5rem;
margin-bottom: 0.5rem;
box-shadow: var(--card-shadow);
border: none;
transition: var(--transition);
}
.form-card:hover {
transform: translateY(-5px);
box-shadow: 0 15px 30px -10px rgba(0, 0, 0, 0.15);
}
.card-header {
display: flex;
align-items: center;
margin-bottom: 1.5rem;
}
.card-title {
font-size: 1.3rem;
font-weight: 700;
color: #1e293b;
margin-bottom: 0.25rem;
}
/* 开关样式 */
.meal-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.2rem;
border-radius: 16px;
background: var(--light-bg);
margin-bottom: 1rem;
cursor: pointer;
transition: var(--transition);
border: 2px solid transparent;
}
.meal-toggle:hover {
background: #f1f5f9;
}
.meal-toggle.active {
background: white;
border-color: var(--primary-color);
box-shadow: 0 5px 15px rgba(79, 70, 229, 0.1);
}
.toggle-label {
display: flex;
align-items: center;
font-size: 1.1rem;
font-weight: 600;
color: #1e293b;
}
/* 自定义单选按钮 */
.custom-radio {
display: none;
}
.custom-radio:checked + .meal-toggle {
background: white;
border-color: var(--primary-color);
box-shadow: 0 5px 15px rgba(79, 70, 229, 0.1);
}
/* 表单控件 */
.form-control-custom {
height: 56px;
border-radius: 12px;
border: 2px solid #e2e8f0;
font-size: 1.1rem;
padding: 0 1.2rem;
transition: var(--transition);
width: 100%;
}
.form-control-custom:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
textarea.form-control-custom {
height: auto;
padding: 1.2rem;
resize: vertical;
min-height: 120px;
}
/* 金额显示区域 */
.amount-display {
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
border-radius: 16px;
padding: 1.5rem;
margin-top: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.amount-label {
font-size: 1.1rem;
color: #475569;
font-weight: 600;
}
.amount-value {
font-size: 2rem;
font-weight: 800;
color: var(--success-color);
}
/* 提交按钮 */
.submit-btn {
height: 64px;
border-radius: 16px;
background: linear-gradient(135deg, var(--primary-color) 0%, #7c3aed 100%);
border: none;
font-size: 1.3rem;
font-weight: 700;
color: white;
width: 100%;
margin-top: 1.5rem;
transition: var(--transition);
display: flex;
align-items: center;
justify-content: center;
gap: 0.8rem;
box-shadow: 0 10px 20px rgba(79, 70, 229, 0.3);
}
.submit-btn:hover {
transform: translateY(-3px);
box-shadow: 0 15px 30px rgba(79, 70, 229, 0.4);
}
.submit-btn:active {
transform: translateY(0);
}
.alert-success {
background-color: var(--success-color);
}
.alert-danger {
background-color: var(--warning-color);
}
/* 响应式调整 */
@media (max-width: 768px) {
.catering-container {
margin: 1rem auto;
padding: 0 0.75rem;
}
.page-header {
padding: 1.25rem;
}
.page-title {
font-size: 1.8rem;
}
.form-card {
padding: 0.5rem;
}
.card-header {
flex-direction: column;
align-items: flex-start;
}
}
</style>
<!-- 主容器 -->
<div class="catering-container">
<!-- 页面标题 -->
<div class="page-header">
<h1 class="page-title">
员工报餐登记
</h1>
</div>
<!-- 整个提交 form -->
<form id="cateringForm" action="/catering/create" method="post">
<!-- 当前员工ID -->
<input type="hidden" name="employee_id" t-att-value="employee.id"/>
<!-- 日期选择卡片 -->
<div class="form-card">
<div class="card-header">
<h3 class="card-title">选择用餐日期</h3>
</div>
<input type="date" name="date" required="1" class="form-control form-control-custom" t-att-value="date"/>
</div>
<!-- 早餐选择卡片 -->
<div class="form-card">
<div class="card-header">
<h3 class="card-title">早餐选择</h3>
</div>
<input type="radio" id="breakfast-true" name="breakfast" value="true" class="custom-radio" t-att-checked="'checked' if breakfast else None"/>
<label for="breakfast-true" class="meal-toggle">
<span class="toggle-label">
需要早餐
</span>
</label>
<input type="radio" id="breakfast-false" name="breakfast" value="false" class="custom-radio" t-att-checked="'checked' if not breakfast else None"/>
<label for="breakfast-false" class="meal-toggle">
<span class="toggle-label">
不需要早餐
</span>
</label>
</div>
<!-- 午餐选择卡片 -->
<div class="form-card">
<div class="card-header">
<h3 class="card-title">午餐选择</h3>
</div>
<input type="radio" id="lunch-true" name="lunch" value="true" class="custom-radio" t-att-checked="'checked' if lunch else None"/>
<label for="lunch-true" class="meal-toggle">
<span class="toggle-label">
需要午餐
</span>
</label>
<input type="radio" id="lunch-false" name="lunch" value="false" class="custom-radio" t-att-checked="'checked' if not lunch else None"/>
<label for="lunch-false" class="meal-toggle">
<span class="toggle-label">
不需要午餐
</span>
</label>
</div>
<!-- 金额输入卡片 -->
<div class="form-card">
<div class="card-header">
<h3 class="card-title">结算金额</h3>
</div>
<input type="number" step="0.01" min="0" name="amount" class="form-control form-control-custom" placeholder="如需手动调整金额,请在此输入"/>
</div>
<!-- 备注卡片 -->
<div class="form-card">
<div class="card-header">
<h3 class="card-title">备注信息</h3>
</div>
<textarea name="note" class="form-control form-control-custom" rows="3" placeholder="例如:素食、忌口食物、送餐时间要求等"/>
</div>
<div class="form-card alert alert-success" t-if="tips">
<t t-esc="tips"/>
</div>
<div class="form-card alert alert-danger" t-if="error">
<t t-esc="error"/>
</div>
<!-- 提交按钮 -->
<button type="submit" class="submit-btn">
<i class="fas fa-paper-plane"></i> 提交报餐登记
</button>
</form>
</div>
</template>
</odoo>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<menuitem id="yuthon_catering_root_menu" name="餐饮" sequence="1">
<menuitem id="yuthon_catering_conf_root_menu" name="配置" sequence="99"/>
</menuitem>
</data>
</odoo>
+37
View File
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<!-- tree视图 -->
<record id="yuthon_catering_tree_view" model="ir.ui.view">
<field name="name">yuthon.catering.tree</field>
<field name="model">yuthon.catering</field>
<field name="arch" type="xml">
<tree string="餐饮" editable="top">
<field name="date"/>
<field name="employee_id"/>
<field name="breakfast"/>
<field name="lunch"/>
<field name="amount"/>
<field name="note"/>
</tree>
</field>
</record>
<!-- 跳转菜单 -->
<record id="yuthon_catering_action_window" model="ir.actions.act_window">
<field name="name">餐饮收集任务</field>
<field name="res_model">yuthon.catering</field>
<field name="view_mode">tree,form</field>
</record>
<!-- 二级菜单 -->
<menuitem
id="yuthon_catering_menu"
name="餐饮收集任务"
parent="yuthon_catering_root_menu"
sequence="1"
action="yuthon_catering_action_window"/>
</data>
</odoo>
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<data>
<!-- tree视图 -->
<record id="yuthon_catering_conf_tree_view" model="ir.ui.view">
<field name="name">yuthon.catering.conf.tree</field>
<field name="model">yuthon.catering.conf</field>
<field name="arch" type="xml">
<tree string="配置" editable="top">
<field name="company_id"/>
<field name="employee_ids"/>
</tree>
</field>
</record>
<!-- 跳转菜单 -->
<record id="yuthon_catering_conf_action_window" model="ir.actions.act_window">
<field name="name">餐饮人员配置</field>
<field name="res_model">yuthon.catering.conf</field>
<field name="view_mode">tree</field>
<field name="search_view_id" ref=""/>
</record>
<!-- 二级菜单 -->
<menuitem
id="yuthon_catering_conf_menu"
name="餐饮人员配置"
parent="yuthon_catering_conf_root_menu"
sequence="1"
action="yuthon_catering_conf_action_window"/>
</data>
</odoo>